clusdr 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.
clusdr/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Application SDK for the local Clusdr daemon.
2
+
3
+ Applications talk only to the daemon on the same host (Docker-style).
4
+ The daemon is the cluster member; this package does not dial other nodes.
5
+
6
+ from clusdr import local
7
+
8
+ cluster = local()
9
+ members = cluster.members()
10
+ cluster.publish("deployment", {"sha": "abc"})
11
+ for event in cluster.watch():
12
+ ...
13
+ """
14
+
15
+ from clusdr._client import Cluster, Event, Member, dial, local
16
+ from clusdr._coord import Lease, Lock
17
+ from clusdr._errors import ClusdrError
18
+
19
+ __all__ = [
20
+ "Cluster",
21
+ "ClusdrError",
22
+ "Event",
23
+ "Lease",
24
+ "Lock",
25
+ "Member",
26
+ "dial",
27
+ "local",
28
+ ]
clusdr/_client.py ADDED
@@ -0,0 +1,315 @@
1
+ """gRPC client for the local daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ import grpc
15
+
16
+ from clusdr._coord import CoordMixin, Lease, Lock
17
+ from clusdr._errors import ClusdrError
18
+ from clusdr._options import (
19
+ DEFAULT_READY_TIMEOUT,
20
+ DEFAULT_REQUEST_TIMEOUT,
21
+ MAX_PAYLOAD,
22
+ Options,
23
+ env_addr,
24
+ env_insecure,
25
+ )
26
+ from clusdr._retry import retry, transient
27
+ from clusdr._tls import open_channel
28
+ from clusdr.v1alpha1 import (
29
+ events_pb2,
30
+ events_pb2_grpc,
31
+ health_pb2,
32
+ health_pb2_grpc,
33
+ leases_pb2_grpc,
34
+ locks_pb2_grpc,
35
+ membership_pb2,
36
+ membership_pb2_grpc,
37
+ watch_pb2,
38
+ watch_pb2_grpc,
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Member:
44
+ """A cluster node as seen by the local daemon."""
45
+
46
+ id: str
47
+ address: str
48
+ status: str
49
+ leader: bool
50
+ role: str = "voter"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Event:
55
+ """A cluster or custom event from the Watch stream."""
56
+
57
+ type: str
58
+ source: str
59
+ payload: bytes
60
+ timestamp: datetime
61
+ seq: int
62
+
63
+
64
+ class Cluster(CoordMixin):
65
+ """Application view of the local Clusdr daemon."""
66
+
67
+ def __init__(self, channel: grpc.Channel, opts: Options) -> None:
68
+ self._channel = channel
69
+ self._opts = opts
70
+ self._closed = threading.Event()
71
+ self._watch_lock = threading.Lock()
72
+ self._watch_call: Any = None
73
+ self._mem = membership_pb2_grpc.MembershipServiceStub(channel)
74
+ self._watch = watch_pb2_grpc.WatchServiceStub(channel)
75
+ self._ev = events_pb2_grpc.EventServiceStub(channel)
76
+ self._health = health_pb2_grpc.HealthServiceStub(channel)
77
+ self._lock = locks_pb2_grpc.LockServiceStub(channel)
78
+ self._lease = leases_pb2_grpc.LeaseServiceStub(channel)
79
+ self._holder = opts.holder or f"sdk-{uuid.uuid4()}"
80
+ self._coord = threading.Lock()
81
+ self._held: dict[str, Lock] = {}
82
+ self._leased: dict[str, Lease] = {}
83
+
84
+ def members(self, timeout: float | None = None) -> list[Member]:
85
+ deadline = self._deadline(timeout)
86
+
87
+ def call() -> membership_pb2.ListMembersResponse:
88
+ return self._mem.ListMembers(
89
+ membership_pb2.ListMembersRequest(),
90
+ timeout=self._remaining(deadline),
91
+ )
92
+
93
+ try:
94
+ resp = retry(call, deadline=deadline)
95
+ except (grpc.RpcError, TimeoutError) as exc:
96
+ raise ClusdrError(f"clusdr: members: {exc}") from exc
97
+ return [
98
+ Member(
99
+ id=m.id,
100
+ address=m.address,
101
+ status=m.status,
102
+ leader=m.leader,
103
+ role=m.role or "voter",
104
+ )
105
+ for m in resp.members
106
+ ]
107
+
108
+ def leader(self, timeout: float | None = None) -> Member:
109
+ deadline = self._deadline(timeout)
110
+
111
+ def call() -> membership_pb2.GetLeaderResponse:
112
+ return self._mem.GetLeader(
113
+ membership_pb2.GetLeaderRequest(),
114
+ timeout=self._remaining(deadline),
115
+ )
116
+
117
+ try:
118
+ resp = retry(call, deadline=deadline)
119
+ except (grpc.RpcError, TimeoutError) as exc:
120
+ raise ClusdrError(f"clusdr: leader: {exc}") from exc
121
+ return Member(
122
+ id=resp.leader_id,
123
+ address=resp.address,
124
+ status="alive",
125
+ leader=True,
126
+ role="voter",
127
+ )
128
+
129
+ def publish(
130
+ self,
131
+ topic: str,
132
+ payload: bytes | str | dict[str, Any] | list[Any] | None = None,
133
+ timeout: float | None = None,
134
+ ) -> None:
135
+ body = encode_payload(payload)
136
+ if len(body) > MAX_PAYLOAD:
137
+ raise ClusdrError(f"clusdr: publish payload exceeds {MAX_PAYLOAD} bytes")
138
+ deadline = self._deadline(timeout)
139
+
140
+ def call() -> events_pb2.PublishEventResponse:
141
+ return self._ev.PublishEvent(
142
+ events_pb2.PublishEventRequest(topic=topic, payload=body),
143
+ timeout=self._remaining(deadline),
144
+ )
145
+
146
+ try:
147
+ resp = retry(call, deadline=deadline)
148
+ except (grpc.RpcError, TimeoutError) as exc:
149
+ raise ClusdrError(f"clusdr: publish: {exc}") from exc
150
+ if resp is not None and not resp.accepted:
151
+ raise ClusdrError(f"clusdr: publish rejected: {resp.message}")
152
+
153
+ def watch(self) -> Iterator[Event]:
154
+ last_seq = 0
155
+ backoff = 0.05
156
+ while not self._closed.is_set():
157
+ try:
158
+ call = self._watch.Watch(watch_pb2.WatchRequest(last_seq=last_seq))
159
+ except grpc.RpcError as exc:
160
+ if self._closed.is_set() or exc.code() == grpc.StatusCode.CANCELLED:
161
+ return
162
+ if not self._sleep(backoff):
163
+ return
164
+ backoff = min(backoff * 2, 2.0)
165
+ continue
166
+ with self._watch_lock:
167
+ self._watch_call = call
168
+ backoff = 0.05
169
+ try:
170
+ for resp in call:
171
+ if self._closed.is_set():
172
+ return
173
+ if resp.seq > last_seq:
174
+ last_seq = resp.seq
175
+ yield Event(
176
+ type=resp.type,
177
+ source=resp.source,
178
+ payload=bytes(resp.payload),
179
+ timestamp=_ts(resp.timestamp_unix_ms),
180
+ seq=resp.seq,
181
+ )
182
+ except grpc.RpcError as exc:
183
+ if self._closed.is_set() or exc.code() == grpc.StatusCode.CANCELLED:
184
+ return
185
+ finally:
186
+ with self._watch_lock:
187
+ if self._watch_call is call:
188
+ self._watch_call = None
189
+ if not self._sleep(backoff):
190
+ return
191
+ backoff = min(backoff * 2, 2.0)
192
+
193
+ def close(self) -> None:
194
+ self._closed.set()
195
+ self._release_grants()
196
+ with self._watch_lock:
197
+ if self._watch_call is not None:
198
+ self._watch_call.cancel()
199
+ self._watch_call = None
200
+ self._channel.close()
201
+
202
+ def __enter__(self) -> Cluster:
203
+ return self
204
+
205
+ def __exit__(self, *exc: object) -> None:
206
+ self.close()
207
+
208
+ def _deadline(self, timeout: float | None) -> float:
209
+ if timeout is None:
210
+ timeout = self._opts.request_timeout
211
+ return time.monotonic() + timeout
212
+
213
+ def _remaining(self, deadline: float) -> float:
214
+ left = deadline - time.monotonic()
215
+ if left <= 0:
216
+ raise TimeoutError("clusdr: request timeout")
217
+ return left
218
+
219
+ def _sleep(self, seconds: float) -> bool:
220
+ return not self._closed.wait(seconds)
221
+
222
+
223
+ def local(
224
+ *,
225
+ insecure: bool = False,
226
+ data_dir: str = "",
227
+ holder: str = "",
228
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
229
+ ready_timeout: float = DEFAULT_READY_TIMEOUT,
230
+ server_name: str = "",
231
+ ) -> Cluster:
232
+ """Connect to the daemon on this host (CLUSDR_GRPC_ADDR or 127.0.0.1:7947)."""
233
+ return connect(
234
+ Options(
235
+ addr=env_addr(),
236
+ insecure=insecure,
237
+ data_dir=data_dir,
238
+ holder=holder.strip(),
239
+ request_timeout=request_timeout,
240
+ ready_timeout=ready_timeout,
241
+ server_name=server_name,
242
+ )
243
+ )
244
+
245
+
246
+ def dial(
247
+ addr: str,
248
+ *,
249
+ insecure: bool = False,
250
+ data_dir: str = "",
251
+ holder: str = "",
252
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
253
+ ready_timeout: float = DEFAULT_READY_TIMEOUT,
254
+ server_name: str = "",
255
+ ) -> Cluster:
256
+ """Connect to addr. Tests and operators use this; applications use local()."""
257
+ if not addr.strip():
258
+ raise ClusdrError("clusdr: empty dial address")
259
+ return connect(
260
+ Options(
261
+ addr=addr.strip(),
262
+ insecure=insecure,
263
+ data_dir=data_dir,
264
+ holder=holder.strip(),
265
+ request_timeout=request_timeout,
266
+ ready_timeout=ready_timeout,
267
+ server_name=server_name,
268
+ )
269
+ )
270
+
271
+
272
+ def connect(opts: Options) -> Cluster:
273
+ if not opts.insecure and env_insecure() and not opts.data_dir:
274
+ opts.insecure = True
275
+ if not opts.addr:
276
+ raise ClusdrError("clusdr: empty dial address")
277
+ channel = open_channel(opts)
278
+ cluster = Cluster(channel, opts)
279
+ if opts.ready_timeout > 0:
280
+ deadline = time.monotonic() + opts.ready_timeout
281
+ try:
282
+ retry(
283
+ lambda: cluster._health.Health(
284
+ health_pb2.HealthRequest(),
285
+ timeout=min(0.5, max(0.05, deadline - time.monotonic())),
286
+ ),
287
+ deadline=deadline,
288
+ is_transient=_ready_transient,
289
+ )
290
+ except (grpc.RpcError, TimeoutError) as exc:
291
+ cluster.close()
292
+ raise ClusdrError(f"clusdr: daemon not ready at {opts.addr}: {exc}") from exc
293
+ return cluster
294
+
295
+
296
+ def _ready_transient(exc: BaseException) -> bool:
297
+ if transient(exc):
298
+ return True
299
+ return isinstance(exc, grpc.RpcError) and exc.code() == grpc.StatusCode.DEADLINE_EXCEEDED
300
+
301
+
302
+ def encode_payload(payload: bytes | str | dict[str, Any] | list[Any] | None) -> bytes:
303
+ if payload is None:
304
+ return b""
305
+ if isinstance(payload, bytes):
306
+ return payload
307
+ if isinstance(payload, str):
308
+ return payload.encode("utf-8")
309
+ if isinstance(payload, (dict, list)):
310
+ return json.dumps(payload, separators=(",", ":")).encode("utf-8")
311
+ raise TypeError(f"clusdr: payload must be bytes, str, dict, or list; got {type(payload).__name__}")
312
+
313
+
314
+ def _ts(unix_ms: int) -> datetime:
315
+ return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
clusdr/_coord.py ADDED
@@ -0,0 +1,345 @@
1
+ """Lock and lease methods on Cluster. Same surface as the Go SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import TYPE_CHECKING
9
+
10
+ import grpc
11
+
12
+ from clusdr._errors import ClusdrError
13
+ from clusdr._retry import retry
14
+ from clusdr.v1alpha1 import leases_pb2, locks_pb2
15
+
16
+ if TYPE_CHECKING:
17
+ from clusdr._client import Cluster
18
+
19
+
20
+ class Lock:
21
+ """A held exclusive lock. token is the fencing token."""
22
+
23
+ def __init__(self, name: str, holder: str, token: int, deadline: datetime | None) -> None:
24
+ self.name = name
25
+ self.holder = holder
26
+ self.token = token
27
+ self._deadline = deadline
28
+ self._mu = threading.Lock()
29
+ self._stop = threading.Event()
30
+ self._cluster: Cluster | None = None
31
+
32
+ @property
33
+ def deadline(self) -> datetime | None:
34
+ with self._mu:
35
+ return self._deadline
36
+
37
+ def _set_deadline(self, value: datetime | None) -> None:
38
+ with self._mu:
39
+ self._deadline = value
40
+
41
+
42
+ class Lease:
43
+ """A held TTL grant. token is the fencing token."""
44
+
45
+ def __init__(self, name: str, owner: str, token: int, deadline: datetime | None) -> None:
46
+ self.name = name
47
+ self.owner = owner
48
+ self.token = token
49
+ self._deadline = deadline
50
+ self._mu = threading.Lock()
51
+ self._stop = threading.Event()
52
+ self._cluster: Cluster | None = None
53
+
54
+ @property
55
+ def deadline(self) -> datetime | None:
56
+ with self._mu:
57
+ return self._deadline
58
+
59
+ def _set_deadline(self, value: datetime | None) -> None:
60
+ with self._mu:
61
+ self._deadline = value
62
+
63
+ def stop_renew(self) -> None:
64
+ """Stop background renewal; the grant then expires at its deadline."""
65
+ self._stop.set()
66
+
67
+
68
+ class CoordMixin:
69
+ def lock(self: Cluster, name: str, ttl: float | None = None, timeout: float | None = None) -> Lock:
70
+ existing = self._held_lock(name)
71
+ if existing is not None:
72
+ return existing
73
+ deadline = self._deadline(timeout)
74
+
75
+ def call() -> locks_pb2.LockResponse:
76
+ return self._lock.Lock(
77
+ locks_pb2.LockRequest(name=name, holder=self._holder, ttl_ms=_ttl_ms(ttl)),
78
+ timeout=self._remaining(deadline),
79
+ )
80
+
81
+ try:
82
+ resp = retry(call, deadline=deadline)
83
+ except (grpc.RpcError, TimeoutError) as exc:
84
+ raise ClusdrError(f"clusdr: lock {name!r}: {exc}") from exc
85
+ if resp is None or not resp.acquired:
86
+ msg = resp.message if resp is not None and resp.message else "not acquired"
87
+ raise ClusdrError(f"clusdr: lock {name!r}: {msg}")
88
+ return self._adopt_lock(resp, name, ttl)
89
+
90
+ def try_lock(self: Cluster, name: str, ttl: float | None = None, timeout: float | None = None) -> Lock | None:
91
+ existing = self._held_lock(name)
92
+ if existing is not None:
93
+ return existing
94
+ deadline = self._deadline(timeout)
95
+
96
+ def call() -> locks_pb2.LockResponse:
97
+ return self._lock.TryLock(
98
+ locks_pb2.LockRequest(name=name, holder=self._holder, ttl_ms=_ttl_ms(ttl)),
99
+ timeout=self._remaining(deadline),
100
+ )
101
+
102
+ try:
103
+ resp = retry(call, deadline=deadline)
104
+ except (grpc.RpcError, TimeoutError) as exc:
105
+ raise ClusdrError(f"clusdr: trylock {name!r}: {exc}") from exc
106
+ if resp is None or not resp.acquired:
107
+ return None
108
+ return self._adopt_lock(resp, name, ttl)
109
+
110
+ def unlock(self: Cluster, name: str, timeout: float | None = None) -> None:
111
+ lk = self._held_lock(name)
112
+ if lk is None:
113
+ raise ClusdrError(f"clusdr: lock {name!r} is not held by this client")
114
+ self._release_lock(lk, timeout)
115
+
116
+ def lease(
117
+ self: Cluster,
118
+ name: str,
119
+ ttl: float | None = None,
120
+ *,
121
+ stop: threading.Event | None = None,
122
+ timeout: float | None = None,
123
+ ) -> Lease:
124
+ existing = self._held_lease(name)
125
+ if existing is not None:
126
+ return existing
127
+ deadline = self._deadline(timeout)
128
+
129
+ def call() -> leases_pb2.GrantLeaseResponse:
130
+ return self._lease.Grant(
131
+ leases_pb2.GrantLeaseRequest(name=name, owner=self._holder, ttl_ms=_ttl_ms(ttl)),
132
+ timeout=self._remaining(deadline),
133
+ )
134
+
135
+ try:
136
+ resp = retry(call, deadline=deadline)
137
+ except (grpc.RpcError, TimeoutError) as exc:
138
+ raise ClusdrError(f"clusdr: lease {name!r}: {exc}") from exc
139
+ if resp is None or not resp.granted:
140
+ msg = resp.message if resp is not None and resp.message else "not granted"
141
+ owner = resp.owner if resp is not None else ""
142
+ if owner:
143
+ raise ClusdrError(f"clusdr: lease {name!r}: {msg} (owner {owner})")
144
+ raise ClusdrError(f"clusdr: lease {name!r}: {msg}")
145
+ return self._adopt_lease(resp, name, ttl, stop)
146
+
147
+ def renew(self: Cluster, name: str, timeout: float | None = None) -> None:
148
+ ls = self._held_lease(name)
149
+ if ls is None:
150
+ raise ClusdrError(f"clusdr: lease {name!r} is not held by this client")
151
+ deadline = self._deadline(timeout)
152
+
153
+ def call() -> leases_pb2.RenewLeaseResponse:
154
+ return self._lease.Renew(
155
+ leases_pb2.RenewLeaseRequest(name=ls.name, owner=ls.owner, fencing_token=ls.token),
156
+ timeout=self._remaining(deadline),
157
+ )
158
+
159
+ try:
160
+ resp = retry(call, deadline=deadline)
161
+ except (grpc.RpcError, TimeoutError) as exc:
162
+ raise ClusdrError(f"clusdr: renew {name!r}: {exc}") from exc
163
+ if resp is not None and not resp.renewed:
164
+ raise ClusdrError(f"clusdr: renew {name!r}: {resp.message}")
165
+ if resp is not None and resp.deadline_unix_ms:
166
+ ls._set_deadline(_from_ms(resp.deadline_unix_ms))
167
+
168
+ def revoke(self: Cluster, name: str, timeout: float | None = None) -> None:
169
+ ls = self._held_lease(name)
170
+ if ls is None:
171
+ raise ClusdrError(f"clusdr: lease {name!r} is not held by this client")
172
+ self._drop_lease(ls, timeout)
173
+
174
+ def _held_lock(self: Cluster, name: str) -> Lock | None:
175
+ with self._coord:
176
+ return self._held.get(name)
177
+
178
+ def _held_lease(self: Cluster, name: str) -> Lease | None:
179
+ with self._coord:
180
+ return self._leased.get(name)
181
+
182
+ def _adopt_lock(self: Cluster, resp: locks_pb2.LockResponse, name: str, ttl: float | None) -> Lock:
183
+ lk = Lock(name, resp.holder or self._holder, resp.fencing_token, _from_ms(resp.deadline_unix_ms))
184
+ lk._cluster = self
185
+ with self._coord:
186
+ existing = self._held.get(name)
187
+ if existing is not None and existing.token == lk.token:
188
+ return existing
189
+ self._held[name] = lk
190
+ self._start_lock_renew(lk, ttl)
191
+ return lk
192
+
193
+ def _adopt_lease(
194
+ self: Cluster,
195
+ resp: leases_pb2.GrantLeaseResponse,
196
+ name: str,
197
+ ttl: float | None,
198
+ stop: threading.Event | None,
199
+ ) -> Lease:
200
+ ls = Lease(name, resp.owner or self._holder, resp.fencing_token, _from_ms(resp.deadline_unix_ms))
201
+ ls._cluster = self
202
+ if stop is not None:
203
+ ls._stop = stop
204
+ with self._coord:
205
+ existing = self._leased.get(name)
206
+ if existing is not None and existing.token == ls.token:
207
+ return existing
208
+ self._leased[name] = ls
209
+ self._start_lease_renew(ls, ttl)
210
+ return ls
211
+
212
+ def _release_lock(self: Cluster, lk: Lock, timeout: float | None) -> None:
213
+ lk._stop.set()
214
+ deadline = self._deadline(timeout)
215
+
216
+ def call() -> locks_pb2.UnlockResponse:
217
+ return self._lock.Unlock(
218
+ locks_pb2.UnlockRequest(name=lk.name, holder=lk.holder, fencing_token=lk.token),
219
+ timeout=self._remaining(deadline),
220
+ )
221
+
222
+ try:
223
+ resp = retry(call, deadline=deadline)
224
+ except grpc.RpcError as exc:
225
+ if exc.code() == grpc.StatusCode.FAILED_PRECONDITION:
226
+ self._forget_lock(lk.name, lk.token)
227
+ raise ClusdrError(f"clusdr: unlock {lk.name!r}: {exc}") from exc
228
+ except TimeoutError as exc:
229
+ raise ClusdrError(f"clusdr: unlock {lk.name!r}: {exc}") from exc
230
+ if resp is not None and not resp.released:
231
+ raise ClusdrError(f"clusdr: unlock {lk.name!r}: {resp.message}")
232
+ self._forget_lock(lk.name, lk.token)
233
+
234
+ def _drop_lease(self: Cluster, ls: Lease, timeout: float | None) -> None:
235
+ ls._stop.set()
236
+ deadline = self._deadline(timeout)
237
+
238
+ def call() -> leases_pb2.RevokeLeaseResponse:
239
+ return self._lease.Revoke(
240
+ leases_pb2.RevokeLeaseRequest(name=ls.name, owner=ls.owner, fencing_token=ls.token),
241
+ timeout=self._remaining(deadline),
242
+ )
243
+
244
+ try:
245
+ resp = retry(call, deadline=deadline)
246
+ except grpc.RpcError as exc:
247
+ if exc.code() == grpc.StatusCode.FAILED_PRECONDITION:
248
+ self._forget_lease(ls.name, ls.token)
249
+ raise ClusdrError(f"clusdr: revoke {ls.name!r}: {exc}") from exc
250
+ except TimeoutError as exc:
251
+ raise ClusdrError(f"clusdr: revoke {ls.name!r}: {exc}") from exc
252
+ if resp is not None and not resp.revoked:
253
+ raise ClusdrError(f"clusdr: revoke {ls.name!r}: {resp.message}")
254
+ self._forget_lease(ls.name, ls.token)
255
+
256
+ def _forget_lock(self: Cluster, name: str, token: int) -> None:
257
+ with self._coord:
258
+ cur = self._held.get(name)
259
+ if cur is not None and cur.token == token:
260
+ del self._held[name]
261
+
262
+ def _forget_lease(self: Cluster, name: str, token: int) -> None:
263
+ with self._coord:
264
+ cur = self._leased.get(name)
265
+ if cur is not None and cur.token == token:
266
+ del self._leased[name]
267
+
268
+ def _release_grants(self: Cluster) -> None:
269
+ with self._coord:
270
+ locks = list(self._held.values())
271
+ leases = list(self._leased.values())
272
+ for lk in locks:
273
+ try:
274
+ self._release_lock(lk, None)
275
+ except ClusdrError:
276
+ pass
277
+ for ls in leases:
278
+ try:
279
+ self._drop_lease(ls, None)
280
+ except ClusdrError:
281
+ pass
282
+
283
+ def _start_lock_renew(self: Cluster, lk: Lock, ttl: float | None) -> None:
284
+ interval = _renew_interval(ttl, lk.deadline)
285
+ thread = threading.Thread(target=self._renew_lock_loop, args=(lk, interval), daemon=True)
286
+ thread.start()
287
+
288
+ def _start_lease_renew(self: Cluster, ls: Lease, ttl: float | None) -> None:
289
+ interval = _renew_interval(ttl, ls.deadline)
290
+ thread = threading.Thread(target=self._renew_lease_loop, args=(ls, interval), daemon=True)
291
+ thread.start()
292
+
293
+ def _renew_lock_loop(self: Cluster, lk: Lock, interval: float) -> None:
294
+ while not lk._stop.wait(interval):
295
+ if self._closed.is_set():
296
+ return
297
+ try:
298
+ resp = self._lock.Renew(
299
+ locks_pb2.RenewLockRequest(name=lk.name, holder=lk.holder, fencing_token=lk.token),
300
+ timeout=self._opts.request_timeout,
301
+ )
302
+ except grpc.RpcError as exc:
303
+ if exc.code() in (grpc.StatusCode.FAILED_PRECONDITION, grpc.StatusCode.CANCELLED):
304
+ return
305
+ continue
306
+ if resp is not None and resp.deadline_unix_ms:
307
+ lk._set_deadline(_from_ms(resp.deadline_unix_ms))
308
+
309
+ def _renew_lease_loop(self: Cluster, ls: Lease, interval: float) -> None:
310
+ while not ls._stop.wait(interval):
311
+ if self._closed.is_set():
312
+ return
313
+ try:
314
+ resp = self._lease.Renew(
315
+ leases_pb2.RenewLeaseRequest(name=ls.name, owner=ls.owner, fencing_token=ls.token),
316
+ timeout=self._opts.request_timeout,
317
+ )
318
+ except grpc.RpcError as exc:
319
+ if exc.code() in (grpc.StatusCode.FAILED_PRECONDITION, grpc.StatusCode.CANCELLED):
320
+ return
321
+ continue
322
+ if resp is not None and resp.deadline_unix_ms:
323
+ ls._set_deadline(_from_ms(resp.deadline_unix_ms))
324
+
325
+
326
+ def _ttl_ms(ttl: float | None) -> int:
327
+ if ttl is None or ttl <= 0:
328
+ return 0
329
+ return max(1, int(ttl * 1000))
330
+
331
+
332
+ def _from_ms(unix_ms: int) -> datetime | None:
333
+ if not unix_ms:
334
+ return None
335
+ return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
336
+
337
+
338
+ def _renew_interval(ttl: float | None, deadline: datetime | None) -> float:
339
+ d = ttl
340
+ if d is None or d <= 0:
341
+ if deadline is not None:
342
+ d = (deadline - datetime.now(timezone.utc)).total_seconds()
343
+ if d is None or d <= 0:
344
+ d = 15.0
345
+ return max(0.05, d / 3.0)
clusdr/_errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """SDK errors."""
2
+
3
+
4
+ class ClusdrError(Exception):
5
+ """A Clusdr daemon or SDK failure."""