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.
- connpool/__init__.py +46 -0
- connpool/core/__init__.py +14 -0
- connpool/core/clock.py +45 -0
- connpool/core/connection.py +62 -0
- connpool/core/errors.py +41 -0
- connpool/core/timeouts.py +25 -0
- connpool/factories/__init__.py +6 -0
- connpool/factories/async_factory.py +68 -0
- connpool/factories/sync_factory.py +72 -0
- connpool/observability/__init__.py +5 -0
- connpool/observability/events.py +36 -0
- connpool/observability/stats.py +118 -0
- connpool/policy/__init__.py +5 -0
- connpool/policy/config.py +72 -0
- connpool/policy/lifecycle.py +75 -0
- connpool/pools/__init__.py +6 -0
- connpool/pools/async_pool.py +484 -0
- connpool/pools/registry.py +107 -0
- connpool/pools/sync_pool.py +466 -0
- connpool/py.typed +0 -0
- connpool/resilience/__init__.py +5 -0
- connpool/resilience/breaker.py +161 -0
- connpool/resilience/retry.py +44 -0
- connpool-1.0.2.dist-info/METADATA +157 -0
- connpool-1.0.2.dist-info/RECORD +28 -0
- connpool-1.0.2.dist-info/WHEEL +5 -0
- connpool-1.0.2.dist-info/licenses/LICENSE +21 -0
- connpool-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -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()
|