connpool 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ """The pure "functional core": every rule about *when a connection must go away*.
2
+
3
+ No locks, no I/O, no clocks of its own - just functions of (connection, config, now).
4
+ Both the sync and async pools call these, so the rules live in exactly one place and
5
+ can be unit-tested without threads or event loops.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ from collections import deque
11
+ from typing import Any, Deque, Iterable, List, Optional, Tuple, TypeVar
12
+
13
+ from connpool.policy.config import IdleStrategy, PoolConfig
14
+ from connpool.core.connection import CloseReason, PooledConnection
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ def new_connection(raw: T, cfg: PoolConfig, now: float, rng: random.Random) -> PooledConnection[T]:
20
+ expires_at = None
21
+ if cfg.max_lifetime is not None:
22
+ expires_at = now + cfg.max_lifetime * (1.0 - cfg.lifetime_jitter * rng.random())
23
+ return PooledConnection(raw=raw, created_at=now, expires_at=expires_at)
24
+
25
+
26
+ def pop_idle(idle: Deque[PooledConnection[T]], strategy: IdleStrategy) -> Optional[PooledConnection[T]]:
27
+ if not idle:
28
+ return None
29
+ return idle.pop() if strategy is IdleStrategy.LIFO else idle.popleft()
30
+
31
+
32
+ def rotation_reason(c: PooledConnection[Any], cfg: PoolConfig, now: float) -> Optional[CloseReason]:
33
+ """Connections that must be retired no matter what (lifetime / use-count rotation)."""
34
+ if c.expires_at is not None and now >= c.expires_at:
35
+ return CloseReason.EXPIRED
36
+ if cfg.max_uses is not None and c.use_count >= cfg.max_uses:
37
+ return CloseReason.MAX_USES
38
+ return None
39
+
40
+
41
+ def idle_too_long(c: PooledConnection[Any], cfg: PoolConfig, now: float) -> bool:
42
+ return cfg.max_idle_time is not None and c.idle_time(now) >= cfg.max_idle_time
43
+
44
+
45
+ def needs_validation(c: PooledConnection[Any], cfg: PoolConfig, now: float) -> bool:
46
+ return cfg.validate_after_idle is not None and c.idle_time(now) >= cfg.validate_after_idle
47
+
48
+
49
+ def borrow_reason(c: PooledConnection[Any], cfg: PoolConfig, now: float) -> Optional[CloseReason]:
50
+ """Static checks applied to an idle connection about to be handed out
51
+ (validation, which needs I/O, is done by the pool afterwards)."""
52
+ reason = rotation_reason(c, cfg, now)
53
+ if reason is None and idle_too_long(c, cfg, now):
54
+ reason = CloseReason.IDLE
55
+ return reason
56
+
57
+
58
+ def sweep(
59
+ idle: Iterable[PooledConnection[T]], total_size: int, cfg: PoolConfig, now: float
60
+ ) -> Tuple[List[PooledConnection[T]], List[Tuple[PooledConnection[T], CloseReason]]]:
61
+ """Split idle connections into (keep, evict). Rotation victims always go;
62
+ idle-timeout victims go only while the pool stays at or above ``min_size``."""
63
+ keep: List[PooledConnection[T]] = []
64
+ doomed: List[Tuple[PooledConnection[T], CloseReason]] = []
65
+ remaining = total_size
66
+ for c in idle:
67
+ reason = rotation_reason(c, cfg, now)
68
+ if reason is None and idle_too_long(c, cfg, now) and remaining > cfg.min_size:
69
+ reason = CloseReason.IDLE
70
+ if reason is None:
71
+ keep.append(c)
72
+ else:
73
+ doomed.append((c, reason))
74
+ remaining -= 1
75
+ return keep, doomed
@@ -0,0 +1,6 @@
1
+ """The pools themselves: plumbing that wires every other layer together."""
2
+ from connpool.pools.async_pool import AsyncConnectionPool
3
+ from connpool.pools.registry import AsyncPoolRegistry, PoolRegistry
4
+ from connpool.pools.sync_pool import ConnectionPool
5
+
6
+ __all__ = ["AsyncConnectionPool", "AsyncPoolRegistry", "ConnectionPool", "PoolRegistry"]
@@ -0,0 +1,484 @@
1
+ """asyncio connection pool. Same model and same rules (``lifecycle``) as the sync pool.
2
+
3
+ Differences worth knowing:
4
+ * ``create_timeout`` is enforced here (``asyncio.timeout``); sync factories enforce their own.
5
+ * Listener hooks stay synchronous (keep them fast).
6
+ * Requires Python 3.11+ (``asyncio.timeout``).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import random
13
+ from collections import deque
14
+ from contextlib import asynccontextmanager
15
+ from typing import Any, AsyncIterator, Callable, Deque, Dict, Generic, Optional, Sequence, TypeVar
16
+
17
+ from connpool.policy import lifecycle
18
+ from connpool.core.timeouts import UNSET, Timeout, compute_deadline, remaining
19
+ from connpool.resilience.breaker import CircuitBreaker
20
+ from connpool.core.clock import Clock, SystemClock
21
+ from connpool.policy.config import PoolConfig
22
+ from connpool.core.connection import CloseReason, PooledConnection
23
+ from connpool.core.errors import (
24
+ ConnectionCreateError, InvalidReleaseError, PoolClosedError, PoolExhaustedError, PoolTimeoutError,
25
+ )
26
+ from connpool.observability.events import ListenerGroup, PoolListener
27
+ from connpool.factories.async_factory import AsyncCallableFactory, AsyncConnectionFactory
28
+ from connpool.observability.stats import PoolStats, StatsCollector
29
+
30
+ T = TypeVar("T")
31
+ log = logging.getLogger("connpool")
32
+
33
+
34
+ class AsyncConnectionPool(Generic[T]):
35
+ """
36
+ pool = AsyncConnectionPool(MyAsyncFactory(), PoolConfig(max_size=20))
37
+ async with pool:
38
+ async with pool.connection() as conn:
39
+ await conn.do_something()
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ factory: AsyncConnectionFactory[T],
45
+ config: Optional[PoolConfig] = None,
46
+ *,
47
+ listeners: Sequence[PoolListener] = (),
48
+ clock: Optional[Clock] = None,
49
+ rng: Optional[random.Random] = None,
50
+ name: str = "pool",
51
+ ) -> None:
52
+ self.name = name
53
+ self._factory = factory
54
+ self._cfg = config or PoolConfig()
55
+ self._clock = clock or SystemClock()
56
+ self._rng = rng or random.Random()
57
+
58
+ self._cond = asyncio.Condition()
59
+ self._idle: Deque[PooledConnection[T]] = deque()
60
+ self._checked_out: Dict[int, PooledConnection[T]] = {}
61
+ self._size = 0
62
+ self._waiters = 0
63
+ self._closed = False
64
+
65
+ self._stats = StatsCollector()
66
+ self._events = ListenerGroup([self._stats, *listeners])
67
+ self._breaker: Optional[CircuitBreaker] = (
68
+ CircuitBreaker(self._cfg.breaker, self._clock,
69
+ on_state_change=lambda o, n: self._events.dispatch("on_breaker_state_change", o, n))
70
+ if self._cfg.breaker else None
71
+ )
72
+ self._task: Optional["asyncio.Task[None]"] = None
73
+ self._opened = False
74
+
75
+ @classmethod
76
+ def from_callables(
77
+ cls, create: Callable[[], Any], *, close: Optional[Callable[[T], Any]] = None,
78
+ validate: Optional[Callable[[T], Any]] = None, reset: Optional[Callable[[T], Any]] = None,
79
+ config: Optional[PoolConfig] = None, **kwargs: Any,
80
+ ) -> "AsyncConnectionPool[T]":
81
+ return cls(AsyncCallableFactory(create, close=close, validate=validate, reset=reset), config, **kwargs)
82
+
83
+ # ------------------------------------------------------------------ lifecycle
84
+ async def open(self) -> "AsyncConnectionPool[T]":
85
+ if self._closed:
86
+ raise PoolClosedError(f"pool {self.name!r} is closed")
87
+ if self._opened:
88
+ return self
89
+ self._opened = True
90
+ await self._fill_to_min()
91
+ # _fill_to_min awaits, so close() may have run meanwhile. Re-check under the
92
+ # single-threaded loop (no await between here and create_task): otherwise the
93
+ # task is started after close() already saw _task is None and would never be
94
+ # cancelled - a maintenance loop leaked past shutdown.
95
+ if self._cfg.maintenance_interval is not None and not self._closed:
96
+ self._task = asyncio.create_task(self._maintenance_loop(), name=f"connpool-{self.name}")
97
+ return self
98
+
99
+ async def close(self, timeout: Optional[float] = None) -> None:
100
+ """Close idle connections now; checked-out ones close on release. Idempotent.
101
+ ``timeout`` (seconds) drains gracefully: wait up to this long for in-use
102
+ connections to return. ``None`` (default) does not wait; ``0`` returns at once.
103
+ Draining waits on checked-out connections only; one still being created is
104
+ destroyed as soon as it is built, possibly just after this call returns."""
105
+ async with self._cond:
106
+ if self._closed:
107
+ return
108
+ self._closed = True
109
+ idle = list(self._idle)
110
+ self._idle.clear()
111
+ self._cond.notify_all()
112
+ if timeout is not None:
113
+ deadline = self._clock.monotonic() + timeout
114
+ while self._checked_out:
115
+ left = deadline - self._clock.monotonic()
116
+ if left <= 0:
117
+ break
118
+ try:
119
+ async with asyncio.timeout(left):
120
+ await self._cond.wait()
121
+ except TimeoutError:
122
+ break
123
+ if self._task is not None:
124
+ self._task.cancel()
125
+ try:
126
+ await self._task
127
+ except asyncio.CancelledError:
128
+ pass
129
+ for pooled in idle:
130
+ await self._destroy(pooled, CloseReason.POOL_CLOSED)
131
+
132
+ async def __aenter__(self) -> "AsyncConnectionPool[T]":
133
+ return await self.open()
134
+
135
+ async def __aexit__(self, *exc: object) -> None:
136
+ await self.close()
137
+
138
+ @property
139
+ def closed(self) -> bool:
140
+ return self._closed
141
+
142
+ # --------------------------------------------------------------------- usage
143
+ @asynccontextmanager
144
+ async def connection(self, timeout: Timeout = UNSET) -> AsyncIterator[T]:
145
+ conn = await self.acquire(timeout)
146
+ try:
147
+ yield conn
148
+ except BaseException as exc:
149
+ await self.release(conn, error=exc)
150
+ raise
151
+ else:
152
+ await self.release(conn)
153
+
154
+ async def acquire(self, timeout: Timeout = UNSET) -> T:
155
+ started = self._clock.monotonic()
156
+ deadline = compute_deadline(timeout, self._cfg.acquire_timeout, started)
157
+ try:
158
+ while True:
159
+ pooled = await self._take_idle_or_reserve(deadline)
160
+ if pooled is None:
161
+ pooled, created_in = await self._create(deadline)
162
+ try:
163
+ await self._register_new(pooled, created_in)
164
+ except (ConnectionCreateError, PoolClosedError):
165
+ raise # _register_new already cleaned up its own cases
166
+ except BaseException: # cancelled while registering: don't leak the new conn
167
+ await self._destroy(pooled, CloseReason.ERROR)
168
+ raise
169
+ break
170
+ try:
171
+ reason = await self._borrow_reason(pooled)
172
+ except BaseException: # e.g. cancelled mid-validation: don't leak it
173
+ await self._destroy(pooled, CloseReason.ERROR)
174
+ raise
175
+ if reason is None:
176
+ break
177
+ await self._destroy(pooled, reason)
178
+ except PoolTimeoutError:
179
+ self._events.dispatch("on_acquire_timeout", self._clock.monotonic() - started)
180
+ raise
181
+ now = self._clock.monotonic()
182
+ pooled.mark_checked_out(now)
183
+ self._events.dispatch("on_acquired", pooled, now - started)
184
+ return pooled.raw
185
+
186
+ async def release(self, conn: T, *, discard: bool = False, error: Optional[BaseException] = None) -> None:
187
+ async with self._cond:
188
+ pooled = self._checked_out.pop(id(conn), None)
189
+ if pooled is None:
190
+ raise InvalidReleaseError("object is not checked out from this pool (double release?)")
191
+
192
+ now = self._clock.monotonic()
193
+ hold = pooled.hold_time(now)
194
+ try:
195
+ reason = await self._release_reason(pooled, conn, discard, error, now)
196
+ if reason is None and not await self._park(pooled, now):
197
+ reason = CloseReason.POOL_CLOSED
198
+ except BaseException: # cancelled during reset/validate: close, don't leak
199
+ await self._destroy(pooled, CloseReason.ERROR)
200
+ raise
201
+ if reason is not None:
202
+ await self._destroy(pooled, reason)
203
+ self._events.dispatch("on_released", pooled, hold)
204
+
205
+ def stats(self) -> PoolStats:
206
+ # Single-threaded event loop: reading state without the lock is consistent.
207
+ return self._stats.snapshot(
208
+ name=self.name, max_size=self._cfg.max_size, size=self._size, idle=len(self._idle),
209
+ in_use=len(self._checked_out), waiting=self._waiters,
210
+ breaker_state=self._breaker.state.value if self._breaker else None)
211
+
212
+ async def run_maintenance(self) -> None:
213
+ async with self._cond:
214
+ if self._closed:
215
+ return
216
+ keep, doomed = lifecycle.sweep(self._idle, self._size, self._cfg, self._clock.monotonic())
217
+ self._idle = deque(keep)
218
+ for pooled, reason in doomed:
219
+ await self._destroy(pooled, reason)
220
+ await self._health_check_idle()
221
+ await self._fill_to_min()
222
+ self._check_leaks()
223
+
224
+ async def _health_check_idle(self) -> None:
225
+ """Validate idle connections and evict dead ones. Opt-in via ``validate_idle``;
226
+ driven by the maintenance sweep. Each idle connection is briefly reserved (so no
227
+ caller can take it mid-check), validated outside the lock, then returned if healthy
228
+ or destroyed if not. Returning preserves the idle timer (no ``mark_returned``)."""
229
+ if not self._cfg.validate_idle:
230
+ return
231
+ async with self._cond:
232
+ batch = list(self._idle)
233
+ self._idle.clear()
234
+ for pooled in batch:
235
+ self._checked_out[id(pooled.raw)] = pooled
236
+ for pooled in batch:
237
+ healthy = await self._is_healthy(pooled.raw)
238
+ async with self._cond:
239
+ self._checked_out.pop(id(pooled.raw), None)
240
+ keep = healthy and not self._closed
241
+ if keep:
242
+ self._idle.append(pooled)
243
+ self._cond.notify()
244
+ if not keep:
245
+ await self._destroy(pooled, CloseReason.UNHEALTHY if not healthy else CloseReason.POOL_CLOSED)
246
+
247
+ def _check_leaks(self) -> None:
248
+ """Warn about connections borrowed longer than ``leak_detection_timeout`` (warn-only).
249
+ Safe to read ``_checked_out`` without the lock: the event loop is single-threaded."""
250
+ timeout = self._cfg.leak_detection_timeout
251
+ if timeout is None:
252
+ return
253
+ now = self._clock.monotonic()
254
+ for pooled in list(self._checked_out.values()):
255
+ if not pooled.leak_reported and pooled.hold_time(now) >= timeout:
256
+ pooled.leak_reported = True
257
+ held = pooled.hold_time(now)
258
+ log.warning("pool %r: possible connection leak: %r held for %.1fs", self.name, pooled, held)
259
+ self._events.dispatch("on_connection_leak", pooled, held)
260
+
261
+ # ----------------------------------------------------------------- internals
262
+ async def _take_idle_or_reserve(self, deadline: Optional[float]) -> Optional[PooledConnection[T]]:
263
+ async with self._cond:
264
+ while True:
265
+ if self._closed:
266
+ raise PoolClosedError(f"pool {self.name!r} is closed")
267
+ pooled = lifecycle.pop_idle(self._idle, self._cfg.idle_strategy)
268
+ if pooled is not None:
269
+ self._checked_out[id(pooled.raw)] = pooled
270
+ return pooled
271
+ if self._size < self._cfg.max_size:
272
+ self._size += 1
273
+ return None
274
+ left = remaining(deadline, self._clock.monotonic())
275
+ if left is not None and left <= 0:
276
+ raise PoolTimeoutError(
277
+ f"pool {self.name!r}: no connection available (max_size={self._cfg.max_size})")
278
+ if self._cfg.max_waiters is not None and self._waiters >= self._cfg.max_waiters:
279
+ raise PoolExhaustedError(
280
+ f"pool {self.name!r}: waiter queue full (max_waiters={self._cfg.max_waiters})")
281
+ self._waiters += 1
282
+ try:
283
+ async with asyncio.timeout(left):
284
+ await self._cond.wait()
285
+ except TimeoutError:
286
+ pass # loop re-checks state, then raises PoolTimeoutError
287
+ finally:
288
+ self._waiters -= 1
289
+
290
+ async def _borrow_reason(self, pooled: PooledConnection[T]) -> Optional[CloseReason]:
291
+ now = self._clock.monotonic()
292
+ reason = lifecycle.borrow_reason(pooled, self._cfg, now)
293
+ if reason is None and lifecycle.needs_validation(pooled, self._cfg, now):
294
+ if not await self._is_healthy(pooled.raw):
295
+ return CloseReason.UNHEALTHY
296
+ return reason
297
+
298
+ async def _release_reason(
299
+ self, pooled: PooledConnection[T], conn: T, discard: bool,
300
+ error: Optional[BaseException], now: float,
301
+ ) -> Optional[CloseReason]:
302
+ if discard:
303
+ return CloseReason.DISCARDED
304
+ if error is not None and (self._factory.is_fatal_error(error) or not await self._is_healthy(conn)):
305
+ return CloseReason.ERROR
306
+ reason = lifecycle.rotation_reason(pooled, self._cfg, now)
307
+ if reason is not None:
308
+ return reason
309
+ try:
310
+ await self._factory.reset(conn)
311
+ except Exception: # noqa: BLE001
312
+ log.warning("pool %r: reset failed; discarding %r", self.name, pooled, exc_info=True)
313
+ return CloseReason.RESET_FAILED
314
+ return None
315
+
316
+ async def _is_healthy(self, raw: T) -> bool:
317
+ try:
318
+ return bool(await self._factory.validate(raw))
319
+ except Exception: # noqa: BLE001
320
+ log.debug("pool %r: validate raised", self.name, exc_info=True)
321
+ return False
322
+
323
+ async def _park(self, pooled: PooledConnection[T], now: float) -> bool:
324
+ async with self._cond:
325
+ if self._closed:
326
+ return False
327
+ pooled.mark_returned(now)
328
+ self._idle.append(pooled)
329
+ self._cond.notify()
330
+ return True
331
+
332
+ async def _create(self, deadline: Optional[float]) -> tuple[PooledConnection[T], float]:
333
+ """Returns the connection and how long the factory took; the caller fires
334
+ ``on_connection_created`` once the connection is actually adopted."""
335
+ started = self._clock.monotonic()
336
+ try:
337
+ raw = await self._create_raw(deadline)
338
+ except BaseException:
339
+ await self._free_slot()
340
+ raise
341
+ now = self._clock.monotonic()
342
+ pooled = lifecycle.new_connection(raw, self._cfg, now, self._rng)
343
+ return pooled, now - started
344
+
345
+ def _create_limit(self, deadline: Optional[float]) -> Optional[float]:
346
+ limits = [x for x in (self._cfg.create_timeout, remaining(deadline, self._clock.monotonic()))
347
+ if x is not None]
348
+ return max(0.0, min(limits)) if limits else None
349
+
350
+ async def _register_new(self, pooled: PooledConnection[T], created_in: float) -> None:
351
+ """Async twin of the sync ``_register_new``: guards close-during-create and a
352
+ factory that returns an object the pool already manages."""
353
+ async with self._cond:
354
+ if self._closed:
355
+ action = "closed"
356
+ elif id(pooled.raw) in self._checked_out or any(p.raw is pooled.raw for p in self._idle):
357
+ action = "duplicate" # already tracked (idle or checked out)
358
+ else:
359
+ self._checked_out[id(pooled.raw)] = pooled
360
+ action = "ok"
361
+ if action == "duplicate":
362
+ # The same object we already manage: not a new connection, so it is not
363
+ # counted as created, and we never close it (its real owner still holds it).
364
+ await self._free_slot()
365
+ raise ConnectionCreateError(
366
+ f"pool {self.name!r}: factory.create() returned an object the pool already manages; "
367
+ "each create() must return a distinct connection")
368
+ self._events.dispatch("on_connection_created", pooled, created_in)
369
+ if action == "closed": # built but unusable: created + closed
370
+ await self._destroy(pooled, CloseReason.POOL_CLOSED)
371
+ raise PoolClosedError(f"pool {self.name!r} is closed")
372
+
373
+ async def _create_raw(self, deadline: Optional[float]) -> T:
374
+ """The breaker sees one logical creation per call: fail-fast when open, a single
375
+ success/failure for the whole retry sequence (see the sync pool for rationale)."""
376
+ if self._breaker:
377
+ self._breaker.before_call()
378
+ try:
379
+ raw = await self._create_with_retry(deadline)
380
+ except ConnectionCreateError: # the backend actually failed
381
+ if self._breaker:
382
+ self._breaker.record_failure()
383
+ raise
384
+ except BaseException: # timeout / closed / cancelled: not a
385
+ if self._breaker: # backend fault, so don't trip the breaker
386
+ self._breaker.record_abort()
387
+ raise
388
+ if self._breaker:
389
+ self._breaker.record_success()
390
+ return raw
391
+
392
+ async def _create_with_retry(self, deadline: Optional[float]) -> T:
393
+ retry, attempt = self._cfg.retry, 0
394
+ while True:
395
+ if self._closed:
396
+ raise PoolClosedError(f"pool {self.name!r} is closed")
397
+ attempt += 1
398
+ try:
399
+ async with asyncio.timeout(self._create_limit(deadline)):
400
+ raw = await self._factory.create()
401
+ except Exception as exc:
402
+ # A timeout whose binding limit was the acquire deadline is caller
403
+ # impatience, not a backend fault: surface it as PoolTimeoutError so the
404
+ # breaker records an abort, not a failure. A create_timeout firing *is* a
405
+ # backend fault and flows through the normal create-failure path below.
406
+ left = remaining(deadline, self._clock.monotonic())
407
+ if isinstance(exc, TimeoutError) and left is not None and left <= 0:
408
+ raise PoolTimeoutError(
409
+ f"pool {self.name!r}: acquire deadline reached while creating connection") from exc
410
+ self._events.dispatch("on_create_failed", exc)
411
+ if attempt >= retry.max_attempts or not retry.should_retry(exc):
412
+ raise ConnectionCreateError(
413
+ f"pool {self.name!r}: could not create connection after {attempt} attempt(s): "
414
+ f"{exc or type(exc).__name__}") from exc
415
+ delay = retry.delay_for(attempt, self._rng)
416
+ if left is not None and delay >= left:
417
+ raise PoolTimeoutError(
418
+ f"pool {self.name!r}: acquire deadline reached while retrying creation") from exc
419
+ await self._clock.async_sleep(delay)
420
+ continue
421
+ return raw
422
+
423
+ async def _free_slot(self) -> None:
424
+ async with self._cond:
425
+ self._size -= 1
426
+ self._cond.notify()
427
+
428
+ async def _destroy(self, pooled: PooledConnection[T], reason: CloseReason) -> None:
429
+ """Dispose of a connection exactly once. Shielded, so a cancellation that lands while
430
+ ``factory.close()`` is awaiting I/O (or while the accounting lock is contended) can
431
+ neither interrupt backend disposal, skip the slot decrement, nor drop the
432
+ ``on_connection_closed`` event; the original exception still propagates to the caller
433
+ unchanged. Every disposal path (acquire, release, maintenance, shutdown) goes through
434
+ here, so they all share this guarantee - there is no interruptible disposal to leak a
435
+ half-closed resource or unbalance the ``created``/``closed`` accounting."""
436
+ await asyncio.shield(self._dispose(pooled, reason))
437
+
438
+ async def _dispose(self, pooled: PooledConnection[T], reason: CloseReason) -> None:
439
+ try:
440
+ await self._factory.close(pooled.raw)
441
+ except Exception: # noqa: BLE001
442
+ log.warning("pool %r: error closing %r", self.name, pooled, exc_info=True)
443
+ finally:
444
+ async with self._cond:
445
+ self._checked_out.pop(id(pooled.raw), None)
446
+ self._size -= 1
447
+ self._cond.notify()
448
+ self._events.dispatch("on_connection_closed", pooled, reason)
449
+
450
+ async def _fill_to_min(self) -> None:
451
+ while True:
452
+ async with self._cond:
453
+ if self._closed or self._size >= self._cfg.min_size:
454
+ return
455
+ self._size += 1
456
+ try:
457
+ pooled, created_in = await self._create(deadline=None)
458
+ except Exception as exc: # noqa: BLE001
459
+ log.warning("pool %r: warm-up failed: %s", self.name, exc)
460
+ return
461
+ self._events.dispatch("on_connection_created", pooled, created_in)
462
+ if not await self._park(pooled, self._clock.monotonic()):
463
+ await self._destroy(pooled, CloseReason.POOL_CLOSED)
464
+
465
+ async def _maintenance_loop(self) -> None:
466
+ interval = self._cfg.maintenance_interval
467
+ assert interval is not None # the task is only started when it is set
468
+ while True:
469
+ await asyncio.sleep(interval)
470
+ try:
471
+ await self.run_maintenance()
472
+ except Exception: # noqa: BLE001
473
+ log.exception("pool %r: maintenance pass failed", self.name)
474
+
475
+ def __repr__(self) -> str:
476
+ s = self.stats()
477
+ return f"<AsyncConnectionPool {self.name!r} size={s.size}/{s.max_size} idle={s.idle} in_use={s.in_use}>"
478
+
479
+ def __del__(self) -> None:
480
+ # Unlike the sync pool we cannot await close() here; just warn (stdlib style).
481
+ if not getattr(self, "_closed", True) and getattr(self, "_size", 0) > 0:
482
+ import warnings
483
+ warnings.warn(f"pool {self.name!r} was not closed; {self._size} connection(s) may leak",
484
+ ResourceWarning, stacklevel=2, source=self)
@@ -0,0 +1,107 @@
1
+ """One pool per key (e.g. per host, per tenant, per database) created on demand.
2
+
3
+ Both registries are *lazy*: a pool is built on first use and is **not** ``open()``-ed,
4
+ so it does no background maintenance unless you open it yourself (``registry.pool(key).open()``).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import threading
10
+ from contextlib import asynccontextmanager, contextmanager
11
+ from typing import AsyncIterator, Callable, Dict, Generic, Hashable, Iterator, Optional, TypeVar
12
+
13
+ from connpool.policy.config import PoolConfig
14
+ from connpool.core.timeouts import UNSET, Timeout
15
+ from connpool.factories.async_factory import AsyncConnectionFactory
16
+ from connpool.factories.sync_factory import ConnectionFactory
17
+ from connpool.pools.async_pool import AsyncConnectionPool
18
+ from connpool.pools.sync_pool import ConnectionPool
19
+
20
+ K = TypeVar("K", bound=Hashable)
21
+ T = TypeVar("T")
22
+
23
+
24
+ class PoolRegistry(Generic[K, T]):
25
+ """
26
+ hosts = PoolRegistry(lambda host: HttpFactory(host), PoolConfig(max_size=4))
27
+ with hosts.connection("api.example.com") as conn: ...
28
+ """
29
+
30
+ def __init__(self, factory_for: Callable[[K], ConnectionFactory[T]],
31
+ config: Optional[PoolConfig] = None, **pool_kwargs: object) -> None:
32
+ self._factory_for, self._config, self._kwargs = factory_for, config, pool_kwargs
33
+ self._pools: Dict[K, ConnectionPool[T]] = {}
34
+ self._lock = threading.Lock()
35
+ self._closed = False
36
+
37
+ def pool(self, key: K) -> ConnectionPool[T]:
38
+ with self._lock:
39
+ if self._closed:
40
+ raise RuntimeError("registry is closed")
41
+ pool = self._pools.get(key)
42
+ if pool is None:
43
+ pool = self._pools[key] = ConnectionPool(
44
+ self._factory_for(key), self._config, name=str(key), **self._kwargs) # type: ignore[arg-type]
45
+ return pool
46
+
47
+ @contextmanager
48
+ def connection(self, key: K, timeout: Timeout = UNSET) -> Iterator[T]:
49
+ with self.pool(key).connection(timeout) as conn:
50
+ yield conn
51
+
52
+ def close(self) -> None:
53
+ with self._lock:
54
+ self._closed = True
55
+ pools, self._pools = list(self._pools.values()), {}
56
+ for pool in pools:
57
+ pool.close()
58
+
59
+ def __enter__(self) -> "PoolRegistry[K, T]":
60
+ return self
61
+
62
+ def __exit__(self, *exc: object) -> None:
63
+ self.close()
64
+
65
+
66
+ class AsyncPoolRegistry(Generic[K, T]):
67
+ """Async twin of ``PoolRegistry``: one ``AsyncConnectionPool`` per key.
68
+
69
+ hosts = AsyncPoolRegistry(lambda host: HttpFactory(host), PoolConfig(max_size=4))
70
+ async with hosts.connection("api.example.com") as conn: ...
71
+ """
72
+
73
+ def __init__(self, factory_for: Callable[[K], AsyncConnectionFactory[T]],
74
+ config: Optional[PoolConfig] = None, **pool_kwargs: object) -> None:
75
+ self._factory_for, self._config, self._kwargs = factory_for, config, pool_kwargs
76
+ self._pools: Dict[K, AsyncConnectionPool[T]] = {}
77
+ self._lock = asyncio.Lock()
78
+ self._closed = False
79
+
80
+ async def pool(self, key: K) -> AsyncConnectionPool[T]:
81
+ async with self._lock:
82
+ if self._closed:
83
+ raise RuntimeError("registry is closed")
84
+ pool = self._pools.get(key)
85
+ if pool is None:
86
+ pool = self._pools[key] = AsyncConnectionPool(
87
+ self._factory_for(key), self._config, name=str(key), **self._kwargs) # type: ignore[arg-type]
88
+ return pool
89
+
90
+ @asynccontextmanager
91
+ async def connection(self, key: K, timeout: Timeout = UNSET) -> AsyncIterator[T]:
92
+ pool = await self.pool(key)
93
+ async with pool.connection(timeout) as conn:
94
+ yield conn
95
+
96
+ async def close(self) -> None:
97
+ async with self._lock:
98
+ self._closed = True
99
+ pools, self._pools = list(self._pools.values()), {}
100
+ for pool in pools:
101
+ await pool.close()
102
+
103
+ async def __aenter__(self) -> "AsyncPoolRegistry[K, T]":
104
+ return self
105
+
106
+ async def __aexit__(self, *exc: object) -> None:
107
+ await self.close()