Skip to content

Commit edcd90f

Browse files
authored
Merge pull request #333 from durable-workflow/sdk-python/218-acknowledged-checkpoint
Expose acknowledged activity heartbeat checkpoints
2 parents f5bc91f + 97b07b0 commit edcd90f

10 files changed

Lines changed: 461 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Read-only acknowledged heartbeat checkpoint accessors distinguish unsupported claims from an explicitly empty checkpoint. Received details, contract version and acknowledgement time remain immutable across heartbeat outcomes. This source addition still requires package publication and installed replacement qualification. (durable-workflow/.github#218)
12+
913
## [2.18.0] - 2026-11-14
1014

1115
### Added

‎docs/sdk-reference.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,32 @@ supervision. The SDK cannot forcibly stop a running native thread or undo its
13901390
external effects. Keep side effects idempotent and heartbeat at useful progress
13911391
boundaries.
13921392

1393+
### Acknowledged heartbeat checkpoints (unreleased)
1394+
1395+
The source API adds `activity.context().last_heartbeat_checkpoint()`,
1396+
`last_heartbeat_details(result_type=...)`, `last_heartbeat_at()` and
1397+
`heartbeat_resume_version()`. Package publication and installed cold-replacement
1398+
qualification are pending under [shared #218](https://github.com/durable-workflow/.github/issues/218).
1399+
1400+
A participating claim must advertise `activity_heartbeat_resume` version 1 and
1401+
carry both checkpoint fields for its original execution, attempt, run and
1402+
namespace. `None` means this supported claim explicitly has no checkpoint.
1403+
Older or nonparticipating claims, inline local activities and Server-free tests
1404+
raise `ActivityHeartbeatResumeUnavailable`; they do not invent empty state.
1405+
1406+
The checkpoint preserves received details and the original RFC3339
1407+
acknowledgement timestamp. Details are returned as independent copies, with
1408+
optional decoding to the SDK's supported annotated types. Its immutable
1409+
`contract_version` is the negotiated format version, not a write counter.
1410+
Server v1 supplies no per-checkpoint sequence number.
1411+
1412+
This is the snapshot received at claim time. A successful, rejected or uncertain
1413+
heartbeat cannot change it. After a lost response, a later replacement claim
1414+
may contain the committed checkpoint even though the earlier caller could not
1415+
confirm acknowledgement. A checkpoint does not atomically commit an external
1416+
business effect. Keep stable idempotency keys, provider reconciliation and
1417+
external fencing where the effect requires them.
1418+
13931419
## Metrics
13941420

13951421
Pass a recorder to `Client(metrics=...)` or `Worker(metrics=...)` to collect request, poll, and task metrics. The SDK ships a no-op default, an `InMemoryMetrics` recorder for tests or custom exporter loops, and `PrometheusMetrics` for deployments that install the optional extra:

‎src/durable_workflow/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
__version__ = "0.0.0+unknown"
88

99
from . import activity, sync, testing, workflow
10+
from ._activity_checkpoint import ActivityHeartbeatCheckpoint
1011
from .activity import ActivityContext, ActivityInfo, ActivityRetryInfo
1112
from .auth_composition import (
1213
AUTH_COMPOSITION_CONTRACT_SCHEMA,
@@ -70,6 +71,7 @@
7071
from .errors import (
7172
ActivityCancelled,
7273
ActivityFailed,
74+
ActivityHeartbeatResumeUnavailable,
7375
ChildWorkflowCancelled,
7476
ChildWorkflowFailed,
7577
ChildWorkflowTerminated,
@@ -260,6 +262,8 @@
260262
"ActivityHandler",
261263
"ActivityInfo",
262264
"ActivityRetryInfo",
265+
"ActivityHeartbeatCheckpoint",
266+
"ActivityHeartbeatResumeUnavailable",
263267
"ActivityInterceptorContext",
264268
"ActivityRetryPolicy",
265269
"BridgeAdapterOutcome",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Immutable heartbeat-resume metadata captured from one negotiated poll."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import re
7+
from collections.abc import Mapping
8+
from dataclasses import dataclass, field
9+
from datetime import datetime
10+
from typing import Any
11+
12+
from ._typed_values import decode_payload_value, validate_annotation
13+
from .errors import ActivityHeartbeatResumeUnavailable
14+
15+
_IDENTITY = ("task_id", "activity_execution_id", "activity_attempt_id", "workflow_id", "run_id", "namespace")
16+
_TIMESTAMP = re.compile(
17+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]"
18+
r"(?:\.[0-9]{1,9})?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])"
19+
)
20+
21+
22+
def _invalid() -> ValueError:
23+
return ValueError("activity_heartbeat_resume v1 claim has an invalid checkpoint")
24+
25+
26+
@dataclass(frozen=True, init=False)
27+
class ActivityHeartbeatCheckpoint:
28+
"""The acknowledged snapshot received with this claim, never a pending write.
29+
30+
``contract_version`` describes the negotiated wire contract. Server v1
31+
supplies no checkpoint write sequence. Acknowledgement does not commit an
32+
external business effect atomically.
33+
"""
34+
35+
acknowledged_at: str
36+
contract_version: int = field(default=1, init=False)
37+
_details_json: str = field(repr=False)
38+
39+
def __init__(self, details: Any, acknowledged_at: str) -> None:
40+
if details is None or not isinstance(acknowledged_at, str) or not _TIMESTAMP.fullmatch(acknowledged_at):
41+
raise _invalid()
42+
try:
43+
# Python 3.10's parser accepts fewer fractional forms. Validate the
44+
# calendar/offset separately and retain the original precision.
45+
calendar = re.sub(r"\.\d+(?=Z|[+-])", "", acknowledged_at)
46+
datetime.fromisoformat(calendar.replace("Z", "+00:00"))
47+
encoded = json.dumps(details, allow_nan=False)
48+
except (ValueError, TypeError, OverflowError, RecursionError):
49+
raise _invalid() from None
50+
object.__setattr__(self, "acknowledged_at", acknowledged_at)
51+
object.__setattr__(self, "_details_json", encoded)
52+
53+
def details(self, *, result_type: Any = None) -> Any:
54+
"""Return an independent copy, optionally decoded using SDK typed values."""
55+
value = json.loads(self._details_json)
56+
if result_type is None:
57+
return value
58+
validate_annotation(result_type, "checkpoint")
59+
return decode_payload_value(value, result_type, "checkpoint")
60+
61+
62+
@dataclass(frozen=True)
63+
class _HeartbeatResume:
64+
available: bool = False
65+
checkpoint: ActivityHeartbeatCheckpoint | None = None
66+
identity: tuple[Any, ...] = ()
67+
68+
def get(self) -> ActivityHeartbeatCheckpoint | None:
69+
if not self.available:
70+
raise ActivityHeartbeatResumeUnavailable()
71+
return self.checkpoint
72+
73+
74+
class _ActivityTask(dict[str, Any]):
75+
"""Keep poll metadata outside the task's unchanged public wire dictionary."""
76+
77+
def __init__(self, task: Mapping[str, Any], resume: _HeartbeatResume) -> None:
78+
super().__init__(task)
79+
self.heartbeat_resume = resume
80+
81+
82+
def _identity(task: Mapping[str, Any]) -> tuple[Any, ...]:
83+
values = tuple(task.get(name) for name in _IDENTITY)
84+
attempt = task.get("attempt_number")
85+
if (any(not isinstance(value, str) or not value.strip() for value in values)
86+
or type(attempt) is not int or attempt < 1):
87+
raise _invalid()
88+
return (*values, attempt, task.get("lease_owner"), task.get("activity_type"), task.get("task_queue"))
89+
90+
91+
def capture_poll(data: Mapping[str, Any], namespace: str, worker_id: str, task_queue: str | None = None) -> Any:
92+
task = data.get("task")
93+
capabilities = data.get("server_capabilities")
94+
capability = capabilities.get("activity_heartbeat_resume") if isinstance(capabilities, Mapping) else None
95+
if (not isinstance(task, dict) or not isinstance(capability, Mapping)
96+
or capability.get("supported") is not True or type(capability.get("version")) is not int
97+
or capability["version"] != 1):
98+
return task
99+
if "last_heartbeat_details" not in task and "last_heartbeat_at" not in task:
100+
return task
101+
identity = _identity(task)
102+
if task["namespace"] != namespace or task.get("lease_owner") != worker_id:
103+
raise _invalid()
104+
if task_queue is not None and "task_queue" in task and task["task_queue"] != task_queue:
105+
raise _invalid()
106+
if "last_heartbeat_details" not in task or "last_heartbeat_at" not in task:
107+
raise _invalid()
108+
details, at = task["last_heartbeat_details"], task["last_heartbeat_at"]
109+
if details is None and at is None:
110+
checkpoint = None
111+
elif details is not None and task["attempt_number"] > 1:
112+
checkpoint = ActivityHeartbeatCheckpoint(details, at)
113+
else:
114+
raise _invalid()
115+
return _ActivityTask(task, _HeartbeatResume(True, checkpoint, identity))
116+
117+
118+
def claim_resume(
119+
task: Mapping[str, Any], namespace: str | None, task_id: str, attempt_id: str, attempt: int, worker_id: str,
120+
activity_type: str, task_queue: str,
121+
) -> _HeartbeatResume:
122+
if not isinstance(task, _ActivityTask):
123+
return _HeartbeatResume()
124+
resume = task.heartbeat_resume
125+
if (resume.identity != _identity(task) or task.get("namespace") != namespace
126+
or task.get("task_id") != task_id or task.get("activity_attempt_id") != attempt_id
127+
or type(attempt) is not int or task.get("attempt_number") != attempt
128+
or task.get("lease_owner") != worker_id or task.get("activity_type") != activity_type
129+
or "task_queue" in task and task["task_queue"] != task_queue):
130+
raise _invalid()
131+
return resume

‎src/durable_workflow/activity.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from dataclasses import InitVar, dataclass, field
99
from typing import TYPE_CHECKING, Any
1010

11+
from ._activity_checkpoint import ActivityHeartbeatCheckpoint, _HeartbeatResume, claim_resume
1112
from ._activity_lease import current_activity_authority
1213
from .errors import ActivityCancelled, ActivityLeaseAuthorityLost
1314

@@ -79,14 +80,20 @@ class ActivityInfo:
7980
task_queue: str
8081
worker_id: str
8182
task_metadata: InitVar[Mapping[str, Any] | None] = None
83+
task_namespace: InitVar[str | None] = None
8284
retry_info: ActivityRetryInfo = field(init=False)
85+
_heartbeat_resume: _HeartbeatResume = field(init=False, repr=False)
8386
workflow_id: str | None = field(init=False)
8487
run_id: str | None = field(init=False)
8588
activity_execution_id: str | None = field(init=False)
8689

87-
def __post_init__(self, task_metadata: Mapping[str, Any] | None) -> None:
90+
def __post_init__(self, task_metadata: Mapping[str, Any] | None, task_namespace: str | None) -> None:
8891
task = task_metadata if task_metadata is not None else {}
8992
object.__setattr__(self, "retry_info", ActivityRetryInfo.from_task(self.attempt_number, task))
93+
object.__setattr__(self, "_heartbeat_resume", claim_resume(
94+
task, task_namespace, self.task_id, self.activity_attempt_id, self.attempt_number, self.worker_id,
95+
self.activity_type, self.task_queue,
96+
))
9097
for name in ("workflow_id", "run_id"):
9198
value = task.get(name)
9299
object.__setattr__(self, name, value if isinstance(value, str) and value else None)
@@ -125,6 +132,29 @@ def is_cancelled(self) -> bool:
125132
"""``True`` once the server has signalled that the owning workflow cancelled this activity."""
126133
return self._cancel_requested
127134

135+
def heartbeat_resume_version(self) -> int | None:
136+
"""The received contract version, or ``None`` when unavailable."""
137+
return 1 if self._info._heartbeat_resume.available else None
138+
139+
def last_heartbeat_checkpoint(self) -> ActivityHeartbeatCheckpoint | None:
140+
"""Return the received snapshot, or ``None`` for supported empty state.
141+
142+
Raises ActivityHeartbeatResumeUnavailable for older or nonparticipating
143+
claims, inline local activities and Server-free tests. Heartbeat writes
144+
never change this snapshot, including rejected or uncertain writes.
145+
"""
146+
return self._info._heartbeat_resume.get()
147+
148+
def last_heartbeat_details(self, *, result_type: Any = None) -> Any:
149+
"""Read acknowledged details, optionally decoded to an application type."""
150+
checkpoint = self.last_heartbeat_checkpoint()
151+
return checkpoint.details(result_type=result_type) if checkpoint is not None else None
152+
153+
def last_heartbeat_at(self) -> str | None:
154+
"""The original RFC3339 acknowledgement time without losing precision."""
155+
checkpoint = self.last_heartbeat_checkpoint()
156+
return checkpoint.acknowledged_at if checkpoint is not None else None
157+
128158
def heartbeat_sync(self, details: dict[str, Any] | None = None) -> None:
129159
"""Heartbeat from a synchronous activity thread and check attempt authority.
130160

‎src/durable_workflow/client.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import httpx
4242

4343
from . import serializer
44+
from ._activity_checkpoint import capture_poll as _capture_activity_checkpoint
4445
from ._activity_lease import ActivityLeaseAuthority, current_activity_authority
4546
from ._cancellation_scope import CancellationScopeOpenReceipt, scope_identity
4647
from ._cancellation_scope_history import (
@@ -6782,7 +6783,7 @@ async def poll_activity_task(
67826783
except httpx.TimeoutException:
67836784
continue
67846785

6785-
return (data or {}).get("task")
6786+
return _capture_activity_checkpoint(data or {}, self.namespace, worker_id, task_queue)
67866787

67876788
return None
67886789

‎src/durable_workflow/errors.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class DurableWorkflowError(Exception):
2727
"""Base class for every exception raised by the SDK."""
2828

2929

30+
class ActivityHeartbeatResumeUnavailable(DurableWorkflowError):
31+
"""This claim did not supply the supported heartbeat-resume contract."""
32+
33+
def __init__(self) -> None:
34+
super().__init__("activity_heartbeat_resume v1 is unavailable for this claim")
35+
36+
3037
class ReplayClockUnavailable(DurableWorkflowError):
3138
"""The current execution has no proved business replay-time boundary."""
3239

‎src/durable_workflow/worker.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@
151151
DURABLE_SELECTION_CAPABILITY = "durable_selection"
152152
LOCAL_ACTIVITIES_CAPABILITY = "local_activities"
153153

154-
# Command capabilities implemented by every high-level Python worker. Keep this
154+
# Base capabilities implemented by every high-level Python worker. Keep this
155155
# advertisement explicit: worker capabilities are not inferred from runtime,
156156
# SDK, or protocol-version metadata.
157157
_WORKFLOW_COMMAND_CAPABILITIES = (
@@ -160,6 +160,7 @@
160160
DURABLE_SELECTION_CAPABILITY,
161161
LOCAL_ACTIVITIES_CAPABILITY,
162162
"workflow_entry_arguments_validation",
163+
"activity_heartbeat_resume",
163164
)
164165

165166
_WORKFLOW_WORK_TASK_KINDS = ("workflow", "update_validation")
@@ -3416,6 +3417,7 @@ async def _run_activity_task_body(self, task: dict[str, Any]) -> str:
34163417
task_queue=self.task_queue,
34173418
worker_id=self.worker_id,
34183419
task_metadata=task,
3420+
task_namespace=getattr(self.client, "namespace", None),
34193421
),
34203422
client=self.client,
34213423
)

0 commit comments

Comments
 (0)