durable-workflow 2.4.2__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {durable_workflow-2.4.2/src/durable_workflow.egg-info → durable_workflow-2.5.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/__init__.py +2 -0
  4. durable_workflow-2.5.0/src/durable_workflow/_sticky_workflow_cache.py +103 -0
  5. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/client.py +7 -2
  6. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/errors.py +30 -0
  7. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/worker.py +128 -3
  8. {durable_workflow-2.4.2 → durable_workflow-2.5.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  10. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_client.py +29 -0
  11. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_errors.py +53 -0
  12. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_regression_corpus.py +21 -0
  13. durable_workflow-2.5.0/tests/test_sticky_execution.py +236 -0
  14. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_sync.py +29 -1
  15. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_worker.py +28 -1
  16. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/LICENSE +0 -0
  17. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/README.md +0 -0
  18. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/setup.cfg +0 -0
  19. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_activity_process.py +0 -0
  20. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_avro.py +0 -0
  21. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  22. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  23. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  24. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  25. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/activity.py +0 -0
  26. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/auth_composition.py +0 -0
  27. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/cancellation.py +0 -0
  28. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/testing.py +0 -0
  44. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/worker_session.py +0 -0
  45. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/workflow.py +0 -0
  46. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  48. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  49. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  50. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  51. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_cancellation_policies.py +0 -0
  52. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_context.py +0 -0
  53. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_activity_process.py +0 -0
  54. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_auth_composition.py +0 -0
  55. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_avro_value_benchmark.py +0 -0
  56. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_avro_value_protocol.py +0 -0
  57. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_context.py +0 -0
  58. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_remaining_time.py +0 -0
  59. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_admission.py +0 -0
  60. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_authoring.py +0 -0
  61. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_boundary.py +0 -0
  62. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cancellation_scope_opening.py +0 -0
  63. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_child_workflow_policies.py +0 -0
  64. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_ci_checkout.py +0 -0
  65. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_ci_integration_endpoint.py +0 -0
  66. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_client_timeouts.py +0 -0
  67. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_codec_regression_corpus.py +0 -0
  68. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  69. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  70. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  71. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  72. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  73. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation.py +0 -0
  74. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_client.py +0 -0
  75. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  76. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_cooperative_remote_worker.py +0 -0
  77. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_docstring_cross_references.py +0 -0
  78. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_durable_selection.py +0 -0
  79. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_storage.py +0 -0
  80. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_task_input.py +0 -0
  81. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_external_task_result.py +0 -0
  82. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_golden_history_replay.py +0 -0
  83. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_history_bundle_verify.py +0 -0
  84. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_history_event_contract.py +0 -0
  85. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_invocable.py +0 -0
  86. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_message_streams.py +0 -0
  87. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_metrics.py +0 -0
  88. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_order_processing_example.py +0 -0
  89. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_parallel_completion_order_replay.py +0 -0
  90. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_parallel_saga_authoring.py +0 -0
  91. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_pending_operation_replay.py +0 -0
  92. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_client.py +0 -0
  93. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_groups.py +0 -0
  94. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_policies.py +0 -0
  95. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_prepared_local_activity_worker.py +0 -0
  96. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_public_boundary_scanner.py +0 -0
  97. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_pypi_project_surface.py +0 -0
  98. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_python_conformance.py +0 -0
  99. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_queries.py +0 -0
  100. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_readme_quickstart.py +0 -0
  101. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_redrive_replay.py +0 -0
  102. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_release_metadata.py +0 -0
  103. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay.py +0 -0
  104. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_conformance.py +0 -0
  105. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_replay_verify.py +0 -0
  106. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_retry_policy.py +0 -0
  107. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  108. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_runtime_external_payload_transport.py +0 -0
  109. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_schedules.py +0 -0
  110. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  111. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_serializer.py +0 -0
  112. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_signals.py +0 -0
  113. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_sleep.py +0 -0
  114. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_standalone_activity_client.py +0 -0
  115. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_storage_admission.py +0 -0
  116. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_testing_harness.py +0 -0
  117. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_update_signal_condition_replay.py +0 -0
  118. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_updates.py +0 -0
  119. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_wait_condition.py +0 -0
  120. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_worker_session.py +0 -0
  121. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_workflow_result_timeout.py +0 -0
  122. {durable_workflow-2.4.2 → durable_workflow-2.5.0}/tests/test_workflow_streams.py +0 -0
  123. {durable_workflow-2.4.2 → 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.2
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.2"
7
+ version = "2.5.0"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.4.2"
75
- registry-version = "2.4.2"
74
+ product-train = "2.5.0"
75
+ registry-version = "2.5.0"
76
76
  supported-server-versions = "2.5.0"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -95,6 +95,7 @@ from .errors import (
95
95
  ServerError,
96
96
  SignalFailed,
97
97
  Unauthorized,
98
+ UpdateFailed,
98
99
  UpdateRejected,
99
100
  UpdateValidationFailed,
100
101
  WorkflowAlreadyStarted,
@@ -385,6 +386,7 @@ __all__ = [
385
386
  "SignalFailed",
386
387
  "TransportRetryPolicy",
387
388
  "Unauthorized",
389
+ "UpdateFailed",
388
390
  "UpdateRejected",
389
391
  "UpdateValidationFailed",
390
392
  "WorkflowAlreadyStarted",
@@ -0,0 +1,103 @@
1
+ """Bounded snapshots of durable wire history, never live workflow instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections import OrderedDict
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ CacheKey = tuple[str, str, str]
12
+
13
+
14
+ def complete_history(history: list[dict[str, Any]]) -> bool:
15
+ if not history or any(not isinstance(event, dict) for event in history):
16
+ return False
17
+ types = [event.get("event_type") for event in history[:2]]
18
+ if types[0] != "WorkflowStarted" and types != ["StartAccepted", "WorkflowStarted"]:
19
+ return False
20
+ return all(type(event.get("sequence")) is int and event["sequence"] == index
21
+ for index, event in enumerate(history, 1))
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class _Entry:
26
+ encoded: bytes
27
+ expires_at: float
28
+ resume_token: str | None
29
+ resume_offset: int
30
+
31
+
32
+ class StickyWorkflowCache:
33
+ def __init__(self, capacity: int, max_bytes: int, ttl_seconds: int) -> None:
34
+ for name, value, minimum in (("capacity", capacity, 0), ("max_bytes", max_bytes, 1),
35
+ ("ttl_seconds", ttl_seconds, 1)):
36
+ if type(value) is not int or value < minimum:
37
+ raise ValueError(f"sticky_cache_{name} must be an integer of at least {minimum}")
38
+ if ttl_seconds > 3600:
39
+ raise ValueError("sticky_cache_ttl_seconds must be at most 3600")
40
+ self.capacity = capacity
41
+ self.max_bytes = max_bytes
42
+ self.ttl_seconds = ttl_seconds
43
+ self._entries: OrderedDict[CacheKey, _Entry] = OrderedDict()
44
+ self._bytes = 0
45
+ self._metrics = {"hit": 0, "miss": 0, "eviction": 0, "forced_cold_replay": 0}
46
+
47
+ @property
48
+ def enabled(self) -> bool:
49
+ return self.capacity > 0
50
+
51
+ def _expire(self) -> None:
52
+ now = time.monotonic()
53
+ for key, entry in list(self._entries.items()):
54
+ if entry.expires_at <= now:
55
+ self.discard(key)
56
+
57
+ def discard(self, key: CacheKey) -> None:
58
+ entry = self._entries.pop(key, None)
59
+ if entry is not None:
60
+ self._bytes -= len(entry.encoded)
61
+
62
+ def lookup(self, key: CacheKey) -> tuple[list[dict[str, Any]], str | None, int] | None:
63
+ self._expire()
64
+ entry = self._entries.get(key)
65
+ if entry is None:
66
+ return None
67
+ self._entries.move_to_end(key)
68
+ # Decoding isolates the retained snapshot from replay and handler mutation.
69
+ history: list[dict[str, Any]] = json.loads(entry.encoded)
70
+ return history, entry.resume_token, entry.resume_offset
71
+
72
+ def remember(self, key: CacheKey, history: list[dict[str, Any]], *,
73
+ resume_token: str | None = None, resume_offset: int = 0) -> bool:
74
+ self._expire()
75
+ self.discard(key)
76
+ if not self.enabled or not complete_history(history):
77
+ return False
78
+ try:
79
+ encoded = json.dumps(history, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
80
+ except (TypeError, ValueError):
81
+ return False
82
+ if len(encoded) > self.max_bytes:
83
+ return False
84
+ while self._entries and (len(self._entries) >= self.capacity or self._bytes + len(encoded) > self.max_bytes):
85
+ oldest = next(iter(self._entries))
86
+ self.discard(oldest)
87
+ self._metrics["eviction"] += 1
88
+ self._entries[key] = _Entry(encoded, time.monotonic() + self.ttl_seconds, resume_token, resume_offset)
89
+ self._bytes += len(encoded)
90
+ return True
91
+
92
+ def record_replay(self, *, hit: bool, forced: bool = False) -> None:
93
+ self._metrics["hit" if hit else "miss"] += 1
94
+ if forced:
95
+ self._metrics["forced_cold_replay"] += 1
96
+
97
+ def metrics(self) -> dict[str, int]:
98
+ self._expire()
99
+ return {**self._metrics, "entries": len(self._entries), "history_bytes": self._bytes}
100
+
101
+ def clear(self) -> None:
102
+ self._entries.clear()
103
+ self._bytes = 0
@@ -87,7 +87,7 @@ PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST: dict[str, dict[str, str | bool]] =
87
87
  "sticky_execution": {
88
88
  "supported": False,
89
89
  "minimum_protocol_version": "1.18",
90
- "reason": "python_worker_uses_complete_durable_history_replay",
90
+ "reason": "sticky_cache_disabled",
91
91
  },
92
92
  }
93
93
  _MESSAGE_STREAMS_CAPABILITY = "message_streams"
@@ -4349,7 +4349,9 @@ class Client:
4349
4349
  :class:`~durable_workflow.errors.UpdateRejected` when the workflow's
4350
4350
  validator rejects the update, or
4351
4351
  :class:`~durable_workflow.errors.UpdateValidationFailed` when the
4352
- declared validation boundary cannot be enforced.
4352
+ declared validation boundary cannot be enforced. An accepted update
4353
+ whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
4354
+ with the failure message, Server response and durable identities.
4353
4355
  """
4354
4356
  await self._require_update_wait_stage(wait_for or "accepted")
4355
4357
  body: dict[str, Any] = {}
@@ -5110,6 +5112,7 @@ class Client:
5110
5112
  commands: list[dict[str, Any]],
5111
5113
  message_stream_cursors: list[dict[str, Any]] | None = None,
5112
5114
  message_stream_waits: list[dict[str, Any]] | None = None,
5115
+ sticky_cache: dict[str, Any] | None = None,
5113
5116
  ) -> Any:
5114
5117
  """Report successful execution of a workflow task with its emitted commands.
5115
5118
 
@@ -5141,6 +5144,8 @@ class Client:
5141
5144
  body["message_stream_cursors"] = message_stream_cursors
5142
5145
  if message_stream_waits:
5143
5146
  body["message_stream_waits"] = message_stream_waits
5147
+ if sticky_cache is not None:
5148
+ body["sticky_cache"] = sticky_cache
5144
5149
  return await self._request(
5145
5150
  "POST", f"/worker/workflow-tasks/{task_id}/complete", worker=True, json=body
5146
5151
  )
@@ -453,6 +453,29 @@ class NonDeterministicReplayError(DurableWorkflowError):
453
453
  self.recorded_event_types = list(recorded_event_types)
454
454
 
455
455
 
456
+ class UpdateFailed(InvalidArgument):
457
+ """An accepted workflow update failed during handler execution.
458
+
459
+ ``body`` preserves the Server response, including its durable failure
460
+ identity. This inherits from :class:`InvalidArgument` to preserve catches
461
+ written for earlier SDK versions that mapped all HTTP 422 responses there.
462
+ """
463
+
464
+ def __init__(self, message: str, *, status: int, body: dict[str, Any]) -> None:
465
+ super().__init__(message)
466
+ self.status = status
467
+ self.body = body
468
+ self.workflow_id = self._identity(body, "workflow_id")
469
+ self.run_id = self._identity(body, "run_id")
470
+ self.update_id = self._identity(body, "update_id")
471
+ self.failure_id = self._identity(body, "failure_id")
472
+
473
+ @staticmethod
474
+ def _identity(body: dict[str, Any], name: str) -> str | None:
475
+ value = body.get(name)
476
+ return value if isinstance(value, str) and value.strip() else None
477
+
478
+
456
479
  class UpdateRejected(DurableWorkflowError):
457
480
  """A workflow update was rejected by the workflow's validator."""
458
481
 
@@ -859,6 +882,13 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
859
882
  raise signal_failed("signal argument validation failed")
860
883
  if reason == "invalid_query_arguments":
861
884
  raise query_failed("query argument validation failed")
885
+ if isinstance(body, dict) and body.get("update_status") == "failed":
886
+ failure_message = body.get("failure_message")
887
+ if not isinstance(failure_message, str) or not failure_message.strip():
888
+ failure_message = message
889
+ if not isinstance(failure_message, str) or not failure_message.strip():
890
+ failure_message = "workflow update failed"
891
+ raise UpdateFailed(failure_message, status=status, body=body)
862
892
  errors = None
863
893
  if isinstance(body, dict):
864
894
  errors = body.get("errors") or body.get("validation_errors")
@@ -41,6 +41,7 @@ from ._activity_process import CallbackFailure, CallbackInvocation, CallbackProc
41
41
  from ._cancellation_scope_history import CancellationScopeBudget, CancellationScopeDeliveryReceipt
42
42
  from ._cooperative_cancellation import CancellationRequest, read_cancellation_history
43
43
  from ._prepared_local_activity import PreparedAttempt, PreparedCancellationObserved, PreparedLocalRunner
44
+ from ._sticky_workflow_cache import CacheKey, StickyWorkflowCache, complete_history
44
45
  from .activity import ActivityContext, ActivityInfo, _set_context
45
46
  from .auth_composition import (
46
47
  AUTH_COMPOSITION_CONTRACT_SCHEMA,
@@ -53,6 +54,7 @@ from .client import (
53
54
  CONTROL_PLANE_REQUEST_CONTRACT_SCHEMA,
54
55
  CONTROL_PLANE_REQUEST_CONTRACT_VERSION,
55
56
  CONTROL_PLANE_VERSION,
57
+ DEFAULT_SDK_VERSION,
56
58
  PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
57
59
  PROTOCOL_VERSION,
58
60
  Client,
@@ -1008,6 +1010,9 @@ class Worker:
1008
1010
  max_concurrent_workflow_tasks: int = 10,
1009
1011
  max_concurrent_activity_tasks: int = 10,
1010
1012
  max_concurrent_worker_sessions: int = 10,
1013
+ sticky_cache_capacity: int = 0,
1014
+ sticky_cache_max_bytes: int = 16 * 1024 * 1024,
1015
+ sticky_cache_ttl_seconds: int = 300,
1011
1016
  shutdown_timeout: float = 30.0,
1012
1017
  heartbeat_interval: float = 60.0,
1013
1018
  metrics: MetricsRecorder | None = None,
@@ -1029,6 +1034,11 @@ class Worker:
1029
1034
  }
1030
1035
  self.activities = {_activity_name(a): a for a in activities}
1031
1036
  self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
1037
+ self._sticky_cache = StickyWorkflowCache(
1038
+ sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
1039
+ )
1040
+ if "sticky_execution" in self.capabilities and not self._sticky_cache.enabled:
1041
+ raise ValueError("sticky_execution requires a positive sticky_cache_capacity")
1032
1042
  self._cooperative_cancellation_supported = False
1033
1043
  self._prepared_local_activities_supported = False
1034
1044
  self._prepared_local_activity_groups_supported = False
@@ -1211,6 +1221,13 @@ class Worker:
1211
1221
  _validate_server_compatibility(info)
1212
1222
  protocol = info.get("worker_protocol")
1213
1223
  server_capabilities = protocol.get("server_capabilities") if isinstance(protocol, Mapping) else None
1224
+ sticky_support = (
1225
+ server_capabilities.get("sticky_execution") if isinstance(server_capabilities, Mapping) else None
1226
+ )
1227
+ if self._sticky_cache.enabled and (
1228
+ not isinstance(sticky_support, Mapping) or sticky_support.get("supported") is not True
1229
+ ):
1230
+ raise RuntimeError("sticky execution requires Server's advertised sticky_execution support")
1214
1231
  self._cooperative_cancellation_supported = (
1215
1232
  "cooperative_cancellation" in self.capabilities
1216
1233
  and isinstance(protocol, Mapping)
@@ -1292,6 +1309,15 @@ class Worker:
1292
1309
  capabilities.append("prepared_local_activity_cancellation_policies")
1293
1310
  if PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST["worker_sessions"]["supported"]:
1294
1311
  capabilities.append("worker_sessions")
1312
+ affinity_manifest = {name: dict(entry) for name, entry in PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST.items()}
1313
+ if self._sticky_cache.enabled:
1314
+ if "sticky_execution" not in capabilities:
1315
+ capabilities.append("sticky_execution")
1316
+ affinity_manifest["sticky_execution"] = {
1317
+ "supported": True,
1318
+ "minimum_protocol_version": "1.18",
1319
+ "implementation": "bounded_durable_history_cache",
1320
+ }
1295
1321
 
1296
1322
  ack = await self.client.register_worker(
1297
1323
  worker_id=self.worker_id,
@@ -1306,7 +1332,7 @@ class Worker:
1306
1332
  build_id=self.build_id,
1307
1333
  capabilities=capabilities,
1308
1334
  capability_manifest={
1309
- **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.2
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
@@ -30,6 +30,7 @@ from durable_workflow.errors import (
30
30
  ServerError,
31
31
  SignalFailed,
32
32
  Unauthorized,
33
+ UpdateFailed,
33
34
  UpdateRejected,
34
35
  WorkflowAlreadyStarted,
35
36
  WorkflowNotFound,
@@ -3090,6 +3091,34 @@ class TestHeartbeatActivityTask:
3090
3091
 
3091
3092
 
3092
3093
  class TestUpdateWorkflow:
3094
+ @pytest.mark.asyncio
3095
+ async def test_failed_update_retries_preserve_failure_identity(self, client: Client) -> None:
3096
+ body = {
3097
+ "update_status": "failed", "accepted": True,
3098
+ "failure_message": "inventory unavailable", "workflow_id": "wf-1",
3099
+ "run_id": "run-1", "update_id": "update-1", "failure_id": "failure-1",
3100
+ }
3101
+ with patch.object(
3102
+ client._http, "request", new_callable=AsyncMock, return_value=_mock_response(422, body),
3103
+ ) as request:
3104
+ failures = []
3105
+ for _ in range(2):
3106
+ with pytest.raises(UpdateFailed) as exc_info:
3107
+ await client.update_workflow(
3108
+ "wf-1", "reserve", wait_for="completed", request_id="request-1",
3109
+ )
3110
+ failures.append(exc_info.value)
3111
+ assert request.await_count == 2
3112
+ assert all(call.kwargs["json"]["request_id"] == "request-1"
3113
+ for call in request.await_args_list)
3114
+ for error in failures:
3115
+ assert str(error) == "inventory unavailable"
3116
+ assert error.status == 422
3117
+ assert error.body == body
3118
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
3119
+ "wf-1", "run-1", "update-1", "failure-1",
3120
+ )
3121
+
3093
3122
  @pytest.mark.asyncio
3094
3123
  async def test_unsupported_wait_stage_raises_typed_error_before_update(self) -> None:
3095
3124
  client = Client("http://localhost:8080", token="test-token")
@@ -13,6 +13,7 @@ from durable_workflow.errors import (
13
13
  ServerError,
14
14
  SignalFailed,
15
15
  Unauthorized,
16
+ UpdateFailed,
16
17
  UpdateRejected,
17
18
  UpdateValidationFailed,
18
19
  WorkflowAlreadyStarted,
@@ -102,6 +103,56 @@ class TestRaiseForStatus:
102
103
  with pytest.raises(InvalidArgument) as exc_info:
103
104
  _raise_for_status(422, {"message": "bad", "errors": {"f": ["req"]}})
104
105
  assert exc_info.value.errors == {"f": ["req"]}
106
+ assert type(exc_info.value) is InvalidArgument
107
+
108
+ def test_422_accepted_update_failure_preserves_diagnostics_and_legacy_catch(self) -> None:
109
+ body = {
110
+ "update_status": "failed", "accepted": True,
111
+ "failure_message": "inventory unavailable", "message": "generic failure",
112
+ "workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
113
+ "failure_id": "failure-1",
114
+ }
115
+ with pytest.raises(InvalidArgument) as exc_info:
116
+ _raise_for_status(422, body)
117
+ error = exc_info.value
118
+ assert isinstance(error, UpdateFailed)
119
+ assert str(error) == "inventory unavailable"
120
+ assert error.status == 422
121
+ assert error.body is body
122
+ assert error.errors is None
123
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
124
+ "wf-1", "run-1", "update-1", "failure-1",
125
+ )
126
+
127
+ @pytest.mark.parametrize("failure_message", [None, "", " ", 42])
128
+ @pytest.mark.parametrize("message", ["handler failed", None, "", " ", 42])
129
+ def test_update_failure_message_fallback(self, failure_message: object, message: object) -> None:
130
+ with pytest.raises(UpdateFailed) as exc_info:
131
+ _raise_for_status(422, {
132
+ "update_status": "failed", "failure_message": failure_message, "message": message,
133
+ })
134
+ assert str(exc_info.value) == (
135
+ message if message == "handler failed" else "workflow update failed"
136
+ )
137
+
138
+ def test_update_failure_missing_or_malformed_identities_remain_absent(self) -> None:
139
+ with pytest.raises(UpdateFailed) as exc_info:
140
+ _raise_for_status(422, {
141
+ "update_status": "failed", "workflow_id": 42, "run_id": " ", "update_id": None,
142
+ })
143
+ error = exc_info.value
144
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
145
+ None, None, None, None,
146
+ )
147
+
148
+ def test_rejected_update_arguments_are_not_handler_failure(self) -> None:
149
+ with pytest.raises(InvalidArgument) as exc_info:
150
+ _raise_for_status(422, {
151
+ "update_status": "rejected", "message": "invalid arguments",
152
+ "validation_errors": {"quantity": ["must be positive"]},
153
+ })
154
+ assert type(exc_info.value) is InvalidArgument
155
+ assert exc_info.value.errors == {"quantity": ["must be positive"]}
105
156
 
106
157
  def test_422_update_validator_rejected_is_typed(self) -> None:
107
158
  with pytest.raises(UpdateRejected) as exc_info:
@@ -109,6 +160,7 @@ class TestRaiseForStatus:
109
160
  422,
110
161
  {
111
162
  "reason": "update_validator_rejected",
163
+ "update_status": "failed",
112
164
  "message": "approval required",
113
165
  "validation_errors": {"approved": ["must be true"]},
114
166
  },
@@ -132,6 +184,7 @@ class TestRaiseForStatus:
132
184
  status,
133
185
  {
134
186
  "reason": reason,
187
+ "update_status": "failed",
135
188
  "message": "validation could not complete",
136
189
  "retryable": retryable,
137
190
  },
@@ -489,6 +489,27 @@ async def test_worker_replays_recorded_side_effects_across_history_pages() -> No
489
489
  assert serializer.decode_envelope(commands[0]["result"]) == 198
490
490
 
491
491
 
492
+ @pytest.mark.asyncio
493
+ async def test_replacement_with_sticky_suffix_fetches_cold_history_before_replay() -> None:
494
+ fixture = json.loads((FIXTURE_DIR / "sticky-cold-prefix-side-effects-avro.json").read_text(encoding="utf-8"))
495
+ history = fixture["history"]
496
+ client = AsyncMock(spec=Client)
497
+ client.workflow_task_history.return_value = {"history_events": history, "next_history_page_token": None}
498
+ worker = Worker(client, task_queue="cold-sticky", workflows=[PagedRecordedSideEffectsWorkflow])
499
+ commands = await worker._run_workflow_task_core({
500
+ "task_id": "replacement-task", "workflow_id": "cold-sticky", "run_id": "original-run",
501
+ "workflow_type": fixture["workflow"]["type"], "workflow_task_attempt": 2,
502
+ "history_events": history[-1:], "sticky_replay_mode": "sticky_hit_expected",
503
+ "arguments": serializer.encode([], codec="avro"), "payload_codec": "avro",
504
+ })
505
+ client.workflow_task_history.assert_awaited_once_with(
506
+ task_id="replacement-task", next_history_page_token="MA==", lease_owner=worker.worker_id,
507
+ workflow_task_attempt=2,
508
+ )
509
+ assert commands is not None
510
+ assert serializer.decode_envelope(commands[0]["result"]) == 198
511
+
512
+
492
513
  @pytest.mark.parametrize(
493
514
  "fixture",
494
515
  [