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 +28 -0
- clusdr/_client.py +315 -0
- clusdr/_coord.py +345 -0
- clusdr/_errors.py +5 -0
- clusdr/_options.py +50 -0
- clusdr/_retry.py +56 -0
- clusdr/_tls.py +115 -0
- clusdr/py.typed +0 -0
- clusdr/v1alpha1/__init__.py +1 -0
- clusdr/v1alpha1/events_pb2.py +41 -0
- clusdr/v1alpha1/events_pb2.pyi +31 -0
- clusdr/v1alpha1/events_pb2_grpc.py +118 -0
- clusdr/v1alpha1/health_pb2.py +41 -0
- clusdr/v1alpha1/health_pb2.pyi +21 -0
- clusdr/v1alpha1/health_pb2_grpc.py +103 -0
- clusdr/v1alpha1/leases_pb2.py +55 -0
- clusdr/v1alpha1/leases_pb2.pyi +97 -0
- clusdr/v1alpha1/leases_pb2_grpc.py +238 -0
- clusdr/v1alpha1/locks_pb2.py +55 -0
- clusdr/v1alpha1/locks_pb2.pyi +97 -0
- clusdr/v1alpha1/locks_pb2_grpc.py +286 -0
- clusdr/v1alpha1/membership_pb2.py +47 -0
- clusdr/v1alpha1/membership_pb2.pyi +43 -0
- clusdr/v1alpha1/membership_pb2_grpc.py +148 -0
- clusdr/v1alpha1/watch_pb2.py +41 -0
- clusdr/v1alpha1/watch_pb2.pyi +31 -0
- clusdr/v1alpha1/watch_pb2_grpc.py +115 -0
- clusdr-0.1.0.dist-info/METADATA +14 -0
- clusdr-0.1.0.dist-info/RECORD +31 -0
- clusdr-0.1.0.dist-info/WHEEL +5 -0
- clusdr-0.1.0.dist-info/top_level.txt +1 -0
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)
|