python-etcd3-async 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.
- etcd3/__init__.py +90 -0
- etcd3/async_client.py +1495 -0
- etcd3/async_client_extensions.py +878 -0
- etcd3/async_endpoint.py +140 -0
- etcd3/async_managers.py +498 -0
- etcd3/async_monitor.py +16 -0
- etcd3/async_watch.py +462 -0
- etcd3/client.py +2021 -0
- etcd3/endpoint.py +79 -0
- etcd3/events.py +40 -0
- etcd3/exceptions.py +47 -0
- etcd3/exporters/__init__.py +18 -0
- etcd3/exporters/prometheus.py +365 -0
- etcd3/leases.py +106 -0
- etcd3/locks.py +593 -0
- etcd3/log.py +146 -0
- etcd3/log_formatters.py +228 -0
- etcd3/log_sampling.py +221 -0
- etcd3/members.py +44 -0
- etcd3/observability.py +431 -0
- etcd3/shared.py +486 -0
- etcd3/trace.py +231 -0
- etcd3/trace_attributes.py +434 -0
- etcd3/transactions.py +280 -0
- etcd3/utils.py +158 -0
- etcd3/watch.py +270 -0
- etcdrpc/__init__.py +16 -0
- etcdrpc/auth.proto +49 -0
- etcdrpc/auth_pb2.py +43 -0
- etcdrpc/auth_pb2.pyi +44 -0
- etcdrpc/auth_pb2_grpc.py +24 -0
- etcdrpc/kv.proto +43 -0
- etcdrpc/kv_pb2.py +40 -0
- etcdrpc/kv_pb2.pyi +39 -0
- etcdrpc/kv_pb2_grpc.py +24 -0
- etcdrpc/rpc.proto +924 -0
- etcdrpc/rpc_pb2.py +237 -0
- etcdrpc/rpc_pb2.pyi +811 -0
- etcdrpc/rpc_pb2_grpc.py +1893 -0
- python_etcd3_async-0.1.0.dist-info/METADATA +328 -0
- python_etcd3_async-0.1.0.dist-info/RECORD +42 -0
- python_etcd3_async-0.1.0.dist-info/WHEEL +4 -0
etcd3/locks.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from etcd3 import events, exceptions
|
|
8
|
+
from etcd3.trace import get_current_span, is_tracing_available
|
|
9
|
+
from etcd3.trace_attributes import (
|
|
10
|
+
build_lock_attributes,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Lock(object):
|
|
15
|
+
"""
|
|
16
|
+
A distributed lock.
|
|
17
|
+
|
|
18
|
+
This can be used as a context manager, with the lock being acquired and
|
|
19
|
+
released as you would expect:
|
|
20
|
+
|
|
21
|
+
.. code-block:: python
|
|
22
|
+
|
|
23
|
+
etcd = etcd3.client()
|
|
24
|
+
|
|
25
|
+
# create a lock that expires after 20 seconds
|
|
26
|
+
with etcd.lock('/myapp/mylock', ttl=20) as lock:
|
|
27
|
+
# do something that requires the lock
|
|
28
|
+
print(lock.is_acquired())
|
|
29
|
+
|
|
30
|
+
# refresh the timeout on the lease
|
|
31
|
+
lock.refresh()
|
|
32
|
+
|
|
33
|
+
:param name: name (full path) of the lock. The full key path should be provided,
|
|
34
|
+
e.g., '/myapp/mylock' or 'election/my-service'.
|
|
35
|
+
:type name: string or bytes
|
|
36
|
+
:param ttl: length of time for the lock to live for in seconds. The lock
|
|
37
|
+
will be released after this time elapses, unless refreshed
|
|
38
|
+
:type ttl: int
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, name, ttl=60, etcd_client=None):
|
|
42
|
+
self.name = name
|
|
43
|
+
self.ttl = ttl
|
|
44
|
+
if etcd_client is not None:
|
|
45
|
+
self.etcd_client = etcd_client
|
|
46
|
+
self.key = name
|
|
47
|
+
self.lease = None
|
|
48
|
+
# store uuid as bytes, since it avoids having to decode each time we
|
|
49
|
+
# need to compare. Using uuid4 for better privacy (no MAC address).
|
|
50
|
+
self.uuid = uuid.uuid4().bytes
|
|
51
|
+
|
|
52
|
+
def acquire(self, timeout=10):
|
|
53
|
+
"""Acquire the lock.
|
|
54
|
+
|
|
55
|
+
:params timeout: Maximum time to wait before returning. `None` means
|
|
56
|
+
forever, any other value equal or greater than 0 is
|
|
57
|
+
the number of seconds.
|
|
58
|
+
:returns: True if the lock has been acquired, False otherwise.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
if timeout is not None:
|
|
62
|
+
deadline = time.time() + timeout
|
|
63
|
+
|
|
64
|
+
while True:
|
|
65
|
+
if self._try_acquire():
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
if timeout is not None:
|
|
69
|
+
remaining_timeout = max(deadline - time.time(), 0)
|
|
70
|
+
if remaining_timeout == 0:
|
|
71
|
+
return False
|
|
72
|
+
else:
|
|
73
|
+
remaining_timeout = None
|
|
74
|
+
|
|
75
|
+
# Wait for the lock to be deleted, then retry immediately
|
|
76
|
+
deleted = self._wait_delete_event(remaining_timeout)
|
|
77
|
+
if not deleted:
|
|
78
|
+
# Timeout or error occurred, exit loop
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def _try_acquire(self):
|
|
82
|
+
# Use a longer TTL during acquisition to avoid race condition
|
|
83
|
+
# where lease expires between creation and transaction execution
|
|
84
|
+
acquisition_ttl = max(self.ttl, 10) # Minimum 10 seconds for safety
|
|
85
|
+
self.lease = self.etcd_client.lease(acquisition_ttl)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
success, metadata = self.etcd_client.transaction(
|
|
89
|
+
compare=[self.etcd_client.transactions.create(self.key) == 0],
|
|
90
|
+
success=[
|
|
91
|
+
self.etcd_client.transactions.put(self.key, self.uuid, lease=self.lease)
|
|
92
|
+
],
|
|
93
|
+
failure=[self.etcd_client.transactions.get(self.key)],
|
|
94
|
+
)
|
|
95
|
+
if success is True:
|
|
96
|
+
self.revision = metadata[0].header.revision
|
|
97
|
+
# Refresh lease immediately to ensure it has the full TTL
|
|
98
|
+
# and to verify the lease is still valid
|
|
99
|
+
try:
|
|
100
|
+
next(self.lease.refresh())
|
|
101
|
+
except StopIteration:
|
|
102
|
+
# If refresh fails, the lock acquisition failed
|
|
103
|
+
self.release()
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
self.revision = metadata[0][0][1].mod_revision
|
|
107
|
+
# Clean up the lease that was created but not needed
|
|
108
|
+
self.etcd_client.revoke_lease(self.lease.id)
|
|
109
|
+
self.lease = None
|
|
110
|
+
return False
|
|
111
|
+
except Exception:
|
|
112
|
+
# Clean up lease on any error to avoid resource leak
|
|
113
|
+
if self.lease is not None:
|
|
114
|
+
try:
|
|
115
|
+
self.etcd_client.revoke_lease(self.lease.id)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass # Ignore cleanup errors
|
|
118
|
+
self.lease = None
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def _wait_delete_event(self, timeout):
|
|
122
|
+
"""Wait for the lock key to be deleted.
|
|
123
|
+
|
|
124
|
+
:param timeout: Maximum time to wait in seconds, or None for infinite.
|
|
125
|
+
:returns: True if the key was deleted, False if timeout occurred.
|
|
126
|
+
|
|
127
|
+
This method handles the race condition where the lock may be deleted
|
|
128
|
+
between the time we try to acquire it and when we create the watch.
|
|
129
|
+
"""
|
|
130
|
+
# FIRST: Check if key still exists and get its current revision.
|
|
131
|
+
# This must be done BEFORE creating the watch to avoid a race window.
|
|
132
|
+
value, metadata = self.etcd_client.get(self.key)
|
|
133
|
+
if value is None:
|
|
134
|
+
# Key already deleted, return True so acquire() will retry
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
# Use the current key's mod_revision to ensure we don't miss events
|
|
138
|
+
# The watch should start from the revision AFTER the key was last modified
|
|
139
|
+
current_revision = metadata.mod_revision
|
|
140
|
+
start_revision = current_revision + 1
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
event_iter, cancel = self.etcd_client.watch(
|
|
144
|
+
self.key, start_revision=start_revision
|
|
145
|
+
)
|
|
146
|
+
except exceptions.WatchTimedOut:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Double-check after creating watch (very small window, but possible)
|
|
150
|
+
value, _ = self.etcd_client.get(self.key)
|
|
151
|
+
if value is None:
|
|
152
|
+
cancel()
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
if timeout is not None:
|
|
156
|
+
timer = threading.Timer(timeout, cancel)
|
|
157
|
+
timer.start()
|
|
158
|
+
else:
|
|
159
|
+
timer = None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
for event in event_iter:
|
|
163
|
+
if isinstance(event, events.DeleteEvent):
|
|
164
|
+
return True
|
|
165
|
+
finally:
|
|
166
|
+
# Ensure cleanup happens regardless of how we exit
|
|
167
|
+
if timer is not None:
|
|
168
|
+
timer.cancel()
|
|
169
|
+
cancel()
|
|
170
|
+
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def release(self, raise_error=False):
|
|
174
|
+
"""Release the lock.
|
|
175
|
+
|
|
176
|
+
:param raise_error: If True, raise an exception on release failure.
|
|
177
|
+
If False (default), return a status string.
|
|
178
|
+
:returns: One of 'released' (successfully released),
|
|
179
|
+
'not_owner' (lock was held by someone else or expired),
|
|
180
|
+
'not_found' (lock key didn't exist),
|
|
181
|
+
or 'error' (transaction failed for another reason).
|
|
182
|
+
:raises LockReleaseError: If raise_error=True and release fails.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
success, responses = self.etcd_client.transaction(
|
|
186
|
+
compare=[self.etcd_client.transactions.value(self.key) == self.uuid],
|
|
187
|
+
success=[self.etcd_client.transactions.delete(self.key)],
|
|
188
|
+
failure=[self.etcd_client.transactions.get(self.key)],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if success:
|
|
192
|
+
return 'released'
|
|
193
|
+
|
|
194
|
+
# Transaction succeeded but comparison failed
|
|
195
|
+
# Check if key exists and who owns it
|
|
196
|
+
if responses and responses[0]:
|
|
197
|
+
current_value = responses[0][0][0] if responses[0][0] else None
|
|
198
|
+
if current_value is None:
|
|
199
|
+
if raise_error:
|
|
200
|
+
raise exceptions.LockReleaseError(
|
|
201
|
+
"Lock key does not exist - may have expired or been released"
|
|
202
|
+
)
|
|
203
|
+
return 'not_found'
|
|
204
|
+
if current_value != self.uuid:
|
|
205
|
+
if raise_error:
|
|
206
|
+
raise exceptions.LockReleaseError(
|
|
207
|
+
"Lock is held by another owner - cannot release"
|
|
208
|
+
)
|
|
209
|
+
return 'not_owner'
|
|
210
|
+
|
|
211
|
+
if raise_error:
|
|
212
|
+
raise exceptions.LockReleaseError("Unknown lock release failure")
|
|
213
|
+
return 'error'
|
|
214
|
+
|
|
215
|
+
except exceptions.Etcd3Exception:
|
|
216
|
+
if raise_error:
|
|
217
|
+
raise
|
|
218
|
+
return 'error'
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
if raise_error:
|
|
221
|
+
raise exceptions.LockReleaseError(f"Lock release failed: {exc}") from exc
|
|
222
|
+
return 'error'
|
|
223
|
+
|
|
224
|
+
def refresh(self):
|
|
225
|
+
"""Refresh the time to live on this lock."""
|
|
226
|
+
if self.lease is not None:
|
|
227
|
+
return self.lease.refresh()
|
|
228
|
+
else:
|
|
229
|
+
raise ValueError(
|
|
230
|
+
"No lease associated with this lock - have you acquired the lock yet?"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def is_acquired(self):
|
|
234
|
+
"""Check if this lock is currently acquired."""
|
|
235
|
+
uuid, _ = self.etcd_client.get(self.key)
|
|
236
|
+
|
|
237
|
+
if uuid is None:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
return uuid == self.uuid
|
|
241
|
+
|
|
242
|
+
def __enter__(self):
|
|
243
|
+
self.acquire()
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, exception_type, exception_value, traceback):
|
|
247
|
+
self.release()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class AsyncLock(object):
|
|
251
|
+
"""
|
|
252
|
+
An asynchronous distributed lock.
|
|
253
|
+
|
|
254
|
+
This can be used as an async context manager:
|
|
255
|
+
|
|
256
|
+
.. code-block:: python
|
|
257
|
+
|
|
258
|
+
etcd = etcd3.async_client()
|
|
259
|
+
|
|
260
|
+
# create a lock that expires after 20 seconds
|
|
261
|
+
async with await etcd.lock('/myapp/mylock', ttl=20) as lock:
|
|
262
|
+
# do something that requires the lock
|
|
263
|
+
print(await lock.is_acquired())
|
|
264
|
+
|
|
265
|
+
# refresh the timeout on the lease
|
|
266
|
+
await lock.refresh()
|
|
267
|
+
|
|
268
|
+
:param name: name (full path) of the lock. The full key path should be provided,
|
|
269
|
+
e.g., '/myapp/mylock' or 'election/my-service'.
|
|
270
|
+
:type name: string or bytes
|
|
271
|
+
:param ttl: length of time for the lock to live for in seconds. The lock
|
|
272
|
+
will be released after this time elapses, unless refreshed
|
|
273
|
+
:type ttl: int
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(self, name, ttl=60, etcd_client=None):
|
|
277
|
+
self.name = name
|
|
278
|
+
self.ttl = ttl
|
|
279
|
+
if etcd_client is not None:
|
|
280
|
+
self.etcd_client = etcd_client
|
|
281
|
+
self.key = name
|
|
282
|
+
self.lease = None
|
|
283
|
+
# store uuid as bytes, since it avoids having to decode each time we
|
|
284
|
+
# need to compare. Using uuid4 for better privacy (no MAC address).
|
|
285
|
+
self.uuid = uuid.uuid4().bytes
|
|
286
|
+
|
|
287
|
+
async def acquire(self, timeout=10):
|
|
288
|
+
"""Acquire the lock asynchronously.
|
|
289
|
+
|
|
290
|
+
:params timeout: Maximum time to wait before returning. `None` means
|
|
291
|
+
forever, any other value equal or greater than 0 is
|
|
292
|
+
the number of seconds.
|
|
293
|
+
:returns: True if the lock has been acquired, False otherwise.
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
if timeout is not None:
|
|
297
|
+
deadline = time.time() + timeout
|
|
298
|
+
|
|
299
|
+
while True:
|
|
300
|
+
if await self._try_acquire():
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
if timeout is not None:
|
|
304
|
+
remaining_timeout = max(deadline - time.time(), 0)
|
|
305
|
+
if remaining_timeout == 0:
|
|
306
|
+
return False
|
|
307
|
+
else:
|
|
308
|
+
remaining_timeout = None
|
|
309
|
+
|
|
310
|
+
# Wait for the lock to be deleted, then retry immediately
|
|
311
|
+
deleted = await self._wait_delete_event(remaining_timeout)
|
|
312
|
+
if not deleted:
|
|
313
|
+
# Timeout or error occurred, exit loop
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def _set_lock_span_attributes(self, acquired: bool = None):
|
|
317
|
+
"""Set lock-related attributes on current span.
|
|
318
|
+
|
|
319
|
+
:param acquired: Whether the lock was acquired (optional)
|
|
320
|
+
"""
|
|
321
|
+
if not is_tracing_available():
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
span = get_current_span()
|
|
325
|
+
if span is None:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
attrs = build_lock_attributes(
|
|
329
|
+
name=self.key if isinstance(self.key, str) else self.key.decode('utf-8', errors='replace'),
|
|
330
|
+
uuid=self.uuid,
|
|
331
|
+
acquired=acquired
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
for key, value in attrs.items():
|
|
335
|
+
if value is not None:
|
|
336
|
+
span.set_attribute(key, value)
|
|
337
|
+
|
|
338
|
+
async def _try_acquire(self):
|
|
339
|
+
# Use a longer TTL during acquisition to avoid race condition
|
|
340
|
+
# where lease expires between creation and transaction execution
|
|
341
|
+
acquisition_ttl = max(self.ttl, 10) # Minimum 10 seconds for safety
|
|
342
|
+
self.lease = await self.etcd_client.lease(acquisition_ttl)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
success, metadata = await self.etcd_client.transaction(
|
|
346
|
+
compare=[self.etcd_client.transactions.create(self.key) == 0],
|
|
347
|
+
success=[
|
|
348
|
+
self.etcd_client.transactions.put(self.key, self.uuid, lease=self.lease)
|
|
349
|
+
],
|
|
350
|
+
failure=[self.etcd_client.transactions.get(self.key)],
|
|
351
|
+
)
|
|
352
|
+
if success is True:
|
|
353
|
+
self.revision = metadata[0].header.revision
|
|
354
|
+
# Refresh lease immediately to ensure it has the full TTL
|
|
355
|
+
# and to verify the lease is still valid
|
|
356
|
+
try:
|
|
357
|
+
async for _ in self.lease.refresh():
|
|
358
|
+
break # Just need one response to verify lease is alive
|
|
359
|
+
except Exception:
|
|
360
|
+
# If refresh fails, the lock acquisition failed
|
|
361
|
+
await self.release()
|
|
362
|
+
self._set_lock_span_attributes(acquired=False)
|
|
363
|
+
return False
|
|
364
|
+
self._set_lock_span_attributes(acquired=True)
|
|
365
|
+
return True
|
|
366
|
+
self.revision = metadata[0][0][1].mod_revision
|
|
367
|
+
# Clean up the lease that was created but not needed
|
|
368
|
+
await self.etcd_client.revoke_lease(self.lease.id)
|
|
369
|
+
self.lease = None
|
|
370
|
+
return False
|
|
371
|
+
except Exception:
|
|
372
|
+
# Clean up lease on any error to avoid resource leak
|
|
373
|
+
if self.lease is not None:
|
|
374
|
+
try:
|
|
375
|
+
await self.etcd_client.revoke_lease(self.lease.id)
|
|
376
|
+
except Exception:
|
|
377
|
+
pass # Ignore cleanup errors
|
|
378
|
+
self.lease = None
|
|
379
|
+
raise
|
|
380
|
+
|
|
381
|
+
async def _wait_delete_event(self, timeout):
|
|
382
|
+
"""Wait for the lock key to be deleted.
|
|
383
|
+
|
|
384
|
+
:param timeout: Maximum time to wait in seconds, or None for infinite.
|
|
385
|
+
:returns: True if the key was deleted, False if timeout occurred.
|
|
386
|
+
|
|
387
|
+
This method handles the race condition where the lock may be deleted
|
|
388
|
+
between the time we try to acquire it and when we create the watch.
|
|
389
|
+
"""
|
|
390
|
+
# FIRST: Check if key still exists and get its current revision.
|
|
391
|
+
# This must be done BEFORE creating the watch to avoid a race window.
|
|
392
|
+
value, metadata = await self.etcd_client.get(self.key)
|
|
393
|
+
if value is None:
|
|
394
|
+
# Key already deleted, return True so acquire() will retry
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
# Use the current key's mod_revision to ensure we don't miss events
|
|
398
|
+
# The watch should start from the revision AFTER the key was last modified
|
|
399
|
+
current_revision = metadata.mod_revision
|
|
400
|
+
start_revision = current_revision + 1
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
event_iter, cancel = await self.etcd_client.watch(
|
|
404
|
+
self.key, start_revision=start_revision
|
|
405
|
+
)
|
|
406
|
+
except exceptions.WatchTimedOut:
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
# Double-check after creating watch (very small window, but possible)
|
|
410
|
+
value, _ = await self.etcd_client.get(self.key)
|
|
411
|
+
if value is None:
|
|
412
|
+
cancel()
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
async def wait_for_delete():
|
|
416
|
+
"""Wait for delete event"""
|
|
417
|
+
async for event in event_iter:
|
|
418
|
+
if isinstance(event, events.DeleteEvent):
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
if timeout is not None:
|
|
424
|
+
return await asyncio.wait_for(wait_for_delete(), timeout=timeout)
|
|
425
|
+
else:
|
|
426
|
+
return await wait_for_delete()
|
|
427
|
+
except asyncio.TimeoutError:
|
|
428
|
+
return False
|
|
429
|
+
finally:
|
|
430
|
+
# Ensure watch is cancelled
|
|
431
|
+
cancel()
|
|
432
|
+
|
|
433
|
+
async def release(self, raise_error=False):
|
|
434
|
+
"""Release the lock asynchronously.
|
|
435
|
+
|
|
436
|
+
:param raise_error: If True, raise an exception on release failure.
|
|
437
|
+
If False (default), return a status string.
|
|
438
|
+
:returns: One of 'released' (successfully released),
|
|
439
|
+
'not_owner' (lock was held by someone else or expired),
|
|
440
|
+
'not_found' (lock key didn't exist),
|
|
441
|
+
or 'error' (transaction failed for another reason).
|
|
442
|
+
:raises LockReleaseError: If raise_error=True and release fails.
|
|
443
|
+
"""
|
|
444
|
+
try:
|
|
445
|
+
success, responses = await self.etcd_client.transaction(
|
|
446
|
+
compare=[self.etcd_client.transactions.value(self.key) == self.uuid],
|
|
447
|
+
success=[self.etcd_client.transactions.delete(self.key)],
|
|
448
|
+
failure=[self.etcd_client.transactions.get(self.key)],
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if success:
|
|
452
|
+
self._set_lock_span_attributes(acquired=False)
|
|
453
|
+
return 'released'
|
|
454
|
+
|
|
455
|
+
# Transaction succeeded but comparison failed
|
|
456
|
+
# Check if key exists and who owns it
|
|
457
|
+
if responses and responses[0]:
|
|
458
|
+
current_value = responses[0][0][0] if responses[0][0] else None
|
|
459
|
+
if current_value is None:
|
|
460
|
+
self._set_lock_span_attributes(acquired=False)
|
|
461
|
+
if raise_error:
|
|
462
|
+
raise exceptions.LockReleaseError(
|
|
463
|
+
"Lock key does not exist - may have expired or been released"
|
|
464
|
+
)
|
|
465
|
+
return 'not_found'
|
|
466
|
+
if current_value != self.uuid:
|
|
467
|
+
self._set_lock_span_attributes(acquired=None) # Owned by another
|
|
468
|
+
if raise_error:
|
|
469
|
+
raise exceptions.LockReleaseError(
|
|
470
|
+
"Lock is held by another owner - cannot release"
|
|
471
|
+
)
|
|
472
|
+
return 'not_owner'
|
|
473
|
+
|
|
474
|
+
self._set_lock_span_attributes(acquired=None)
|
|
475
|
+
if raise_error:
|
|
476
|
+
raise exceptions.LockReleaseError("Unknown lock release failure")
|
|
477
|
+
return 'error'
|
|
478
|
+
|
|
479
|
+
except exceptions.Etcd3Exception:
|
|
480
|
+
self._set_lock_span_attributes(acquired=None)
|
|
481
|
+
if raise_error:
|
|
482
|
+
raise
|
|
483
|
+
return 'error'
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
if raise_error:
|
|
486
|
+
raise exceptions.LockReleaseError(f"Lock release failed: {exc}") from exc
|
|
487
|
+
return 'error'
|
|
488
|
+
|
|
489
|
+
def refresh(self):
|
|
490
|
+
"""Refresh the time to lease on this lock.
|
|
491
|
+
|
|
492
|
+
Returns an async generator that yields keep-alive responses.
|
|
493
|
+
The caller should iterate over the result to keep the lease alive.
|
|
494
|
+
|
|
495
|
+
Example:
|
|
496
|
+
async for response in lock.refresh():
|
|
497
|
+
# Lease is kept alive while iterating
|
|
498
|
+
pass
|
|
499
|
+
"""
|
|
500
|
+
if self.lease is not None:
|
|
501
|
+
return self.lease.refresh()
|
|
502
|
+
else:
|
|
503
|
+
raise ValueError(
|
|
504
|
+
"No lease associated with this lock - have you acquired the lock yet?"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
@asynccontextmanager
|
|
508
|
+
async def auto_refresh(self, interval=None):
|
|
509
|
+
"""Async context manager that automatically refreshes the lock in background.
|
|
510
|
+
|
|
511
|
+
This context manager starts a background task that continuously refreshes
|
|
512
|
+
the lock lease while the context is active. This is useful for long-running
|
|
513
|
+
operations that need to maintain lock ownership.
|
|
514
|
+
|
|
515
|
+
Example:
|
|
516
|
+
async with lock.auto_refresh(interval=10):
|
|
517
|
+
# Lock is automatically refreshed every 10 seconds
|
|
518
|
+
await do_long_work()
|
|
519
|
+
# Lock is released when exiting the context
|
|
520
|
+
|
|
521
|
+
:param interval: Refresh interval in seconds. If None, defaults to TTL/3.
|
|
522
|
+
:type interval: float, optional
|
|
523
|
+
:yields: The lock instance
|
|
524
|
+
:rtype: AsyncLock
|
|
525
|
+
"""
|
|
526
|
+
if self.lease is None:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
"No lease associated with this lock - have you acquired the lock yet?"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Default interval is TTL/3 to ensure timely refresh
|
|
532
|
+
refresh_interval = interval or (self.ttl / 3)
|
|
533
|
+
stop_event = asyncio.Event()
|
|
534
|
+
|
|
535
|
+
async def refresh_loop():
|
|
536
|
+
"""Background task that keeps refreshing the lease."""
|
|
537
|
+
while not stop_event.is_set():
|
|
538
|
+
try:
|
|
539
|
+
# Get the async generator from refresh
|
|
540
|
+
refresh_gen = self.refresh()
|
|
541
|
+
# Get one response from the generator to trigger keepalive
|
|
542
|
+
try:
|
|
543
|
+
await asyncio.wait_for(
|
|
544
|
+
refresh_gen.__anext__(), timeout=5.0
|
|
545
|
+
)
|
|
546
|
+
# Successfully refreshed
|
|
547
|
+
except StopAsyncIteration:
|
|
548
|
+
# Generator ended unexpectedly
|
|
549
|
+
break
|
|
550
|
+
except asyncio.TimeoutError:
|
|
551
|
+
# No response received, will retry
|
|
552
|
+
pass
|
|
553
|
+
finally:
|
|
554
|
+
# Close the generator to release resources
|
|
555
|
+
await refresh_gen.aclose()
|
|
556
|
+
except Exception:
|
|
557
|
+
# If refresh fails, stop the loop
|
|
558
|
+
break
|
|
559
|
+
# Wait before next refresh
|
|
560
|
+
try:
|
|
561
|
+
await asyncio.wait_for(stop_event.wait(), timeout=refresh_interval)
|
|
562
|
+
except asyncio.TimeoutError:
|
|
563
|
+
# Time for next refresh
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# Start background refresh task
|
|
567
|
+
task = asyncio.create_task(refresh_loop())
|
|
568
|
+
try:
|
|
569
|
+
yield self
|
|
570
|
+
finally:
|
|
571
|
+
# Signal the background task to stop
|
|
572
|
+
stop_event.set()
|
|
573
|
+
task.cancel()
|
|
574
|
+
try:
|
|
575
|
+
await task
|
|
576
|
+
except asyncio.CancelledError:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
async def is_acquired(self):
|
|
580
|
+
"""Check if this lock is currently acquired asynchronously."""
|
|
581
|
+
uuid, _ = await self.etcd_client.get(self.key)
|
|
582
|
+
|
|
583
|
+
if uuid is None:
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
return uuid == self.uuid
|
|
587
|
+
|
|
588
|
+
async def __aenter__(self):
|
|
589
|
+
await self.acquire()
|
|
590
|
+
return self
|
|
591
|
+
|
|
592
|
+
async def __aexit__(self, exception_type, exception_value, traceback):
|
|
593
|
+
await self.release()
|