grpc-client-kit 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.
- grpc_client_kit/__init__.py +166 -0
- grpc_client_kit/__version__.py +1 -0
- grpc_client_kit/balancers.py +362 -0
- grpc_client_kit/channel.py +511 -0
- grpc_client_kit/client.py +236 -0
- grpc_client_kit/config.py +185 -0
- grpc_client_kit/deadline.py +122 -0
- grpc_client_kit/errors.py +51 -0
- grpc_client_kit/factory.py +482 -0
- grpc_client_kit/health.py +408 -0
- grpc_client_kit/interceptors/__init__.py +523 -0
- grpc_client_kit/interceptors/base.py +476 -0
- grpc_client_kit/interceptors/circuit_breaker.py +408 -0
- grpc_client_kit/interceptors/client_logging.py +329 -0
- grpc_client_kit/interceptors/context.py +135 -0
- grpc_client_kit/interceptors/deadline.py +154 -0
- grpc_client_kit/interceptors/metrics.py +219 -0
- grpc_client_kit/interceptors/outlier.py +78 -0
- grpc_client_kit/interceptors/retry.py +501 -0
- grpc_client_kit/interceptors/timeout.py +141 -0
- grpc_client_kit/interceptors/tracing.py +307 -0
- grpc_client_kit/interceptors/wait_for_ready.py +139 -0
- grpc_client_kit/protocols.py +449 -0
- grpc_client_kit/py.typed +0 -0
- grpc_client_kit/utils.py +99 -0
- grpc_client_kit/validation.py +232 -0
- grpc_client_kit-0.1.0.dist-info/METADATA +560 -0
- grpc_client_kit-0.1.0.dist-info/RECORD +30 -0
- grpc_client_kit-0.1.0.dist-info/WHEEL +4 -0
- grpc_client_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
from weakref import WeakKeyDictionary
|
|
9
|
+
|
|
10
|
+
import grpc.aio
|
|
11
|
+
|
|
12
|
+
from .protocols import (
|
|
13
|
+
ChannelProviderProtocol,
|
|
14
|
+
GrpcClientMetricsProtocol,
|
|
15
|
+
HealthCheckerProtocol,
|
|
16
|
+
)
|
|
17
|
+
from .utils import create_aio_channel
|
|
18
|
+
from .validation import validate_target
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_CHANNELS_PER_TARGET = 1
|
|
23
|
+
DEFAULT_IDLE_TIMEOUT = 300.0 # 5 minutes
|
|
24
|
+
|
|
25
|
+
# Idle management is delegated to gRPC core: past this timeout without active RPCs the channel
|
|
26
|
+
# parks its connection (goes IDLE) and transparently reconnects on the next call. The pool must
|
|
27
|
+
# never close idle channels itself — a channel object handed to a caller stays valid for life,
|
|
28
|
+
# and an in-flight RPC can never be killed by idleness (core only idles when there are none).
|
|
29
|
+
_IDLE_OPTION = "grpc.client_idle_timeout_ms"
|
|
30
|
+
|
|
31
|
+
# Each interceptor instance is tagged once and keeps its tag for life. A pooled channel holds its
|
|
32
|
+
# interceptors alive, so a tag can never be reassigned to a different object the way id() is after
|
|
33
|
+
# garbage collection — and the pool outlives the clients that filled it.
|
|
34
|
+
_INTERCEPTOR_TOKENS: WeakKeyDictionary[grpc.aio.ClientInterceptor, str] = WeakKeyDictionary()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _interceptor_token(interceptor: grpc.aio.ClientInterceptor) -> str:
|
|
38
|
+
"""Return the stable token of a single interceptor, minting it on first use.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
interceptor: The interceptor instance to identify.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A token that lives exactly as long as the interceptor instance.
|
|
45
|
+
"""
|
|
46
|
+
token = _INTERCEPTOR_TOKENS.get(interceptor)
|
|
47
|
+
if token is None:
|
|
48
|
+
token = uuid4().hex
|
|
49
|
+
_INTERCEPTOR_TOKENS[interceptor] = token
|
|
50
|
+
return token
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def chain_token(interceptors: list[grpc.aio.ClientInterceptor] | None) -> str | None:
|
|
54
|
+
"""Return a stable identity for an interceptor chain.
|
|
55
|
+
|
|
56
|
+
The token changes only when the chain's members or their order change, so a client that reuses
|
|
57
|
+
its chain keeps hitting the same pooled channel, while a client with a different chain never
|
|
58
|
+
inherits someone else's.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
interceptors: The chain to identify, in call order.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A token identifying the chain, or None when the chain is empty (a channel built without
|
|
65
|
+
interceptors is interchangeable with any other such channel).
|
|
66
|
+
"""
|
|
67
|
+
if not interceptors:
|
|
68
|
+
return None
|
|
69
|
+
return "|".join(_interceptor_token(interceptor) for interceptor in interceptors)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ChannelKey:
|
|
74
|
+
"""Full identity of a pooled channel.
|
|
75
|
+
|
|
76
|
+
Two callers may share a channel only when every field matches: gRPC binds credentials, options,
|
|
77
|
+
compression and interceptors to a channel at creation time and none of them can be changed or
|
|
78
|
+
added afterwards, so a channel built for one combination cannot serve another.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
target: The target address (host:port).
|
|
82
|
+
insecure: Whether the channel is insecure.
|
|
83
|
+
credentials: TLS credentials, compared by identity because gRPC credentials define no
|
|
84
|
+
equality — reuse one credentials object instead of rebuilding it per call.
|
|
85
|
+
options: gRPC channel options, normalized to a tuple and compared by value.
|
|
86
|
+
compression: The channel compression setting.
|
|
87
|
+
interceptors_token: Stable identity of the interceptor chain, or None when there is none.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
target: str
|
|
91
|
+
insecure: bool
|
|
92
|
+
credentials: grpc.ChannelCredentials | None = None
|
|
93
|
+
options: tuple[tuple[str, Any], ...] | None = None
|
|
94
|
+
compression: grpc.Compression | None = None
|
|
95
|
+
interceptors_token: str | None = None
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def build(
|
|
99
|
+
cls,
|
|
100
|
+
target: str,
|
|
101
|
+
insecure: bool = False,
|
|
102
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
103
|
+
options: list[tuple[str, Any]] | None = None,
|
|
104
|
+
compression: grpc.Compression | None = None,
|
|
105
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
106
|
+
) -> ChannelKey:
|
|
107
|
+
"""Build a key from the arguments of a channel request.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
target: The target address (host:port).
|
|
111
|
+
insecure: Whether to use an insecure channel.
|
|
112
|
+
credentials: Optional TLS credentials for a secure channel.
|
|
113
|
+
options: Optional gRPC channel options.
|
|
114
|
+
compression: Optional gRPC compression setting.
|
|
115
|
+
interceptors: Optional interceptor chain to bind to the channel.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The hashable identity of the requested channel.
|
|
119
|
+
"""
|
|
120
|
+
return cls(
|
|
121
|
+
target=target,
|
|
122
|
+
insecure=insecure,
|
|
123
|
+
credentials=credentials,
|
|
124
|
+
options=tuple(options) if options is not None else None,
|
|
125
|
+
compression=compression,
|
|
126
|
+
interceptors_token=chain_token(interceptors),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(slots=True)
|
|
131
|
+
class ChannelWrapper:
|
|
132
|
+
"""Wrapper for a gRPC channel with health status."""
|
|
133
|
+
|
|
134
|
+
channel: grpc.aio.Channel
|
|
135
|
+
target: str
|
|
136
|
+
is_healthy: bool = True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(slots=True)
|
|
140
|
+
class _PoolEntry:
|
|
141
|
+
"""Everything the pool keeps for one channel identity.
|
|
142
|
+
|
|
143
|
+
Attributes:
|
|
144
|
+
lock: Guards ``channels`` and ``index``.
|
|
145
|
+
channels: Channels created for this identity.
|
|
146
|
+
index: Cursor for round-robin selection among healthy channels.
|
|
147
|
+
users: Number of coroutines that checked the entry out. An entry is only ever dropped at
|
|
148
|
+
zero, so a coroutine can never end up holding a lock the pool has already replaced.
|
|
149
|
+
active: Cleared when the entry leaves the pool, so a late waiter does not resurrect it.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
153
|
+
channels: list[ChannelWrapper] = field(default_factory=list)
|
|
154
|
+
index: int = 0
|
|
155
|
+
users: int = 0
|
|
156
|
+
active: bool = True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ChannelPool(ChannelProviderProtocol):
|
|
160
|
+
"""Advanced channel pool for gRPC channels.
|
|
161
|
+
|
|
162
|
+
Channels are pooled by their full identity (see :class:`ChannelKey`), not by target alone:
|
|
163
|
+
asking for ``host:1`` over TLS never returns the insecure channel someone else opened for the
|
|
164
|
+
same address, and a client never inherits another client's interceptor chain.
|
|
165
|
+
|
|
166
|
+
Async-safe through per-identity locking to minimize contention and prevent deadlocks.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
max_channels_per_target: int = DEFAULT_MAX_CHANNELS_PER_TARGET,
|
|
172
|
+
idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
|
|
173
|
+
health_checker: HealthCheckerProtocol | None = None,
|
|
174
|
+
metrics: GrpcClientMetricsProtocol | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Initialize the channel pool.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
max_channels_per_target: Maximum number of concurrent channels to keep per identity.
|
|
180
|
+
idle_timeout: Seconds without active RPCs after which a channel parks its connection.
|
|
181
|
+
Enforced by gRPC core (``grpc.client_idle_timeout_ms``), never by the pool closing
|
|
182
|
+
channels: a parked channel reconnects transparently on the next call, so held stubs
|
|
183
|
+
and long streams survive any idle period. ``0`` or less disables idling.
|
|
184
|
+
health_checker: Optional health checker for monitoring target health.
|
|
185
|
+
metrics: Optional metrics registry for pool statistics.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: If max_channels_per_target is not positive.
|
|
189
|
+
"""
|
|
190
|
+
if max_channels_per_target <= 0:
|
|
191
|
+
raise ValueError("max_channels_per_target must be positive")
|
|
192
|
+
|
|
193
|
+
self._max_channels_per_target = max_channels_per_target
|
|
194
|
+
self._idle_timeout = idle_timeout
|
|
195
|
+
self._health_checker = health_checker
|
|
196
|
+
self._metrics = metrics
|
|
197
|
+
|
|
198
|
+
self._entries: dict[ChannelKey, _PoolEntry] = {}
|
|
199
|
+
self._pool_lock = asyncio.Lock()
|
|
200
|
+
self._closing = False
|
|
201
|
+
|
|
202
|
+
async def _checkout(self, key: ChannelKey) -> _PoolEntry:
|
|
203
|
+
"""Get or create the entry for a key and mark it as in use.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
key: The channel identity.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The entry, whose lock the caller must take before touching its channels.
|
|
210
|
+
"""
|
|
211
|
+
async with self._pool_lock:
|
|
212
|
+
entry = self._entries.get(key)
|
|
213
|
+
if entry is None:
|
|
214
|
+
entry = _PoolEntry()
|
|
215
|
+
self._entries[key] = entry
|
|
216
|
+
entry.users += 1
|
|
217
|
+
return entry
|
|
218
|
+
|
|
219
|
+
async def _checkin(self, key: ChannelKey, entry: _PoolEntry) -> None:
|
|
220
|
+
"""Release an entry and drop it if it became empty and unused.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
key: The channel identity the entry was checked out under.
|
|
224
|
+
entry: The entry to release.
|
|
225
|
+
"""
|
|
226
|
+
async with self._pool_lock:
|
|
227
|
+
entry.users -= 1
|
|
228
|
+
# Nobody holds the entry at zero users, so reading `channels` without its lock is safe
|
|
229
|
+
# and no coroutine can be waiting on the lock we are about to discard.
|
|
230
|
+
if entry.users == 0 and not entry.channels and self._entries.get(key) is entry:
|
|
231
|
+
entry.active = False
|
|
232
|
+
del self._entries[key]
|
|
233
|
+
|
|
234
|
+
def _update_pool_metrics(self) -> None:
|
|
235
|
+
"""Update pool usage metrics if available."""
|
|
236
|
+
if not self._metrics:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
total_channels = sum(len(entry.channels) for entry in self._entries.values())
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
self._metrics.record_pool_stats(
|
|
243
|
+
active_channels=total_channels,
|
|
244
|
+
# One target can back several identities, so report distinct addresses.
|
|
245
|
+
idle_targets=len({key.target for key in self._entries}),
|
|
246
|
+
)
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.exception("Failed to record pool metrics")
|
|
249
|
+
|
|
250
|
+
def make_key(
|
|
251
|
+
self,
|
|
252
|
+
target: str,
|
|
253
|
+
insecure: bool = False,
|
|
254
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
255
|
+
options: list[tuple[str, Any]] | None = None,
|
|
256
|
+
compression: grpc.Compression | None = None,
|
|
257
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
258
|
+
) -> ChannelKey:
|
|
259
|
+
"""Validate a channel request and build its pool identity.
|
|
260
|
+
|
|
261
|
+
The result is safe to cache and hand back through ``get_channel(key=...)``: a client whose
|
|
262
|
+
target, configuration and chain never change should pay for validation and identity
|
|
263
|
+
hashing once, not on every call.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
target: The target address (host:port).
|
|
267
|
+
insecure: Whether to use an insecure channel.
|
|
268
|
+
credentials: Optional TLS credentials for a secure channel.
|
|
269
|
+
options: Optional gRPC channel options.
|
|
270
|
+
compression: Optional gRPC compression setting.
|
|
271
|
+
interceptors: Optional interceptor chain to bind to the channel.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The hashable identity the pool files this request under.
|
|
275
|
+
"""
|
|
276
|
+
validate_target(target)
|
|
277
|
+
return ChannelKey.build(
|
|
278
|
+
target,
|
|
279
|
+
insecure=insecure,
|
|
280
|
+
credentials=credentials,
|
|
281
|
+
options=self._with_idle_option(options),
|
|
282
|
+
compression=compression,
|
|
283
|
+
interceptors=interceptors,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _with_idle_option(self, options: list[tuple[str, Any]] | None) -> list[tuple[str, Any]] | None:
|
|
287
|
+
"""Merge the pool's idle timeout into channel options, letting an explicit value win."""
|
|
288
|
+
if self._idle_timeout <= 0:
|
|
289
|
+
return options
|
|
290
|
+
if options is not None and any(name == _IDLE_OPTION for name, _ in options):
|
|
291
|
+
return options
|
|
292
|
+
merged: list[tuple[str, Any]] = list(options) if options is not None else []
|
|
293
|
+
merged.append((_IDLE_OPTION, int(self._idle_timeout * 1000)))
|
|
294
|
+
return merged
|
|
295
|
+
|
|
296
|
+
async def get_channel(
|
|
297
|
+
self,
|
|
298
|
+
target: str,
|
|
299
|
+
insecure: bool = False,
|
|
300
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
301
|
+
options: list[tuple[str, Any]] | None = None,
|
|
302
|
+
compression: grpc.Compression | None = None,
|
|
303
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
304
|
+
key: ChannelKey | None = None,
|
|
305
|
+
) -> grpc.aio.Channel:
|
|
306
|
+
"""Get or create a gRPC channel for the requested channel identity.
|
|
307
|
+
|
|
308
|
+
Implements round-robin selection among healthy channels of that identity. If all channels
|
|
309
|
+
are unhealthy or the limit is reached, it will either create a new channel or return an
|
|
310
|
+
existing one as a fallback.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
target: The target address (host:port).
|
|
314
|
+
insecure: Whether to use an insecure channel.
|
|
315
|
+
credentials: Optional TLS credentials for a secure channel.
|
|
316
|
+
options: Optional gRPC channel options.
|
|
317
|
+
compression: Optional gRPC compression setting.
|
|
318
|
+
interceptors: Optional list of interceptors to bind to the channel.
|
|
319
|
+
key: Precomputed identity from :meth:`make_key`. When given, the target is not
|
|
320
|
+
re-validated and the identity is not rebuilt; the other arguments must be the ones
|
|
321
|
+
the key was built from.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
An async gRPC channel.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
RuntimeError: If the pool is closing.
|
|
328
|
+
"""
|
|
329
|
+
if self._closing:
|
|
330
|
+
raise RuntimeError("ChannelPool is closing")
|
|
331
|
+
|
|
332
|
+
if key is None:
|
|
333
|
+
key = self.make_key(
|
|
334
|
+
target,
|
|
335
|
+
insecure=insecure,
|
|
336
|
+
credentials=credentials,
|
|
337
|
+
options=options,
|
|
338
|
+
compression=compression,
|
|
339
|
+
interceptors=interceptors,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
entry = await self._checkout(key)
|
|
343
|
+
try:
|
|
344
|
+
async with entry.lock:
|
|
345
|
+
# close_all() may have detached this entry while we waited for the lock; handing
|
|
346
|
+
# out a channel the pool no longer tracks would leak it past shutdown.
|
|
347
|
+
if self._closing or not entry.active:
|
|
348
|
+
raise RuntimeError("ChannelPool is closing")
|
|
349
|
+
|
|
350
|
+
wrapper = self._select_channel(entry)
|
|
351
|
+
if wrapper is None:
|
|
352
|
+
wrapper = self._add_channel(entry, key, interceptors)
|
|
353
|
+
finally:
|
|
354
|
+
await self._checkin(key, entry)
|
|
355
|
+
|
|
356
|
+
return wrapper.channel
|
|
357
|
+
|
|
358
|
+
def _select_channel(self, entry: _PoolEntry) -> ChannelWrapper | None:
|
|
359
|
+
"""Pick the next healthy channel of an entry in round-robin order.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
entry: The entry to select from; its lock must be held.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
The selected channel wrapper, or None if the entry has no healthy channel.
|
|
366
|
+
"""
|
|
367
|
+
# The flag is written through the public update_channel_health seam, so it is honoured
|
|
368
|
+
# regardless of whether this pool owns a checker — an external checker counts too.
|
|
369
|
+
healthy = [w for w in entry.channels if w.is_healthy]
|
|
370
|
+
if not healthy:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
position = entry.index % len(healthy)
|
|
374
|
+
entry.index = (position + 1) % len(healthy)
|
|
375
|
+
return healthy[position]
|
|
376
|
+
|
|
377
|
+
def _add_channel(
|
|
378
|
+
self,
|
|
379
|
+
entry: _PoolEntry,
|
|
380
|
+
key: ChannelKey,
|
|
381
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None,
|
|
382
|
+
) -> ChannelWrapper:
|
|
383
|
+
"""Grow an entry by one channel, or fall back to an existing one at the limit.
|
|
384
|
+
|
|
385
|
+
The channel is built from the key it is filed under, so what the pool promises and what
|
|
386
|
+
gRPC was asked for cannot drift apart.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
entry: The entry to grow; its lock must be held.
|
|
390
|
+
key: The identity of the channel to create.
|
|
391
|
+
interceptors: The chain the key's token stands for.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
The channel wrapper to serve this request with.
|
|
395
|
+
"""
|
|
396
|
+
if len(entry.channels) >= self._max_channels_per_target:
|
|
397
|
+
# Every channel is unhealthy and the limit is reached: serve the oldest one anyway, it
|
|
398
|
+
# may have recovered since the last health check and is better than no channel at all.
|
|
399
|
+
return entry.channels[0]
|
|
400
|
+
|
|
401
|
+
logger.info("Creating new gRPC channel for target: %s", key.target)
|
|
402
|
+
channel = self._create_aio_channel(
|
|
403
|
+
key.target,
|
|
404
|
+
insecure=key.insecure,
|
|
405
|
+
credentials=key.credentials,
|
|
406
|
+
options=list(key.options) if key.options is not None else None,
|
|
407
|
+
compression=key.compression,
|
|
408
|
+
interceptors=interceptors,
|
|
409
|
+
)
|
|
410
|
+
wrapper = ChannelWrapper(channel=channel, target=key.target)
|
|
411
|
+
entry.channels.append(wrapper)
|
|
412
|
+
self._update_pool_metrics()
|
|
413
|
+
return wrapper
|
|
414
|
+
|
|
415
|
+
def _create_aio_channel(
|
|
416
|
+
self,
|
|
417
|
+
target: str,
|
|
418
|
+
insecure: bool = False,
|
|
419
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
420
|
+
options: list[tuple[str, Any]] | None = None,
|
|
421
|
+
compression: grpc.Compression | None = None,
|
|
422
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
423
|
+
) -> grpc.aio.Channel:
|
|
424
|
+
"""Internal helper to create a new async gRPC channel."""
|
|
425
|
+
return create_aio_channel(
|
|
426
|
+
target,
|
|
427
|
+
insecure=insecure,
|
|
428
|
+
credentials=credentials,
|
|
429
|
+
options=options,
|
|
430
|
+
compression=compression,
|
|
431
|
+
interceptors=interceptors,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
async def close_all(self, grace: float | None = None) -> None:
|
|
435
|
+
"""Close all pooled channels.
|
|
436
|
+
|
|
437
|
+
The pool stays usable afterwards: this drains it rather than retiring it, so a shared pool
|
|
438
|
+
can outlive one shutdown and an ``async with`` block can be entered again.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
grace: Optional time to wait for active RPCs to finish.
|
|
442
|
+
"""
|
|
443
|
+
self._closing = True
|
|
444
|
+
try:
|
|
445
|
+
async with self._pool_lock:
|
|
446
|
+
entries = list(self._entries.values())
|
|
447
|
+
self._entries.clear()
|
|
448
|
+
|
|
449
|
+
wrappers: list[ChannelWrapper] = []
|
|
450
|
+
for entry in entries:
|
|
451
|
+
entry.active = False
|
|
452
|
+
wrappers.extend(entry.channels)
|
|
453
|
+
entry.channels.clear()
|
|
454
|
+
|
|
455
|
+
if wrappers:
|
|
456
|
+
logger.info("Closing %d pooled gRPC channels", len(wrappers))
|
|
457
|
+
await asyncio.gather(*(w.channel.close(grace=grace) for w in wrappers), return_exceptions=True)
|
|
458
|
+
|
|
459
|
+
self._update_pool_metrics()
|
|
460
|
+
finally:
|
|
461
|
+
self._closing = False
|
|
462
|
+
|
|
463
|
+
async def health_check(self, target: str | None = None) -> bool:
|
|
464
|
+
"""Check if pool is healthy (ready to accept requests).
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
target: Optional target to check for reachability instead of the pool itself.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
True if the pool (or the given target) can serve requests.
|
|
471
|
+
"""
|
|
472
|
+
if self._closing:
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
if target and self._health_checker:
|
|
476
|
+
return await self._health_checker.check_health(target)
|
|
477
|
+
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
async def update_channel_health(self, target: str, is_healthy: bool) -> None:
|
|
481
|
+
"""Update health status for every pooled channel of a target.
|
|
482
|
+
|
|
483
|
+
All channel identities sharing the address are updated, since health is a property of the
|
|
484
|
+
server rather than of the channel configuration.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
target: The target address (host:port).
|
|
488
|
+
is_healthy: Whether the target is healthy.
|
|
489
|
+
"""
|
|
490
|
+
async with self._pool_lock:
|
|
491
|
+
checked_out = [(key, entry) for key, entry in self._entries.items() if key.target == target]
|
|
492
|
+
for _, entry in checked_out:
|
|
493
|
+
entry.users += 1
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
for _, entry in checked_out:
|
|
497
|
+
async with entry.lock:
|
|
498
|
+
for wrapper in entry.channels:
|
|
499
|
+
wrapper.is_healthy = is_healthy
|
|
500
|
+
finally:
|
|
501
|
+
for key, entry in checked_out:
|
|
502
|
+
await self._checkin(key, entry)
|
|
503
|
+
|
|
504
|
+
async def __aenter__(self) -> ChannelPool:
|
|
505
|
+
return self
|
|
506
|
+
|
|
507
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
508
|
+
await self.close_all()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
__all__ = ["ChannelKey", "ChannelPool", "ChannelWrapper", "chain_token"]
|