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/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()