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.
@@ -0,0 +1,878 @@
1
+ """Extensions for async etcd3 client.
2
+
3
+ This module provides auxiliary classes to split the functionality of AsyncMultiEndpointEtcd3Client
4
+ into smaller, more manageable components.
5
+ """
6
+
7
+ import asyncio
8
+ import contextvars
9
+ import logging
10
+ import random
11
+ from typing import Any
12
+
13
+ import grpc
14
+
15
+ import etcdrpc
16
+
17
+ from etcd3 import exceptions
18
+ from etcd3 import transactions
19
+ from etcd3 import utils
20
+ from etcd3.client import KVMetadata
21
+ from etcd3.leases import AsyncLease
22
+ from etcd3.locks import AsyncLock
23
+ from etcd3.shared import (
24
+ _make_async_handle_errors_with_tracing,
25
+ _make_async_handle_generator_errors_with_tracing,
26
+ RequestBuilder,
27
+ )
28
+ from etcd3.trace import get_current_span, is_tracing_available
29
+ from etcd3.trace_attributes import (
30
+ build_response_attributes,
31
+ build_lease_attributes,
32
+ TraceAttributes,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # Context variables for request tracking
39
+ _request_context: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
40
+ "request_context", default=None
41
+ )
42
+
43
+
44
+ def get_request_context() -> dict[str, Any] | None:
45
+ """Get the current request context.
46
+
47
+ :returns: The current request context dictionary, or None if not set
48
+ """
49
+ return _request_context.get()
50
+
51
+
52
+ def set_request_context(**kwargs) -> contextvars.Token:
53
+ """Set values in the request context.
54
+
55
+ :param kwargs: Key-value pairs to set in the context
56
+ :returns: A token that can be used to reset the context
57
+ """
58
+ current = _request_context.get() or {}
59
+ new_context = {**current, **kwargs}
60
+ return _request_context.set(new_context)
61
+
62
+
63
+ def _manage_client_errors(self, exc):
64
+ """Error handler for extensions that routes to client's error handler."""
65
+ self.client._manage_grpc_errors(exc)
66
+
67
+
68
+ def _set_span_attributes_from_response(response, endpoint: str = None):
69
+ """Set span attributes from etcd response.
70
+
71
+ :param response: etcd response object
72
+ :param endpoint: Optional endpoint string
73
+ """
74
+ if not is_tracing_available():
75
+ return
76
+
77
+ span = get_current_span()
78
+ if span is None:
79
+ return
80
+
81
+ # Build attributes from response
82
+ attrs = build_response_attributes(header=getattr(response, 'header', None))
83
+
84
+ # Add count if available
85
+ if hasattr(response, 'count'):
86
+ attrs[TraceAttributes.ETCD_COUNT] = response.count
87
+
88
+ # Add deleted if available
89
+ if hasattr(response, 'deleted'):
90
+ attrs[TraceAttributes.ETCD_DELETED] = response.deleted
91
+
92
+ # Add endpoint if provided
93
+ if endpoint:
94
+ attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
95
+
96
+ # Set attributes on span
97
+ for key, value in attrs.items():
98
+ if value is not None:
99
+ span.set_attribute(key, value)
100
+
101
+
102
+ # Create trace-aware error handlers
103
+ _async_handle_errors = _make_async_handle_errors_with_tracing(
104
+ _manage_client_errors, "etcd3.operation"
105
+ )
106
+ _async_handle_generator_errors = _make_async_handle_generator_errors_with_tracing(
107
+ _manage_client_errors, "etcd3.operation"
108
+ )
109
+ # Error handler for lease keepalive that propagates errors to allow detection of connection loss
110
+ _async_handle_keepalive_errors = _make_async_handle_generator_errors_with_tracing(
111
+ _manage_client_errors, "etcd3.keepalive", propagate_errors=True
112
+ )
113
+
114
+
115
+ class AsyncKVOperations:
116
+ """Handles key-value operations for AsyncMultiEndpointEtcd3Client."""
117
+
118
+ def __init__(self, client, max_concurrent=100):
119
+ self.client = client
120
+ self._semaphore = asyncio.Semaphore(max_concurrent)
121
+ self._batch_semaphore = asyncio.Semaphore(10) # Limit concurrent batches
122
+
123
+ async def _get_kv_stub(self):
124
+ """Get the KV stub using the current channel with auto-reconnection.
125
+
126
+ Creates a new stub each time to ensure it uses the latest channel,
127
+ which supports automatic reconnection after etcd server restart.
128
+ """
129
+ channel = await self.client._get_channel()
130
+ return etcdrpc.KVStub(channel)
131
+
132
+ @_async_handle_errors
133
+ async def get_response(self, key, **kwargs):
134
+ """Get the value of a key from etcd.
135
+
136
+ :param key: key in etcd to get
137
+ :type key: str or bytes
138
+ :param kwargs: Additional arguments (revision, limit, sort_order, etc.)
139
+ :returns: RangeResponse from etcd
140
+ """
141
+ range_request = self.client._build_get_range_request(key, **kwargs)
142
+
143
+ kv_stub = await self._get_kv_stub()
144
+ response = await kv_stub.Range(
145
+ range_request,
146
+ timeout=self.client.timeout,
147
+ credentials=self.client.call_credentials,
148
+ metadata=self.client.metadata,
149
+ )
150
+
151
+ # Set trace attributes from response
152
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
153
+ _set_span_attributes_from_response(response, endpoint)
154
+
155
+ return response
156
+
157
+ async def get(self, key, **kwargs):
158
+ """Get the value of a key from etcd."""
159
+ range_response = await self.get_response(key, **kwargs)
160
+ if range_response.count < 1:
161
+ return None, None
162
+ kv = range_response.kvs[0]
163
+ return kv.value, KVMetadata(kv, range_response.header)
164
+
165
+ async def get_prefix_response(self, key_prefix, **kwargs):
166
+ """Get a range of keys with a prefix."""
167
+ if any(kwarg in kwargs for kwarg in ("key", "range_end")):
168
+ raise TypeError("Don't use key or range_end with prefix")
169
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
170
+ return await self.get_response(key_prefix, **kwargs)
171
+
172
+ @_async_handle_generator_errors
173
+ async def get_prefix(self, key_prefix, **kwargs):
174
+ """Get a range of keys with a prefix."""
175
+ range_response = await self.get_prefix_response(key_prefix, **kwargs)
176
+ for kv in range_response.kvs:
177
+ yield kv.value, KVMetadata(kv, range_response.header)
178
+
179
+ async def get_range_response(
180
+ self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
181
+ ):
182
+ """Get a range of keys."""
183
+ if any(kwarg in kwargs for kwarg in ("key", "range_end")):
184
+ raise TypeError("Don't use key or range_end with get_range")
185
+ kwargs["range_end"] = range_end
186
+ if sort_order is not None:
187
+ kwargs["sort_order"] = sort_order
188
+ kwargs["sort_target"] = sort_target
189
+ return await self.get_response(range_start, **kwargs)
190
+
191
+ @_async_handle_generator_errors
192
+ async def get_range(self, range_start, range_end, **kwargs):
193
+ """Get a range of keys."""
194
+ range_response = await self.get_range_response(range_start, range_end, **kwargs)
195
+ for kv in range_response.kvs:
196
+ yield kv.value, KVMetadata(kv, range_response.header)
197
+
198
+ async def get_all_response(
199
+ self, sort_order=None, sort_target="key", keys_only=False
200
+ ):
201
+ """Get all keys currently stored in etcd."""
202
+ kwargs = {}
203
+ if sort_order is not None:
204
+ kwargs["sort_order"] = sort_order
205
+ kwargs["sort_target"] = sort_target
206
+ if keys_only:
207
+ kwargs["keys_only"] = True
208
+ # Use b'\x00' as the minimum key and b'\x00' as range_end to get all keys
209
+ # This works because etcd stores keys in sorted order by key bytes
210
+ # b'\x00' is the smallest non-empty key, and range_end=b'\x00' means
211
+ # include keys >= b'\x00' up to (but not including) b'\x00'
212
+ # Actually, we need range_end > key. Using high bytes works.
213
+ return await self.get_response(b"\x00", range_end=b"\xff" * 16, **kwargs)
214
+
215
+ @_async_handle_generator_errors
216
+ async def get_all(self, **kwargs):
217
+ """Get all keys currently stored in etcd."""
218
+ range_response = await self.get_all_response(**kwargs)
219
+ for kv in range_response.kvs:
220
+ yield kv.value, KVMetadata(kv, range_response.header)
221
+
222
+ @_async_handle_errors
223
+ async def put(
224
+ self,
225
+ key,
226
+ value=None,
227
+ lease=None,
228
+ prev_kv=False,
229
+ ignore_value=False,
230
+ ignore_lease=False,
231
+ ):
232
+ """Save a value to etcd.
233
+
234
+ :param key: key in etcd to set
235
+ :param value: value to set key to (optional if ignore_value=True)
236
+ :type value: bytes
237
+ :param lease: Lease to associate with this key.
238
+ :type lease: either :class:`.AsyncLease`, or int (ID of lease)
239
+ :param prev_kv: return the previous key-value pair
240
+ :type prev_kv: bool
241
+ :param ignore_value: if True, update the key using the current value
242
+ :type ignore_value: bool
243
+ :param ignore_lease: if True, update the key using the current lease
244
+ :type ignore_lease: bool
245
+ """
246
+ put_request = RequestBuilder.build_put_request(
247
+ key=key,
248
+ value=value,
249
+ lease=lease,
250
+ prev_kv=prev_kv,
251
+ ignore_value=ignore_value,
252
+ ignore_lease=ignore_lease,
253
+ )
254
+
255
+ kv_stub = await self._get_kv_stub()
256
+ put_response = await kv_stub.Put(
257
+ put_request,
258
+ timeout=self.client.timeout,
259
+ credentials=self.client.call_credentials,
260
+ metadata=self.client.metadata,
261
+ )
262
+
263
+ # Set trace attributes from response
264
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
265
+ _set_span_attributes_from_response(put_response, endpoint)
266
+
267
+ if prev_kv:
268
+ return KVMetadata(put_response.prev_kv, put_response.header)
269
+ return put_response.header
270
+
271
+ @_async_handle_errors
272
+ async def put_if_not_exists(self, key, value, lease=None):
273
+ """Save a value to etcd only if the key doesn't exist."""
274
+ status, _ = await self.client.transaction(
275
+ compare=[transactions.Create(key) == 0],
276
+ success=[transactions.Put(key, value, lease=lease)],
277
+ failure=[],
278
+ )
279
+
280
+ return status
281
+
282
+ @_async_handle_errors
283
+ async def replace(self, key, initial_value, new_value):
284
+ """Replace the value of a key in etcd."""
285
+ status, _ = await self.client.transaction(
286
+ compare=[transactions.Value(key) == initial_value],
287
+ success=[transactions.Put(key, new_value)],
288
+ failure=[],
289
+ )
290
+
291
+ return status
292
+
293
+ @_async_handle_errors
294
+ async def delete(self, key, prev_kv=False, return_response=False, range_end=None):
295
+ """Delete a key or range of keys from etcd.
296
+
297
+ :param key: key in etcd to delete
298
+ :type key: str or bytes
299
+ :param prev_kv: return the deleted key-value pair
300
+ :type prev_kv: bool
301
+ :param return_response: return the full response
302
+ :type return_response: bool
303
+ :param range_end: End of the key range (for range deletes)
304
+ :type range_end: str or bytes, optional
305
+ :returns: Number of deleted keys or full response
306
+ """
307
+ delete_request = RequestBuilder.build_delete_request(
308
+ key=key,
309
+ prev_kv=prev_kv,
310
+ range_end=range_end,
311
+ )
312
+
313
+ kv_stub = await self._get_kv_stub()
314
+ delete_response = await kv_stub.DeleteRange(
315
+ delete_request,
316
+ timeout=self.client.timeout,
317
+ credentials=self.client.call_credentials,
318
+ metadata=self.client.metadata,
319
+ )
320
+
321
+ # Set trace attributes from response
322
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
323
+ _set_span_attributes_from_response(delete_response, endpoint)
324
+
325
+ if return_response:
326
+ return delete_response
327
+
328
+ return delete_response.deleted
329
+
330
+ @_async_handle_errors
331
+ async def delete_prefix(self, prefix):
332
+ """Delete a range of keys with a prefix."""
333
+ delete_request = RequestBuilder.build_delete_request(
334
+ key=prefix,
335
+ range_end=utils.prefix_range_end(utils.to_bytes(prefix)),
336
+ prev_kv=False,
337
+ )
338
+
339
+ kv_stub = await self._get_kv_stub()
340
+ delete_response = await kv_stub.DeleteRange(
341
+ delete_request,
342
+ timeout=self.client.timeout,
343
+ credentials=self.client.call_credentials,
344
+ metadata=self.client.metadata,
345
+ )
346
+
347
+ return delete_response.deleted
348
+
349
+ async def batch_put(self, items, concurrency=10, atomic=True):
350
+ """Batch put multiple key-value pairs.
351
+
352
+ By default, this method executes all puts as a single atomic transaction,
353
+ ensuring either all operations succeed or none do. If atomic=False,
354
+ operations are executed in parallel with concurrency control.
355
+
356
+ :param items: Iterable of (key, value) tuples to put
357
+ :type items: Iterable[tuple[str | bytes, str | bytes]]
358
+ :param concurrency: Maximum number of concurrent put operations (only used if atomic=False)
359
+ :type concurrency: int
360
+ :param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
361
+ :type atomic: bool
362
+ :returns: Number of successful operations (atomic=True: either all or none)
363
+ :rtype: int
364
+ :raises BatchOperationError: If atomic=True and some operations fail
365
+ """
366
+ # Convert to list to allow multiple iterations
367
+ items_list = list(items)
368
+ if not items_list:
369
+ return 0
370
+
371
+ if atomic:
372
+ # Use atomic transaction for all operations
373
+ # This ensures either all succeed or none do
374
+ success_ops = [transactions.Put(key, value) for key, value in items_list]
375
+ # Use a dummy compare that always succeeds (create_revision > -1 is always true)
376
+ compare = [transactions.Create(key) > -1 for key, _ in items_list]
377
+
378
+ try:
379
+ success, _ = await self.client.transaction(
380
+ compare=compare,
381
+ success=success_ops,
382
+ failure=[],
383
+ )
384
+ if success:
385
+ return len(items_list)
386
+ else:
387
+ # Transaction failed - this shouldn't happen with our compare
388
+ raise exceptions.BatchOperationError(
389
+ f"Atomic batch put failed for {len(items_list)} items"
390
+ )
391
+ except Exception as exc:
392
+ if isinstance(exc, exceptions.BatchOperationError):
393
+ raise
394
+ raise exceptions.BatchOperationError(f"Batch put failed: {exc}") from exc
395
+ else:
396
+ # Non-atomic: parallel execution with concurrency control
397
+ semaphore = asyncio.Semaphore(concurrency)
398
+ results = []
399
+
400
+ async def put_with_semaphore(key, value):
401
+ async with semaphore:
402
+ try:
403
+ await self.put(key, value)
404
+ results.append((key, 1))
405
+ except Exception:
406
+ results.append((key, 0))
407
+
408
+ async with asyncio.TaskGroup() as tg:
409
+ for k, v in items_list:
410
+ tg.create_task(put_with_semaphore(k, v))
411
+
412
+ # Count successful operations
413
+ return sum(1 for _, success in results if success == 1)
414
+
415
+ async def batch_delete(self, keys, concurrency=10, atomic=True):
416
+ """Batch delete multiple keys.
417
+
418
+ By default, this method executes all deletes as a single atomic transaction,
419
+ ensuring either all operations succeed or none do. If atomic=False,
420
+ operations are executed in parallel with concurrency control.
421
+
422
+ :param keys: Iterable of keys to delete
423
+ :type keys: Iterable[str | bytes]
424
+ :param concurrency: Maximum number of concurrent delete operations (only used if atomic=False)
425
+ :type concurrency: int
426
+ :param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
427
+ :type atomic: bool
428
+ :returns: Number of successful operations (atomic=True: either all or none)
429
+ :rtype: int
430
+ :raises BatchOperationError: If atomic=True and some operations fail
431
+ """
432
+ # Convert to list to allow multiple iterations
433
+ keys_list = list(keys)
434
+ if not keys_list:
435
+ return 0
436
+
437
+ if atomic:
438
+ # Use atomic transaction for all operations
439
+ # This ensures either all succeed or none do
440
+ delete_ops = [transactions.Delete(key) for key in keys_list]
441
+ # Use a dummy compare that always succeeds (create_revision > -1 is always true)
442
+ compare = [transactions.Create(key) > -1 for key in keys_list]
443
+
444
+ try:
445
+ success, _ = await self.client.transaction(
446
+ compare=compare,
447
+ success=delete_ops,
448
+ failure=[],
449
+ )
450
+ if success:
451
+ return len(keys_list)
452
+ else:
453
+ raise exceptions.BatchOperationError(
454
+ f"Atomic batch delete failed for {len(keys_list)} keys"
455
+ )
456
+ except Exception as exc:
457
+ if isinstance(exc, exceptions.BatchOperationError):
458
+ raise
459
+ raise exceptions.BatchOperationError(f"Batch delete failed: {exc}") from exc
460
+ else:
461
+ # Non-atomic: parallel execution with concurrency control
462
+ semaphore = asyncio.Semaphore(concurrency)
463
+ results = []
464
+
465
+ async def delete_with_semaphore(key):
466
+ async with semaphore:
467
+ try:
468
+ result = await self.delete(key)
469
+ # delete returns True/False or deleted count
470
+ if isinstance(result, bool):
471
+ results.append((key, 1 if result else 0))
472
+ else:
473
+ results.append((key, 1 if result > 0 else 0))
474
+ except Exception:
475
+ results.append((key, 0))
476
+
477
+ async with asyncio.TaskGroup() as tg:
478
+ for k in keys_list:
479
+ tg.create_task(delete_with_semaphore(k))
480
+
481
+ # Count successful operations
482
+ return sum(success for _, success in results)
483
+
484
+ async def put_many(self, items, timeout=None):
485
+ """Put multiple key-value pairs with controlled concurrency.
486
+
487
+ This is a simpler interface for batch puts that handles all
488
+ the asyncio complexity internally.
489
+
490
+ :param items: Dict of {key: value} pairs or iterable of (key, value) tuples
491
+ :type items: dict or Iterable[tuple]
492
+ :param timeout: Optional timeout for each individual put operation
493
+ :type timeout: float
494
+ :returns: Tuple of (success_count, failed_keys)
495
+ :rtype: tuple[int, list]
496
+ """
497
+ if isinstance(items, dict):
498
+ items = list(items.items())
499
+
500
+ success_count = 0
501
+ failed_keys = []
502
+
503
+ for key, value in items:
504
+ try:
505
+ async with self._semaphore:
506
+ await asyncio.wait_for(self.put(key, value), timeout=timeout)
507
+ success_count += 1
508
+ except Exception as exc:
509
+ failed_keys.append((key, exc))
510
+
511
+ return success_count, failed_keys
512
+
513
+
514
+ class AsyncLeaseOperations:
515
+ """Handles lease operations for AsyncMultiEndpointEtcd3Client."""
516
+
517
+ def __init__(self, client):
518
+ self.client = client
519
+
520
+ async def _get_lease_stub(self):
521
+ """Get the lease stub using the current channel with auto-reconnection.
522
+
523
+ Creates a new stub each time to ensure it uses the latest channel,
524
+ which supports automatic reconnection after etcd server restart.
525
+ """
526
+ channel = await self.client._get_channel()
527
+ return etcdrpc.LeaseStub(channel)
528
+
529
+ @_async_handle_errors
530
+ async def lease(self, ttl, lease_id=None):
531
+ """Create a new lease."""
532
+ lease_grant_request = etcdrpc.LeaseGrantRequest(TTL=ttl, ID=lease_id)
533
+ lease_stub = await self._get_lease_stub()
534
+ lease_grant_response = await lease_stub.LeaseGrant(
535
+ lease_grant_request,
536
+ timeout=self.client.timeout,
537
+ credentials=self.client.call_credentials,
538
+ metadata=self.client.metadata,
539
+ )
540
+
541
+ # Set trace attributes for lease
542
+ if is_tracing_available():
543
+ span = get_current_span()
544
+ if span:
545
+ attrs = build_lease_attributes(
546
+ lease_id=lease_grant_response.ID,
547
+ ttl=lease_grant_response.TTL
548
+ )
549
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
550
+ if endpoint:
551
+ attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
552
+ for key, value in attrs.items():
553
+ span.set_attribute(key, value)
554
+
555
+ return AsyncLease(
556
+ lease_id=lease_grant_response.ID,
557
+ ttl=lease_grant_response.TTL,
558
+ etcd_client=self.client,
559
+ )
560
+
561
+ @_async_handle_errors
562
+ async def revoke_lease(self, lease_id):
563
+ """Revoke a lease."""
564
+ lease_revoke_request = etcdrpc.LeaseRevokeRequest(ID=lease_id)
565
+ lease_stub = await self._get_lease_stub()
566
+ await lease_stub.LeaseRevoke(
567
+ lease_revoke_request,
568
+ timeout=self.client.timeout,
569
+ credentials=self.client.call_credentials,
570
+ metadata=self.client.metadata,
571
+ )
572
+
573
+ # Set trace attributes for lease revocation
574
+ if is_tracing_available():
575
+ span = get_current_span()
576
+ if span:
577
+ attrs = build_lease_attributes(lease_id=lease_id)
578
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
579
+ if endpoint:
580
+ attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
581
+ for key, value in attrs.items():
582
+ span.set_attribute(key, value)
583
+
584
+ @_async_handle_keepalive_errors
585
+ async def refresh_lease(self, lease_id):
586
+ """Refresh a lease continuously with automatic reconnection.
587
+
588
+ This method creates a long-lived gRPC stream that sends keep-alive requests
589
+ periodically to maintain the lease alive. The caller should iterate over
590
+ this generator as long as they want the lease to be kept alive.
591
+
592
+ The stream will yield a response whenever the lease TTL is refreshed by
593
+ the server (typically when TTL drops below a threshold).
594
+
595
+ This method automatically retries on connection errors, allowing recovery
596
+ from temporary ETCD server restarts (as long as the lease hasn't expired).
597
+
598
+ Raises:
599
+ grpc.RpcError: When connection errors exceed max retry attempts or
600
+ lease is permanently not found
601
+ """
602
+ consecutive_errors = 0
603
+ max_consecutive_errors = 10 # Max consecutive errors before giving up
604
+ base_retry_delay = 1.0 # Base retry delay in seconds
605
+ max_retry_delay = 30.0 # Max retry delay in seconds
606
+
607
+ while True:
608
+ try:
609
+ lease_stub = await self._get_lease_stub()
610
+ keep_alive_request = etcdrpc.LeaseKeepAliveRequest(ID=lease_id)
611
+ # Note: We use iter([request]) for each iteration. The gRPC stream
612
+ # remains open, and etcd server will send keep-alive responses
613
+ # periodically when the TTL approaches expiration.
614
+ async for response in lease_stub.LeaseKeepAlive(
615
+ iter([keep_alive_request]),
616
+ timeout=self.client.timeout,
617
+ credentials=self.client.call_credentials,
618
+ metadata=self.client.metadata,
619
+ ):
620
+ # Reset error count on successful response
621
+ consecutive_errors = 0
622
+ yield response
623
+ except grpc.RpcError as exc:
624
+ code = exc.code()
625
+
626
+ # Lease not found (expired or revoked) - permanent failure, stop retrying
627
+ if code == grpc.StatusCode.NOT_FOUND:
628
+ logger.debug(f"Lease {lease_id} not found, stopping keepalive")
629
+ break
630
+
631
+ # Connection errors - retry with exponential backoff
632
+ if code in (
633
+ grpc.StatusCode.UNAVAILABLE,
634
+ grpc.StatusCode.DEADLINE_EXCEEDED,
635
+ grpc.StatusCode.INTERNAL,
636
+ ):
637
+ consecutive_errors += 1
638
+
639
+ if consecutive_errors >= max_consecutive_errors:
640
+ logger.error(
641
+ f"Lease {lease_id} keepalive failed after "
642
+ f"{max_consecutive_errors} consecutive errors"
643
+ )
644
+ raise # Exceeded max retries, propagate the error
645
+
646
+ # Exponential backoff with jitter
647
+ retry_delay = min(
648
+ base_retry_delay * (2 ** (consecutive_errors - 1)),
649
+ max_retry_delay
650
+ )
651
+ jitter = random.uniform(0, retry_delay * 0.1)
652
+ actual_delay = retry_delay + jitter
653
+
654
+ logger.warning(
655
+ f"Lease {lease_id} keepalive connection error ({code}), "
656
+ f"retrying in {actual_delay:.1f}s "
657
+ f"(attempt {consecutive_errors}/{max_consecutive_errors})"
658
+ )
659
+
660
+ await asyncio.sleep(actual_delay)
661
+ continue # Retry the loop
662
+
663
+ # Other errors - propagate to let the decorator handle them
664
+ raise
665
+ except GeneratorExit:
666
+ # Generator was closed externally (e.g., garbage collected or iteration stopped)
667
+ break
668
+
669
+ @_async_handle_errors
670
+ async def get_lease_info(self, lease_id):
671
+ """Get information about a lease."""
672
+ lease_timing_request = etcdrpc.LeaseTimeToLiveRequest(ID=lease_id, keys=True)
673
+ lease_stub = await self._get_lease_stub()
674
+ lease_timing_response = await lease_stub.LeaseTimeToLive(
675
+ lease_timing_request,
676
+ timeout=self.client.timeout,
677
+ credentials=self.client.call_credentials,
678
+ metadata=self.client.metadata,
679
+ )
680
+
681
+ # Set trace attributes for lease info
682
+ if is_tracing_available():
683
+ span = get_current_span()
684
+ if span:
685
+ attrs = build_lease_attributes(
686
+ lease_id=lease_id,
687
+ ttl=lease_timing_response.TTL,
688
+ remaining_ttl=getattr(lease_timing_response, 'TTL', None)
689
+ )
690
+ endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
691
+ if endpoint:
692
+ attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
693
+ for key, value in attrs.items():
694
+ if value is not None:
695
+ span.set_attribute(key, value)
696
+
697
+ return lease_timing_response
698
+
699
+
700
+ class AsyncLockOperations:
701
+ """Handles lock operations for AsyncMultiEndpointEtcd3Client."""
702
+
703
+ def __init__(self, client):
704
+ self.client = client
705
+
706
+ async def lock(self, name, ttl=60):
707
+ """Create a new async lock."""
708
+ return AsyncLock(name, ttl=ttl, etcd_client=self.client)
709
+
710
+
711
+ class AsyncClusterOperations:
712
+ """Handles cluster operations for AsyncMultiEndpointEtcd3Client."""
713
+
714
+ def __init__(self, client):
715
+ self.client = client
716
+
717
+ async def add_member(self, urls):
718
+ """Add a member into the cluster."""
719
+ return await self.client.cluster_manager.add_member(urls)
720
+
721
+ async def remove_member(self, member_id):
722
+ """Remove an existing member from the cluster."""
723
+ return await self.client.cluster_manager.remove_member(member_id)
724
+
725
+ async def update_member(self, member_id, peer_urls):
726
+ """Update the configuration of an existing member in the cluster."""
727
+ return await self.client.cluster_manager.update_member(member_id, peer_urls)
728
+
729
+ async def members(self):
730
+ """List of all members associated with the cluster."""
731
+ async for member in self.client.cluster_manager.members():
732
+ yield member
733
+
734
+
735
+ class AsyncMaintenanceOperations:
736
+ """Handles maintenance operations for AsyncMultiEndpointEtcd3Client."""
737
+
738
+ def __init__(self, client):
739
+ self.client = client
740
+
741
+ async def _get_maintenance_stub(self):
742
+ """Get the maintenance stub using the current channel with auto-reconnection.
743
+
744
+ Creates a new stub each time to ensure it uses the latest channel,
745
+ which supports automatic reconnection after etcd server restart.
746
+ """
747
+ channel = await self.client._get_channel()
748
+ return etcdrpc.MaintenanceStub(channel)
749
+
750
+ async def status(self):
751
+ """Get the status of the responding member."""
752
+ return await self.client.maintenance_manager.status()
753
+
754
+ @_async_handle_errors
755
+ async def compact(self, revision, physical=False):
756
+ """Compact the event history in etcd."""
757
+ compact_request = etcdrpc.CompactionRequest(
758
+ revision=revision, physical=physical
759
+ )
760
+ maintenance_stub = await self._get_maintenance_stub()
761
+ await maintenance_stub.Compact(
762
+ compact_request,
763
+ timeout=self.client.timeout,
764
+ credentials=self.client.call_credentials,
765
+ metadata=self.client.metadata,
766
+ )
767
+
768
+ async def defragment(self):
769
+ """Defragment a member's backend database to recover storage space."""
770
+ return await self.client.maintenance_manager.defragment()
771
+
772
+ async def hash(self):
773
+ """Return the hash of the local KV state."""
774
+ return await self.client.maintenance_manager.hash()
775
+
776
+ @_async_handle_errors
777
+ async def create_alarm(self, member_id=0):
778
+ """Create an alarm.
779
+
780
+ If no member id is given, the alarm is activated for all the
781
+ members of the cluster. Only the `no space` alarm can be raised.
782
+
783
+ :param member_id: The cluster member id to create an alarm to.
784
+ If 0, the alarm is created for all the members
785
+ of the cluster.
786
+ :returns: list of :class:`.Alarm`
787
+ """
788
+ return await self.client.maintenance_manager.create_alarm(member_id)
789
+
790
+ @_async_handle_generator_errors
791
+ async def list_alarms(self, member_id=0, alarm_type="none"):
792
+ """List the activated alarms.
793
+
794
+ :param member_id: The cluster member id to list alarms from.
795
+ If 0, alarms are listed for all the members
796
+ of the cluster.
797
+ :param alarm_type: The type of alarm to filter by.
798
+ Can be "none" or "no space".
799
+ :returns: sequence of :class:`.Alarm`
800
+ """
801
+ async for alarm in self.client.maintenance_manager.list_alarms(
802
+ member_id, alarm_type
803
+ ):
804
+ yield alarm
805
+
806
+ @_async_handle_errors
807
+ async def disarm_alarm(self, member_id=0):
808
+ """Cancel an alarm.
809
+
810
+ :param member_id: The cluster member id to cancel an alarm.
811
+ If 0, the alarm is canceled for all the members
812
+ of the cluster.
813
+ :returns: List of :class:`.Alarm`
814
+ """
815
+ return await self.client.maintenance_manager.disarm_alarm(member_id)
816
+
817
+ @_async_handle_errors
818
+ async def snapshot(self, file_obj):
819
+ """Take a snapshot of the database.
820
+
821
+ :param file_obj: A file-like object to write the database contents in.
822
+ """
823
+ return await self.client.maintenance_manager.snapshot(file_obj)
824
+
825
+
826
+ class AsyncWatchOperations:
827
+ """Handles watch operations for AsyncMultiEndpointEtcd3Client."""
828
+
829
+ def __init__(self, client):
830
+ self.client = client
831
+
832
+ async def add_watch_callback(self, *args, **kwargs):
833
+ """Watch a key or range of keys and call a callback on every response."""
834
+ return await self.client.watch_manager.add_watch_callback(*args, **kwargs)
835
+
836
+ async def add_watch_prefix_callback(self, *args, **kwargs):
837
+ """Watch a prefix and call a callback on every response."""
838
+ return await self.client.watch_manager.add_watch_prefix_callback(
839
+ *args, **kwargs
840
+ )
841
+
842
+ async def watch_response(self, *args, **kwargs):
843
+ """Watch a key."""
844
+ return await self.client.watch_manager.watch_response(*args, **kwargs)
845
+
846
+ async def watch(self, *args, **kwargs):
847
+ """Watch a key."""
848
+ return await self.client.watch_manager.watch(*args, **kwargs)
849
+
850
+ async def watch_prefix_response(self, *args, **kwargs):
851
+ """Watch a range of keys with a prefix."""
852
+ return await self.client.watch_manager.watch_prefix_response(*args, **kwargs)
853
+
854
+ async def watch_prefix(self, *args, **kwargs):
855
+ """Watch a range of keys with a prefix."""
856
+ return await self.client.watch_manager.watch_prefix(*args, **kwargs)
857
+
858
+ async def watch_once_response(self, *args, **kwargs):
859
+ """Watch a key and stop after the first response."""
860
+ return await self.client.watch_manager.watch_once_response(*args, **kwargs)
861
+
862
+ async def watch_once(self, *args, **kwargs):
863
+ """Watch a key and stop after the first event."""
864
+ return await self.client.watch_manager.watch_once(*args, **kwargs)
865
+
866
+ async def watch_prefix_once_response(self, *args, **kwargs):
867
+ """Watch a range of keys with a prefix and stop after the first response."""
868
+ return await self.client.watch_manager.watch_prefix_once_response(
869
+ *args, **kwargs
870
+ )
871
+
872
+ async def watch_prefix_once(self, *args, **kwargs):
873
+ """Watch a range of keys with a prefix and stop after the first event."""
874
+ return await self.client.watch_manager.watch_prefix_once(*args, **kwargs)
875
+
876
+ async def cancel_watch(self, watch_id):
877
+ """Cancel a watch."""
878
+ return await self.client.watch_manager.cancel_watch(watch_id)