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/client.py
ADDED
|
@@ -0,0 +1,2021 @@
|
|
|
1
|
+
"""Synchronous etcd3 client implementation.
|
|
2
|
+
|
|
3
|
+
This module provides synchronous client classes for interacting with etcd v3 API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional, Literal, Any
|
|
11
|
+
|
|
12
|
+
import grpc
|
|
13
|
+
import etcdrpc
|
|
14
|
+
import queue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
import etcd3.exceptions as exceptions
|
|
18
|
+
import etcd3.leases as leases
|
|
19
|
+
import etcd3.locks as locks
|
|
20
|
+
import etcd3.members
|
|
21
|
+
import etcd3.transactions as transactions
|
|
22
|
+
import etcd3.utils as utils
|
|
23
|
+
import etcd3.watch as watch
|
|
24
|
+
from etcd3.log import log_operation
|
|
25
|
+
from etcd3.exporters.prometheus import get_metrics_interface, monitor_request
|
|
26
|
+
|
|
27
|
+
from etcd3.shared import (
|
|
28
|
+
Transactions,
|
|
29
|
+
KVMetadata,
|
|
30
|
+
Status,
|
|
31
|
+
Alarm,
|
|
32
|
+
EtcdTokenCallCredentials,
|
|
33
|
+
_EXCEPTIONS_BY_CODE,
|
|
34
|
+
_FAILED_EP_CODES,
|
|
35
|
+
get_secure_creds,
|
|
36
|
+
_make_sync_handle_errors_with_tracing,
|
|
37
|
+
_make_sync_handle_generator_errors_with_tracing,
|
|
38
|
+
RequestBuilder,
|
|
39
|
+
)
|
|
40
|
+
from etcd3.endpoint import BaseEndpoint
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _manage_client_errors(self, exc):
|
|
44
|
+
"""Error handler for sync operations that routes to client's error handler."""
|
|
45
|
+
self._manage_grpc_errors(exc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Create trace-aware error handlers for sync operations
|
|
49
|
+
_sync_handle_errors = _make_sync_handle_errors_with_tracing(
|
|
50
|
+
_manage_client_errors, "etcd3.operation"
|
|
51
|
+
)
|
|
52
|
+
_sync_handle_generator_errors = _make_sync_handle_generator_errors_with_tracing(
|
|
53
|
+
_manage_client_errors, "etcd3.operation"
|
|
54
|
+
)
|
|
55
|
+
# Error handler for lease keepalive that propagates errors to allow detection of connection loss
|
|
56
|
+
_sync_handle_keepalive_errors = _make_sync_handle_generator_errors_with_tracing(
|
|
57
|
+
_manage_client_errors, "etcd3.keepalive", propagate_errors=True
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(kw_only=True)
|
|
62
|
+
class RangeRequestConfig:
|
|
63
|
+
"""Configuration for range request.
|
|
64
|
+
|
|
65
|
+
:param range_end: End of the key range
|
|
66
|
+
:type range_end: str or bytes
|
|
67
|
+
:param limit: Maximum number of keys to return
|
|
68
|
+
:type limit: int
|
|
69
|
+
:param revision: Revision to get
|
|
70
|
+
:type revision: int
|
|
71
|
+
:param sort_order: Sort order ('ascend' or 'descend')
|
|
72
|
+
:type sort_order: str
|
|
73
|
+
:param sort_target: Sort target ('key', 'version', 'create', 'mod')
|
|
74
|
+
:type sort_target: str
|
|
75
|
+
:param serializable: Whether to use serializable read
|
|
76
|
+
:type serializable: bool
|
|
77
|
+
:param keys_only: Whether to return only keys
|
|
78
|
+
:type keys_only: bool
|
|
79
|
+
:param count_only: Whether to return only count
|
|
80
|
+
:type count_only: bool
|
|
81
|
+
:param min_mod_revision: Minimum modification revision
|
|
82
|
+
:type min_mod_revision: int
|
|
83
|
+
:param max_mod_revision: Maximum modification revision
|
|
84
|
+
:type max_mod_revision: int
|
|
85
|
+
:param min_create_revision: Minimum creation revision
|
|
86
|
+
:type min_create_revision: int
|
|
87
|
+
:param max_create_revision: Maximum creation revision
|
|
88
|
+
:type max_create_revision: int
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
range_end: Optional[bytes | str] = None
|
|
92
|
+
limit: Optional[int] = None
|
|
93
|
+
revision: Optional[int] = None
|
|
94
|
+
sort_order: Optional[Literal["ascend", "descend"]] = None
|
|
95
|
+
sort_target: Literal["key", "version", "create", "mod"] = "key"
|
|
96
|
+
serializable: bool = False
|
|
97
|
+
keys_only: bool = False
|
|
98
|
+
count_only: bool = False
|
|
99
|
+
min_mod_revision: Optional[int] = None
|
|
100
|
+
max_mod_revision: Optional[int] = None
|
|
101
|
+
min_create_revision: Optional[int] = None
|
|
102
|
+
max_create_revision: Optional[int] = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
if self.limit is not None and self.limit < 1:
|
|
106
|
+
raise ValueError("limit must be a positive integer")
|
|
107
|
+
if self.revision is not None and self.revision < 0:
|
|
108
|
+
raise ValueError("revision must be a non-negative integer")
|
|
109
|
+
if self.min_mod_revision is not None and self.min_mod_revision < 0:
|
|
110
|
+
raise ValueError("min_mod_revision must be a non-negative integer")
|
|
111
|
+
if self.max_mod_revision is not None and self.max_mod_revision < 0:
|
|
112
|
+
raise ValueError("max_mod_revision must be a non-negative integer")
|
|
113
|
+
if self.min_create_revision is not None and self.min_create_revision < 0:
|
|
114
|
+
raise ValueError("min_create_revision must be a non-negative integer")
|
|
115
|
+
if self.max_create_revision is not None and self.max_create_revision < 0:
|
|
116
|
+
raise ValueError("max_create_revision must be a non-negative integer")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(kw_only=True)
|
|
120
|
+
class ClientConfig:
|
|
121
|
+
"""Configuration for single endpoint client creation.
|
|
122
|
+
|
|
123
|
+
:param host: etcd host
|
|
124
|
+
:type host: str
|
|
125
|
+
:param port: etcd port
|
|
126
|
+
:type port: int
|
|
127
|
+
:param ca_cert: CA certificate path
|
|
128
|
+
:type ca_cert: str
|
|
129
|
+
:param cert_key: client certificate key path
|
|
130
|
+
:type cert_key: str
|
|
131
|
+
:param cert_cert: client certificate path
|
|
132
|
+
:type cert_cert: str
|
|
133
|
+
:param timeout: request timeout in seconds
|
|
134
|
+
:type timeout: int or float
|
|
135
|
+
:param user: username for authentication
|
|
136
|
+
:type user: str
|
|
137
|
+
:param password: password for authentication
|
|
138
|
+
:type password: str
|
|
139
|
+
:param grpc_options: additional gRPC options
|
|
140
|
+
:type grpc_options: dict[str, Any]
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
host: str = "localhost"
|
|
144
|
+
port: int = 2379
|
|
145
|
+
ca_cert: Optional[str] = None
|
|
146
|
+
cert_key: Optional[str] = None
|
|
147
|
+
cert_cert: Optional[str] = None
|
|
148
|
+
timeout: Optional[float] = None
|
|
149
|
+
user: Optional[str] = None
|
|
150
|
+
password: Optional[str] = None
|
|
151
|
+
grpc_options: dict[str, Any] = field(default_factory=dict)
|
|
152
|
+
|
|
153
|
+
def __post_init__(self):
|
|
154
|
+
if self.port < 1 or self.port > 65535:
|
|
155
|
+
raise ValueError("port must be between 1 and 65535")
|
|
156
|
+
if self.timeout is not None and self.timeout <= 0:
|
|
157
|
+
raise ValueError("timeout must be a positive number")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(kw_only=True)
|
|
161
|
+
class MultiEndpointConfig:
|
|
162
|
+
"""Configuration for MultiEndpointEtcd3Client creation.
|
|
163
|
+
|
|
164
|
+
:param endpoints: Endpoints to use
|
|
165
|
+
:type endpoints: Iterable[Endpoint]
|
|
166
|
+
:param timeout: Timeout for all RPC in seconds
|
|
167
|
+
:type timeout: float, optional
|
|
168
|
+
:param user: Username for authentication
|
|
169
|
+
:type user: str, optional
|
|
170
|
+
:param password: Password for authentication
|
|
171
|
+
:type password: str, optional
|
|
172
|
+
:param failover: Failover between endpoints, default False
|
|
173
|
+
:type failover: bool
|
|
174
|
+
:param max_retries: Maximum number of retries for transient errors
|
|
175
|
+
:type max_retries: int
|
|
176
|
+
:param retry_backoff: Initial backoff time in seconds for retries
|
|
177
|
+
:type retry_backoff: float
|
|
178
|
+
:param retry_max_backoff: Maximum backoff time in seconds for retries
|
|
179
|
+
:type retry_max_backoff: float
|
|
180
|
+
:param load_balancer: Load balancing strategy
|
|
181
|
+
:type load_balancer: LoadBalancerStrategy
|
|
182
|
+
:param health_check_interval: Health check interval in seconds
|
|
183
|
+
:type health_check_interval: float
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
endpoints: Optional[list] = None
|
|
187
|
+
timeout: Optional[float] = None
|
|
188
|
+
user: Optional[str] = None
|
|
189
|
+
password: Optional[str] = None
|
|
190
|
+
failover: bool = False
|
|
191
|
+
max_retries: int = 3
|
|
192
|
+
retry_backoff: float = 0.1
|
|
193
|
+
retry_max_backoff: float = 10.0
|
|
194
|
+
load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY
|
|
195
|
+
health_check_interval: float = 30.0
|
|
196
|
+
|
|
197
|
+
def __post_init__(self):
|
|
198
|
+
if self.max_retries < 0:
|
|
199
|
+
raise ValueError("max_retries must be a non-negative integer")
|
|
200
|
+
if self.retry_backoff < 0:
|
|
201
|
+
raise ValueError("retry_backoff must be a non-negative number")
|
|
202
|
+
if self.retry_max_backoff < 0:
|
|
203
|
+
raise ValueError("retry_max_backoff must be a non-negative number")
|
|
204
|
+
if self.retry_backoff > self.retry_max_backoff:
|
|
205
|
+
raise ValueError("retry_backoff must not exceed retry_max_backoff")
|
|
206
|
+
if self.health_check_interval <= 0:
|
|
207
|
+
raise ValueError("health_check_interval must be a positive number")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Endpoint(BaseEndpoint):
|
|
211
|
+
"""Represents an etcd cluster endpoint.
|
|
212
|
+
|
|
213
|
+
:param str host: Endpoint host
|
|
214
|
+
:param int port: Endpoint port
|
|
215
|
+
:param bool secure: Use secure channel, default True
|
|
216
|
+
:param creds: Credentials to use for secure channel, required if
|
|
217
|
+
secure=True
|
|
218
|
+
:type creds: grpc.ChannelCredentials, optional
|
|
219
|
+
:param time_retry: Seconds to wait before retrying this endpoint after
|
|
220
|
+
failure, default 300.0
|
|
221
|
+
:type time_retry: int or float
|
|
222
|
+
:param opts: Additional gRPC options
|
|
223
|
+
:type opts: dict, optional
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self, host, port, secure=True, creds=None, time_retry=300.0, opts=None
|
|
228
|
+
):
|
|
229
|
+
super().__init__(host, port, secure, creds, time_retry, opts)
|
|
230
|
+
self.channel = self._mkchannel(opts)
|
|
231
|
+
|
|
232
|
+
def close(self):
|
|
233
|
+
"""Close the gRPC channel."""
|
|
234
|
+
try:
|
|
235
|
+
self.channel.close()
|
|
236
|
+
except Exception:
|
|
237
|
+
pass # Ignore close errors
|
|
238
|
+
|
|
239
|
+
def rebuild_channel(self):
|
|
240
|
+
"""Rebuild the gRPC channel.
|
|
241
|
+
|
|
242
|
+
This method closes the existing channel and creates a new one,
|
|
243
|
+
which is useful when the connection has become stale (e.g., after
|
|
244
|
+
etcd server restart).
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
self.channel.close()
|
|
248
|
+
except Exception:
|
|
249
|
+
pass # Ignore close errors
|
|
250
|
+
|
|
251
|
+
self.channel = self._mkchannel(self.opts)
|
|
252
|
+
|
|
253
|
+
def _mkchannel(self, opts):
|
|
254
|
+
if self.secure:
|
|
255
|
+
return grpc.secure_channel(self.netloc, self.credentials, options=opts)
|
|
256
|
+
else:
|
|
257
|
+
return grpc.insecure_channel(self.netloc, options=opts)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class MultiEndpointEtcd3Client(object):
|
|
261
|
+
"""
|
|
262
|
+
etcd v3 API client with multiple endpoints.
|
|
263
|
+
|
|
264
|
+
When failover is enabled, requests still will not be auto-retried.
|
|
265
|
+
Instead, the application may retry the request, and the ``Etcd3Client``
|
|
266
|
+
will then attempt to send it to a different endpoint that has not recently
|
|
267
|
+
failed. If all configured endpoints have failed and are not ready to be
|
|
268
|
+
retried, an ``exceptions.NoServerAvailableError`` will be raised.
|
|
269
|
+
|
|
270
|
+
:param config: MultiEndpointConfig object with client configuration
|
|
271
|
+
:param endpoints: Endpoints to use in lieu of host and port
|
|
272
|
+
:type endpoints: Iterable(Endpoint), optional
|
|
273
|
+
:param timeout: Timeout for all RPC in seconds
|
|
274
|
+
:type timeout: int or float, optional
|
|
275
|
+
:param user: Username for authentication
|
|
276
|
+
:type user: str, optional
|
|
277
|
+
:param password: Password for authentication
|
|
278
|
+
:type password: str, optional
|
|
279
|
+
:param bool failover: Failover between endpoints, default False
|
|
280
|
+
:param int max_retries: Maximum number of retries for transient errors
|
|
281
|
+
:param float retry_backoff: Initial backoff time in seconds for retries
|
|
282
|
+
:param float retry_max_backoff: Maximum backoff time in seconds for retries
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(
|
|
286
|
+
self,
|
|
287
|
+
config=None,
|
|
288
|
+
endpoints=None,
|
|
289
|
+
timeout=None,
|
|
290
|
+
user=None,
|
|
291
|
+
password=None,
|
|
292
|
+
failover=False,
|
|
293
|
+
max_retries=3,
|
|
294
|
+
retry_backoff=0.1,
|
|
295
|
+
retry_max_backoff=10.0,
|
|
296
|
+
load_balancer=None,
|
|
297
|
+
health_check_interval=30.0,
|
|
298
|
+
):
|
|
299
|
+
if config is not None:
|
|
300
|
+
endpoints = config.endpoints
|
|
301
|
+
timeout = config.timeout
|
|
302
|
+
user = config.user
|
|
303
|
+
password = config.password
|
|
304
|
+
failover = config.failover
|
|
305
|
+
max_retries = config.max_retries
|
|
306
|
+
retry_backoff = config.retry_backoff
|
|
307
|
+
retry_max_backoff = config.retry_max_backoff
|
|
308
|
+
load_balancer = config.load_balancer
|
|
309
|
+
health_check_interval = config.health_check_interval
|
|
310
|
+
|
|
311
|
+
if endpoints is None:
|
|
312
|
+
raise ValueError("Endpoints must be provided")
|
|
313
|
+
|
|
314
|
+
if not endpoints:
|
|
315
|
+
raise ValueError("Endpoints list cannot be empty")
|
|
316
|
+
|
|
317
|
+
self.metadata = None
|
|
318
|
+
self.failover = failover
|
|
319
|
+
self.max_retries = max_retries
|
|
320
|
+
self.retry_backoff = retry_backoff
|
|
321
|
+
self.retry_max_backoff = retry_max_backoff
|
|
322
|
+
self.load_balancer = load_balancer or utils.LoadBalancerStrategy.PRIORITY
|
|
323
|
+
self.health_check_interval = health_check_interval
|
|
324
|
+
self._rr_index = 0 # Round-robin index
|
|
325
|
+
|
|
326
|
+
# Endpoint lock for thread-safe endpoint management
|
|
327
|
+
self._endpoint_lock = threading.Lock()
|
|
328
|
+
|
|
329
|
+
# Step 1: setup endpoints
|
|
330
|
+
self.endpoints = {ep.netloc: ep for ep in endpoints}
|
|
331
|
+
self._current_ep_label = random.choice(list(self.endpoints.keys()))
|
|
332
|
+
|
|
333
|
+
# Step 2: if auth is enabled, call the auth endpoint
|
|
334
|
+
self.timeout = timeout
|
|
335
|
+
self.call_credentials = None
|
|
336
|
+
cred_params = [c is not None for c in (user, password)]
|
|
337
|
+
|
|
338
|
+
if all(cred_params):
|
|
339
|
+
auth_request = etcdrpc.AuthenticateRequest(name=user, password=password)
|
|
340
|
+
|
|
341
|
+
# Create auth stub directly (no caching needed)
|
|
342
|
+
auth_stub = etcdrpc.AuthStub(self.channel)
|
|
343
|
+
resp = auth_stub.Authenticate(auth_request, self.timeout)
|
|
344
|
+
self.metadata = (("token", resp.token),)
|
|
345
|
+
self.call_credentials = grpc.metadata_call_credentials(
|
|
346
|
+
EtcdTokenCallCredentials(resp.token)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
elif any(cred_params):
|
|
350
|
+
raise Exception(
|
|
351
|
+
"if using authentication credentials both user and password "
|
|
352
|
+
"must be specified."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
self.transactions = Transactions()
|
|
356
|
+
|
|
357
|
+
# Initialize metrics
|
|
358
|
+
self.metrics = get_metrics_interface()
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def _current_endpoint_label(self):
|
|
362
|
+
return self._current_ep_label
|
|
363
|
+
|
|
364
|
+
@_current_endpoint_label.setter
|
|
365
|
+
def _current_endpoint_label(self, value):
|
|
366
|
+
with self._endpoint_lock:
|
|
367
|
+
self._current_ep_label = value
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def endpoint_in_use(self):
|
|
371
|
+
"""Get the current endpoint in use."""
|
|
372
|
+
if self._current_endpoint_label is None:
|
|
373
|
+
return None
|
|
374
|
+
return self.endpoints[self._current_endpoint_label]
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def channel(self):
|
|
378
|
+
"""
|
|
379
|
+
Get an available channel on the first node that's not failed.
|
|
380
|
+
|
|
381
|
+
Raises an exception if no node is available
|
|
382
|
+
"""
|
|
383
|
+
with self._endpoint_lock:
|
|
384
|
+
try:
|
|
385
|
+
channel = self.endpoint_in_use.use()
|
|
386
|
+
# Record endpoint usage
|
|
387
|
+
self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
|
|
388
|
+
return channel
|
|
389
|
+
except ValueError:
|
|
390
|
+
if not self.failover:
|
|
391
|
+
raise
|
|
392
|
+
# We're failing over. We get the first non-failed channel
|
|
393
|
+
# we encounter, and use it by calling this function again,
|
|
394
|
+
# recursively
|
|
395
|
+
for label, endpoint in self.endpoints.items():
|
|
396
|
+
if endpoint.is_failed():
|
|
397
|
+
continue
|
|
398
|
+
self._current_endpoint_label = label
|
|
399
|
+
channel = self.endpoint_in_use.use()
|
|
400
|
+
# Record endpoint usage for the new endpoint
|
|
401
|
+
self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
|
|
402
|
+
return channel
|
|
403
|
+
raise exceptions.NoServerAvailableError(
|
|
404
|
+
"No endpoint available and not failed"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def _get_next_endpoint(self):
|
|
408
|
+
"""Get the next endpoint based on load balancing strategy.
|
|
409
|
+
|
|
410
|
+
:returns: A tuple of (endpoint_label, endpoint) or (None, None) if no healthy endpoint
|
|
411
|
+
"""
|
|
412
|
+
healthy_endpoints = [
|
|
413
|
+
(label, ep) for label, ep in self.endpoints.items() if not ep.is_failed()
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
if not healthy_endpoints:
|
|
417
|
+
return None, None
|
|
418
|
+
|
|
419
|
+
if self.load_balancer == utils.LoadBalancerStrategy.ROUND_ROBIN:
|
|
420
|
+
# Round-robin: select next endpoint in order
|
|
421
|
+
self._rr_index = (self._rr_index + 1) % len(healthy_endpoints)
|
|
422
|
+
return healthy_endpoints[self._rr_index - 1]
|
|
423
|
+
|
|
424
|
+
elif self.load_balancer == utils.LoadBalancerStrategy.RANDOM:
|
|
425
|
+
# Random: select random endpoint
|
|
426
|
+
return random.choice(healthy_endpoints)
|
|
427
|
+
|
|
428
|
+
elif self.load_balancer == utils.LoadBalancerStrategy.STICKY:
|
|
429
|
+
# Sticky: keep current endpoint if healthy
|
|
430
|
+
current = self.endpoint_in_use
|
|
431
|
+
if current is not None and not current.is_failed():
|
|
432
|
+
return self._current_ep_label, current
|
|
433
|
+
# Fallback to first healthy endpoint
|
|
434
|
+
return healthy_endpoints[0]
|
|
435
|
+
|
|
436
|
+
else:
|
|
437
|
+
# PRIORITY (default): keep current endpoint if healthy
|
|
438
|
+
current = self.endpoint_in_use
|
|
439
|
+
if current is not None and not current.is_failed():
|
|
440
|
+
return self._current_ep_label, current
|
|
441
|
+
# Fallback to first healthy endpoint
|
|
442
|
+
return healthy_endpoints[0]
|
|
443
|
+
|
|
444
|
+
def _try_rebuild_channel(self, endpoint):
|
|
445
|
+
"""Try to rebuild the channel, return True on success, False on failure."""
|
|
446
|
+
try:
|
|
447
|
+
endpoint.rebuild_channel()
|
|
448
|
+
return True
|
|
449
|
+
except Exception:
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
def _get_channel(self):
|
|
453
|
+
"""Get an available channel with automatic reconnection support.
|
|
454
|
+
|
|
455
|
+
Uses a two-phase locking strategy:
|
|
456
|
+
1. Quick status check (locked)
|
|
457
|
+
2. I/O operations outside the lock
|
|
458
|
+
3. Status update (locked again)
|
|
459
|
+
|
|
460
|
+
:returns: gRPC channel
|
|
461
|
+
:rtype: grpc.Channel
|
|
462
|
+
"""
|
|
463
|
+
# Phase 1: Quick status check (locked)
|
|
464
|
+
with self._endpoint_lock:
|
|
465
|
+
endpoint = self.endpoint_in_use
|
|
466
|
+
if endpoint is None:
|
|
467
|
+
raise ValueError("No current endpoint")
|
|
468
|
+
|
|
469
|
+
# Check if endpoint has recovered health
|
|
470
|
+
if endpoint.is_failed() and endpoint.is_healthy():
|
|
471
|
+
# Recovered health, reset failure mark
|
|
472
|
+
endpoint.last_failed = 0
|
|
473
|
+
endpoint.in_use = True
|
|
474
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
475
|
+
channel = endpoint.channel
|
|
476
|
+
self.metrics.record_endpoint_usage(str(endpoint))
|
|
477
|
+
return channel
|
|
478
|
+
|
|
479
|
+
needs_rebuild = endpoint.is_failed()
|
|
480
|
+
|
|
481
|
+
# Phase 2: I/O operations outside the lock (critical optimization)
|
|
482
|
+
rebuild_success = False
|
|
483
|
+
if needs_rebuild:
|
|
484
|
+
rebuild_success = self._try_rebuild_channel(endpoint)
|
|
485
|
+
if not rebuild_success:
|
|
486
|
+
raise exceptions.NoServerAvailableError("Failed to rebuild connection")
|
|
487
|
+
|
|
488
|
+
# Phase 3: Update status (acquire lock again)
|
|
489
|
+
with self._endpoint_lock:
|
|
490
|
+
# Double-check: confirm status wasn't modified by other threads during I/O
|
|
491
|
+
if endpoint.is_failed() and endpoint.is_healthy():
|
|
492
|
+
# Status has recovered
|
|
493
|
+
endpoint.last_failed = 0
|
|
494
|
+
endpoint.in_use = True
|
|
495
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
496
|
+
elif needs_rebuild and not endpoint.is_failed():
|
|
497
|
+
# Status after successful rebuild
|
|
498
|
+
endpoint.in_use = True
|
|
499
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
500
|
+
|
|
501
|
+
channel = endpoint.channel
|
|
502
|
+
self.metrics.record_endpoint_usage(str(endpoint))
|
|
503
|
+
return channel
|
|
504
|
+
|
|
505
|
+
def close(self):
|
|
506
|
+
"""Call the GRPC channel close semantics."""
|
|
507
|
+
for endpoint in self.endpoints.values():
|
|
508
|
+
endpoint.close()
|
|
509
|
+
|
|
510
|
+
def __enter__(self):
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
def __exit__(self, *args):
|
|
514
|
+
self.close()
|
|
515
|
+
|
|
516
|
+
def _manage_grpc_errors(self, exc):
|
|
517
|
+
"""Handle gRPC errors and convert to library exceptions."""
|
|
518
|
+
code = exc.code()
|
|
519
|
+
if code in _FAILED_EP_CODES:
|
|
520
|
+
# This sets the current node to failed.
|
|
521
|
+
# If others are available, they will be used on
|
|
522
|
+
# subsequent requests.
|
|
523
|
+
with self._endpoint_lock:
|
|
524
|
+
self.endpoint_in_use.fail()
|
|
525
|
+
# Record endpoint failure for monitoring
|
|
526
|
+
if hasattr(self, "monitor") and self.endpoint_in_use:
|
|
527
|
+
self.metrics.record_endpoint_failure(str(self.endpoint_in_use))
|
|
528
|
+
exception = _EXCEPTIONS_BY_CODE.get(code)
|
|
529
|
+
if exception is None:
|
|
530
|
+
raise exc # Re-raise the original exception
|
|
531
|
+
raise exception()
|
|
532
|
+
|
|
533
|
+
def _should_retry(self, exc):
|
|
534
|
+
"""Check if the error is retryable."""
|
|
535
|
+
code = getattr(exc, "code", lambda: None)()
|
|
536
|
+
if code in [
|
|
537
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
538
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
539
|
+
grpc.StatusCode.INTERNAL,
|
|
540
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED,
|
|
541
|
+
]:
|
|
542
|
+
return True
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
def _retry_with_backoff(self, func, *args, **kwargs):
|
|
546
|
+
"""Retry function with exponential backoff."""
|
|
547
|
+
retries = 0
|
|
548
|
+
backoff = self.retry_backoff
|
|
549
|
+
|
|
550
|
+
while retries <= self.max_retries:
|
|
551
|
+
try:
|
|
552
|
+
return func(*args, **kwargs)
|
|
553
|
+
except Exception as exc:
|
|
554
|
+
if not self._should_retry(exc) or retries >= self.max_retries:
|
|
555
|
+
raise
|
|
556
|
+
|
|
557
|
+
retries += 1
|
|
558
|
+
if retries > self.max_retries:
|
|
559
|
+
raise
|
|
560
|
+
|
|
561
|
+
# Record retry
|
|
562
|
+
self.metrics.record_retry(func.__name__, "unknown")
|
|
563
|
+
|
|
564
|
+
# Exponential backoff with jitter
|
|
565
|
+
backoff_time = min(
|
|
566
|
+
backoff * (2 ** (retries - 1)), self.retry_max_backoff
|
|
567
|
+
)
|
|
568
|
+
jitter = random.uniform(0, backoff_time * 0.1)
|
|
569
|
+
time.sleep(backoff_time + jitter)
|
|
570
|
+
|
|
571
|
+
def _build_get_range_request(
|
|
572
|
+
self,
|
|
573
|
+
key,
|
|
574
|
+
range_end=None,
|
|
575
|
+
limit=None,
|
|
576
|
+
revision=None,
|
|
577
|
+
sort_order=None,
|
|
578
|
+
sort_target="key",
|
|
579
|
+
serializable=False,
|
|
580
|
+
keys_only=False,
|
|
581
|
+
count_only=False,
|
|
582
|
+
min_mod_revision=None,
|
|
583
|
+
max_mod_revision=None,
|
|
584
|
+
min_create_revision=None,
|
|
585
|
+
max_create_revision=None,
|
|
586
|
+
):
|
|
587
|
+
return RequestBuilder.build_get_range_request(
|
|
588
|
+
key=key,
|
|
589
|
+
range_end=range_end,
|
|
590
|
+
limit=limit,
|
|
591
|
+
revision=revision,
|
|
592
|
+
sort_order=sort_order,
|
|
593
|
+
sort_target=sort_target,
|
|
594
|
+
serializable=serializable,
|
|
595
|
+
keys_only=keys_only,
|
|
596
|
+
count_only=count_only,
|
|
597
|
+
min_mod_revision=min_mod_revision,
|
|
598
|
+
max_mod_revision=max_mod_revision,
|
|
599
|
+
min_create_revision=min_create_revision,
|
|
600
|
+
max_create_revision=max_create_revision,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
@_sync_handle_errors
|
|
604
|
+
@monitor_request("kv.get_response")
|
|
605
|
+
@log_operation("kv.get")
|
|
606
|
+
def get_response(self, key, **kwargs):
|
|
607
|
+
"""Get the value of a key from etcd."""
|
|
608
|
+
range_request = self._build_get_range_request(key, **kwargs)
|
|
609
|
+
|
|
610
|
+
# Create KV stub directly
|
|
611
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
612
|
+
return kv_stub.Range(
|
|
613
|
+
range_request,
|
|
614
|
+
self.timeout,
|
|
615
|
+
credentials=self.call_credentials,
|
|
616
|
+
metadata=self.metadata,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def get(self, key, **kwargs):
|
|
620
|
+
"""
|
|
621
|
+
Get the value of a key from etcd.
|
|
622
|
+
|
|
623
|
+
example usage:
|
|
624
|
+
|
|
625
|
+
.. code-block:: python
|
|
626
|
+
|
|
627
|
+
>>> import etcd3
|
|
628
|
+
>>> etcd = etcd3.client()
|
|
629
|
+
>>> etcd.get('/thing/key')
|
|
630
|
+
'hello world'
|
|
631
|
+
|
|
632
|
+
:param key: key in etcd to get
|
|
633
|
+
:returns: value of key and metadata
|
|
634
|
+
:rtype: bytes, ``KVMetadata``
|
|
635
|
+
"""
|
|
636
|
+
range_response = self.get_response(key, **kwargs)
|
|
637
|
+
if range_response.count < 1:
|
|
638
|
+
return None, None
|
|
639
|
+
else:
|
|
640
|
+
kv = range_response.kvs.pop()
|
|
641
|
+
return kv.value, KVMetadata(kv, range_response.header)
|
|
642
|
+
|
|
643
|
+
@_sync_handle_errors
|
|
644
|
+
def get_prefix_response(self, key_prefix, **kwargs):
|
|
645
|
+
"""Get a range of keys with a prefix."""
|
|
646
|
+
if any(kwarg in kwargs for kwarg in ("key", "range_end")):
|
|
647
|
+
raise TypeError("Don't use key or range_end with prefix")
|
|
648
|
+
|
|
649
|
+
range_request = self._build_get_range_request(
|
|
650
|
+
key=key_prefix,
|
|
651
|
+
range_end=utils.prefix_range_end(utils.to_bytes(key_prefix)),
|
|
652
|
+
**kwargs,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# Create KV stub directly
|
|
656
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
657
|
+
return kv_stub.Range(
|
|
658
|
+
range_request,
|
|
659
|
+
self.timeout,
|
|
660
|
+
credentials=self.call_credentials,
|
|
661
|
+
metadata=self.metadata,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def get_prefix(self, key_prefix, **kwargs):
|
|
665
|
+
"""
|
|
666
|
+
Get a range of keys with a prefix.
|
|
667
|
+
|
|
668
|
+
:param key_prefix: first key in range
|
|
669
|
+
:param keys_only: if True, retrieve only the keys, not the values
|
|
670
|
+
|
|
671
|
+
:returns: sequence of (value, metadata) tuples
|
|
672
|
+
"""
|
|
673
|
+
range_response = self.get_prefix_response(key_prefix, **kwargs)
|
|
674
|
+
return (
|
|
675
|
+
(kv.value, KVMetadata(kv, range_response.header))
|
|
676
|
+
for kv in range_response.kvs
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
@_sync_handle_errors
|
|
680
|
+
def get_range_response(
|
|
681
|
+
self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
|
|
682
|
+
):
|
|
683
|
+
"""Get a range of keys."""
|
|
684
|
+
range_request = self._build_get_range_request(
|
|
685
|
+
key=range_start,
|
|
686
|
+
range_end=range_end,
|
|
687
|
+
sort_order=sort_order,
|
|
688
|
+
sort_target=sort_target,
|
|
689
|
+
**kwargs,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Create KV stub directly
|
|
693
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
694
|
+
return kv_stub.Range(
|
|
695
|
+
range_request,
|
|
696
|
+
self.timeout,
|
|
697
|
+
credentials=self.call_credentials,
|
|
698
|
+
metadata=self.metadata,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def get_range(self, range_start, range_end, **kwargs):
|
|
702
|
+
"""
|
|
703
|
+
Get a range of keys.
|
|
704
|
+
|
|
705
|
+
:param range_start: first key in range
|
|
706
|
+
:param range_end: last key in range
|
|
707
|
+
:returns: sequence of (value, metadata) tuples
|
|
708
|
+
"""
|
|
709
|
+
range_response = self.get_range_response(range_start, range_end, **kwargs)
|
|
710
|
+
for kv in range_response.kvs:
|
|
711
|
+
yield (kv.value, KVMetadata(kv, range_response.header))
|
|
712
|
+
|
|
713
|
+
@_sync_handle_errors
|
|
714
|
+
def get_all_response(self, sort_order=None, sort_target="key", keys_only=False):
|
|
715
|
+
"""Get all keys currently stored in etcd."""
|
|
716
|
+
# Use b'\x00' as the minimum key and b'\xff' * 16 as range_end to get all keys
|
|
717
|
+
# This works because etcd stores keys in sorted order by key bytes
|
|
718
|
+
# b'\x00' is the smallest non-empty key, and b'\xff' * 16 is a key that is
|
|
719
|
+
# larger than any valid key (max key length in etcd is 16 bytes)
|
|
720
|
+
range_request = self._build_get_range_request(
|
|
721
|
+
key=b"\x00",
|
|
722
|
+
range_end=b"\xff" * 16,
|
|
723
|
+
sort_order=sort_order,
|
|
724
|
+
sort_target=sort_target,
|
|
725
|
+
keys_only=keys_only,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# Create KV stub directly
|
|
729
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
730
|
+
return kv_stub.Range(
|
|
731
|
+
range_request,
|
|
732
|
+
self.timeout,
|
|
733
|
+
credentials=self.call_credentials,
|
|
734
|
+
metadata=self.metadata,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
def get_all(self, **kwargs):
|
|
738
|
+
"""
|
|
739
|
+
Get all keys currently stored in etcd.
|
|
740
|
+
|
|
741
|
+
:param keys_only: if True, retrieve only the keys, not the values
|
|
742
|
+
:returns: sequence of (value, metadata) tuples
|
|
743
|
+
"""
|
|
744
|
+
range_response = self.get_all_response(**kwargs)
|
|
745
|
+
for kv in range_response.kvs:
|
|
746
|
+
yield (kv.value, KVMetadata(kv, range_response.header))
|
|
747
|
+
|
|
748
|
+
def _build_put_request(
|
|
749
|
+
self,
|
|
750
|
+
key,
|
|
751
|
+
value=None,
|
|
752
|
+
lease=None,
|
|
753
|
+
prev_kv=False,
|
|
754
|
+
ignore_value=False,
|
|
755
|
+
ignore_lease=False,
|
|
756
|
+
):
|
|
757
|
+
return RequestBuilder.build_put_request(
|
|
758
|
+
key=key,
|
|
759
|
+
value=value,
|
|
760
|
+
lease=lease,
|
|
761
|
+
prev_kv=prev_kv,
|
|
762
|
+
ignore_value=ignore_value,
|
|
763
|
+
ignore_lease=ignore_lease,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
@_sync_handle_errors
|
|
767
|
+
@monitor_request("kv.put")
|
|
768
|
+
@log_operation("kv.put")
|
|
769
|
+
def put(
|
|
770
|
+
self,
|
|
771
|
+
key,
|
|
772
|
+
value=None,
|
|
773
|
+
lease=None,
|
|
774
|
+
prev_kv=False,
|
|
775
|
+
ignore_value=False,
|
|
776
|
+
ignore_lease=False,
|
|
777
|
+
):
|
|
778
|
+
"""
|
|
779
|
+
Save a value to etcd.
|
|
780
|
+
|
|
781
|
+
Example usage:
|
|
782
|
+
|
|
783
|
+
.. code-block:: python
|
|
784
|
+
|
|
785
|
+
>>> import etcd3
|
|
786
|
+
>>> etcd = etcd3.client()
|
|
787
|
+
>>> etcd.put('/thing/key', 'hello world')
|
|
788
|
+
|
|
789
|
+
:param key: key in etcd to set
|
|
790
|
+
:param value: value to set key to
|
|
791
|
+
:type value: bytes
|
|
792
|
+
:param lease: Lease to associate with this key.
|
|
793
|
+
:type lease: either :class:`.Lease`, or int (ID of lease)
|
|
794
|
+
:param prev_kv: return the previous key-value pair
|
|
795
|
+
:type prev_kv: bool
|
|
796
|
+
:param ignore_value: if True, update the key using the current value
|
|
797
|
+
:type ignore_value: bool
|
|
798
|
+
:param ignore_lease: if True, update the key using the current lease
|
|
799
|
+
:type ignore_lease: bool
|
|
800
|
+
:returns: a response containing a header and the prev_kv
|
|
801
|
+
:rtype: :class:`.rpc_pb2.PutResponse`
|
|
802
|
+
"""
|
|
803
|
+
put_request = self._build_put_request(
|
|
804
|
+
key,
|
|
805
|
+
value=value,
|
|
806
|
+
lease=lease,
|
|
807
|
+
prev_kv=prev_kv,
|
|
808
|
+
ignore_value=ignore_value,
|
|
809
|
+
ignore_lease=ignore_lease,
|
|
810
|
+
)
|
|
811
|
+
# Create KV stub directly
|
|
812
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
813
|
+
return kv_stub.Put(
|
|
814
|
+
put_request,
|
|
815
|
+
self.timeout,
|
|
816
|
+
credentials=self.call_credentials,
|
|
817
|
+
metadata=self.metadata,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
@_sync_handle_errors
|
|
821
|
+
def put_if_not_exists(self, key, value, lease=None):
|
|
822
|
+
"""
|
|
823
|
+
Atomically puts a value only if the key previously had no value.
|
|
824
|
+
|
|
825
|
+
This is the etcdv3 equivalent to setting a key with the etcdv2
|
|
826
|
+
parameter prevExist=false.
|
|
827
|
+
|
|
828
|
+
:param key: key in etcd to put
|
|
829
|
+
:param value: value to be written to key
|
|
830
|
+
:type value: bytes
|
|
831
|
+
:param lease: Lease to associate with this key.
|
|
832
|
+
:type lease: either :class:`.Lease`, or int (ID of lease)
|
|
833
|
+
:returns: state of transaction, ``True`` if the put was successful,
|
|
834
|
+
``False`` otherwise
|
|
835
|
+
:rtype: bool
|
|
836
|
+
"""
|
|
837
|
+
status, _ = self.transaction(
|
|
838
|
+
compare=[self.transactions.create(key) == "0"],
|
|
839
|
+
success=[self.transactions.put(key, value, lease=lease)],
|
|
840
|
+
failure=[],
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return status
|
|
844
|
+
|
|
845
|
+
@_sync_handle_errors
|
|
846
|
+
def replace(self, key, initial_value, new_value):
|
|
847
|
+
"""
|
|
848
|
+
Atomically replace the value of a key with a new value.
|
|
849
|
+
|
|
850
|
+
This compares the current value of a key, then replaces it with a new
|
|
851
|
+
value if it is equal to a specified value. This operation takes place
|
|
852
|
+
in a transaction.
|
|
853
|
+
|
|
854
|
+
:param key: key in etcd to replace
|
|
855
|
+
:param initial_value: old value to replace
|
|
856
|
+
:type initial_value: bytes
|
|
857
|
+
:param new_value: new value of the key
|
|
858
|
+
:type new_value: bytes
|
|
859
|
+
:returns: status of transaction, ``True`` if the replace was
|
|
860
|
+
successful, ``False`` otherwise
|
|
861
|
+
:rtype: bool
|
|
862
|
+
"""
|
|
863
|
+
status, _ = self.transaction(
|
|
864
|
+
compare=[self.transactions.value(key) == initial_value],
|
|
865
|
+
success=[self.transactions.put(key, new_value)],
|
|
866
|
+
failure=[],
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
return status
|
|
870
|
+
|
|
871
|
+
def _build_delete_request(self, key, range_end=None, prev_kv=False):
|
|
872
|
+
return RequestBuilder.build_delete_request(
|
|
873
|
+
key=key,
|
|
874
|
+
range_end=range_end,
|
|
875
|
+
prev_kv=prev_kv,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
@_sync_handle_errors
|
|
879
|
+
@monitor_request("kv.delete")
|
|
880
|
+
@log_operation("kv.delete")
|
|
881
|
+
def delete(self, key, prev_kv=False, return_response=False, range_end=None):
|
|
882
|
+
"""
|
|
883
|
+
Delete a key or range of keys in etcd.
|
|
884
|
+
|
|
885
|
+
:param key: key in etcd to delete
|
|
886
|
+
:type key: str or bytes
|
|
887
|
+
:param prev_kv: return the deleted key-value pair
|
|
888
|
+
:type prev_kv: bool
|
|
889
|
+
:param return_response: return the full response
|
|
890
|
+
:type return_response: bool
|
|
891
|
+
:param range_end: End of the key range (for range deletes)
|
|
892
|
+
:type range_end: str or bytes, optional
|
|
893
|
+
:returns: True if the key has been deleted when
|
|
894
|
+
``return_response`` is False and a response containing
|
|
895
|
+
a header, the number of deleted keys and prev_kvs when
|
|
896
|
+
``return_response`` is True
|
|
897
|
+
"""
|
|
898
|
+
delete_request = self._build_delete_request(
|
|
899
|
+
key, range_end=range_end, prev_kv=prev_kv
|
|
900
|
+
)
|
|
901
|
+
# Create KV stub directly
|
|
902
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
903
|
+
delete_response = kv_stub.DeleteRange(
|
|
904
|
+
delete_request,
|
|
905
|
+
self.timeout,
|
|
906
|
+
credentials=self.call_credentials,
|
|
907
|
+
metadata=self.metadata,
|
|
908
|
+
)
|
|
909
|
+
if return_response:
|
|
910
|
+
return delete_response
|
|
911
|
+
return delete_response.deleted >= 1
|
|
912
|
+
|
|
913
|
+
@_sync_handle_errors
|
|
914
|
+
@monitor_request("kv.delete_prefix")
|
|
915
|
+
@log_operation("kv.delete_prefix")
|
|
916
|
+
def delete_prefix(self, prefix):
|
|
917
|
+
"""Delete a range of keys with a prefix in etcd."""
|
|
918
|
+
delete_request = self._build_delete_request(
|
|
919
|
+
prefix, range_end=utils.prefix_range_end(utils.to_bytes(prefix))
|
|
920
|
+
)
|
|
921
|
+
# Create KV stub directly
|
|
922
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
923
|
+
return kv_stub.DeleteRange(
|
|
924
|
+
delete_request,
|
|
925
|
+
self.timeout,
|
|
926
|
+
credentials=self.call_credentials,
|
|
927
|
+
metadata=self.metadata,
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
@_sync_handle_errors
|
|
931
|
+
@monitor_request("maintenance.status")
|
|
932
|
+
@log_operation("maintenance.status")
|
|
933
|
+
def status(self):
|
|
934
|
+
"""Get the status of the responding member."""
|
|
935
|
+
status_request = etcdrpc.StatusRequest()
|
|
936
|
+
# Create maintenance stub directly
|
|
937
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
938
|
+
status_response = maintenance_stub.Status(
|
|
939
|
+
status_request,
|
|
940
|
+
self.timeout,
|
|
941
|
+
credentials=self.call_credentials,
|
|
942
|
+
metadata=self.metadata,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
for m in self.members:
|
|
946
|
+
if m.id == status_response.leader:
|
|
947
|
+
leader = m
|
|
948
|
+
break
|
|
949
|
+
else:
|
|
950
|
+
# raise exception?
|
|
951
|
+
leader = None
|
|
952
|
+
|
|
953
|
+
return Status(
|
|
954
|
+
status_response.version,
|
|
955
|
+
status_response.dbSize,
|
|
956
|
+
leader,
|
|
957
|
+
status_response.raftIndex,
|
|
958
|
+
status_response.raftTerm,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
def _get_watcher(self):
|
|
962
|
+
"""Create a new watcher instance."""
|
|
963
|
+
watchstub = etcdrpc.WatchStub(self.channel)
|
|
964
|
+
return watch.Watcher(
|
|
965
|
+
watchstub,
|
|
966
|
+
timeout=self.timeout,
|
|
967
|
+
call_credentials=self.call_credentials,
|
|
968
|
+
metadata=self.metadata,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
@_sync_handle_errors
|
|
972
|
+
@monitor_request("watch.add_callback")
|
|
973
|
+
@log_operation("watch.add_callback")
|
|
974
|
+
def add_watch_callback(self, *args, **kwargs):
|
|
975
|
+
"""
|
|
976
|
+
Watch a key or range of keys and call a callback on every response.
|
|
977
|
+
|
|
978
|
+
If timeout was declared during the client initialization and
|
|
979
|
+
the watch cannot be created during that time the method raises
|
|
980
|
+
a ``WatchTimedOut`` exception.
|
|
981
|
+
|
|
982
|
+
:param key: key to watch
|
|
983
|
+
:param callback: callback function
|
|
984
|
+
|
|
985
|
+
:returns: watch_id. Later it could be used for cancelling watch.
|
|
986
|
+
"""
|
|
987
|
+
watcher = self._get_watcher()
|
|
988
|
+
try:
|
|
989
|
+
return watcher.add_callback(*args, **kwargs)
|
|
990
|
+
except queue.Empty:
|
|
991
|
+
raise exceptions.WatchTimedOut()
|
|
992
|
+
|
|
993
|
+
@_sync_handle_errors
|
|
994
|
+
def add_watch_prefix_callback(self, key_prefix, callback, **kwargs):
|
|
995
|
+
"""
|
|
996
|
+
Watch a prefix and call a callback on every response.
|
|
997
|
+
|
|
998
|
+
If timeout was declared during the client initialization and
|
|
999
|
+
the watch cannot be created during that time the method raises
|
|
1000
|
+
a ``WatchTimedOut`` exception.
|
|
1001
|
+
|
|
1002
|
+
:param key_prefix: prefix to watch
|
|
1003
|
+
:param callback: callback function
|
|
1004
|
+
|
|
1005
|
+
:returns: watch_id. Later it could be used for cancelling watch.
|
|
1006
|
+
"""
|
|
1007
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
1008
|
+
|
|
1009
|
+
return self.add_watch_callback(key_prefix, callback, **kwargs)
|
|
1010
|
+
|
|
1011
|
+
@_sync_handle_errors
|
|
1012
|
+
def watch_response(self, key, timeout=None, **kwargs):
|
|
1013
|
+
"""
|
|
1014
|
+
Watch a key.
|
|
1015
|
+
|
|
1016
|
+
Example usage:
|
|
1017
|
+
|
|
1018
|
+
.. code-block:: python
|
|
1019
|
+
|
|
1020
|
+
responses_iterator, cancel = etcd.watch_response('/doot/key')
|
|
1021
|
+
for response in responses_iterator:
|
|
1022
|
+
print(response)
|
|
1023
|
+
|
|
1024
|
+
:param key: key to watch
|
|
1025
|
+
:param timeout: (optional) timeout in seconds for each response.
|
|
1026
|
+
If None, blocks indefinitely.
|
|
1027
|
+
|
|
1028
|
+
:returns: tuple of ``responses_iterator`` and ``cancel``.
|
|
1029
|
+
Use ``responses_iterator`` to get the watch responses,
|
|
1030
|
+
each of which contains a header and a list of events.
|
|
1031
|
+
Use ``cancel`` to cancel the watch request.
|
|
1032
|
+
"""
|
|
1033
|
+
response_queue = queue.Queue()
|
|
1034
|
+
|
|
1035
|
+
def callback(response):
|
|
1036
|
+
response_queue.put(response)
|
|
1037
|
+
|
|
1038
|
+
watch_id = self.add_watch_callback(key, callback, **kwargs)
|
|
1039
|
+
canceled = threading.Event()
|
|
1040
|
+
|
|
1041
|
+
def cancel():
|
|
1042
|
+
canceled.set()
|
|
1043
|
+
response_queue.put(None)
|
|
1044
|
+
self.cancel_watch(watch_id)
|
|
1045
|
+
|
|
1046
|
+
def iterator():
|
|
1047
|
+
try:
|
|
1048
|
+
while not canceled.is_set():
|
|
1049
|
+
try:
|
|
1050
|
+
response = response_queue.get(timeout=timeout)
|
|
1051
|
+
except queue.Empty:
|
|
1052
|
+
# Timeout reached, exit iterator
|
|
1053
|
+
canceled.set()
|
|
1054
|
+
return
|
|
1055
|
+
if response is None:
|
|
1056
|
+
canceled.set()
|
|
1057
|
+
if isinstance(response, Exception):
|
|
1058
|
+
canceled.set()
|
|
1059
|
+
raise response
|
|
1060
|
+
if not canceled.is_set():
|
|
1061
|
+
yield response
|
|
1062
|
+
except grpc.RpcError as exc:
|
|
1063
|
+
self._manage_grpc_errors(exc)
|
|
1064
|
+
|
|
1065
|
+
return iterator(), cancel
|
|
1066
|
+
|
|
1067
|
+
def watch(self, key, timeout=None, **kwargs):
|
|
1068
|
+
"""
|
|
1069
|
+
Watch a key.
|
|
1070
|
+
|
|
1071
|
+
Example usage:
|
|
1072
|
+
|
|
1073
|
+
.. code-block:: python
|
|
1074
|
+
|
|
1075
|
+
events_iterator, cancel = etcd.watch('/doot/key')
|
|
1076
|
+
for event in events_iterator:
|
|
1077
|
+
print(event)
|
|
1078
|
+
|
|
1079
|
+
:param key: key to watch
|
|
1080
|
+
:param timeout: (optional) timeout in seconds for each response.
|
|
1081
|
+
If None, blocks indefinitely.
|
|
1082
|
+
|
|
1083
|
+
:returns: tuple of ``events_iterator`` and ``cancel``.
|
|
1084
|
+
Use ``events_iterator`` to get the events of key changes
|
|
1085
|
+
and ``cancel`` to cancel the watch request.
|
|
1086
|
+
"""
|
|
1087
|
+
response_iterator, cancel = self.watch_response(key, timeout=timeout, **kwargs)
|
|
1088
|
+
return utils.response_to_event_iterator(response_iterator), cancel
|
|
1089
|
+
|
|
1090
|
+
def watch_prefix_response(self, key_prefix, timeout=None, **kwargs):
|
|
1091
|
+
"""
|
|
1092
|
+
Watch a range of keys with a prefix.
|
|
1093
|
+
|
|
1094
|
+
:param key_prefix: prefix to watch
|
|
1095
|
+
:param timeout: (optional) timeout in seconds for each response.
|
|
1096
|
+
If None, blocks indefinitely.
|
|
1097
|
+
|
|
1098
|
+
:returns: tuple of ``responses_iterator`` and ``cancel``.
|
|
1099
|
+
"""
|
|
1100
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
1101
|
+
return self.watch_response(key_prefix, timeout=timeout, **kwargs)
|
|
1102
|
+
|
|
1103
|
+
def watch_prefix(self, key_prefix, timeout=None, **kwargs):
|
|
1104
|
+
"""
|
|
1105
|
+
Watch a range of keys with a prefix.
|
|
1106
|
+
|
|
1107
|
+
:param key_prefix: prefix to watch
|
|
1108
|
+
:param timeout: (optional) timeout in seconds for each response.
|
|
1109
|
+
If None, blocks indefinitely.
|
|
1110
|
+
|
|
1111
|
+
:returns: tuple of ``events_iterator`` and ``cancel``.
|
|
1112
|
+
"""
|
|
1113
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
1114
|
+
return self.watch(key_prefix, timeout=timeout, **kwargs)
|
|
1115
|
+
|
|
1116
|
+
@_sync_handle_errors
|
|
1117
|
+
def watch_once_response(self, key, timeout=None, **kwargs):
|
|
1118
|
+
"""
|
|
1119
|
+
Watch a key and stop after the first response.
|
|
1120
|
+
|
|
1121
|
+
If the timeout was specified and response didn't arrive method
|
|
1122
|
+
will raise ``WatchTimedOut`` exception.
|
|
1123
|
+
|
|
1124
|
+
:param key: key to watch
|
|
1125
|
+
:param timeout: (optional) timeout in seconds.
|
|
1126
|
+
|
|
1127
|
+
:returns: ``WatchResponse``
|
|
1128
|
+
"""
|
|
1129
|
+
response_queue = queue.Queue()
|
|
1130
|
+
|
|
1131
|
+
def callback(response):
|
|
1132
|
+
response_queue.put(response)
|
|
1133
|
+
|
|
1134
|
+
watch_id = self.add_watch_callback(key, callback, **kwargs)
|
|
1135
|
+
|
|
1136
|
+
try:
|
|
1137
|
+
return response_queue.get(timeout=timeout)
|
|
1138
|
+
except queue.Empty:
|
|
1139
|
+
raise exceptions.WatchTimedOut()
|
|
1140
|
+
finally:
|
|
1141
|
+
self.cancel_watch(watch_id)
|
|
1142
|
+
|
|
1143
|
+
def watch_once(self, key, timeout=None, **kwargs):
|
|
1144
|
+
"""
|
|
1145
|
+
Watch a key and stop after the first event.
|
|
1146
|
+
|
|
1147
|
+
If the timeout was specified and event didn't arrive method
|
|
1148
|
+
will raise ``WatchTimedOut`` exception.
|
|
1149
|
+
|
|
1150
|
+
:param key: key to watch
|
|
1151
|
+
:param timeout: (optional) timeout in seconds.
|
|
1152
|
+
|
|
1153
|
+
:returns: ``Event``
|
|
1154
|
+
"""
|
|
1155
|
+
response = self.watch_once_response(key, timeout=timeout, **kwargs)
|
|
1156
|
+
if not response.events:
|
|
1157
|
+
raise IndexError("No events in watch response")
|
|
1158
|
+
return response.events[0]
|
|
1159
|
+
|
|
1160
|
+
def watch_prefix_once_response(self, key_prefix, timeout=None, **kwargs):
|
|
1161
|
+
"""
|
|
1162
|
+
Watch a range of keys with a prefix and stop after the first response.
|
|
1163
|
+
|
|
1164
|
+
If the timeout was specified and response didn't arrive method
|
|
1165
|
+
will raise ``WatchTimedOut`` exception.
|
|
1166
|
+
"""
|
|
1167
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
1168
|
+
return self.watch_once_response(key_prefix, timeout=timeout, **kwargs)
|
|
1169
|
+
|
|
1170
|
+
def watch_prefix_once(self, key_prefix, timeout=None, **kwargs):
|
|
1171
|
+
"""
|
|
1172
|
+
Watch a range of keys with a prefix and stop after the first event.
|
|
1173
|
+
|
|
1174
|
+
If the timeout was specified and event didn't arrive method
|
|
1175
|
+
will raise ``WatchTimedOut`` exception.
|
|
1176
|
+
"""
|
|
1177
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
1178
|
+
return self.watch_once(key_prefix, timeout=timeout, **kwargs)
|
|
1179
|
+
|
|
1180
|
+
@_sync_handle_errors
|
|
1181
|
+
@monitor_request("watch.cancel")
|
|
1182
|
+
@log_operation("watch.cancel")
|
|
1183
|
+
def cancel_watch(self, watch_id):
|
|
1184
|
+
"""
|
|
1185
|
+
Stop watching a key or range of keys.
|
|
1186
|
+
|
|
1187
|
+
:param watch_id: watch_id returned by ``add_watch_callback`` method
|
|
1188
|
+
"""
|
|
1189
|
+
watcher = self._get_watcher()
|
|
1190
|
+
watcher.cancel(watch_id)
|
|
1191
|
+
|
|
1192
|
+
def _ops_to_requests(self, ops):
|
|
1193
|
+
"""
|
|
1194
|
+
Return a list of grpc requests.
|
|
1195
|
+
|
|
1196
|
+
Returns list from an input list of etcd3.transactions.{Put, Get,
|
|
1197
|
+
Delete, Txn} objects.
|
|
1198
|
+
"""
|
|
1199
|
+
request_ops = []
|
|
1200
|
+
for op in ops:
|
|
1201
|
+
if isinstance(op, transactions.Put):
|
|
1202
|
+
request = self._build_put_request(
|
|
1203
|
+
op.key,
|
|
1204
|
+
op.value,
|
|
1205
|
+
op.lease,
|
|
1206
|
+
op.prev_kv,
|
|
1207
|
+
op.ignore_value,
|
|
1208
|
+
op.ignore_lease,
|
|
1209
|
+
)
|
|
1210
|
+
request_op = etcdrpc.RequestOp(request_put=request)
|
|
1211
|
+
request_ops.append(request_op)
|
|
1212
|
+
|
|
1213
|
+
elif isinstance(op, transactions.Get):
|
|
1214
|
+
request = self._build_get_range_request(op.key, op.range_end)
|
|
1215
|
+
request_op = etcdrpc.RequestOp(request_range=request)
|
|
1216
|
+
request_ops.append(request_op)
|
|
1217
|
+
|
|
1218
|
+
elif isinstance(op, transactions.Delete):
|
|
1219
|
+
request = self._build_delete_request(op.key, op.range_end, op.prev_kv)
|
|
1220
|
+
request_op = etcdrpc.RequestOp(request_delete_range=request)
|
|
1221
|
+
request_ops.append(request_op)
|
|
1222
|
+
|
|
1223
|
+
elif isinstance(op, transactions.Txn):
|
|
1224
|
+
compare = [c.build_message() for c in op.compare]
|
|
1225
|
+
success_ops = self._ops_to_requests(op.success)
|
|
1226
|
+
failure_ops = self._ops_to_requests(op.failure)
|
|
1227
|
+
request = etcdrpc.TxnRequest(
|
|
1228
|
+
compare=compare, success=success_ops, failure=failure_ops
|
|
1229
|
+
)
|
|
1230
|
+
request_op = etcdrpc.RequestOp(request_txn=request)
|
|
1231
|
+
request_ops.append(request_op)
|
|
1232
|
+
|
|
1233
|
+
else:
|
|
1234
|
+
raise Exception("Unknown request class {}".format(op.__class__))
|
|
1235
|
+
return request_ops
|
|
1236
|
+
|
|
1237
|
+
@_sync_handle_errors
|
|
1238
|
+
@monitor_request("kv.transaction")
|
|
1239
|
+
@log_operation("kv.transaction")
|
|
1240
|
+
def transaction(self, compare, success=None, failure=None):
|
|
1241
|
+
"""
|
|
1242
|
+
Perform a transaction.
|
|
1243
|
+
|
|
1244
|
+
Example usage:
|
|
1245
|
+
|
|
1246
|
+
.. code-block:: python
|
|
1247
|
+
|
|
1248
|
+
etcd.transaction(
|
|
1249
|
+
compare=[
|
|
1250
|
+
etcd.transactions.value('/doot/testing') == 'doot',
|
|
1251
|
+
etcd.transactions.version('/doot/testing') > 0,
|
|
1252
|
+
],
|
|
1253
|
+
success=[
|
|
1254
|
+
etcd.transactions.put('/doot/testing', 'success'),
|
|
1255
|
+
],
|
|
1256
|
+
failure=[
|
|
1257
|
+
etcd.transactions.put('/doot/testing', 'failure')
|
|
1258
|
+
]
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
:param compare: A list of comparisons to make
|
|
1262
|
+
:param success: A list of operations to perform if all the comparisons
|
|
1263
|
+
are true
|
|
1264
|
+
:param failure: A list of operations to perform if any of the
|
|
1265
|
+
comparisons are false
|
|
1266
|
+
:return: A tuple of (operation status, responses)
|
|
1267
|
+
"""
|
|
1268
|
+
compare = [c.build_message() for c in compare]
|
|
1269
|
+
|
|
1270
|
+
success_ops = self._ops_to_requests(success)
|
|
1271
|
+
failure_ops = self._ops_to_requests(failure)
|
|
1272
|
+
|
|
1273
|
+
transaction_request = etcdrpc.TxnRequest(
|
|
1274
|
+
compare=compare, success=success_ops, failure=failure_ops
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
# Create KV stub directly
|
|
1278
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
1279
|
+
txn_response = kv_stub.Txn(
|
|
1280
|
+
transaction_request,
|
|
1281
|
+
self.timeout,
|
|
1282
|
+
credentials=self.call_credentials,
|
|
1283
|
+
metadata=self.metadata,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
responses = []
|
|
1287
|
+
for response in txn_response.responses:
|
|
1288
|
+
response_type = response.WhichOneof("response")
|
|
1289
|
+
if response_type == "response_range":
|
|
1290
|
+
responses.append(
|
|
1291
|
+
[
|
|
1292
|
+
(kv.value, KVMetadata(kv, txn_response.header))
|
|
1293
|
+
for kv in response.response_range.kvs
|
|
1294
|
+
]
|
|
1295
|
+
)
|
|
1296
|
+
elif response_type == "response_put":
|
|
1297
|
+
responses.append(response.response_put)
|
|
1298
|
+
elif response_type == "response_delete_range":
|
|
1299
|
+
responses.append(response.response_delete_range)
|
|
1300
|
+
elif response_type == "response_txn":
|
|
1301
|
+
responses.append(response.response_txn)
|
|
1302
|
+
else:
|
|
1303
|
+
# Unknown response type - raise error for visibility
|
|
1304
|
+
raise exceptions.InternalServerError(
|
|
1305
|
+
f"Unknown transaction response type: {response_type}"
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
return txn_response.succeeded, responses
|
|
1309
|
+
|
|
1310
|
+
@_sync_handle_errors
|
|
1311
|
+
@monitor_request("lease.create")
|
|
1312
|
+
@log_operation("lease.create")
|
|
1313
|
+
def lease(self, ttl, lease_id=None):
|
|
1314
|
+
"""
|
|
1315
|
+
Create a new lease.
|
|
1316
|
+
|
|
1317
|
+
All keys attached to this lease will be expired and deleted if the
|
|
1318
|
+
lease expires. A lease can be sent keep alive messages to refresh the
|
|
1319
|
+
ttl.
|
|
1320
|
+
|
|
1321
|
+
:param ttl: Requested time to live
|
|
1322
|
+
:param lease_id: Requested ID for the lease
|
|
1323
|
+
|
|
1324
|
+
:returns: new lease
|
|
1325
|
+
:rtype: :class:`.Lease`
|
|
1326
|
+
"""
|
|
1327
|
+
lease_grant_request = etcdrpc.LeaseGrantRequest(TTL=ttl, ID=lease_id)
|
|
1328
|
+
# Create lease stub directly
|
|
1329
|
+
lease_stub = etcdrpc.LeaseStub(self.channel)
|
|
1330
|
+
lease_grant_response = lease_stub.LeaseGrant(
|
|
1331
|
+
lease_grant_request,
|
|
1332
|
+
self.timeout,
|
|
1333
|
+
credentials=self.call_credentials,
|
|
1334
|
+
metadata=self.metadata,
|
|
1335
|
+
)
|
|
1336
|
+
return leases.Lease(
|
|
1337
|
+
lease_id=lease_grant_response.ID,
|
|
1338
|
+
ttl=lease_grant_response.TTL,
|
|
1339
|
+
etcd_client=self,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
@_sync_handle_errors
|
|
1343
|
+
@monitor_request("lease.revoke")
|
|
1344
|
+
@log_operation("lease.revoke")
|
|
1345
|
+
def revoke_lease(self, lease_id):
|
|
1346
|
+
"""
|
|
1347
|
+
Revoke a lease.
|
|
1348
|
+
|
|
1349
|
+
:param lease_id: ID of the lease to revoke.
|
|
1350
|
+
"""
|
|
1351
|
+
lease_revoke_request = etcdrpc.LeaseRevokeRequest(ID=lease_id)
|
|
1352
|
+
# Create lease stub directly
|
|
1353
|
+
lease_stub = etcdrpc.LeaseStub(self.channel)
|
|
1354
|
+
lease_stub.LeaseRevoke(
|
|
1355
|
+
lease_revoke_request,
|
|
1356
|
+
self.timeout,
|
|
1357
|
+
credentials=self.call_credentials,
|
|
1358
|
+
metadata=self.metadata,
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
@_sync_handle_keepalive_errors
|
|
1362
|
+
def refresh_lease(
|
|
1363
|
+
self,
|
|
1364
|
+
lease_id,
|
|
1365
|
+
max_consecutive_errors=10,
|
|
1366
|
+
base_retry_delay=1.0,
|
|
1367
|
+
max_retry_delay=30.0,
|
|
1368
|
+
):
|
|
1369
|
+
"""Refresh a lease continuously with automatic reconnection.
|
|
1370
|
+
|
|
1371
|
+
This method creates a long-lived gRPC stream that sends keep-alive requests
|
|
1372
|
+
periodically to maintain the lease alive. The caller should iterate over
|
|
1373
|
+
this generator as long as they want the lease to be kept alive.
|
|
1374
|
+
|
|
1375
|
+
The stream will yield a response whenever the lease TTL is refreshed by
|
|
1376
|
+
the server (typically when TTL drops below a threshold).
|
|
1377
|
+
|
|
1378
|
+
This method automatically retries on connection errors, allowing recovery
|
|
1379
|
+
from temporary ETCD server restarts (as long as the lease hasn't expired).
|
|
1380
|
+
|
|
1381
|
+
:param lease_id: ID of the lease to refresh
|
|
1382
|
+
:param max_consecutive_errors: Maximum consecutive errors before giving up (default: 10)
|
|
1383
|
+
:param base_retry_delay: Base retry delay in seconds (default: 1.0)
|
|
1384
|
+
:param max_retry_delay: Maximum retry delay in seconds (default: 30.0)
|
|
1385
|
+
|
|
1386
|
+
Raises:
|
|
1387
|
+
grpc.RpcError: When connection errors exceed max retry attempts or
|
|
1388
|
+
lease is permanently not found
|
|
1389
|
+
"""
|
|
1390
|
+
import logging
|
|
1391
|
+
|
|
1392
|
+
logger = logging.getLogger(__name__)
|
|
1393
|
+
|
|
1394
|
+
consecutive_errors = 0
|
|
1395
|
+
|
|
1396
|
+
while True:
|
|
1397
|
+
try:
|
|
1398
|
+
lease_stub = etcdrpc.LeaseStub(self.channel)
|
|
1399
|
+
keep_alive_request = etcdrpc.LeaseKeepAliveRequest(ID=lease_id)
|
|
1400
|
+
# Note: We use iter([request]) for each iteration. The gRPC stream
|
|
1401
|
+
# remains open, and etcd server will send keep-alive responses
|
|
1402
|
+
# periodically when the TTL approaches expiration.
|
|
1403
|
+
for response in lease_stub.LeaseKeepAlive(
|
|
1404
|
+
iter([keep_alive_request]),
|
|
1405
|
+
self.timeout,
|
|
1406
|
+
credentials=self.call_credentials,
|
|
1407
|
+
metadata=self.metadata,
|
|
1408
|
+
):
|
|
1409
|
+
# Reset error count on successful response
|
|
1410
|
+
consecutive_errors = 0
|
|
1411
|
+
yield response
|
|
1412
|
+
except grpc.RpcError as exc:
|
|
1413
|
+
code = exc.code()
|
|
1414
|
+
|
|
1415
|
+
# Lease not found (expired or revoked) - permanent failure, stop retrying
|
|
1416
|
+
if code == grpc.StatusCode.NOT_FOUND:
|
|
1417
|
+
logger.debug(f"Lease {lease_id} not found, stopping keepalive")
|
|
1418
|
+
break
|
|
1419
|
+
|
|
1420
|
+
# Connection errors - retry with exponential backoff
|
|
1421
|
+
if code in (
|
|
1422
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
1423
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
1424
|
+
grpc.StatusCode.INTERNAL,
|
|
1425
|
+
):
|
|
1426
|
+
consecutive_errors += 1
|
|
1427
|
+
|
|
1428
|
+
if consecutive_errors >= max_consecutive_errors:
|
|
1429
|
+
logger.error(
|
|
1430
|
+
f"Lease {lease_id} keepalive failed after "
|
|
1431
|
+
f"{max_consecutive_errors} consecutive errors"
|
|
1432
|
+
)
|
|
1433
|
+
raise # Exceeded max retries, propagate the error
|
|
1434
|
+
|
|
1435
|
+
# Exponential backoff with jitter
|
|
1436
|
+
retry_delay = min(
|
|
1437
|
+
base_retry_delay * (2 ** (consecutive_errors - 1)),
|
|
1438
|
+
max_retry_delay,
|
|
1439
|
+
)
|
|
1440
|
+
jitter = random.uniform(0, retry_delay * 0.1)
|
|
1441
|
+
actual_delay = retry_delay + jitter
|
|
1442
|
+
|
|
1443
|
+
logger.warning(
|
|
1444
|
+
f"Lease {lease_id} keepalive connection error ({code}), "
|
|
1445
|
+
f"retrying in {actual_delay:.1f}s "
|
|
1446
|
+
f"(attempt {consecutive_errors}/{max_consecutive_errors})"
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
time.sleep(actual_delay)
|
|
1450
|
+
continue # Retry the loop
|
|
1451
|
+
|
|
1452
|
+
# Other errors - propagate to let the decorator handle them
|
|
1453
|
+
raise
|
|
1454
|
+
except GeneratorExit:
|
|
1455
|
+
# Generator was closed externally (e.g., garbage collected or iteration stopped)
|
|
1456
|
+
break
|
|
1457
|
+
|
|
1458
|
+
@_sync_handle_errors
|
|
1459
|
+
@monitor_request("lease.get_info")
|
|
1460
|
+
@log_operation("lease.get_info")
|
|
1461
|
+
def get_lease_info(self, lease_id):
|
|
1462
|
+
# only available in etcd v3.1.0 and later
|
|
1463
|
+
ttl_request = etcdrpc.LeaseTimeToLiveRequest(ID=lease_id, keys=True)
|
|
1464
|
+
# Create lease stub directly
|
|
1465
|
+
lease_stub = etcdrpc.LeaseStub(self.channel)
|
|
1466
|
+
return lease_stub.LeaseTimeToLive(
|
|
1467
|
+
ttl_request,
|
|
1468
|
+
self.timeout,
|
|
1469
|
+
credentials=self.call_credentials,
|
|
1470
|
+
metadata=self.metadata,
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
@_sync_handle_errors
|
|
1474
|
+
def lock(self, name, ttl=60):
|
|
1475
|
+
"""
|
|
1476
|
+
Create a new lock.
|
|
1477
|
+
|
|
1478
|
+
:param name: name of the lock
|
|
1479
|
+
:type name: string or bytes
|
|
1480
|
+
:param ttl: length of time for the lock to live for in seconds. The
|
|
1481
|
+
lock will be released after this time elapses, unless
|
|
1482
|
+
refreshed
|
|
1483
|
+
:type ttl: int
|
|
1484
|
+
:returns: new lock
|
|
1485
|
+
:rtype: :class:`.Lock`
|
|
1486
|
+
"""
|
|
1487
|
+
return locks.Lock(name, ttl=ttl, etcd_client=self)
|
|
1488
|
+
|
|
1489
|
+
@_sync_handle_errors
|
|
1490
|
+
def add_member(self, urls):
|
|
1491
|
+
"""
|
|
1492
|
+
Add a member into the cluster.
|
|
1493
|
+
|
|
1494
|
+
:returns: new member
|
|
1495
|
+
:rtype: :class:`.Member`
|
|
1496
|
+
"""
|
|
1497
|
+
member_add_request = etcdrpc.MemberAddRequest(peerURLs=urls)
|
|
1498
|
+
|
|
1499
|
+
# Create cluster stub directly
|
|
1500
|
+
cluster_stub = etcdrpc.ClusterStub(self.channel)
|
|
1501
|
+
member_add_response = cluster_stub.MemberAdd(
|
|
1502
|
+
member_add_request,
|
|
1503
|
+
self.timeout,
|
|
1504
|
+
credentials=self.call_credentials,
|
|
1505
|
+
metadata=self.metadata,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
member = member_add_response.member
|
|
1509
|
+
return etcd3.members.Member(
|
|
1510
|
+
member.ID, member.name, member.peerURLs, member.clientURLs, etcd_client=self
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
@_sync_handle_errors
|
|
1514
|
+
def remove_member(self, member_id):
|
|
1515
|
+
"""
|
|
1516
|
+
Remove an existing member from the cluster.
|
|
1517
|
+
|
|
1518
|
+
:param member_id: ID of the member to remove
|
|
1519
|
+
"""
|
|
1520
|
+
member_rm_request = etcdrpc.MemberRemoveRequest(ID=member_id)
|
|
1521
|
+
# Create cluster stub directly
|
|
1522
|
+
cluster_stub = etcdrpc.ClusterStub(self.channel)
|
|
1523
|
+
cluster_stub.MemberRemove(
|
|
1524
|
+
member_rm_request,
|
|
1525
|
+
self.timeout,
|
|
1526
|
+
credentials=self.call_credentials,
|
|
1527
|
+
metadata=self.metadata,
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
@_sync_handle_errors
|
|
1531
|
+
def update_member(self, member_id, peer_urls):
|
|
1532
|
+
"""
|
|
1533
|
+
Update the configuration of an existing member in the cluster.
|
|
1534
|
+
|
|
1535
|
+
:param member_id: ID of the member to update
|
|
1536
|
+
:param peer_urls: new list of peer urls the member will use to
|
|
1537
|
+
communicate with the cluster
|
|
1538
|
+
"""
|
|
1539
|
+
member_update_request = etcdrpc.MemberUpdateRequest(
|
|
1540
|
+
ID=member_id, peerURLs=peer_urls
|
|
1541
|
+
)
|
|
1542
|
+
# Create cluster stub directly
|
|
1543
|
+
cluster_stub = etcdrpc.ClusterStub(self.channel)
|
|
1544
|
+
cluster_stub.MemberUpdate(
|
|
1545
|
+
member_update_request,
|
|
1546
|
+
self.timeout,
|
|
1547
|
+
credentials=self.call_credentials,
|
|
1548
|
+
metadata=self.metadata,
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
@property
|
|
1552
|
+
def members(self):
|
|
1553
|
+
"""
|
|
1554
|
+
List of all members associated with the cluster.
|
|
1555
|
+
|
|
1556
|
+
:type: sequence of :class:`.Member`
|
|
1557
|
+
|
|
1558
|
+
"""
|
|
1559
|
+
member_list_request = etcdrpc.MemberListRequest()
|
|
1560
|
+
# Create cluster stub directly
|
|
1561
|
+
cluster_stub = etcdrpc.ClusterStub(self.channel)
|
|
1562
|
+
member_list_response = cluster_stub.MemberList(
|
|
1563
|
+
member_list_request,
|
|
1564
|
+
self.timeout,
|
|
1565
|
+
credentials=self.call_credentials,
|
|
1566
|
+
metadata=self.metadata,
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
for member in member_list_response.members:
|
|
1570
|
+
yield etcd3.members.Member(
|
|
1571
|
+
member.ID,
|
|
1572
|
+
member.name,
|
|
1573
|
+
member.peerURLs,
|
|
1574
|
+
member.clientURLs,
|
|
1575
|
+
etcd_client=self,
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
@_sync_handle_errors
|
|
1579
|
+
@monitor_request("maintenance.compact")
|
|
1580
|
+
@log_operation("maintenance.compact")
|
|
1581
|
+
def compact(self, revision, physical=False):
|
|
1582
|
+
"""
|
|
1583
|
+
Compact the event history in etcd up to a given revision.
|
|
1584
|
+
|
|
1585
|
+
All superseded keys with a revision less than the compaction revision
|
|
1586
|
+
will be removed.
|
|
1587
|
+
|
|
1588
|
+
:param revision: revision for the compaction operation
|
|
1589
|
+
:param physical: if set to True, the request will wait until the
|
|
1590
|
+
compaction is physically applied to the local database
|
|
1591
|
+
such that compacted entries are totally removed from
|
|
1592
|
+
the backend database
|
|
1593
|
+
"""
|
|
1594
|
+
compact_request = etcdrpc.CompactionRequest(
|
|
1595
|
+
revision=revision, physical=physical
|
|
1596
|
+
)
|
|
1597
|
+
# Create KV stub directly
|
|
1598
|
+
kv_stub = etcdrpc.KVStub(self.channel)
|
|
1599
|
+
kv_stub.Compact(
|
|
1600
|
+
compact_request,
|
|
1601
|
+
self.timeout,
|
|
1602
|
+
credentials=self.call_credentials,
|
|
1603
|
+
metadata=self.metadata,
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
@_sync_handle_errors
|
|
1607
|
+
@monitor_request("maintenance.defragment")
|
|
1608
|
+
@log_operation("maintenance.defragment")
|
|
1609
|
+
def defragment(self):
|
|
1610
|
+
"""Defragment a member's backend database to recover storage space."""
|
|
1611
|
+
defrag_request = etcdrpc.DefragmentRequest()
|
|
1612
|
+
# Create maintenance stub directly
|
|
1613
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1614
|
+
maintenance_stub.Defragment(
|
|
1615
|
+
defrag_request,
|
|
1616
|
+
self.timeout,
|
|
1617
|
+
credentials=self.call_credentials,
|
|
1618
|
+
metadata=self.metadata,
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
@_sync_handle_errors
|
|
1622
|
+
def hash(self):
|
|
1623
|
+
"""
|
|
1624
|
+
Return the hash of the local KV state.
|
|
1625
|
+
|
|
1626
|
+
:returns: kv state hash
|
|
1627
|
+
:rtype: int
|
|
1628
|
+
"""
|
|
1629
|
+
hash_request = etcdrpc.HashRequest()
|
|
1630
|
+
# Create maintenance stub directly
|
|
1631
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1632
|
+
return maintenance_stub.Hash(hash_request).hash
|
|
1633
|
+
|
|
1634
|
+
def _build_alarm_request(self, alarm_action, member_id, alarm_type):
|
|
1635
|
+
alarm_request = etcdrpc.AlarmRequest()
|
|
1636
|
+
|
|
1637
|
+
if alarm_action == "get":
|
|
1638
|
+
alarm_request.action = etcdrpc.AlarmRequest.GET
|
|
1639
|
+
elif alarm_action == "activate":
|
|
1640
|
+
alarm_request.action = etcdrpc.AlarmRequest.ACTIVATE
|
|
1641
|
+
elif alarm_action == "deactivate":
|
|
1642
|
+
alarm_request.action = etcdrpc.AlarmRequest.DEACTIVATE
|
|
1643
|
+
else:
|
|
1644
|
+
raise ValueError("Unknown alarm action: {}".format(alarm_action))
|
|
1645
|
+
|
|
1646
|
+
alarm_request.memberID = member_id
|
|
1647
|
+
|
|
1648
|
+
if alarm_type == "none":
|
|
1649
|
+
alarm_request.alarm = etcdrpc.NONE
|
|
1650
|
+
elif alarm_type == "no space":
|
|
1651
|
+
alarm_request.alarm = etcdrpc.NOSPACE
|
|
1652
|
+
else:
|
|
1653
|
+
raise ValueError("Unknown alarm type: {}".format(alarm_type))
|
|
1654
|
+
|
|
1655
|
+
return alarm_request
|
|
1656
|
+
|
|
1657
|
+
@_sync_handle_errors
|
|
1658
|
+
def create_alarm(self, member_id=0):
|
|
1659
|
+
"""Create an alarm.
|
|
1660
|
+
|
|
1661
|
+
If no member id is given, the alarm is activated for all the
|
|
1662
|
+
members of the cluster. Only the `no space` alarm can be raised.
|
|
1663
|
+
|
|
1664
|
+
:param member_id: The cluster member id to create an alarm to.
|
|
1665
|
+
If 0, the alarm is created for all the members
|
|
1666
|
+
of the cluster.
|
|
1667
|
+
:returns: list of :class:`.Alarm`
|
|
1668
|
+
"""
|
|
1669
|
+
alarm_request = self._build_alarm_request("activate", member_id, "no space")
|
|
1670
|
+
# Create maintenance stub directly
|
|
1671
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1672
|
+
alarm_response = maintenance_stub.Alarm(
|
|
1673
|
+
alarm_request,
|
|
1674
|
+
self.timeout,
|
|
1675
|
+
credentials=self.call_credentials,
|
|
1676
|
+
metadata=self.metadata,
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
return [Alarm(alarm.alarm, alarm.memberID) for alarm in alarm_response.alarms]
|
|
1680
|
+
|
|
1681
|
+
@_sync_handle_errors
|
|
1682
|
+
def list_alarms(self, member_id=0, alarm_type="none"):
|
|
1683
|
+
"""List the activated alarms.
|
|
1684
|
+
|
|
1685
|
+
:param member_id:
|
|
1686
|
+
:param alarm_type: The cluster member id to create an alarm to.
|
|
1687
|
+
If 0, the alarm is created for all the members
|
|
1688
|
+
of the cluster.
|
|
1689
|
+
:returns: sequence of :class:`.Alarm`
|
|
1690
|
+
"""
|
|
1691
|
+
alarm_request = self._build_alarm_request("get", member_id, alarm_type)
|
|
1692
|
+
# Create maintenance stub directly
|
|
1693
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1694
|
+
alarm_response = maintenance_stub.Alarm(
|
|
1695
|
+
alarm_request,
|
|
1696
|
+
self.timeout,
|
|
1697
|
+
credentials=self.call_credentials,
|
|
1698
|
+
metadata=self.metadata,
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
for alarm in alarm_response.alarms:
|
|
1702
|
+
yield Alarm(alarm.alarm, alarm.memberID)
|
|
1703
|
+
|
|
1704
|
+
@_sync_handle_errors
|
|
1705
|
+
def disarm_alarm(self, member_id=0):
|
|
1706
|
+
"""Cancel an alarm.
|
|
1707
|
+
|
|
1708
|
+
:param member_id: The cluster member id to cancel an alarm.
|
|
1709
|
+
If 0, the alarm is canceled for all the members
|
|
1710
|
+
of the cluster.
|
|
1711
|
+
:returns: List of :class:`.Alarm`
|
|
1712
|
+
"""
|
|
1713
|
+
alarm_request = self._build_alarm_request("deactivate", member_id, "no space")
|
|
1714
|
+
# Create maintenance stub directly
|
|
1715
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1716
|
+
alarm_response = maintenance_stub.Alarm(
|
|
1717
|
+
alarm_request,
|
|
1718
|
+
self.timeout,
|
|
1719
|
+
credentials=self.call_credentials,
|
|
1720
|
+
metadata=self.metadata,
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
return [Alarm(alarm.alarm, alarm.memberID) for alarm in alarm_response.alarms]
|
|
1724
|
+
|
|
1725
|
+
@_sync_handle_errors
|
|
1726
|
+
def snapshot(self, file_obj):
|
|
1727
|
+
"""Take a snapshot of the database.
|
|
1728
|
+
|
|
1729
|
+
:param file_obj: A file-like object to write the database contents in.
|
|
1730
|
+
"""
|
|
1731
|
+
snapshot_request = etcdrpc.SnapshotRequest()
|
|
1732
|
+
# Create maintenance stub directly
|
|
1733
|
+
maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
|
|
1734
|
+
snapshot_response = maintenance_stub.Snapshot(
|
|
1735
|
+
snapshot_request,
|
|
1736
|
+
self.timeout,
|
|
1737
|
+
credentials=self.call_credentials,
|
|
1738
|
+
metadata=self.metadata,
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
for response in snapshot_response:
|
|
1742
|
+
file_obj.write(response.blob)
|
|
1743
|
+
|
|
1744
|
+
def batch_put(self, items, atomic=True):
|
|
1745
|
+
"""Batch put multiple key-value pairs.
|
|
1746
|
+
|
|
1747
|
+
By default, this method executes all puts as a single atomic transaction,
|
|
1748
|
+
ensuring either all operations succeed or none do. If atomic=False,
|
|
1749
|
+
operations are executed sequentially.
|
|
1750
|
+
|
|
1751
|
+
:param items: Iterable of (key, value) tuples to put
|
|
1752
|
+
:type items: Iterable[tuple[str | bytes, str | bytes]]
|
|
1753
|
+
:param atomic: If True (default), execute as atomic transaction.
|
|
1754
|
+
:type atomic: bool
|
|
1755
|
+
:returns: Number of successful operations (atomic=True: either all or none)
|
|
1756
|
+
:rtype: int
|
|
1757
|
+
:raises BatchOperationError: If atomic=True and some operations fail
|
|
1758
|
+
"""
|
|
1759
|
+
# Convert to list to allow multiple iterations
|
|
1760
|
+
items_list = list(items)
|
|
1761
|
+
if not items_list:
|
|
1762
|
+
return 0
|
|
1763
|
+
|
|
1764
|
+
if atomic:
|
|
1765
|
+
# Use atomic transaction for all operations
|
|
1766
|
+
success_ops = [transactions.Put(key, value) for key, value in items_list]
|
|
1767
|
+
# Use a dummy compare that always succeeds (create_revision > -1 is always true)
|
|
1768
|
+
compare = [transactions.Create(key) > -1 for key, _ in items_list]
|
|
1769
|
+
|
|
1770
|
+
try:
|
|
1771
|
+
success, _ = self.transaction(
|
|
1772
|
+
compare=compare,
|
|
1773
|
+
success=success_ops,
|
|
1774
|
+
failure=[],
|
|
1775
|
+
)
|
|
1776
|
+
if success:
|
|
1777
|
+
return len(items_list)
|
|
1778
|
+
else:
|
|
1779
|
+
raise exceptions.BatchOperationError(
|
|
1780
|
+
f"Atomic batch put failed for {len(items_list)} items"
|
|
1781
|
+
)
|
|
1782
|
+
except Exception as exc:
|
|
1783
|
+
if isinstance(exc, exceptions.BatchOperationError):
|
|
1784
|
+
raise
|
|
1785
|
+
raise exceptions.BatchOperationError(
|
|
1786
|
+
f"Batch put failed: {exc}"
|
|
1787
|
+
) from exc
|
|
1788
|
+
else:
|
|
1789
|
+
# Non-atomic: sequential execution
|
|
1790
|
+
success_count = 0
|
|
1791
|
+
for key, value in items_list:
|
|
1792
|
+
try:
|
|
1793
|
+
self.put(key, value)
|
|
1794
|
+
success_count += 1
|
|
1795
|
+
except Exception:
|
|
1796
|
+
pass # Continue with next item
|
|
1797
|
+
return success_count
|
|
1798
|
+
|
|
1799
|
+
def batch_delete(self, keys, atomic=True):
|
|
1800
|
+
"""Batch delete multiple keys.
|
|
1801
|
+
|
|
1802
|
+
By default, this method executes all deletes as a single atomic transaction,
|
|
1803
|
+
ensuring either all operations succeed or none do. If atomic=False,
|
|
1804
|
+
operations are executed sequentially.
|
|
1805
|
+
|
|
1806
|
+
:param keys: Iterable of keys to delete
|
|
1807
|
+
:type keys: Iterable[str | bytes]
|
|
1808
|
+
:param atomic: If True (default), execute as atomic transaction.
|
|
1809
|
+
:type atomic: bool
|
|
1810
|
+
:returns: Number of successful operations (atomic=True: either all or none)
|
|
1811
|
+
:rtype: int
|
|
1812
|
+
:raises BatchOperationError: If atomic=True and some operations fail
|
|
1813
|
+
"""
|
|
1814
|
+
# Convert to list to allow multiple iterations
|
|
1815
|
+
keys_list = list(keys)
|
|
1816
|
+
if not keys_list:
|
|
1817
|
+
return 0
|
|
1818
|
+
|
|
1819
|
+
if atomic:
|
|
1820
|
+
# Use atomic transaction for all operations
|
|
1821
|
+
delete_ops = [transactions.Delete(key) for key in keys_list]
|
|
1822
|
+
# Use a dummy compare that always succeeds (create_revision > -1 is always true)
|
|
1823
|
+
compare = [transactions.Create(key) > -1 for key in keys_list]
|
|
1824
|
+
|
|
1825
|
+
try:
|
|
1826
|
+
success, _ = self.transaction(
|
|
1827
|
+
compare=compare,
|
|
1828
|
+
success=delete_ops,
|
|
1829
|
+
failure=[],
|
|
1830
|
+
)
|
|
1831
|
+
if success:
|
|
1832
|
+
return len(keys_list)
|
|
1833
|
+
else:
|
|
1834
|
+
raise exceptions.BatchOperationError(
|
|
1835
|
+
f"Atomic batch delete failed for {len(keys_list)} keys"
|
|
1836
|
+
)
|
|
1837
|
+
except Exception as exc:
|
|
1838
|
+
if isinstance(exc, exceptions.BatchOperationError):
|
|
1839
|
+
raise
|
|
1840
|
+
raise exceptions.BatchOperationError(
|
|
1841
|
+
f"Batch delete failed: {exc}"
|
|
1842
|
+
) from exc
|
|
1843
|
+
else:
|
|
1844
|
+
# Non-atomic: sequential execution
|
|
1845
|
+
success_count = 0
|
|
1846
|
+
for key in keys_list:
|
|
1847
|
+
try:
|
|
1848
|
+
result = self.delete(key)
|
|
1849
|
+
if isinstance(result, bool):
|
|
1850
|
+
if result:
|
|
1851
|
+
success_count += 1
|
|
1852
|
+
else:
|
|
1853
|
+
success_count += result
|
|
1854
|
+
except Exception:
|
|
1855
|
+
pass # Continue with next item
|
|
1856
|
+
return success_count
|
|
1857
|
+
|
|
1858
|
+
def put_many(self, items, timeout=None):
|
|
1859
|
+
"""Put multiple key-value pairs sequentially.
|
|
1860
|
+
|
|
1861
|
+
This is a simpler interface for batch puts that handles all
|
|
1862
|
+
operations sequentially.
|
|
1863
|
+
|
|
1864
|
+
:param items: Dict of {key: value} pairs or iterable of (key, value) tuples
|
|
1865
|
+
:type items: dict or Iterable[tuple]
|
|
1866
|
+
:param timeout: Optional timeout for each individual put operation
|
|
1867
|
+
:type timeout: float
|
|
1868
|
+
:returns: Tuple of (success_count, failed_keys)
|
|
1869
|
+
:rtype: tuple[int, list]
|
|
1870
|
+
"""
|
|
1871
|
+
if isinstance(items, dict):
|
|
1872
|
+
items = list(items.items())
|
|
1873
|
+
|
|
1874
|
+
success_count = 0
|
|
1875
|
+
failed_keys = []
|
|
1876
|
+
|
|
1877
|
+
for key, value in items:
|
|
1878
|
+
try:
|
|
1879
|
+
self.put(key, value)
|
|
1880
|
+
success_count += 1
|
|
1881
|
+
except Exception as exc:
|
|
1882
|
+
failed_keys.append((key, exc))
|
|
1883
|
+
|
|
1884
|
+
return success_count, failed_keys
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
class Etcd3Client(MultiEndpointEtcd3Client):
|
|
1888
|
+
"""
|
|
1889
|
+
etcd v3 API client.
|
|
1890
|
+
|
|
1891
|
+
:param config: ClientConfig object with client configuration
|
|
1892
|
+
:param host: Host to connect to, 'localhost' if not specified
|
|
1893
|
+
:type host: str, optional
|
|
1894
|
+
:param port: Port to connect to on host, 2379 if not specified
|
|
1895
|
+
:type port: int, optional
|
|
1896
|
+
:param ca_cert: Filesystem path of etcd CA certificate
|
|
1897
|
+
:type ca_cert: str or os.PathLike, optional
|
|
1898
|
+
:param cert_key: Filesystem path of client key
|
|
1899
|
+
:type cert_key: str or os.PathLike, optional
|
|
1900
|
+
:param cert_cert: Filesystem path of client certificate
|
|
1901
|
+
:type cert_cert: str or os.PathLike, optional
|
|
1902
|
+
:param timeout: Timeout for all RPC in seconds
|
|
1903
|
+
:type timeout: int or float, optional
|
|
1904
|
+
:param user: Username for authentication
|
|
1905
|
+
:type user: str, optional
|
|
1906
|
+
:param password: Password for authentication
|
|
1907
|
+
:type password: str, optional
|
|
1908
|
+
:param dict grpc_options: Additional gRPC options
|
|
1909
|
+
:type grpc_options: dict, optional
|
|
1910
|
+
"""
|
|
1911
|
+
|
|
1912
|
+
def __init__(
|
|
1913
|
+
self,
|
|
1914
|
+
config=None,
|
|
1915
|
+
host="localhost",
|
|
1916
|
+
port=2379,
|
|
1917
|
+
ca_cert=None,
|
|
1918
|
+
cert_key=None,
|
|
1919
|
+
cert_cert=None,
|
|
1920
|
+
timeout=None,
|
|
1921
|
+
user=None,
|
|
1922
|
+
password=None,
|
|
1923
|
+
grpc_options=None,
|
|
1924
|
+
):
|
|
1925
|
+
# Use config object if provided
|
|
1926
|
+
if config is not None:
|
|
1927
|
+
host = config.host
|
|
1928
|
+
port = config.port
|
|
1929
|
+
ca_cert = config.ca_cert
|
|
1930
|
+
cert_key = config.cert_key
|
|
1931
|
+
cert_cert = config.cert_cert
|
|
1932
|
+
timeout = config.timeout
|
|
1933
|
+
user = config.user
|
|
1934
|
+
password = config.password
|
|
1935
|
+
grpc_options = config.grpc_options
|
|
1936
|
+
|
|
1937
|
+
# Step 1: verify credentials
|
|
1938
|
+
cert_params = [c is not None for c in (cert_cert, cert_key)]
|
|
1939
|
+
if ca_cert is not None:
|
|
1940
|
+
if all(cert_params):
|
|
1941
|
+
credentials = get_secure_creds(ca_cert, cert_key, cert_cert)
|
|
1942
|
+
self.uses_secure_channel = True
|
|
1943
|
+
elif any(cert_params):
|
|
1944
|
+
# some of the cert parameters are set
|
|
1945
|
+
raise ValueError(
|
|
1946
|
+
"to use a secure channel ca_cert is required by itself, "
|
|
1947
|
+
"or cert_cert and cert_key must both be specified."
|
|
1948
|
+
)
|
|
1949
|
+
else:
|
|
1950
|
+
credentials = get_secure_creds(ca_cert, None, None)
|
|
1951
|
+
self.uses_secure_channel = True
|
|
1952
|
+
else:
|
|
1953
|
+
self.uses_secure_channel = False
|
|
1954
|
+
credentials = None
|
|
1955
|
+
|
|
1956
|
+
# Step 2: create Endpoint
|
|
1957
|
+
ep = Endpoint(
|
|
1958
|
+
host,
|
|
1959
|
+
port,
|
|
1960
|
+
secure=self.uses_secure_channel,
|
|
1961
|
+
creds=credentials,
|
|
1962
|
+
opts=grpc_options,
|
|
1963
|
+
)
|
|
1964
|
+
|
|
1965
|
+
super(Etcd3Client, self).__init__(
|
|
1966
|
+
endpoints=[ep], timeout=timeout, user=user, password=password
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def client(
|
|
1971
|
+
config=None,
|
|
1972
|
+
host="localhost",
|
|
1973
|
+
port=2379,
|
|
1974
|
+
ca_cert=None,
|
|
1975
|
+
cert_key=None,
|
|
1976
|
+
cert_cert=None,
|
|
1977
|
+
timeout=None,
|
|
1978
|
+
user=None,
|
|
1979
|
+
password=None,
|
|
1980
|
+
grpc_options=None,
|
|
1981
|
+
):
|
|
1982
|
+
"""Return an instance of an Etcd3Client.
|
|
1983
|
+
|
|
1984
|
+
:param config: ClientConfig object with client configuration
|
|
1985
|
+
:param host: etcd host (ignored if config is provided)
|
|
1986
|
+
:type host: str, optional
|
|
1987
|
+
:param port: etcd port (ignored if config is provided)
|
|
1988
|
+
:type port: int, optional
|
|
1989
|
+
:param ca_cert: CA certificate path (ignored if config is provided)
|
|
1990
|
+
:type ca_cert: str, optional
|
|
1991
|
+
:param cert_key: client certificate key path (ignored if config is provided)
|
|
1992
|
+
:type cert_key: str, optional
|
|
1993
|
+
:param cert_cert: client certificate path (ignored if config is provided)
|
|
1994
|
+
:type cert_cert: str, optional
|
|
1995
|
+
:param timeout: request timeout in seconds (ignored if config is provided)
|
|
1996
|
+
:type timeout: int or float, optional
|
|
1997
|
+
:param user: username for authentication (ignored if config is provided)
|
|
1998
|
+
:type user: str, optional
|
|
1999
|
+
:param password: password for authentication (ignored if config is provided)
|
|
2000
|
+
:type password: str, optional
|
|
2001
|
+
:param grpc_options: additional gRPC options (ignored if config is provided)
|
|
2002
|
+
:type grpc_options: dict[str, Any], optional
|
|
2003
|
+
:return: Etcd3Client instance
|
|
2004
|
+
"""
|
|
2005
|
+
# Auto-initialize observability
|
|
2006
|
+
from etcd3.observability import auto_init
|
|
2007
|
+
|
|
2008
|
+
auto_init()
|
|
2009
|
+
|
|
2010
|
+
return Etcd3Client(
|
|
2011
|
+
config=config,
|
|
2012
|
+
host=host,
|
|
2013
|
+
port=port,
|
|
2014
|
+
ca_cert=ca_cert,
|
|
2015
|
+
cert_key=cert_key,
|
|
2016
|
+
cert_cert=cert_cert,
|
|
2017
|
+
timeout=timeout,
|
|
2018
|
+
user=user,
|
|
2019
|
+
password=password,
|
|
2020
|
+
grpc_options=grpc_options,
|
|
2021
|
+
)
|