cellaflow 0.1.0__py3-none-any.whl

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.
cellaflow/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from cellaflow.client import CellaflowClient
2
+ from cellaflow.decorators import workflow, step, tool
3
+ from cellaflow.idempotency import IdempotencyScope
4
+
5
+ __all__ = ["CellaflowClient", "workflow", "step", "tool", "IdempotencyScope"]
cellaflow/client.py ADDED
@@ -0,0 +1,165 @@
1
+ import grpc
2
+ from typing import Any, Dict, List, Optional, Tuple, cast
3
+
4
+ from cellaflow.v1 import service_pb2, service_pb2_grpc
5
+ from cellaflow.v1 import common_pb2, idempotency_pb2
6
+ from cellaflow.serialization import serialize, deserialize
7
+
8
+
9
+ class CellaflowClient:
10
+ """
11
+ gRPC Client for the Cellaflow Engine.
12
+ Handles communication with the engine and strictly uses
13
+ MessagePack for state payloads.
14
+ """
15
+
16
+ def __init__(self, target: str = "localhost:50051", secure: bool = False) -> None:
17
+ if secure:
18
+ self.channel = grpc.secure_channel(target, grpc.ssl_channel_credentials())
19
+ else:
20
+ self.channel = grpc.insecure_channel(target)
21
+ self.stub = service_pb2_grpc.WorkflowEngineServiceStub(self.channel)
22
+
23
+ def start_session(
24
+ self, workflow_id: str, version: str, session_id: Optional[str] = None
25
+ ) -> service_pb2.StartSessionResponse:
26
+ req = service_pb2.StartSessionRequest(
27
+ workflow_id=workflow_id,
28
+ version=version,
29
+ )
30
+ if session_id:
31
+ req.session_id = session_id
32
+
33
+ return cast(service_pb2.StartSessionResponse, self.stub.StartSession(req))
34
+
35
+ def commit_step(
36
+ self,
37
+ session_id: str,
38
+ sequence: int,
39
+ name: str,
40
+ status: "common_pb2.StepStatus.ValueType",
41
+ output_payload: Dict[str, Any],
42
+ idempotency_key: Optional[str] = None,
43
+ idempotency_fencing_token: Optional[int] = None,
44
+ ) -> service_pb2.CommitStepResponse:
45
+ # Strictly serialize dict to MessagePack
46
+ serialized_state = serialize(output_payload)
47
+
48
+ step_result = common_pb2.StepResult(
49
+ sequence=sequence,
50
+ name=name,
51
+ status=status,
52
+ output_payload=serialized_state,
53
+ )
54
+
55
+ req = service_pb2.CommitStepRequest(
56
+ session_id=session_id,
57
+ step_result=step_result,
58
+ )
59
+ if idempotency_key is not None:
60
+ req.idempotency_key = idempotency_key
61
+ if idempotency_fencing_token is None:
62
+ raise ValueError(
63
+ "idempotency_fencing_token required if idempotency_key is set"
64
+ )
65
+ req.idempotency_fencing_token = idempotency_fencing_token
66
+
67
+ return cast(service_pb2.CommitStepResponse, self.stub.CommitStep(req))
68
+
69
+ def get_graph(
70
+ self, session_id: str, limit: Optional[int] = None, cursor: Optional[str] = None
71
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
72
+ """
73
+ Returns a tuple of (list of deserialized step results, next_cursor).
74
+ Each step result is a dictionary representation of the StepResult proto,
75
+ with the output_payload fully deserialized into a Python dictionary.
76
+ """
77
+ req = service_pb2.GetGraphRequest(session_id=session_id)
78
+ if limit is not None:
79
+ req.limit = limit
80
+ if cursor is not None:
81
+ req.cursor = cursor
82
+
83
+ resp: service_pb2.GetGraphResponse = self.stub.GetGraph(req)
84
+
85
+ results = []
86
+ for step in resp.steps:
87
+ results.append(
88
+ {
89
+ "sequence": step.sequence,
90
+ "name": step.name,
91
+ "status": step.status,
92
+ "output_payload": deserialize(step.output_payload),
93
+ "idempotency_key": (
94
+ step.idempotency_key
95
+ if step.HasField("idempotency_key")
96
+ else None
97
+ ),
98
+ }
99
+ )
100
+
101
+ next_cursor = resp.next_cursor if resp.next_cursor else None
102
+ return results, next_cursor
103
+
104
+ def check_idempotency_cache(
105
+ self,
106
+ agent_id: str,
107
+ idempotency_key: str,
108
+ wait_timeout_ms: Optional[int] = None,
109
+ lease_ttl_ms: Optional[int] = None,
110
+ ) -> idempotency_pb2.CheckCacheResponse:
111
+ req = idempotency_pb2.CheckCacheRequest(
112
+ agent_id=agent_id,
113
+ idempotency_key=idempotency_key,
114
+ )
115
+ if wait_timeout_ms is not None:
116
+ req.wait_timeout_ms = wait_timeout_ms
117
+ if lease_ttl_ms is not None:
118
+ req.lease_ttl_ms = lease_ttl_ms
119
+
120
+ return cast(
121
+ idempotency_pb2.CheckCacheResponse,
122
+ self.stub.CheckIdempotencyCache(req),
123
+ )
124
+
125
+ def renew_lease(
126
+ self,
127
+ agent_id: str,
128
+ idempotency_key: str,
129
+ fencing_token: int,
130
+ extend_ms: int,
131
+ ) -> idempotency_pb2.RenewLeaseResponse:
132
+ req = idempotency_pb2.RenewLeaseRequest(
133
+ agent_id=agent_id,
134
+ idempotency_key=idempotency_key,
135
+ fencing_token=fencing_token,
136
+ extend_ms=extend_ms,
137
+ )
138
+
139
+ return cast(
140
+ idempotency_pb2.RenewLeaseResponse,
141
+ self.stub.RenewLease(req),
142
+ )
143
+
144
+ def release_lease(
145
+ self,
146
+ agent_id: str,
147
+ idempotency_key: str,
148
+ fencing_token: int,
149
+ reason: Optional[str] = None,
150
+ ) -> idempotency_pb2.ReleaseLeaseResponse:
151
+ req = idempotency_pb2.ReleaseLeaseRequest(
152
+ agent_id=agent_id,
153
+ idempotency_key=idempotency_key,
154
+ fencing_token=fencing_token,
155
+ )
156
+ if reason:
157
+ req.reason = reason
158
+
159
+ return cast(
160
+ idempotency_pb2.ReleaseLeaseResponse,
161
+ self.stub.ReleaseLease(req),
162
+ )
163
+
164
+ def close(self) -> None:
165
+ self.channel.close()
cellaflow/context.py ADDED
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict
3
+ import contextvars
4
+
5
+ from cellaflow.client import CellaflowClient
6
+
7
+
8
+ @dataclass
9
+ class WorkflowContext:
10
+ client: CellaflowClient
11
+ session_id: str
12
+ workflow_version: str
13
+ sequence: int = 0
14
+ # Map sequence number to a deserialized step payload for fast replay lookups
15
+ replayed_steps: Dict[int, Dict[str, Any]] = field(default_factory=dict)
16
+
17
+
18
+ _current_context: contextvars.ContextVar[WorkflowContext] = contextvars.ContextVar(
19
+ "workflow_context"
20
+ )
21
+
22
+
23
+ def get_context() -> WorkflowContext:
24
+ try:
25
+ return _current_context.get()
26
+ except LookupError:
27
+ raise RuntimeError(
28
+ "No active workflow context found. "
29
+ "Are you calling a @step inside a @workflow?"
30
+ )
31
+
32
+
33
+ def set_context(context: WorkflowContext) -> contextvars.Token[WorkflowContext]:
34
+ return _current_context.set(context)
35
+
36
+
37
+ def reset_context(token: contextvars.Token[WorkflowContext]) -> None:
38
+ _current_context.reset(token)
@@ -0,0 +1,304 @@
1
+ import functools
2
+ import inspect
3
+ import uuid
4
+ import asyncio
5
+ import time
6
+ import logging
7
+ from typing import Any, Callable, TypeVar, cast, Optional
8
+
9
+ from cellaflow.client import CellaflowClient
10
+ from cellaflow.context import WorkflowContext, set_context, reset_context, get_context
11
+ from cellaflow.idempotency import derive_idempotency_key, IdempotencyScope
12
+ from cellaflow.lease import LeaseHeartbeat
13
+ from cellaflow.serialization import deserialize
14
+ from cellaflow.v1.common_pb2 import STEP_STATUS_SUCCESS
15
+ from cellaflow.v1.idempotency_pb2 import (
16
+ CACHE_STATUS_HIT,
17
+ CACHE_STATUS_ACQUIRED,
18
+ CACHE_STATUS_IN_PROGRESS,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ F = TypeVar("F", bound=Callable[..., Any])
24
+
25
+
26
+ def workflow(
27
+ version: str = "1.0.0", target: str = "localhost:50051", secure: bool = False
28
+ ) -> Callable[[F], F]:
29
+ def decorator(func: F) -> F:
30
+ @functools.wraps(func)
31
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
32
+ # Pop _session_id if provided for recovery, else generate new
33
+ session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
34
+
35
+ client = CellaflowClient(target=target, secure=secure)
36
+ resp = client.start_session(
37
+ workflow_id=func.__name__, version=version, session_id=session_id
38
+ )
39
+
40
+ replayed_steps = {}
41
+ if resp.is_recovered:
42
+ steps, _ = client.get_graph(resp.session_id)
43
+ for step_info in steps:
44
+ replayed_steps[step_info["sequence"]] = step_info["output_payload"]
45
+
46
+ ctx = WorkflowContext(
47
+ client=client,
48
+ session_id=resp.session_id,
49
+ workflow_version=resp.version,
50
+ sequence=0,
51
+ replayed_steps=replayed_steps,
52
+ )
53
+ token = set_context(ctx)
54
+ try:
55
+ return await func(*args, **kwargs)
56
+ finally:
57
+ reset_context(token)
58
+
59
+ @functools.wraps(func)
60
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
61
+ session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
62
+
63
+ client = CellaflowClient(target=target, secure=secure)
64
+ resp = client.start_session(
65
+ workflow_id=func.__name__, version=version, session_id=session_id
66
+ )
67
+
68
+ replayed_steps = {}
69
+ if resp.is_recovered:
70
+ steps, _ = client.get_graph(resp.session_id)
71
+ for step_info in steps:
72
+ replayed_steps[step_info["sequence"]] = step_info["output_payload"]
73
+
74
+ ctx = WorkflowContext(
75
+ client=client,
76
+ session_id=resp.session_id,
77
+ workflow_version=resp.version,
78
+ sequence=0,
79
+ replayed_steps=replayed_steps,
80
+ )
81
+ token = set_context(ctx)
82
+ try:
83
+ return func(*args, **kwargs)
84
+ finally:
85
+ reset_context(token)
86
+
87
+ if inspect.iscoroutinefunction(func):
88
+ return cast(F, async_wrapper)
89
+ return cast(F, sync_wrapper)
90
+
91
+ return decorator
92
+
93
+
94
+ def step(
95
+ func: Optional[F] = None,
96
+ *,
97
+ idempotency_key: Optional[str] = None,
98
+ agent_id: str = "default",
99
+ tool_name: Optional[str] = None,
100
+ scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
101
+ ) -> Any:
102
+ if func is None:
103
+ return functools.partial(
104
+ step,
105
+ idempotency_key=idempotency_key,
106
+ agent_id=agent_id,
107
+ tool_name=tool_name,
108
+ scope=scope,
109
+ )
110
+
111
+ actual_tool_name = tool_name or func.__name__
112
+
113
+ @functools.wraps(func)
114
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
115
+ ctx = get_context()
116
+ ctx.sequence += 1
117
+ seq = ctx.sequence
118
+
119
+ if seq in ctx.replayed_steps:
120
+ return ctx.replayed_steps[seq].get("result")
121
+
122
+ # Derive idempotency key
123
+ ikey = idempotency_key
124
+ if not ikey:
125
+ ikey = derive_idempotency_key(
126
+ ctx.session_id,
127
+ ctx.workflow_version,
128
+ seq,
129
+ agent_id,
130
+ actual_tool_name,
131
+ scope,
132
+ *args,
133
+ **kwargs,
134
+ )
135
+
136
+ fencing_token = 0
137
+ hb: Optional[LeaseHeartbeat] = None
138
+
139
+ while True:
140
+ resp = ctx.client.check_idempotency_cache(
141
+ agent_id=agent_id,
142
+ idempotency_key=ikey,
143
+ )
144
+ if resp.status == CACHE_STATUS_HIT:
145
+ if resp.cached_result and resp.cached_result.output_payload:
146
+ deserialized = deserialize(resp.cached_result.output_payload)
147
+ return deserialized.get("result")
148
+ return None
149
+ elif resp.status == CACHE_STATUS_IN_PROGRESS:
150
+ retry_ms = resp.retry_after_ms or 1000
151
+ logger.info(
152
+ "Step %s is in progress by another worker. Sleeping %d ms...",
153
+ actual_tool_name,
154
+ retry_ms,
155
+ )
156
+ await asyncio.sleep(retry_ms / 1000.0)
157
+ elif resp.status == CACHE_STATUS_ACQUIRED:
158
+ fencing_token = resp.fencing_token or 0
159
+ interval_ms = resp.heartbeat_interval_ms or 5000
160
+ hb = LeaseHeartbeat(
161
+ client=ctx.client,
162
+ agent_id=agent_id,
163
+ idempotency_key=ikey,
164
+ fencing_token=fencing_token,
165
+ heartbeat_interval_ms=interval_ms,
166
+ )
167
+ hb.start_async()
168
+ break
169
+ else:
170
+ break
171
+
172
+ try:
173
+ result = await func(*args, **kwargs)
174
+ ctx.client.commit_step(
175
+ session_id=ctx.session_id,
176
+ sequence=seq,
177
+ name=actual_tool_name,
178
+ status=STEP_STATUS_SUCCESS,
179
+ output_payload={"result": result},
180
+ idempotency_key=ikey,
181
+ idempotency_fencing_token=fencing_token,
182
+ )
183
+ return result
184
+ except Exception as e:
185
+ if fencing_token > 0:
186
+ ctx.client.release_lease(
187
+ agent_id=agent_id,
188
+ idempotency_key=ikey,
189
+ fencing_token=fencing_token,
190
+ reason="TOOL_ERROR",
191
+ )
192
+ raise e
193
+ finally:
194
+ if hb:
195
+ await hb.stop_async()
196
+
197
+ @functools.wraps(func)
198
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
199
+ ctx = get_context()
200
+ ctx.sequence += 1
201
+ seq = ctx.sequence
202
+
203
+ if seq in ctx.replayed_steps:
204
+ return ctx.replayed_steps[seq].get("result")
205
+
206
+ # Derive idempotency key
207
+ ikey = idempotency_key
208
+ if not ikey:
209
+ ikey = derive_idempotency_key(
210
+ ctx.session_id,
211
+ ctx.workflow_version,
212
+ seq,
213
+ agent_id,
214
+ actual_tool_name,
215
+ scope,
216
+ *args,
217
+ **kwargs,
218
+ )
219
+
220
+ fencing_token = 0
221
+ hb: Optional[LeaseHeartbeat] = None
222
+
223
+ while True:
224
+ resp = ctx.client.check_idempotency_cache(
225
+ agent_id=agent_id,
226
+ idempotency_key=ikey,
227
+ )
228
+ if resp.status == CACHE_STATUS_HIT:
229
+ if resp.cached_result and resp.cached_result.output_payload:
230
+ deserialized = deserialize(resp.cached_result.output_payload)
231
+ return deserialized.get("result")
232
+ return None
233
+ elif resp.status == CACHE_STATUS_IN_PROGRESS:
234
+ retry_ms = resp.retry_after_ms or 1000
235
+ logger.info(
236
+ "Step %s is in progress by another worker. Sleeping %d ms...",
237
+ actual_tool_name,
238
+ retry_ms,
239
+ )
240
+ time.sleep(retry_ms / 1000.0)
241
+ elif resp.status == CACHE_STATUS_ACQUIRED:
242
+ fencing_token = resp.fencing_token or 0
243
+ interval_ms = resp.heartbeat_interval_ms or 5000
244
+ hb = LeaseHeartbeat(
245
+ client=ctx.client,
246
+ agent_id=agent_id,
247
+ idempotency_key=ikey,
248
+ fencing_token=fencing_token,
249
+ heartbeat_interval_ms=interval_ms,
250
+ )
251
+ hb.start_sync()
252
+ break
253
+ else:
254
+ break
255
+
256
+ try:
257
+ result = func(*args, **kwargs)
258
+ ctx.client.commit_step(
259
+ session_id=ctx.session_id,
260
+ sequence=seq,
261
+ name=actual_tool_name,
262
+ status=STEP_STATUS_SUCCESS,
263
+ output_payload={"result": result},
264
+ idempotency_key=ikey,
265
+ idempotency_fencing_token=fencing_token,
266
+ )
267
+ return result
268
+ except Exception as e:
269
+ if fencing_token > 0:
270
+ ctx.client.release_lease(
271
+ agent_id=agent_id,
272
+ idempotency_key=ikey,
273
+ fencing_token=fencing_token,
274
+ reason="TOOL_ERROR",
275
+ )
276
+ raise e
277
+ finally:
278
+ if hb:
279
+ hb.stop_sync()
280
+
281
+ if inspect.iscoroutinefunction(func):
282
+ return cast(F, async_wrapper)
283
+ return cast(F, sync_wrapper)
284
+
285
+
286
+ def tool(
287
+ func: Optional[F] = None,
288
+ *,
289
+ idempotency_key: Optional[str] = None,
290
+ agent_id: str = "default",
291
+ tool_name: Optional[str] = None,
292
+ scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
293
+ ) -> Any:
294
+ """
295
+ Decorator for tool steps that require idempotency tracking.
296
+ Functionally identical to @step for now, but semantically distinct.
297
+ """
298
+ return step(
299
+ func=func,
300
+ idempotency_key=idempotency_key,
301
+ agent_id=agent_id,
302
+ tool_name=tool_name,
303
+ scope=scope,
304
+ )
@@ -0,0 +1,61 @@
1
+ import hashlib
2
+ import rfc8785
3
+ from typing import Any
4
+
5
+
6
+ from enum import Enum
7
+
8
+
9
+ class IdempotencyScope(Enum):
10
+ SCOPE_UNSPECIFIED = 0
11
+ SCOPE_SESSION_WIDE = 1 # Shared across all agents in the session (Default)
12
+ SCOPE_AGENT_PRIVATE = 2 # Isolated to the executing agent
13
+ SCOPE_STEP_LOCAL = 3 # Isolated to the specific superstep / node
14
+
15
+
16
+ def _hash_inputs(*args: Any, **kwargs: Any) -> str:
17
+ """
18
+ Hashes the inputs using RFC 8785 Canonical JSON and SHA-256.
19
+ Returns the first 16 bytes of the hash, hex-encoded (32 characters).
20
+ """
21
+ payload = {"args": list(args), "kwargs": kwargs}
22
+
23
+ # Serialize to canonical JSON bytes according to RFC 8785
24
+ canonical_bytes = rfc8785.dumps(payload) # type: ignore[arg-type]
25
+
26
+ # Hash using SHA-256
27
+ hash_obj = hashlib.sha256(canonical_bytes)
28
+
29
+ # Return the first 16 bytes hex-encoded
30
+ return hash_obj.digest()[:16].hex()
31
+
32
+
33
+ def derive_idempotency_key(
34
+ session_id: str,
35
+ workflow_version: str,
36
+ step_sequence: int,
37
+ agent_id: str,
38
+ tool_name: str,
39
+ scope: IdempotencyScope,
40
+ *args: Any,
41
+ **kwargs: Any,
42
+ ) -> str:
43
+ """
44
+ Derives the canonical idempotency key for a step or tool execution.
45
+ Format varies based on IdempotencyScope.
46
+ """
47
+ inputs_hash = _hash_inputs(*args, **kwargs)
48
+
49
+ seq_part = "session_wide"
50
+ agent_part = "session_wide"
51
+
52
+ if scope == IdempotencyScope.SCOPE_AGENT_PRIVATE:
53
+ agent_part = agent_id
54
+ elif scope == IdempotencyScope.SCOPE_STEP_LOCAL:
55
+ seq_part = str(step_sequence)
56
+ agent_part = agent_id
57
+
58
+ return (
59
+ f"{session_id}:{workflow_version}:{seq_part}:"
60
+ f"{agent_part}:{tool_name}:{inputs_hash}"
61
+ )
cellaflow/lease.py ADDED
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import threading
3
+ import logging
4
+ from typing import Optional, Any
5
+ from cellaflow.client import CellaflowClient
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class LeaseHeartbeat:
11
+ """
12
+ Manages the background heartbeat (RenewLease) for an actively running step.
13
+ Supports both sync (daemon thread) and async (asyncio task) environments.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ client: CellaflowClient,
19
+ agent_id: str,
20
+ idempotency_key: str,
21
+ fencing_token: int,
22
+ heartbeat_interval_ms: int,
23
+ ) -> None:
24
+ self.client = client
25
+ self.agent_id = agent_id
26
+ self.idempotency_key = idempotency_key
27
+ self.fencing_token = fencing_token
28
+ # Renew slightly before expiration
29
+ self.interval_sec = max(0.1, (heartbeat_interval_ms - 100) / 1000.0)
30
+ self.extend_ms = (
31
+ heartbeat_interval_ms * 4
32
+ ) # Usually TTL is 4x heartbeat interval
33
+
34
+ # State tracking
35
+ self._stop_event_sync = threading.Event()
36
+ self._sync_thread: Optional[threading.Thread] = None
37
+
38
+ self._stop_event_async: Optional[asyncio.Event] = None
39
+ self._async_task: Optional[asyncio.Task[Any]] = None
40
+
41
+ def start_sync(self) -> None:
42
+ """Starts a daemon thread for synchronous execution."""
43
+ self._stop_event_sync.clear()
44
+ self._sync_thread = threading.Thread(
45
+ target=self._sync_loop, daemon=True, name=f"lease-hb-{self.fencing_token}"
46
+ )
47
+ self._sync_thread.start()
48
+
49
+ def stop_sync(self) -> None:
50
+ """Stops the daemon thread cleanly."""
51
+ self._stop_event_sync.set()
52
+ if self._sync_thread and self._sync_thread.is_alive():
53
+ self._sync_thread.join(timeout=1.0)
54
+
55
+ def _sync_loop(self) -> None:
56
+ while not self._stop_event_sync.wait(timeout=self.interval_sec):
57
+ try:
58
+ resp = self.client.renew_lease(
59
+ agent_id=self.agent_id,
60
+ idempotency_key=self.idempotency_key,
61
+ fencing_token=self.fencing_token,
62
+ extend_ms=self.extend_ms,
63
+ )
64
+ if not resp.renewed:
65
+ logger.warning(
66
+ f"Lease {self.idempotency_key} failed to renew: "
67
+ f"{resp.failure_reason}"
68
+ )
69
+ break
70
+ except Exception as e:
71
+ logger.error(f"Lease renewal error: {e}")
72
+
73
+ def start_async(self) -> None:
74
+ """Starts an asyncio task for asynchronous execution."""
75
+ self._stop_event_async = asyncio.Event()
76
+ loop = asyncio.get_running_loop()
77
+ self._async_task = loop.create_task(self._async_loop())
78
+
79
+ async def stop_async(self) -> None:
80
+ """Stops the asyncio task cleanly."""
81
+ if self._stop_event_async:
82
+ self._stop_event_async.set()
83
+ if self._async_task and not self._async_task.done():
84
+ self._async_task.cancel()
85
+ try:
86
+ await self._async_task
87
+ except asyncio.CancelledError:
88
+ pass
89
+
90
+ async def _async_loop(self) -> None:
91
+ if not self._stop_event_async:
92
+ return
93
+
94
+ # We use asyncio.wait_for to wait for the stop event, with a timeout
95
+ while True:
96
+ try:
97
+ await asyncio.wait_for(
98
+ self._stop_event_async.wait(), timeout=self.interval_sec
99
+ )
100
+ # If we get here, the stop event was set
101
+ break
102
+ except asyncio.TimeoutError:
103
+ # Timeout means interval elapsed, do heartbeat
104
+ try:
105
+ resp = self.client.renew_lease(
106
+ agent_id=self.agent_id,
107
+ idempotency_key=self.idempotency_key,
108
+ fencing_token=self.fencing_token,
109
+ extend_ms=self.extend_ms,
110
+ )
111
+ if not resp.renewed:
112
+ logger.warning(
113
+ f"Lease {self.idempotency_key} failed to renew: "
114
+ f"{resp.failure_reason}"
115
+ )
116
+ break
117
+ except Exception as e:
118
+ logger.error(f"Lease renewal error: {e}")
119
+ except asyncio.CancelledError:
120
+ break