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/async_client.py
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
"""Async etcd3 client implementation using grpc.aio.
|
|
2
|
+
|
|
3
|
+
This module provides asynchronous client classes for interacting with etcd v3 API
|
|
4
|
+
using the asynchronous gRPC client from grpcio package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import functools
|
|
9
|
+
import random
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional, Literal, Any
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
import etcdrpc
|
|
16
|
+
|
|
17
|
+
from etcd3 import exceptions
|
|
18
|
+
from etcd3 import utils
|
|
19
|
+
from etcd3 import transactions
|
|
20
|
+
from etcd3.async_endpoint import AsyncEndpoint
|
|
21
|
+
from etcd3.shared import (
|
|
22
|
+
KVMetadata,
|
|
23
|
+
EXCEPTIONS_BY_CODE,
|
|
24
|
+
FAILED_EP_CODES,
|
|
25
|
+
Transactions,
|
|
26
|
+
EtcdTokenCallCredentials,
|
|
27
|
+
RequestBuilder,
|
|
28
|
+
)
|
|
29
|
+
from etcd3.async_managers import (
|
|
30
|
+
AsyncWatchManager,
|
|
31
|
+
AsyncClusterManager,
|
|
32
|
+
AsyncMaintenanceManager,
|
|
33
|
+
)
|
|
34
|
+
from etcd3.exporters.prometheus import get_metrics_interface, monitor_request
|
|
35
|
+
from etcd3.log import log_operation_async
|
|
36
|
+
from etcd3.async_client_extensions import (
|
|
37
|
+
AsyncKVOperations,
|
|
38
|
+
AsyncLeaseOperations,
|
|
39
|
+
AsyncLockOperations,
|
|
40
|
+
AsyncClusterOperations,
|
|
41
|
+
AsyncMaintenanceOperations,
|
|
42
|
+
AsyncWatchOperations,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(kw_only=True)
|
|
47
|
+
class RangeRequestConfig:
|
|
48
|
+
"""Configuration for range request.
|
|
49
|
+
|
|
50
|
+
:param range_end: End of the key range
|
|
51
|
+
:type range_end: str or bytes
|
|
52
|
+
:param limit: Maximum number of keys to return
|
|
53
|
+
:type limit: int
|
|
54
|
+
:param revision: Revision to get
|
|
55
|
+
:type revision: int
|
|
56
|
+
:param sort_order: Sort order ('ascend' or 'descend')
|
|
57
|
+
:type sort_order: str
|
|
58
|
+
:param sort_target: Sort target ('key', 'version', 'create', 'mod')
|
|
59
|
+
:type sort_target: str
|
|
60
|
+
:param serializable: Whether to use serializable read
|
|
61
|
+
:type serializable: bool
|
|
62
|
+
:param keys_only: Whether to return only keys
|
|
63
|
+
:type keys_only: bool
|
|
64
|
+
:param count_only: Whether to return only count
|
|
65
|
+
:type count_only: bool
|
|
66
|
+
:param min_mod_revision: Minimum modification revision
|
|
67
|
+
:type min_mod_revision: int
|
|
68
|
+
:param max_mod_revision: Maximum modification revision
|
|
69
|
+
:type max_mod_revision: int
|
|
70
|
+
:param min_create_revision: Minimum creation revision
|
|
71
|
+
:type min_create_revision: int
|
|
72
|
+
:param max_create_revision: Maximum creation revision
|
|
73
|
+
:type max_create_revision: int
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
range_end: Optional[bytes | str] = None
|
|
77
|
+
limit: Optional[int] = None
|
|
78
|
+
revision: Optional[int] = None
|
|
79
|
+
sort_order: Optional[Literal["ascend", "descend"]] = None
|
|
80
|
+
sort_target: Literal["key", "version", "create", "mod"] = "key"
|
|
81
|
+
serializable: bool = False
|
|
82
|
+
keys_only: bool = False
|
|
83
|
+
count_only: bool = False
|
|
84
|
+
min_mod_revision: Optional[int] = None
|
|
85
|
+
max_mod_revision: Optional[int] = None
|
|
86
|
+
min_create_revision: Optional[int] = None
|
|
87
|
+
max_create_revision: Optional[int] = None
|
|
88
|
+
|
|
89
|
+
def __post_init__(self):
|
|
90
|
+
if self.limit is not None and self.limit < 1:
|
|
91
|
+
raise ValueError("limit must be a positive integer")
|
|
92
|
+
if self.revision is not None and self.revision < 0:
|
|
93
|
+
raise ValueError("revision must be a non-negative integer")
|
|
94
|
+
if self.min_mod_revision is not None and self.min_mod_revision < 0:
|
|
95
|
+
raise ValueError("min_mod_revision must be a non-negative integer")
|
|
96
|
+
if self.max_mod_revision is not None and self.max_mod_revision < 0:
|
|
97
|
+
raise ValueError("max_mod_revision must be a non-negative integer")
|
|
98
|
+
if self.min_create_revision is not None and self.min_create_revision < 0:
|
|
99
|
+
raise ValueError("min_create_revision must be a non-negative integer")
|
|
100
|
+
if self.max_create_revision is not None and self.max_create_revision < 0:
|
|
101
|
+
raise ValueError("max_create_revision must be a non-negative integer")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(kw_only=True)
|
|
105
|
+
class AsyncClientConfig:
|
|
106
|
+
"""Configuration for async client creation.
|
|
107
|
+
|
|
108
|
+
:param host: etcd host
|
|
109
|
+
:type host: str
|
|
110
|
+
:param port: etcd port
|
|
111
|
+
:type port: int
|
|
112
|
+
:param ca_cert: CA certificate path
|
|
113
|
+
:type ca_cert: str
|
|
114
|
+
:param cert_key: client certificate key path
|
|
115
|
+
:type cert_key: str
|
|
116
|
+
:param cert_cert: client certificate path
|
|
117
|
+
:type cert_cert: str
|
|
118
|
+
:param timeout: request timeout in seconds
|
|
119
|
+
:type timeout: int or float
|
|
120
|
+
:param user: username for authentication
|
|
121
|
+
:type user: str
|
|
122
|
+
:param password: password for authentication
|
|
123
|
+
:type password: str
|
|
124
|
+
:param grpc_options: additional gRPC options
|
|
125
|
+
:type grpc_options: dict[str, Any]
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
host: str = "localhost"
|
|
129
|
+
port: int = 2379
|
|
130
|
+
ca_cert: Optional[str] = None
|
|
131
|
+
cert_key: Optional[str] = None
|
|
132
|
+
cert_cert: Optional[str] = None
|
|
133
|
+
timeout: Optional[float] = None
|
|
134
|
+
user: Optional[str] = None
|
|
135
|
+
password: Optional[str] = None
|
|
136
|
+
grpc_options: dict[str, Any] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
def __post_init__(self):
|
|
139
|
+
if self.port < 1 or self.port > 65535:
|
|
140
|
+
raise ValueError("port must be between 1 and 65535")
|
|
141
|
+
if self.timeout is not None and self.timeout <= 0:
|
|
142
|
+
raise ValueError("timeout must be a positive number")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(kw_only=True)
|
|
146
|
+
class AsyncMultiEndpointConfig:
|
|
147
|
+
"""Configuration for AsyncMultiEndpointEtcd3Client creation.
|
|
148
|
+
|
|
149
|
+
:param endpoints: Endpoints to use
|
|
150
|
+
:type endpoints: Iterable[Endpoint]
|
|
151
|
+
:param timeout: Timeout for all RPC in seconds
|
|
152
|
+
:type timeout: float, optional
|
|
153
|
+
:param user: Username for authentication
|
|
154
|
+
:type user: str, optional
|
|
155
|
+
:param password: Password for authentication
|
|
156
|
+
:type password: str, optional
|
|
157
|
+
:param failover: Failover between endpoints, default False
|
|
158
|
+
:type failover: bool
|
|
159
|
+
:param max_retries: Maximum number of retries for transient errors
|
|
160
|
+
:type max_retries: int
|
|
161
|
+
:param retry_backoff: Initial backoff time in seconds for retries
|
|
162
|
+
:type retry_backoff: float
|
|
163
|
+
:param retry_max_backoff: Maximum backoff time in seconds for retries
|
|
164
|
+
:type retry_max_backoff: float
|
|
165
|
+
:param load_balancer: Load balancing strategy
|
|
166
|
+
:type load_balancer: LoadBalancerStrategy
|
|
167
|
+
:param health_check_interval: Health check interval in seconds
|
|
168
|
+
:type health_check_interval: float
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
endpoints: Optional[list] = None
|
|
172
|
+
timeout: Optional[float] = None
|
|
173
|
+
user: Optional[str] = None
|
|
174
|
+
password: Optional[str] = None
|
|
175
|
+
failover: bool = False
|
|
176
|
+
max_retries: int = 3
|
|
177
|
+
retry_backoff: float = 0.1
|
|
178
|
+
retry_max_backoff: float = 10.0
|
|
179
|
+
load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY
|
|
180
|
+
health_check_interval: float = 30.0
|
|
181
|
+
|
|
182
|
+
def __post_init__(self):
|
|
183
|
+
if self.max_retries < 0:
|
|
184
|
+
raise ValueError("max_retries must be a non-negative integer")
|
|
185
|
+
if self.retry_backoff < 0:
|
|
186
|
+
raise ValueError("retry_backoff must be a non-negative number")
|
|
187
|
+
if self.retry_max_backoff < 0:
|
|
188
|
+
raise ValueError("retry_max_backoff must be a non-negative number")
|
|
189
|
+
if self.retry_backoff > self.retry_max_backoff:
|
|
190
|
+
raise ValueError("retry_backoff must not exceed retry_max_backoff")
|
|
191
|
+
if self.health_check_interval <= 0:
|
|
192
|
+
raise ValueError("health_check_interval must be a positive number")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AsyncMultiEndpointEtcd3Client:
|
|
196
|
+
"""
|
|
197
|
+
Async etcd v3 API client with multiple endpoints.
|
|
198
|
+
|
|
199
|
+
When failover is enabled, requests still will not be auto-retried.
|
|
200
|
+
Instead, the application may retry the request, and the ``Etcd3Client``
|
|
201
|
+
will then attempt to send it to a different endpoint that has not recently
|
|
202
|
+
failed. If all configured endpoints have failed and are not ready to be
|
|
203
|
+
retried, an ``exceptions.NoServerAvailableError`` will be raised.
|
|
204
|
+
|
|
205
|
+
:param endpoints: Endpoints to use in lieu of host and port
|
|
206
|
+
:type endpoints: Iterable(Endpoint), optional
|
|
207
|
+
:param timeout: Timeout for all RPC in seconds
|
|
208
|
+
:type timeout: int or float, optional
|
|
209
|
+
:param user: Username for authentication
|
|
210
|
+
:type user: str, optional
|
|
211
|
+
:param password: Password for authentication
|
|
212
|
+
:type password: str, optional
|
|
213
|
+
:param bool failover: Failover between endpoints, default False
|
|
214
|
+
:param int max_retries: Maximum number of retries for transient errors
|
|
215
|
+
:param float retry_backoff: Initial backoff time in seconds for retries
|
|
216
|
+
:param float retry_max_backoff: Maximum backoff time in seconds for retries
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
config: Optional[AsyncMultiEndpointConfig] = None,
|
|
222
|
+
endpoints: Optional[list] = None,
|
|
223
|
+
timeout: Optional[float] = None,
|
|
224
|
+
user: Optional[str] = None,
|
|
225
|
+
password: Optional[str] = None,
|
|
226
|
+
failover: bool = False,
|
|
227
|
+
max_retries: int = 3,
|
|
228
|
+
retry_backoff: float = 0.1,
|
|
229
|
+
retry_max_backoff: float = 10.0,
|
|
230
|
+
load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY,
|
|
231
|
+
health_check_interval: float = 30.0,
|
|
232
|
+
):
|
|
233
|
+
if config is not None:
|
|
234
|
+
endpoints = config.endpoints
|
|
235
|
+
timeout = config.timeout
|
|
236
|
+
user = config.user
|
|
237
|
+
password = config.password
|
|
238
|
+
failover = config.failover
|
|
239
|
+
max_retries = config.max_retries
|
|
240
|
+
retry_backoff = config.retry_backoff
|
|
241
|
+
retry_max_backoff = config.retry_max_backoff
|
|
242
|
+
load_balancer = config.load_balancer
|
|
243
|
+
health_check_interval = config.health_check_interval
|
|
244
|
+
|
|
245
|
+
if endpoints is None:
|
|
246
|
+
raise ValueError("Endpoints must be provided")
|
|
247
|
+
|
|
248
|
+
if not endpoints:
|
|
249
|
+
raise ValueError("Endpoints list cannot be empty")
|
|
250
|
+
|
|
251
|
+
self.metadata = None
|
|
252
|
+
self.failover = failover
|
|
253
|
+
self.max_retries = max_retries
|
|
254
|
+
self.load_balancer = load_balancer
|
|
255
|
+
self.health_check_interval = health_check_interval
|
|
256
|
+
self._rr_index = 0 # Round-robin index
|
|
257
|
+
self.retry_backoff = retry_backoff
|
|
258
|
+
self.retry_max_backoff = retry_max_backoff
|
|
259
|
+
|
|
260
|
+
# Lock for synchronous code (setter, property access)
|
|
261
|
+
self._endpoint_lock = threading.Lock()
|
|
262
|
+
# Lock for asynchronous operations in _get_channel
|
|
263
|
+
self._async_endpoint_lock = asyncio.Lock()
|
|
264
|
+
|
|
265
|
+
self.endpoints = {ep.netloc: ep for ep in endpoints}
|
|
266
|
+
self._current_ep_label = random.choice(list(self.endpoints.keys()))
|
|
267
|
+
|
|
268
|
+
self._auth_done = False
|
|
269
|
+
self._auth_lock = asyncio.Lock()
|
|
270
|
+
|
|
271
|
+
self.timeout = timeout
|
|
272
|
+
self.call_credentials = None
|
|
273
|
+
cred_params = [c is not None for c in (user, password)]
|
|
274
|
+
|
|
275
|
+
if all(cred_params):
|
|
276
|
+
self._user = user
|
|
277
|
+
self._password = password
|
|
278
|
+
elif any(cred_params):
|
|
279
|
+
raise ValueError(
|
|
280
|
+
"if using authentication credentials both user and password "
|
|
281
|
+
"must be specified."
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
self._user = None
|
|
285
|
+
self._password = None
|
|
286
|
+
|
|
287
|
+
self.watch_manager = AsyncWatchManager(self)
|
|
288
|
+
self.cluster_manager = AsyncClusterManager(self)
|
|
289
|
+
self.maintenance_manager = AsyncMaintenanceManager(self)
|
|
290
|
+
|
|
291
|
+
self.metrics = get_metrics_interface()
|
|
292
|
+
|
|
293
|
+
self.kv = AsyncKVOperations(self)
|
|
294
|
+
self.lease_ops = AsyncLeaseOperations(self)
|
|
295
|
+
self.lock_ops = AsyncLockOperations(self)
|
|
296
|
+
self.cluster_ops = AsyncClusterOperations(self)
|
|
297
|
+
self.maintenance_ops = AsyncMaintenanceOperations(self)
|
|
298
|
+
self.watch_ops = AsyncWatchOperations(self)
|
|
299
|
+
self.transactions = Transactions()
|
|
300
|
+
|
|
301
|
+
async def _init_auth(self):
|
|
302
|
+
"""Initialize authentication asynchronously."""
|
|
303
|
+
if self._auth_done:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
async with self._auth_lock:
|
|
307
|
+
if self._auth_done:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if self._user and self._password:
|
|
311
|
+
# Create auth stub with auto-reconnection
|
|
312
|
+
channel = await self._get_channel()
|
|
313
|
+
auth_stub = etcdrpc.AuthStub(channel)
|
|
314
|
+
|
|
315
|
+
auth_request = etcdrpc.AuthenticateRequest(
|
|
316
|
+
name=self._user, password=self._password
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
resp = await auth_stub.Authenticate(auth_request, timeout=self.timeout)
|
|
320
|
+
self.metadata = (("token", resp.token),)
|
|
321
|
+
self.call_credentials = grpc.metadata_call_credentials(
|
|
322
|
+
EtcdTokenCallCredentials(resp.token)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
self._auth_done = True
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def _current_endpoint_label(self):
|
|
329
|
+
return self._current_ep_label
|
|
330
|
+
|
|
331
|
+
@_current_endpoint_label.setter
|
|
332
|
+
def _current_endpoint_label(self, value):
|
|
333
|
+
with self._endpoint_lock:
|
|
334
|
+
self._current_ep_label = value
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def endpoint_in_use(self):
|
|
338
|
+
"""Get the current endpoint in use."""
|
|
339
|
+
if self._current_endpoint_label is None:
|
|
340
|
+
return None
|
|
341
|
+
return self.endpoints[self._current_endpoint_label]
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def channel(self):
|
|
345
|
+
"""
|
|
346
|
+
Get an available channel on the first node that's not failed.
|
|
347
|
+
|
|
348
|
+
This property supports automatic reconnection when the
|
|
349
|
+
connection becomes stale (e.g., after etcd server restart).
|
|
350
|
+
If the current endpoint's channel is not healthy, it will
|
|
351
|
+
be rebuilt automatically.
|
|
352
|
+
|
|
353
|
+
Raises an exception if no node is available
|
|
354
|
+
"""
|
|
355
|
+
with self._endpoint_lock:
|
|
356
|
+
try:
|
|
357
|
+
endpoint = self.endpoint_in_use
|
|
358
|
+
if endpoint is None:
|
|
359
|
+
raise ValueError("No current endpoint")
|
|
360
|
+
|
|
361
|
+
channel = endpoint.use()
|
|
362
|
+
# Record endpoint usage
|
|
363
|
+
self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
|
|
364
|
+
return channel
|
|
365
|
+
except ValueError:
|
|
366
|
+
if not self.failover:
|
|
367
|
+
raise
|
|
368
|
+
for label, endpoint in self.endpoints.items():
|
|
369
|
+
if endpoint.is_failed():
|
|
370
|
+
continue
|
|
371
|
+
self._current_ep_label = label
|
|
372
|
+
channel = self.endpoint_in_use.use()
|
|
373
|
+
# Record endpoint usage for the new endpoint
|
|
374
|
+
self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
|
|
375
|
+
return channel
|
|
376
|
+
raise exceptions.NoServerAvailableError(
|
|
377
|
+
"No endpoint available and not failed"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _get_next_endpoint(self):
|
|
381
|
+
"""Get the next endpoint based on load balancing strategy.
|
|
382
|
+
|
|
383
|
+
:returns: A tuple of (endpoint_label, endpoint) or (None, None)
|
|
384
|
+
if no healthy endpoint
|
|
385
|
+
"""
|
|
386
|
+
healthy_endpoints = [
|
|
387
|
+
(label, ep) for label, ep in self.endpoints.items() if not ep.is_failed()
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
if not healthy_endpoints:
|
|
391
|
+
return None, None
|
|
392
|
+
|
|
393
|
+
if self.load_balancer == utils.LoadBalancerStrategy.ROUND_ROBIN:
|
|
394
|
+
# Round-robin: select next endpoint in order
|
|
395
|
+
if not hasattr(self, "_rr_index"):
|
|
396
|
+
self._rr_index = 0
|
|
397
|
+
self._rr_index = (self._rr_index + 1) % len(healthy_endpoints)
|
|
398
|
+
return healthy_endpoints[self._rr_index - 1]
|
|
399
|
+
|
|
400
|
+
elif self.load_balancer == utils.LoadBalancerStrategy.RANDOM:
|
|
401
|
+
# Random: select random endpoint
|
|
402
|
+
import random
|
|
403
|
+
|
|
404
|
+
return random.choice(healthy_endpoints)
|
|
405
|
+
|
|
406
|
+
elif self.load_balancer == utils.LoadBalancerStrategy.STICKY:
|
|
407
|
+
# Sticky: keep current endpoint if healthy
|
|
408
|
+
current = self.endpoint_in_use
|
|
409
|
+
if current is not None and not current.is_failed():
|
|
410
|
+
return self._current_ep_label, current
|
|
411
|
+
# Fallback to first healthy endpoint
|
|
412
|
+
return healthy_endpoints[0]
|
|
413
|
+
|
|
414
|
+
else:
|
|
415
|
+
# PRIORITY (default): keep current endpoint if healthy
|
|
416
|
+
current = self.endpoint_in_use
|
|
417
|
+
if current is not None and not current.is_failed():
|
|
418
|
+
return self._current_ep_label, current
|
|
419
|
+
# Fallback to first healthy endpoint
|
|
420
|
+
return healthy_endpoints[0]
|
|
421
|
+
|
|
422
|
+
async def _try_rebuild_channel(self, endpoint):
|
|
423
|
+
"""尝试重建通道,成功返回 True,失败返回 False"""
|
|
424
|
+
try:
|
|
425
|
+
await endpoint.rebuild_channel()
|
|
426
|
+
return True
|
|
427
|
+
except Exception:
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
async def _get_channel(self):
|
|
431
|
+
"""获取可用通道,支持自动重连。
|
|
432
|
+
|
|
433
|
+
采用两段式锁策略:
|
|
434
|
+
1. 锁内快速检查状态
|
|
435
|
+
2. 锁外执行 I/O 操作
|
|
436
|
+
3. 锁内更新状态
|
|
437
|
+
"""
|
|
438
|
+
# 阶段1:快速检查状态(锁内)
|
|
439
|
+
async with self._async_endpoint_lock:
|
|
440
|
+
endpoint = self.endpoint_in_use
|
|
441
|
+
if endpoint is None:
|
|
442
|
+
raise ValueError("No current endpoint")
|
|
443
|
+
|
|
444
|
+
# 检查 endpoint 是否已恢复健康
|
|
445
|
+
if endpoint.is_failed() and endpoint.is_healthy():
|
|
446
|
+
# 恢复健康状态,重置失败标记
|
|
447
|
+
endpoint.last_failed = 0
|
|
448
|
+
endpoint.in_use = True
|
|
449
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
450
|
+
channel = endpoint.channel
|
|
451
|
+
self.metrics.record_endpoint_usage(str(endpoint))
|
|
452
|
+
return channel
|
|
453
|
+
|
|
454
|
+
needs_rebuild = endpoint.is_failed()
|
|
455
|
+
|
|
456
|
+
# 阶段2:在锁外执行 I/O(关键优化)
|
|
457
|
+
rebuild_success = False
|
|
458
|
+
if needs_rebuild:
|
|
459
|
+
rebuild_success = await self._try_rebuild_channel(endpoint)
|
|
460
|
+
if not rebuild_success:
|
|
461
|
+
raise exceptions.NoServerAvailableError("Failed to rebuild connection")
|
|
462
|
+
|
|
463
|
+
# 阶段3:更新状态(再次获取锁)
|
|
464
|
+
async with self._async_endpoint_lock:
|
|
465
|
+
# 双重检查:确认在锁外期间状态未被其他协程修改
|
|
466
|
+
if endpoint.is_failed() and endpoint.is_healthy():
|
|
467
|
+
# 状态已恢复
|
|
468
|
+
endpoint.last_failed = 0
|
|
469
|
+
endpoint.in_use = True
|
|
470
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
471
|
+
elif needs_rebuild and not endpoint.is_failed():
|
|
472
|
+
# 重建成功后的状态
|
|
473
|
+
endpoint.in_use = True
|
|
474
|
+
self.metrics.record_endpoint_recovery(str(endpoint))
|
|
475
|
+
|
|
476
|
+
channel = endpoint.channel
|
|
477
|
+
self.metrics.record_endpoint_usage(str(endpoint))
|
|
478
|
+
return channel
|
|
479
|
+
|
|
480
|
+
async def _check_all_health_parallel(self, timeout=5.0):
|
|
481
|
+
"""Parallel health check for all endpoints using TaskGroup.
|
|
482
|
+
|
|
483
|
+
Uses asyncio.TaskGroup for efficient concurrent health checks.
|
|
484
|
+
|
|
485
|
+
:param timeout: Maximum time to wait for all health checks in seconds
|
|
486
|
+
:type timeout: float
|
|
487
|
+
:returns: Dictionary of endpoint label to health status
|
|
488
|
+
:rtype: dict[str, bool]
|
|
489
|
+
"""
|
|
490
|
+
health_results = {}
|
|
491
|
+
|
|
492
|
+
async def check_endpoint(label, endpoint):
|
|
493
|
+
"""Check health of a single endpoint."""
|
|
494
|
+
try:
|
|
495
|
+
# Use asyncio timeout for individual check
|
|
496
|
+
async with asyncio.timeout(timeout):
|
|
497
|
+
is_healthy = await endpoint.health_check_async()
|
|
498
|
+
health_results[label] = is_healthy
|
|
499
|
+
except (asyncio.TimeoutError, Exception):
|
|
500
|
+
health_results[label] = False
|
|
501
|
+
|
|
502
|
+
# Use TaskGroup for parallel execution (Python 3.11+)
|
|
503
|
+
try:
|
|
504
|
+
async with asyncio.TaskGroup() as tg:
|
|
505
|
+
for label, endpoint in self.endpoints.items():
|
|
506
|
+
tg.create_task(check_endpoint(label, endpoint))
|
|
507
|
+
except* (asyncio.TimeoutError, Exception):
|
|
508
|
+
# Individual task errors are already handled
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
return health_results
|
|
512
|
+
|
|
513
|
+
async def _get_healthy_endpoints_parallel(self, timeout=3.0):
|
|
514
|
+
"""Get list of healthy endpoints using parallel health check.
|
|
515
|
+
|
|
516
|
+
:param timeout: Maximum time for health checks in seconds
|
|
517
|
+
:type timeout: float
|
|
518
|
+
:returns: List of (label, endpoint) tuples for healthy endpoints
|
|
519
|
+
:rtype: list[tuple[str, AsyncEndpoint]]
|
|
520
|
+
"""
|
|
521
|
+
health_results = await self._check_all_health_parallel(timeout=timeout)
|
|
522
|
+
return [
|
|
523
|
+
(label, ep)
|
|
524
|
+
for label, ep in self.endpoints.items()
|
|
525
|
+
if health_results.get(label, False)
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
async def close(self):
|
|
529
|
+
"""Call the GRPC channel close semantics."""
|
|
530
|
+
for endpoint in self.endpoints.values():
|
|
531
|
+
await endpoint.close()
|
|
532
|
+
|
|
533
|
+
async def __aenter__(self):
|
|
534
|
+
return self
|
|
535
|
+
|
|
536
|
+
async def __aexit__(self, *args):
|
|
537
|
+
await self.close()
|
|
538
|
+
|
|
539
|
+
def context(self, **kwargs):
|
|
540
|
+
"""Create a context manager for tracking request context.
|
|
541
|
+
|
|
542
|
+
This method returns a context manager that sets up request context
|
|
543
|
+
for tracking operations across the etcd client.
|
|
544
|
+
|
|
545
|
+
Example usage:
|
|
546
|
+
|
|
547
|
+
.. code-block:: python
|
|
548
|
+
|
|
549
|
+
async with etcd.context(operation="batch_put", source="worker_1"):
|
|
550
|
+
await etcd.kv.batch_put({f"key_{i}": f"value_{i}" for i in range(100)})
|
|
551
|
+
|
|
552
|
+
# Inside any callback, get the context:
|
|
553
|
+
ctx = get_request_context()
|
|
554
|
+
if ctx:
|
|
555
|
+
print(f"Operation: {ctx.get('operation')}")
|
|
556
|
+
|
|
557
|
+
:param kwargs: Key-value pairs to store in the context
|
|
558
|
+
:returns: A context manager
|
|
559
|
+
"""
|
|
560
|
+
from etcd3.async_client_extensions import _request_context
|
|
561
|
+
|
|
562
|
+
class RequestContextManager:
|
|
563
|
+
def __init__(self, client, **context_kwargs):
|
|
564
|
+
self.client = client
|
|
565
|
+
self.context_kwargs = context_kwargs
|
|
566
|
+
self.token = None
|
|
567
|
+
|
|
568
|
+
async def __aenter__(self):
|
|
569
|
+
# Store context with start time and endpoint info
|
|
570
|
+
start_time = asyncio.get_event_loop().time()
|
|
571
|
+
endpoint = self.client.endpoint_in_use
|
|
572
|
+
context_data = {
|
|
573
|
+
"start_time": start_time,
|
|
574
|
+
"endpoint": str(endpoint.netloc) if endpoint else None,
|
|
575
|
+
**self.context_kwargs,
|
|
576
|
+
}
|
|
577
|
+
self.token = _request_context.set(context_data)
|
|
578
|
+
return self
|
|
579
|
+
|
|
580
|
+
async def __aexit__(self, *args):
|
|
581
|
+
if self.token is not None:
|
|
582
|
+
_request_context.reset(self.token)
|
|
583
|
+
|
|
584
|
+
return RequestContextManager(self, **kwargs)
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def get_secure_creds(ca_cert, cert_key=None, cert_cert=None):
|
|
588
|
+
cert_key_file = None
|
|
589
|
+
cert_cert_file = None
|
|
590
|
+
|
|
591
|
+
with open(ca_cert, "rb") as f:
|
|
592
|
+
ca_cert_file = f.read()
|
|
593
|
+
|
|
594
|
+
if cert_key is not None:
|
|
595
|
+
with open(cert_key, "rb") as f:
|
|
596
|
+
cert_key_file = f.read()
|
|
597
|
+
|
|
598
|
+
if cert_cert is not None:
|
|
599
|
+
with open(cert_cert, "rb") as f:
|
|
600
|
+
cert_cert_file = f.read()
|
|
601
|
+
|
|
602
|
+
return grpc.ssl_channel_credentials(ca_cert_file, cert_key_file, cert_cert_file)
|
|
603
|
+
|
|
604
|
+
def _manage_grpc_errors(self, exc):
|
|
605
|
+
code = exc.code()
|
|
606
|
+
if code in FAILED_EP_CODES:
|
|
607
|
+
with self._endpoint_lock:
|
|
608
|
+
endpoint = self.endpoint_in_use
|
|
609
|
+
if endpoint is not None:
|
|
610
|
+
# Mark endpoint as failed, will retry after time_retry
|
|
611
|
+
endpoint.fail()
|
|
612
|
+
# Record endpoint failure
|
|
613
|
+
self.metrics.record_endpoint_failure(str(endpoint))
|
|
614
|
+
exception = EXCEPTIONS_BY_CODE.get(code)
|
|
615
|
+
if exception is None:
|
|
616
|
+
raise exc
|
|
617
|
+
raise exception()
|
|
618
|
+
|
|
619
|
+
def _should_retry(self, exc):
|
|
620
|
+
"""Check if the error is retryable."""
|
|
621
|
+
code = getattr(exc, "code", lambda: None)()
|
|
622
|
+
if code in [
|
|
623
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
624
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
625
|
+
grpc.StatusCode.INTERNAL,
|
|
626
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED,
|
|
627
|
+
]:
|
|
628
|
+
return True
|
|
629
|
+
return False
|
|
630
|
+
|
|
631
|
+
async def _retry_with_backoff(self, func, *args, **kwargs):
|
|
632
|
+
"""Retry function with exponential backoff."""
|
|
633
|
+
retries = 0
|
|
634
|
+
backoff = self.retry_backoff
|
|
635
|
+
|
|
636
|
+
while retries <= self.max_retries:
|
|
637
|
+
try:
|
|
638
|
+
return await func(*args, **kwargs)
|
|
639
|
+
except grpc.RpcError as exc:
|
|
640
|
+
if not self._should_retry(exc) or retries >= self.max_retries:
|
|
641
|
+
raise
|
|
642
|
+
|
|
643
|
+
retries += 1
|
|
644
|
+
if retries > self.max_retries:
|
|
645
|
+
raise
|
|
646
|
+
|
|
647
|
+
# Record retry
|
|
648
|
+
self.metrics.record_retry(func.__name__, "unknown")
|
|
649
|
+
|
|
650
|
+
# Exponential backoff with jitter
|
|
651
|
+
backoff_time = min(
|
|
652
|
+
backoff * (2 ** (retries - 1)), self.retry_max_backoff
|
|
653
|
+
)
|
|
654
|
+
jitter = random.uniform(0, backoff_time * 0.1)
|
|
655
|
+
await asyncio.sleep(backoff_time + jitter)
|
|
656
|
+
except exceptions.Etcd3Exception as exc:
|
|
657
|
+
# Check if this is a retryable etcd exception
|
|
658
|
+
if not self._should_retry(exc) or retries >= self.max_retries:
|
|
659
|
+
raise
|
|
660
|
+
|
|
661
|
+
retries += 1
|
|
662
|
+
if retries > self.max_retries:
|
|
663
|
+
raise
|
|
664
|
+
|
|
665
|
+
# Record retry
|
|
666
|
+
self.metrics.record_retry(func.__name__, "unknown")
|
|
667
|
+
|
|
668
|
+
# Exponential backoff with jitter
|
|
669
|
+
backoff_time = min(
|
|
670
|
+
backoff * (2 ** (retries - 1)), self.retry_max_backoff
|
|
671
|
+
)
|
|
672
|
+
jitter = random.uniform(0, backoff_time * 0.1)
|
|
673
|
+
await asyncio.sleep(backoff_time + jitter)
|
|
674
|
+
|
|
675
|
+
@staticmethod
|
|
676
|
+
def _handle_errors(payload):
|
|
677
|
+
"""Decorator to handle gRPC errors.
|
|
678
|
+
|
|
679
|
+
:param payload: The function to decorate
|
|
680
|
+
:return: Decorated function with error handling
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
@functools.wraps(payload)
|
|
684
|
+
async def handler(self_inner, *args, **kwargs):
|
|
685
|
+
# Only initialize auth if not already done
|
|
686
|
+
if not self_inner._auth_done:
|
|
687
|
+
await self_inner._init_auth()
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
result = await self_inner._retry_with_backoff(
|
|
691
|
+
payload, self_inner, *args, **kwargs
|
|
692
|
+
)
|
|
693
|
+
return result
|
|
694
|
+
except grpc.RpcError as exc:
|
|
695
|
+
self_inner._manage_grpc_errors(exc)
|
|
696
|
+
|
|
697
|
+
return handler
|
|
698
|
+
|
|
699
|
+
@staticmethod
|
|
700
|
+
def _handle_generator_errors(payload):
|
|
701
|
+
"""Decorator to handle gRPC errors in generators.
|
|
702
|
+
|
|
703
|
+
:param payload: The generator function to decorate
|
|
704
|
+
:return: Decorated generator with error handling
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
@functools.wraps(payload)
|
|
708
|
+
async def handler(self_inner, *args, **kwargs):
|
|
709
|
+
# Only initialize auth if not already done
|
|
710
|
+
if not self_inner._auth_done:
|
|
711
|
+
await self_inner._init_auth()
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
async for item in payload(self_inner, *args, **kwargs):
|
|
715
|
+
yield item
|
|
716
|
+
except grpc.RpcError as exc:
|
|
717
|
+
self_inner._manage_grpc_errors(exc)
|
|
718
|
+
|
|
719
|
+
return handler
|
|
720
|
+
|
|
721
|
+
def _build_get_range_request(self, key, config=None, **kwargs):
|
|
722
|
+
"""Build a range request.
|
|
723
|
+
|
|
724
|
+
:param key: The key to get
|
|
725
|
+
:type key: str or bytes
|
|
726
|
+
:param config: Range request configuration
|
|
727
|
+
:type config: RangeRequestConfig
|
|
728
|
+
:param kwargs: Additional keyword arguments for backward compatibility
|
|
729
|
+
:return: RangeRequest instance
|
|
730
|
+
"""
|
|
731
|
+
# Use config object if provided, otherwise use kwargs or defaults
|
|
732
|
+
if config is None:
|
|
733
|
+
# Create config from kwargs for backward compatibility
|
|
734
|
+
config = RangeRequestConfig(
|
|
735
|
+
range_end=kwargs.get("range_end"),
|
|
736
|
+
limit=kwargs.get("limit"),
|
|
737
|
+
revision=kwargs.get("revision"),
|
|
738
|
+
sort_order=kwargs.get("sort_order"),
|
|
739
|
+
sort_target=kwargs.get("sort_target", "key"),
|
|
740
|
+
serializable=kwargs.get("serializable", False),
|
|
741
|
+
keys_only=kwargs.get("keys_only", False),
|
|
742
|
+
count_only=kwargs.get("count_only", False),
|
|
743
|
+
min_mod_revision=kwargs.get("min_mod_revision"),
|
|
744
|
+
max_mod_revision=kwargs.get("max_mod_revision"),
|
|
745
|
+
min_create_revision=kwargs.get("min_create_revision"),
|
|
746
|
+
max_create_revision=kwargs.get("max_create_revision"),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
return RequestBuilder.build_get_range_request(
|
|
750
|
+
key=key,
|
|
751
|
+
range_end=config.range_end,
|
|
752
|
+
limit=config.limit,
|
|
753
|
+
revision=config.revision,
|
|
754
|
+
sort_order=config.sort_order,
|
|
755
|
+
sort_target=config.sort_target,
|
|
756
|
+
serializable=config.serializable,
|
|
757
|
+
keys_only=config.keys_only,
|
|
758
|
+
count_only=config.count_only,
|
|
759
|
+
min_mod_revision=config.min_mod_revision,
|
|
760
|
+
max_mod_revision=config.max_mod_revision,
|
|
761
|
+
min_create_revision=config.min_create_revision,
|
|
762
|
+
max_create_revision=config.max_create_revision,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
@_handle_errors
|
|
766
|
+
@monitor_request("kv.get_response", is_async=True)
|
|
767
|
+
@log_operation_async("kv.get")
|
|
768
|
+
async def get_response(self, key, **kwargs):
|
|
769
|
+
"""Get the value of a key from etcd.
|
|
770
|
+
|
|
771
|
+
:param key: key in etcd to get
|
|
772
|
+
:type key: str or bytes
|
|
773
|
+
:param kwargs: Additional arguments (revision, limit, sort_order, etc.)
|
|
774
|
+
:returns: RangeResponse from etcd
|
|
775
|
+
"""
|
|
776
|
+
return await self.kv.get_response(key, **kwargs)
|
|
777
|
+
|
|
778
|
+
async def get(self, key, **kwargs):
|
|
779
|
+
"""
|
|
780
|
+
Get the value of a key from etcd.
|
|
781
|
+
|
|
782
|
+
example usage:
|
|
783
|
+
|
|
784
|
+
.. code-block:: python
|
|
785
|
+
|
|
786
|
+
>>> import etcd3
|
|
787
|
+
>>> etcd = etcd3.async_client()
|
|
788
|
+
>>> await etcd.get('/thing/key')
|
|
789
|
+
'hello world'
|
|
790
|
+
|
|
791
|
+
:param key: key in etcd to get
|
|
792
|
+
:returns: value of key and metadata
|
|
793
|
+
:rtype: bytes, ``KVMetadata``
|
|
794
|
+
"""
|
|
795
|
+
return await self.kv.get(key, **kwargs)
|
|
796
|
+
|
|
797
|
+
@_handle_errors
|
|
798
|
+
async def get_prefix_response(self, key_prefix, **kwargs):
|
|
799
|
+
"""Get a range of keys with a prefix."""
|
|
800
|
+
return await self.kv.get_prefix_response(key_prefix, **kwargs)
|
|
801
|
+
|
|
802
|
+
async def get_prefix(self, key_prefix, **kwargs):
|
|
803
|
+
"""
|
|
804
|
+
Get a range of keys with a prefix.
|
|
805
|
+
|
|
806
|
+
:param key_prefix: first key in range
|
|
807
|
+
:param keys_only: if True, retrieve only the keys, not the values
|
|
808
|
+
|
|
809
|
+
:returns: sequence of (value, metadata) tuples
|
|
810
|
+
"""
|
|
811
|
+
async for item in self.kv.get_prefix(key_prefix, **kwargs):
|
|
812
|
+
yield item
|
|
813
|
+
|
|
814
|
+
@_handle_errors
|
|
815
|
+
async def get_range_response(
|
|
816
|
+
self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
|
|
817
|
+
):
|
|
818
|
+
"""Get a range of keys."""
|
|
819
|
+
return await self.kv.get_range_response(
|
|
820
|
+
range_start,
|
|
821
|
+
range_end,
|
|
822
|
+
sort_order=sort_order,
|
|
823
|
+
sort_target=sort_target,
|
|
824
|
+
**kwargs,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
async def get_range(self, range_start, range_end, **kwargs):
|
|
828
|
+
"""
|
|
829
|
+
Get a range of keys.
|
|
830
|
+
|
|
831
|
+
:param range_start: first key in range
|
|
832
|
+
:param range_end: last key in range
|
|
833
|
+
:returns: sequence of (value, metadata) tuples
|
|
834
|
+
"""
|
|
835
|
+
async for item in self.kv.get_range(range_start, range_end, **kwargs):
|
|
836
|
+
yield item
|
|
837
|
+
|
|
838
|
+
@_handle_errors
|
|
839
|
+
async def get_all_response(
|
|
840
|
+
self, sort_order=None, sort_target="key", keys_only=False
|
|
841
|
+
):
|
|
842
|
+
"""Get all keys currently stored in etcd."""
|
|
843
|
+
return await self.kv.get_all_response(
|
|
844
|
+
sort_order=sort_order, sort_target=sort_target, keys_only=keys_only
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
async def get_all(self, **kwargs):
|
|
848
|
+
"""
|
|
849
|
+
Get all keys currently stored in etcd.
|
|
850
|
+
|
|
851
|
+
:param keys_only: if True, retrieve only the keys, not the values
|
|
852
|
+
:returns: sequence of (value, metadata) tuples
|
|
853
|
+
"""
|
|
854
|
+
async for item in self.kv.get_all(**kwargs):
|
|
855
|
+
yield item
|
|
856
|
+
|
|
857
|
+
@_handle_errors
|
|
858
|
+
@monitor_request("kv.put", is_async=True)
|
|
859
|
+
@log_operation_async("kv.put")
|
|
860
|
+
async def put(
|
|
861
|
+
self,
|
|
862
|
+
key,
|
|
863
|
+
value=None,
|
|
864
|
+
lease=None,
|
|
865
|
+
prev_kv=False,
|
|
866
|
+
ignore_value=False,
|
|
867
|
+
ignore_lease=False,
|
|
868
|
+
):
|
|
869
|
+
"""
|
|
870
|
+
Save a value to etcd.
|
|
871
|
+
|
|
872
|
+
Example usage:
|
|
873
|
+
|
|
874
|
+
.. code-block:: python
|
|
875
|
+
|
|
876
|
+
>>> import etcd3
|
|
877
|
+
>>> etcd = etcd3.async_client()
|
|
878
|
+
>>> await etcd.put('/thing/key', 'hello world')
|
|
879
|
+
|
|
880
|
+
:param key: key in etcd to set
|
|
881
|
+
:param value: value to set key to
|
|
882
|
+
:type value: bytes
|
|
883
|
+
:param lease: Lease to associate with this key.
|
|
884
|
+
:type lease: either :class:`.Lease`, or int (ID of lease)
|
|
885
|
+
:param prev_kv: return the previous key-value pair
|
|
886
|
+
:type prev_kv: bool
|
|
887
|
+
:param ignore_value: if True, update the key using the current value
|
|
888
|
+
:type ignore_value: bool
|
|
889
|
+
:param ignore_lease: if True, update the key using the current lease
|
|
890
|
+
:type ignore_lease: bool
|
|
891
|
+
:returns: a response containing a header and the prev_kv
|
|
892
|
+
:rtype: :class:`.rpc_pb2.PutResponse`
|
|
893
|
+
"""
|
|
894
|
+
return await self.kv.put(
|
|
895
|
+
key,
|
|
896
|
+
value=value,
|
|
897
|
+
lease=lease,
|
|
898
|
+
prev_kv=prev_kv,
|
|
899
|
+
ignore_value=ignore_value,
|
|
900
|
+
ignore_lease=ignore_lease,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
@_handle_errors
|
|
904
|
+
async def put_if_not_exists(self, key, value, lease=None):
|
|
905
|
+
"""
|
|
906
|
+
Atomically puts a value only if the key previously had no value.
|
|
907
|
+
|
|
908
|
+
This is the etcdv3 equivalent to setting a key with the etcdv2
|
|
909
|
+
parameter prevExist=false.
|
|
910
|
+
|
|
911
|
+
:param key: key in etcd to put
|
|
912
|
+
:param value: value to be written to key
|
|
913
|
+
:type value: bytes
|
|
914
|
+
:param lease: Lease to associate with this key.
|
|
915
|
+
:type lease: either :class:`.Lease`, or int (ID of lease)
|
|
916
|
+
:returns: state of transaction, ``True`` if the put was successful,
|
|
917
|
+
``False`` otherwise
|
|
918
|
+
:rtype: bool
|
|
919
|
+
"""
|
|
920
|
+
return await self.kv.put_if_not_exists(key, value, lease=lease)
|
|
921
|
+
|
|
922
|
+
@_handle_errors
|
|
923
|
+
async def replace(self, key, initial_value, new_value):
|
|
924
|
+
"""
|
|
925
|
+
Atomically replace the value of a key with a new value.
|
|
926
|
+
|
|
927
|
+
This compares the current value of a key, then replaces it with a new
|
|
928
|
+
value if it is equal to a specified value. This operation takes place
|
|
929
|
+
in a transaction.
|
|
930
|
+
|
|
931
|
+
:param key: key in etcd to replace
|
|
932
|
+
:param initial_value: old value to replace
|
|
933
|
+
:type initial_value: bytes
|
|
934
|
+
:param new_value: new value of the key
|
|
935
|
+
:type new_value: bytes
|
|
936
|
+
:returns: status of transaction, ``True`` if the replace was
|
|
937
|
+
successful, ``False`` otherwise
|
|
938
|
+
:rtype: bool
|
|
939
|
+
"""
|
|
940
|
+
return await self.kv.replace(key, initial_value, new_value)
|
|
941
|
+
|
|
942
|
+
def _ops_to_requests(self, ops):
|
|
943
|
+
"""
|
|
944
|
+
Return a list of grpc requests.
|
|
945
|
+
|
|
946
|
+
Returns list from an input list of etcd3.transactions.{Put, Get,
|
|
947
|
+
Delete, Txn} objects.
|
|
948
|
+
"""
|
|
949
|
+
request_ops = []
|
|
950
|
+
for op in ops:
|
|
951
|
+
if isinstance(op, transactions.Put):
|
|
952
|
+
put_request = etcdrpc.PutRequest()
|
|
953
|
+
put_request.key = utils.to_bytes(op.key)
|
|
954
|
+
put_request.value = utils.to_bytes(op.value)
|
|
955
|
+
put_request.lease = utils.lease_to_id(getattr(op, "lease", None))
|
|
956
|
+
put_request.prev_kv = getattr(op, "prev_kv", False)
|
|
957
|
+
request_op = etcdrpc.RequestOp(request_put=put_request)
|
|
958
|
+
request_ops.append(request_op)
|
|
959
|
+
|
|
960
|
+
elif isinstance(op, transactions.Get):
|
|
961
|
+
range_request = self._build_get_range_request(op.key, op.range_end)
|
|
962
|
+
request_op = etcdrpc.RequestOp(request_range=range_request)
|
|
963
|
+
request_ops.append(request_op)
|
|
964
|
+
|
|
965
|
+
elif isinstance(op, transactions.Delete):
|
|
966
|
+
delete_request = etcdrpc.DeleteRangeRequest()
|
|
967
|
+
delete_request.key = utils.to_bytes(op.key)
|
|
968
|
+
delete_request.prev_kv = op.prev_kv
|
|
969
|
+
if op.range_end is not None:
|
|
970
|
+
delete_request.range_end = utils.to_bytes(op.range_end)
|
|
971
|
+
request_op = etcdrpc.RequestOp(request_delete_range=delete_request)
|
|
972
|
+
request_ops.append(request_op)
|
|
973
|
+
|
|
974
|
+
elif isinstance(op, transactions.Txn):
|
|
975
|
+
compare = [c.build_message() for c in op.compare]
|
|
976
|
+
success_ops = self._ops_to_requests(op.success)
|
|
977
|
+
failure_ops = self._ops_to_requests(op.failure)
|
|
978
|
+
txn_request = etcdrpc.TxnRequest(
|
|
979
|
+
compare=compare, success=success_ops, failure=failure_ops
|
|
980
|
+
)
|
|
981
|
+
request_op = etcdrpc.RequestOp(request_txn=txn_request)
|
|
982
|
+
request_ops.append(request_op)
|
|
983
|
+
|
|
984
|
+
else:
|
|
985
|
+
raise ValueError(f"Unknown request type: {op.__class__}")
|
|
986
|
+
return request_ops
|
|
987
|
+
|
|
988
|
+
@_handle_errors
|
|
989
|
+
@monitor_request("kv.transaction", is_async=True)
|
|
990
|
+
@log_operation_async("kv.transaction")
|
|
991
|
+
async def transaction(self, compare, success=None, failure=None):
|
|
992
|
+
"""
|
|
993
|
+
Perform a transaction.
|
|
994
|
+
|
|
995
|
+
Example usage:
|
|
996
|
+
|
|
997
|
+
.. code-block:: python
|
|
998
|
+
|
|
999
|
+
await etcd.transaction(
|
|
1000
|
+
compare=[
|
|
1001
|
+
etcd.transactions.value('/doot/testing') == 'doot',
|
|
1002
|
+
etcd.transactions.version('/doot/testing') > 0,
|
|
1003
|
+
],
|
|
1004
|
+
success=[
|
|
1005
|
+
etcd.transactions.put('/doot/testing', 'success'),
|
|
1006
|
+
],
|
|
1007
|
+
failure=[
|
|
1008
|
+
etcd.transactions.put('/doot/testing', 'failure'),
|
|
1009
|
+
]
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
:param compare: A list of comparisons to make
|
|
1013
|
+
:param success: A list of operations to perform if all the comparisons
|
|
1014
|
+
are true
|
|
1015
|
+
:param failure: A list of operations to perform if any of the
|
|
1016
|
+
comparisons are false
|
|
1017
|
+
:return: A tuple of (operation status, responses)
|
|
1018
|
+
"""
|
|
1019
|
+
compare = [c.build_message() for c in compare]
|
|
1020
|
+
|
|
1021
|
+
success_ops = self._ops_to_requests(success or [])
|
|
1022
|
+
failure_ops = self._ops_to_requests(failure or [])
|
|
1023
|
+
|
|
1024
|
+
transaction_request = etcdrpc.TxnRequest(
|
|
1025
|
+
compare=compare, success=success_ops, failure=failure_ops
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
# Create KV stub with auto-reconnection
|
|
1029
|
+
channel = await self._get_channel()
|
|
1030
|
+
kv_stub = etcdrpc.KVStub(channel)
|
|
1031
|
+
txn_response = await kv_stub.Txn(
|
|
1032
|
+
transaction_request,
|
|
1033
|
+
timeout=self.timeout,
|
|
1034
|
+
credentials=self.call_credentials,
|
|
1035
|
+
metadata=self.metadata,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
responses = []
|
|
1039
|
+
for response in txn_response.responses:
|
|
1040
|
+
response_type = response.WhichOneof("response")
|
|
1041
|
+
if response_type == "response_range":
|
|
1042
|
+
responses.append(
|
|
1043
|
+
[
|
|
1044
|
+
(kv.value, KVMetadata(kv, txn_response.header))
|
|
1045
|
+
for kv in response.response_range.kvs
|
|
1046
|
+
]
|
|
1047
|
+
)
|
|
1048
|
+
elif response_type == "response_put":
|
|
1049
|
+
responses.append(response.response_put)
|
|
1050
|
+
elif response_type == "response_delete_range":
|
|
1051
|
+
responses.append(response.response_delete_range)
|
|
1052
|
+
elif response_type == "response_txn":
|
|
1053
|
+
responses.append(response.response_txn)
|
|
1054
|
+
else:
|
|
1055
|
+
# Unknown response type - raise error for visibility
|
|
1056
|
+
raise exceptions.InternalServerError(
|
|
1057
|
+
f"Unknown transaction response type: {response_type}"
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
return txn_response.succeeded, responses
|
|
1061
|
+
|
|
1062
|
+
@_handle_errors
|
|
1063
|
+
@monitor_request("kv.delete", is_async=True)
|
|
1064
|
+
@log_operation_async("kv.delete")
|
|
1065
|
+
async def delete(self, key, prev_kv=False, return_response=False, range_end=None):
|
|
1066
|
+
"""
|
|
1067
|
+
Delete a key or range of keys in etcd.
|
|
1068
|
+
|
|
1069
|
+
:param key: key in etcd to delete
|
|
1070
|
+
:type key: str or bytes
|
|
1071
|
+
:param prev_kv: return the deleted key-value pair
|
|
1072
|
+
:type prev_kv: bool
|
|
1073
|
+
:param return_response: return the full response
|
|
1074
|
+
:type return_response: bool
|
|
1075
|
+
:param range_end: End of the key range (for range deletes)
|
|
1076
|
+
:type range_end: str or bytes, optional
|
|
1077
|
+
:returns: True if the key has been deleted when
|
|
1078
|
+
``return_response`` is False and a response containing
|
|
1079
|
+
a header, the number of deleted keys and prev_kvs when
|
|
1080
|
+
``return_response`` is True
|
|
1081
|
+
"""
|
|
1082
|
+
return await self.kv.delete(
|
|
1083
|
+
key, prev_kv=prev_kv, return_response=return_response, range_end=range_end
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
@_handle_errors
|
|
1087
|
+
@monitor_request("kv.delete_prefix", is_async=True)
|
|
1088
|
+
@log_operation_async("kv.delete_prefix")
|
|
1089
|
+
async def delete_prefix(self, prefix):
|
|
1090
|
+
"""Delete a range of keys with a prefix in etcd."""
|
|
1091
|
+
return await self.kv.delete_prefix(prefix)
|
|
1092
|
+
|
|
1093
|
+
@_handle_errors
|
|
1094
|
+
@monitor_request("lease.create", is_async=True)
|
|
1095
|
+
@log_operation_async("lease.create")
|
|
1096
|
+
async def lease(self, ttl, lease_id=None):
|
|
1097
|
+
"""
|
|
1098
|
+
Create a new lease.
|
|
1099
|
+
|
|
1100
|
+
All keys attached to this lease will be expired and deleted if the
|
|
1101
|
+
lease expires. A lease can be sent keep alive messages to refresh the
|
|
1102
|
+
ttl.
|
|
1103
|
+
|
|
1104
|
+
:param ttl: Requested time to live
|
|
1105
|
+
:param lease_id: Requested ID for the lease
|
|
1106
|
+
|
|
1107
|
+
:returns: new lease
|
|
1108
|
+
:rtype: :class:`.AsyncLease`
|
|
1109
|
+
"""
|
|
1110
|
+
return await self.lease_ops.lease(ttl, lease_id=lease_id)
|
|
1111
|
+
|
|
1112
|
+
@_handle_errors
|
|
1113
|
+
@monitor_request("lease.revoke", is_async=True)
|
|
1114
|
+
@log_operation_async("lease.revoke")
|
|
1115
|
+
async def revoke_lease(self, lease_id):
|
|
1116
|
+
"""
|
|
1117
|
+
Revoke a lease.
|
|
1118
|
+
|
|
1119
|
+
:param lease_id: ID of the lease to revoke.
|
|
1120
|
+
"""
|
|
1121
|
+
return await self.lease_ops.revoke_lease(lease_id)
|
|
1122
|
+
|
|
1123
|
+
@_handle_generator_errors
|
|
1124
|
+
async def refresh_lease(self, lease_id):
|
|
1125
|
+
"""
|
|
1126
|
+
Refresh a lease.
|
|
1127
|
+
|
|
1128
|
+
:param lease_id: ID of the lease to refresh.
|
|
1129
|
+
:returns: Async generator of keep alive responses
|
|
1130
|
+
"""
|
|
1131
|
+
async for response in self.lease_ops.refresh_lease(lease_id):
|
|
1132
|
+
yield response
|
|
1133
|
+
|
|
1134
|
+
@_handle_errors
|
|
1135
|
+
@monitor_request("lease.get_info", is_async=True)
|
|
1136
|
+
@log_operation_async("lease.get_info")
|
|
1137
|
+
async def get_lease_info(self, lease_id):
|
|
1138
|
+
"""
|
|
1139
|
+
Get lease information.
|
|
1140
|
+
|
|
1141
|
+
:param lease_id: ID of the lease to get information about.
|
|
1142
|
+
:returns: Lease information
|
|
1143
|
+
"""
|
|
1144
|
+
return await self.lease_ops.get_lease_info(lease_id)
|
|
1145
|
+
|
|
1146
|
+
@_handle_errors
|
|
1147
|
+
async def lock(self, name, ttl=60):
|
|
1148
|
+
"""
|
|
1149
|
+
Create a new async lock.
|
|
1150
|
+
|
|
1151
|
+
:param name: name of the lock
|
|
1152
|
+
:type name: string or bytes
|
|
1153
|
+
:param ttl: length of time for the lock to live for in seconds. The
|
|
1154
|
+
lock will be released after this time elapses, unless
|
|
1155
|
+
refreshed
|
|
1156
|
+
:type ttl: int
|
|
1157
|
+
:returns: new async lock
|
|
1158
|
+
:rtype: :class:`.AsyncLock`
|
|
1159
|
+
"""
|
|
1160
|
+
return await self.lock_ops.lock(name, ttl=ttl)
|
|
1161
|
+
|
|
1162
|
+
async def add_member(self, urls):
|
|
1163
|
+
"""
|
|
1164
|
+
Add a member into the cluster.
|
|
1165
|
+
|
|
1166
|
+
:returns: new member
|
|
1167
|
+
:rtype: :class:`.Member`
|
|
1168
|
+
"""
|
|
1169
|
+
return await self.cluster_ops.add_member(urls)
|
|
1170
|
+
|
|
1171
|
+
async def remove_member(self, member_id):
|
|
1172
|
+
"""
|
|
1173
|
+
Remove an existing member from the cluster.
|
|
1174
|
+
|
|
1175
|
+
:param member_id: ID of the member to remove
|
|
1176
|
+
"""
|
|
1177
|
+
return await self.cluster_ops.remove_member(member_id)
|
|
1178
|
+
|
|
1179
|
+
async def update_member(self, member_id, peer_urls):
|
|
1180
|
+
"""
|
|
1181
|
+
Update the configuration of an existing member in the cluster.
|
|
1182
|
+
|
|
1183
|
+
:param member_id: ID of the member to update
|
|
1184
|
+
:param peer_urls: new list of peer urls the member will use to
|
|
1185
|
+
communicate with the cluster
|
|
1186
|
+
"""
|
|
1187
|
+
return await self.cluster_ops.update_member(member_id, peer_urls)
|
|
1188
|
+
|
|
1189
|
+
async def members(self):
|
|
1190
|
+
"""
|
|
1191
|
+
List of all members associated with the cluster.
|
|
1192
|
+
|
|
1193
|
+
:type: sequence of :class:`.Member`
|
|
1194
|
+
|
|
1195
|
+
"""
|
|
1196
|
+
async for member in self.cluster_ops.members():
|
|
1197
|
+
yield member
|
|
1198
|
+
|
|
1199
|
+
async def status(self):
|
|
1200
|
+
"""Get the status of the responding member."""
|
|
1201
|
+
return await self.maintenance_ops.status()
|
|
1202
|
+
|
|
1203
|
+
@_handle_errors
|
|
1204
|
+
@monitor_request("maintenance.compact", is_async=True)
|
|
1205
|
+
@log_operation_async("maintenance.compact")
|
|
1206
|
+
async def compact(self, revision, physical=False):
|
|
1207
|
+
"""
|
|
1208
|
+
Compact the event history in etcd up to a given revision.
|
|
1209
|
+
|
|
1210
|
+
All superseded keys with a revision less than the compaction revision
|
|
1211
|
+
will be removed.
|
|
1212
|
+
|
|
1213
|
+
:param revision: revision for the compaction operation
|
|
1214
|
+
:param physical: if set to True, the request will wait until the
|
|
1215
|
+
compaction is physically applied to the local database
|
|
1216
|
+
such that compacted entries are totally removed from
|
|
1217
|
+
the backend database
|
|
1218
|
+
"""
|
|
1219
|
+
return await self.maintenance_ops.compact(revision, physical=physical)
|
|
1220
|
+
|
|
1221
|
+
async def defragment(self):
|
|
1222
|
+
"""Defragment a member's backend database to recover storage space."""
|
|
1223
|
+
return await self.maintenance_ops.defragment()
|
|
1224
|
+
|
|
1225
|
+
async def hash(self):
|
|
1226
|
+
"""
|
|
1227
|
+
Return the hash of the local KV state.
|
|
1228
|
+
|
|
1229
|
+
:returns: kv state hash
|
|
1230
|
+
:rtype: int
|
|
1231
|
+
"""
|
|
1232
|
+
return await self.maintenance_ops.hash()
|
|
1233
|
+
|
|
1234
|
+
async def create_alarm(self, member_id=0):
|
|
1235
|
+
"""Create an alarm.
|
|
1236
|
+
|
|
1237
|
+
If no member id is given, the alarm is activated for all the
|
|
1238
|
+
members of the cluster. Only the `no space` alarm can be raised.
|
|
1239
|
+
|
|
1240
|
+
:param member_id: The cluster member id to create an alarm to.
|
|
1241
|
+
If 0, the alarm is created for all the members
|
|
1242
|
+
of the cluster.
|
|
1243
|
+
:returns: list of :class:`.Alarm`
|
|
1244
|
+
"""
|
|
1245
|
+
return await self.maintenance_ops.create_alarm(member_id)
|
|
1246
|
+
|
|
1247
|
+
@_handle_generator_errors
|
|
1248
|
+
async def list_alarms(self, member_id=0, alarm_type="none"):
|
|
1249
|
+
"""List the activated alarms.
|
|
1250
|
+
|
|
1251
|
+
:param member_id:
|
|
1252
|
+
:param alarm_type: The cluster member id to create an alarm to.
|
|
1253
|
+
If 0, the alarm is created for all the members
|
|
1254
|
+
of the cluster.
|
|
1255
|
+
:returns: sequence of :class:`.Alarm`
|
|
1256
|
+
"""
|
|
1257
|
+
async for alarm in self.maintenance_ops.list_alarms(member_id, alarm_type):
|
|
1258
|
+
yield alarm
|
|
1259
|
+
|
|
1260
|
+
async def disarm_alarm(self, member_id=0):
|
|
1261
|
+
"""Cancel an alarm.
|
|
1262
|
+
|
|
1263
|
+
:param member_id: The cluster member id to cancel an alarm.
|
|
1264
|
+
If 0, the alarm is canceled for all the members
|
|
1265
|
+
of the cluster.
|
|
1266
|
+
:returns: List of :class:`.Alarm`
|
|
1267
|
+
"""
|
|
1268
|
+
return await self.maintenance_ops.disarm_alarm(member_id)
|
|
1269
|
+
|
|
1270
|
+
async def snapshot(self, file_obj):
|
|
1271
|
+
"""Take a snapshot of the database.
|
|
1272
|
+
|
|
1273
|
+
:param file_obj: A file-like object to write the database contents in.
|
|
1274
|
+
"""
|
|
1275
|
+
return await self.maintenance_ops.snapshot(file_obj)
|
|
1276
|
+
|
|
1277
|
+
@monitor_request("watch.add_callback", is_async=True)
|
|
1278
|
+
@log_operation_async("watch.add_callback")
|
|
1279
|
+
async def add_watch_callback(self, *args, **kwargs):
|
|
1280
|
+
"""
|
|
1281
|
+
Watch a key or range of keys and call a callback on every response.
|
|
1282
|
+
|
|
1283
|
+
If timeout was declared during the client initialization and
|
|
1284
|
+
the watch cannot be created during that time the method raises
|
|
1285
|
+
a ``WatchTimedOut`` exception.
|
|
1286
|
+
|
|
1287
|
+
:param key: key to watch
|
|
1288
|
+
:param callback: callback function
|
|
1289
|
+
|
|
1290
|
+
:returns: watch_id. Later it could be used for cancelling watch.
|
|
1291
|
+
"""
|
|
1292
|
+
return await self.watch_ops.add_watch_callback(*args, **kwargs)
|
|
1293
|
+
|
|
1294
|
+
async def add_watch_prefix_callback(self, key_prefix, callback, **kwargs):
|
|
1295
|
+
"""
|
|
1296
|
+
Watch a prefix and call a callback on every response.
|
|
1297
|
+
|
|
1298
|
+
If timeout was declared during the client initialization and
|
|
1299
|
+
the watch cannot be created during that time the method raises
|
|
1300
|
+
a ``WatchTimedOut`` exception.
|
|
1301
|
+
|
|
1302
|
+
:param key_prefix: prefix to watch
|
|
1303
|
+
:param callback: callback function
|
|
1304
|
+
|
|
1305
|
+
:returns: watch_id. Later it could be used for cancelling watch.
|
|
1306
|
+
"""
|
|
1307
|
+
return await self.watch_ops.add_watch_prefix_callback(
|
|
1308
|
+
key_prefix, callback, **kwargs
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
async def watch_response(self, key, **kwargs):
|
|
1312
|
+
"""
|
|
1313
|
+
Watch a key.
|
|
1314
|
+
|
|
1315
|
+
Example usage:
|
|
1316
|
+
|
|
1317
|
+
.. code-block:: python
|
|
1318
|
+
|
|
1319
|
+
responses_iterator, cancel = await etcd.watch_response('/doot/key')
|
|
1320
|
+
async for response in responses_iterator:
|
|
1321
|
+
print(response)
|
|
1322
|
+
|
|
1323
|
+
:param key: key to watch
|
|
1324
|
+
|
|
1325
|
+
:returns: tuple of ``responses_iterator`` and ``cancel``.
|
|
1326
|
+
Use ``responses_iterator`` to get the watch responses,
|
|
1327
|
+
each of which contains a header and a list of events.
|
|
1328
|
+
Use ``cancel`` to cancel the watch request.
|
|
1329
|
+
"""
|
|
1330
|
+
return await self.watch_ops.watch_response(key, **kwargs)
|
|
1331
|
+
|
|
1332
|
+
async def watch(self, key, **kwargs):
|
|
1333
|
+
"""
|
|
1334
|
+
Watch a key.
|
|
1335
|
+
|
|
1336
|
+
Example usage:
|
|
1337
|
+
|
|
1338
|
+
.. code-block:: python
|
|
1339
|
+
|
|
1340
|
+
events_iterator, cancel = await etcd.watch('/doot/key')
|
|
1341
|
+
async for event in events_iterator:
|
|
1342
|
+
print(event)
|
|
1343
|
+
|
|
1344
|
+
:param key: key to watch
|
|
1345
|
+
|
|
1346
|
+
:returns: tuple of ``events_iterator`` and ``cancel``.
|
|
1347
|
+
Use ``events_iterator`` to get the events of key changes
|
|
1348
|
+
and ``cancel`` to cancel the watch request.
|
|
1349
|
+
"""
|
|
1350
|
+
return await self.watch_ops.watch(key, **kwargs)
|
|
1351
|
+
|
|
1352
|
+
async def watch_prefix_response(self, key_prefix, **kwargs):
|
|
1353
|
+
"""
|
|
1354
|
+
Watch a range of keys with a prefix.
|
|
1355
|
+
|
|
1356
|
+
:param key_prefix: prefix to watch
|
|
1357
|
+
|
|
1358
|
+
:returns: tuple of ``responses_iterator`` and ``cancel``.
|
|
1359
|
+
"""
|
|
1360
|
+
return await self.watch_ops.watch_prefix_response(key_prefix, **kwargs)
|
|
1361
|
+
|
|
1362
|
+
async def watch_prefix(self, key_prefix, **kwargs):
|
|
1363
|
+
"""
|
|
1364
|
+
Watch a range of keys with a prefix.
|
|
1365
|
+
|
|
1366
|
+
:param key_prefix: prefix to watch
|
|
1367
|
+
|
|
1368
|
+
:returns: tuple of ``events_iterator`` and ``cancel``.
|
|
1369
|
+
"""
|
|
1370
|
+
return await self.watch_ops.watch_prefix(key_prefix, **kwargs)
|
|
1371
|
+
|
|
1372
|
+
async def watch_once_response(self, key, timeout=None, **kwargs):
|
|
1373
|
+
"""
|
|
1374
|
+
Watch a key and stop after the first response.
|
|
1375
|
+
|
|
1376
|
+
If the timeout was specified and response didn't arrive method
|
|
1377
|
+
will raise ``WatchTimedOut`` exception.
|
|
1378
|
+
|
|
1379
|
+
:param key: key to watch
|
|
1380
|
+
:param timeout: (optional) timeout in seconds.
|
|
1381
|
+
|
|
1382
|
+
:returns: ``WatchResponse``
|
|
1383
|
+
"""
|
|
1384
|
+
return await self.watch_ops.watch_once_response(key, timeout=timeout, **kwargs)
|
|
1385
|
+
|
|
1386
|
+
async def watch_once(self, key, timeout=None, **kwargs):
|
|
1387
|
+
"""
|
|
1388
|
+
Watch a key and stop after the first event.
|
|
1389
|
+
|
|
1390
|
+
If the timeout was specified and event didn't arrive method
|
|
1391
|
+
will raise ``WatchTimedOut`` exception.
|
|
1392
|
+
|
|
1393
|
+
:param key: key to watch
|
|
1394
|
+
:param timeout: (optional) timeout in seconds.
|
|
1395
|
+
|
|
1396
|
+
:returns: ``Event``
|
|
1397
|
+
"""
|
|
1398
|
+
return await self.watch_ops.watch_once(key, timeout=timeout, **kwargs)
|
|
1399
|
+
|
|
1400
|
+
async def watch_prefix_once_response(self, key_prefix, timeout=None, **kwargs):
|
|
1401
|
+
"""
|
|
1402
|
+
Watch a range of keys with a prefix and stop after the first response.
|
|
1403
|
+
|
|
1404
|
+
If the timeout was specified and response didn't arrive method
|
|
1405
|
+
will raise ``WatchTimedOut`` exception.
|
|
1406
|
+
"""
|
|
1407
|
+
return await self.watch_ops.watch_prefix_once_response(
|
|
1408
|
+
key_prefix, timeout=timeout, **kwargs
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
async def watch_prefix_once(self, key_prefix, timeout=None, **kwargs):
|
|
1412
|
+
"""
|
|
1413
|
+
Watch a range of keys with a prefix and stop after the first event.
|
|
1414
|
+
|
|
1415
|
+
If the timeout was specified and event didn't arrive method
|
|
1416
|
+
will raise ``WatchTimedOut`` exception.
|
|
1417
|
+
"""
|
|
1418
|
+
return await self.watch_ops.watch_prefix_once(
|
|
1419
|
+
key_prefix, timeout=timeout, **kwargs
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
@monitor_request("watch.cancel", is_async=True)
|
|
1423
|
+
@log_operation_async("watch.cancel")
|
|
1424
|
+
async def cancel_watch(self, watch_id):
|
|
1425
|
+
"""
|
|
1426
|
+
Stop watching a key or range of keys.
|
|
1427
|
+
|
|
1428
|
+
:param watch_id: watch_id returned by ``add_watch_callback`` method
|
|
1429
|
+
"""
|
|
1430
|
+
return await self.watch_ops.cancel_watch(watch_id)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
async def async_client(
|
|
1434
|
+
config=None,
|
|
1435
|
+
host="localhost",
|
|
1436
|
+
port=2379,
|
|
1437
|
+
ca_cert=None,
|
|
1438
|
+
cert_key=None,
|
|
1439
|
+
cert_cert=None,
|
|
1440
|
+
timeout=None,
|
|
1441
|
+
user=None,
|
|
1442
|
+
password=None,
|
|
1443
|
+
grpc_options=None,
|
|
1444
|
+
):
|
|
1445
|
+
"""Return an instance of an AsyncEtcd3Client.
|
|
1446
|
+
|
|
1447
|
+
:param config: AsyncClientConfig object with client configuration
|
|
1448
|
+
:param host: etcd host (ignored if config is provided)
|
|
1449
|
+
:param port: etcd port (ignored if config is provided)
|
|
1450
|
+
:param ca_cert: CA certificate path (ignored if config is provided)
|
|
1451
|
+
:param cert_key: client certificate key path (ignored if config is provided)
|
|
1452
|
+
:param cert_cert: client certificate path (ignored if config is provided)
|
|
1453
|
+
:param timeout: request timeout in seconds (ignored if config is provided)
|
|
1454
|
+
:param user: username for authentication (ignored if config is provided)
|
|
1455
|
+
:param password: password for authentication (ignored if config is provided)
|
|
1456
|
+
:param grpc_options: additional gRPC options (ignored if config is provided)
|
|
1457
|
+
:return: AsyncEtcd3Client instance
|
|
1458
|
+
"""
|
|
1459
|
+
# Auto-initialize observability
|
|
1460
|
+
from etcd3.observability import auto_init
|
|
1461
|
+
|
|
1462
|
+
auto_init()
|
|
1463
|
+
|
|
1464
|
+
# Use config object if provided, otherwise use individual parameters
|
|
1465
|
+
if config is not None:
|
|
1466
|
+
host = config.host
|
|
1467
|
+
port = config.port
|
|
1468
|
+
ca_cert = config.ca_cert
|
|
1469
|
+
cert_key = config.cert_key
|
|
1470
|
+
cert_cert = config.cert_cert
|
|
1471
|
+
timeout = config.timeout
|
|
1472
|
+
user = config.user
|
|
1473
|
+
password = config.password
|
|
1474
|
+
grpc_options = config.grpc_options
|
|
1475
|
+
|
|
1476
|
+
credentials = None
|
|
1477
|
+
secure = True
|
|
1478
|
+
|
|
1479
|
+
if ca_cert is not None:
|
|
1480
|
+
credentials = AsyncMultiEndpointEtcd3Client.get_secure_creds(
|
|
1481
|
+
ca_cert, cert_key, cert_cert
|
|
1482
|
+
)
|
|
1483
|
+
else:
|
|
1484
|
+
secure = False
|
|
1485
|
+
|
|
1486
|
+
endpoint = AsyncEndpoint(
|
|
1487
|
+
host=host, port=port, secure=secure, creds=credentials, opts=grpc_options
|
|
1488
|
+
)
|
|
1489
|
+
return AsyncMultiEndpointEtcd3Client(
|
|
1490
|
+
endpoints=[endpoint], timeout=timeout, user=user, password=password
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
# Create alias for backward compatibility
|
|
1495
|
+
AsyncEtcd3Client = AsyncMultiEndpointEtcd3Client
|