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
connpool/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""connpool - a generic connection pool for Python (sync + asyncio), standard library only.
|
|
2
|
+
|
|
3
|
+
Layers (each only depends on the ones above it):
|
|
4
|
+
|
|
5
|
+
core errors, clocks, connection bookkeeping
|
|
6
|
+
resilience circuit breaker, retry/backoff
|
|
7
|
+
policy PoolConfig + the pure retirement rules
|
|
8
|
+
factories ConnectionFactory: the seam users implement to pool anything
|
|
9
|
+
observability listeners + stats
|
|
10
|
+
pools ConnectionPool, AsyncConnectionPool, PoolRegistry
|
|
11
|
+
|
|
12
|
+
This module is the *public API facade*: everything users need can be imported straight
|
|
13
|
+
from ``connpool`` so that internal modules can move without breaking anyone.
|
|
14
|
+
"""
|
|
15
|
+
from connpool.core import (
|
|
16
|
+
CircuitOpenError, Clock, CloseReason, ConnectionCreateError, InvalidReleaseError,
|
|
17
|
+
ManualClock, PoolClosedError, PoolConfigError, PoolError, PoolExhaustedError,
|
|
18
|
+
PoolTimeoutError, PooledConnection, SystemClock,
|
|
19
|
+
)
|
|
20
|
+
from connpool.factories import (
|
|
21
|
+
AsyncCallableFactory, AsyncConnectionFactory, CallableFactory, ConnectionFactory,
|
|
22
|
+
)
|
|
23
|
+
from connpool.observability import PoolListener, PoolStats
|
|
24
|
+
from connpool.policy import IdleStrategy, PoolConfig
|
|
25
|
+
from connpool.pools import AsyncConnectionPool, AsyncPoolRegistry, ConnectionPool, PoolRegistry
|
|
26
|
+
from connpool.resilience import (
|
|
27
|
+
NO_RETRY, BreakerConfig, BreakerState, CircuitBreaker, RetryPolicy,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = "1.0.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# pools
|
|
34
|
+
"AsyncConnectionPool", "AsyncPoolRegistry", "ConnectionPool", "PoolRegistry",
|
|
35
|
+
# configuration
|
|
36
|
+
"BreakerConfig", "IdleStrategy", "NO_RETRY", "PoolConfig", "RetryPolicy",
|
|
37
|
+
# what you implement
|
|
38
|
+
"AsyncCallableFactory", "AsyncConnectionFactory", "CallableFactory", "ConnectionFactory",
|
|
39
|
+
# observability
|
|
40
|
+
"BreakerState", "CloseReason", "PoolListener", "PoolStats", "PooledConnection",
|
|
41
|
+
# errors
|
|
42
|
+
"CircuitOpenError", "ConnectionCreateError", "InvalidReleaseError", "PoolClosedError",
|
|
43
|
+
"PoolConfigError", "PoolError", "PoolExhaustedError", "PoolTimeoutError",
|
|
44
|
+
# standalone building blocks / testing
|
|
45
|
+
"CircuitBreaker", "Clock", "ManualClock", "SystemClock",
|
|
46
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Foundation layer: errors, clocks, connection bookkeeping, timeout helpers.
|
|
2
|
+
Depends on nothing else in connpool."""
|
|
3
|
+
from connpool.core.clock import Clock, ManualClock, SystemClock
|
|
4
|
+
from connpool.core.connection import CloseReason, PooledConnection
|
|
5
|
+
from connpool.core.errors import (
|
|
6
|
+
CircuitOpenError, ConnectionCreateError, InvalidReleaseError, PoolClosedError,
|
|
7
|
+
PoolConfigError, PoolError, PoolExhaustedError, PoolTimeoutError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CircuitOpenError", "Clock", "CloseReason", "ConnectionCreateError", "InvalidReleaseError",
|
|
12
|
+
"ManualClock", "PoolClosedError", "PoolConfigError", "PoolError", "PoolExhaustedError",
|
|
13
|
+
"PoolTimeoutError", "PooledConnection", "SystemClock",
|
|
14
|
+
]
|
connpool/core/clock.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Time source abstraction so every time-dependent rule is deterministic in tests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Clock(Protocol):
|
|
10
|
+
def monotonic(self) -> float: ...
|
|
11
|
+
def sleep(self, seconds: float) -> None: ...
|
|
12
|
+
async def async_sleep(self, seconds: float) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SystemClock:
|
|
16
|
+
"""Real time. The default."""
|
|
17
|
+
|
|
18
|
+
def monotonic(self) -> float:
|
|
19
|
+
return time.monotonic()
|
|
20
|
+
|
|
21
|
+
def sleep(self, seconds: float) -> None:
|
|
22
|
+
time.sleep(seconds)
|
|
23
|
+
|
|
24
|
+
async def async_sleep(self, seconds: float) -> None:
|
|
25
|
+
await asyncio.sleep(seconds)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ManualClock:
|
|
29
|
+
"""Fake clock for tests: time moves only when you say so; sleeping just advances it."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, start: float = 1000.0) -> None:
|
|
32
|
+
self._now = start
|
|
33
|
+
|
|
34
|
+
def monotonic(self) -> float:
|
|
35
|
+
return self._now
|
|
36
|
+
|
|
37
|
+
def advance(self, seconds: float) -> None:
|
|
38
|
+
self._now += seconds
|
|
39
|
+
|
|
40
|
+
def sleep(self, seconds: float) -> None:
|
|
41
|
+
self._now += seconds
|
|
42
|
+
|
|
43
|
+
async def async_sleep(self, seconds: float) -> None:
|
|
44
|
+
self._now += seconds
|
|
45
|
+
await asyncio.sleep(0)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Bookkeeping wrapper around a raw connection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
import itertools
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Generic, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
_ids = itertools.count(1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CloseReason(str, enum.Enum):
|
|
14
|
+
"""Why a connection left the pool."""
|
|
15
|
+
|
|
16
|
+
EXPIRED = "expired" # max_lifetime reached (rotation)
|
|
17
|
+
MAX_USES = "max_uses" # max_uses reached (rotation)
|
|
18
|
+
IDLE = "idle" # sat idle longer than max_idle_time
|
|
19
|
+
UNHEALTHY = "unhealthy" # failed validation
|
|
20
|
+
ERROR = "error" # caller's block raised and the connection is suspect
|
|
21
|
+
RESET_FAILED = "reset_failed" # factory.reset() raised on release
|
|
22
|
+
DISCARDED = "discarded" # caller asked for it
|
|
23
|
+
POOL_CLOSED = "pool_closed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(eq=False)
|
|
27
|
+
class PooledConnection(Generic[T]):
|
|
28
|
+
"""A raw connection plus the metadata the pool needs to manage it."""
|
|
29
|
+
|
|
30
|
+
raw: T
|
|
31
|
+
created_at: float
|
|
32
|
+
expires_at: Optional[float] = None
|
|
33
|
+
last_returned_at: Optional[float] = None
|
|
34
|
+
checked_out_at: Optional[float] = None
|
|
35
|
+
use_count: int = 0
|
|
36
|
+
leak_reported: bool = False
|
|
37
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.last_returned_at is None:
|
|
41
|
+
self.last_returned_at = self.created_at
|
|
42
|
+
|
|
43
|
+
def age(self, now: float) -> float:
|
|
44
|
+
return now - self.created_at
|
|
45
|
+
|
|
46
|
+
def idle_time(self, now: float) -> float:
|
|
47
|
+
return now - (self.last_returned_at or self.created_at)
|
|
48
|
+
|
|
49
|
+
def hold_time(self, now: float) -> float:
|
|
50
|
+
return 0.0 if self.checked_out_at is None else now - self.checked_out_at
|
|
51
|
+
|
|
52
|
+
def mark_checked_out(self, now: float) -> None:
|
|
53
|
+
self.checked_out_at = now
|
|
54
|
+
self.use_count += 1
|
|
55
|
+
self.leak_reported = False
|
|
56
|
+
|
|
57
|
+
def mark_returned(self, now: float) -> None:
|
|
58
|
+
self.checked_out_at = None
|
|
59
|
+
self.last_returned_at = now
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"<PooledConnection #{self.id} uses={self.use_count}>"
|
connpool/core/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Exception hierarchy. Catch ``PoolError`` to handle anything the pool raises."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PoolError(Exception):
|
|
6
|
+
"""Base class for every error raised by connpool."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PoolConfigError(PoolError, ValueError):
|
|
10
|
+
"""Invalid pool / breaker / retry configuration."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PoolClosedError(PoolError):
|
|
14
|
+
"""The pool has been closed; no more connections can be acquired."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PoolTimeoutError(PoolError, TimeoutError):
|
|
18
|
+
"""No connection became available before the acquire deadline."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PoolExhaustedError(PoolError):
|
|
22
|
+
"""The pool is full and the waiter queue is at ``max_waiters``; shedding load."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConnectionCreateError(PoolError):
|
|
26
|
+
"""The factory failed to create a connection (after all retries).
|
|
27
|
+
|
|
28
|
+
The original exception is available as ``__cause__``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CircuitOpenError(PoolError):
|
|
33
|
+
"""The circuit breaker is rejecting calls to protect a failing backend."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, retry_after: float) -> None:
|
|
36
|
+
super().__init__(f"circuit breaker is open; retry in ~{retry_after:.1f}s")
|
|
37
|
+
self.retry_after = retry_after
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidReleaseError(PoolError):
|
|
41
|
+
"""Released an object that is not checked out from this pool (double release?)."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tiny shared helpers (internal)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Unset(enum.Enum):
|
|
9
|
+
UNSET = "unset"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
UNSET = _Unset.UNSET
|
|
13
|
+
"""Sentinel: "use the configured default" (``None`` already means "wait forever")."""
|
|
14
|
+
|
|
15
|
+
Timeout = Union[float, None, _Unset]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_deadline(timeout: Timeout, default: Optional[float], now: float) -> Optional[float]:
|
|
19
|
+
if isinstance(timeout, _Unset):
|
|
20
|
+
timeout = default
|
|
21
|
+
return None if timeout is None else now + timeout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def remaining(deadline: Optional[float], now: float) -> Optional[float]:
|
|
25
|
+
return None if deadline is None else deadline - now
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""The seam to the outside world: what users implement to pool *any* kind of connection.
|
|
2
|
+
Has no dependency on the rest of connpool."""
|
|
3
|
+
from connpool.factories.async_factory import AsyncCallableFactory, AsyncConnectionFactory
|
|
4
|
+
from connpool.factories.sync_factory import CallableFactory, ConnectionFactory
|
|
5
|
+
|
|
6
|
+
__all__ = ["AsyncCallableFactory", "AsyncConnectionFactory", "CallableFactory", "ConnectionFactory"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Asynchronous factory contract: async twin of ``sync_factory``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, Union, cast
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _maybe_await(value: Union[Any, Awaitable[Any]]) -> Any:
|
|
12
|
+
return await value if inspect.isawaitable(value) else value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncConnectionFactory(ABC, Generic[T]):
|
|
16
|
+
"""Async twin of ``ConnectionFactory``."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def create(self) -> T: ...
|
|
20
|
+
|
|
21
|
+
async def validate(self, conn: T) -> bool:
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
async def reset(self, conn: T) -> None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
async def close(self, conn: T) -> None:
|
|
28
|
+
closer = getattr(conn, "close", None)
|
|
29
|
+
if callable(closer):
|
|
30
|
+
await _maybe_await(closer())
|
|
31
|
+
|
|
32
|
+
def is_fatal_error(self, exc: BaseException) -> bool:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncCallableFactory(AsyncConnectionFactory[T]):
|
|
37
|
+
"""Callables may be sync or async - both work."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
create: Callable[[], Any],
|
|
42
|
+
*,
|
|
43
|
+
close: Optional[Callable[[T], Any]] = None,
|
|
44
|
+
validate: Optional[Callable[[T], Any]] = None,
|
|
45
|
+
reset: Optional[Callable[[T], Any]] = None,
|
|
46
|
+
is_fatal_error: Optional[Callable[[BaseException], bool]] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._create, self._close, self._validate = create, close, validate
|
|
49
|
+
self._reset, self._is_fatal = reset, is_fatal_error
|
|
50
|
+
|
|
51
|
+
async def create(self) -> T:
|
|
52
|
+
return cast(T, await _maybe_await(self._create()))
|
|
53
|
+
|
|
54
|
+
async def validate(self, conn: T) -> bool:
|
|
55
|
+
return True if self._validate is None else bool(await _maybe_await(self._validate(conn)))
|
|
56
|
+
|
|
57
|
+
async def reset(self, conn: T) -> None:
|
|
58
|
+
if self._reset:
|
|
59
|
+
await _maybe_await(self._reset(conn))
|
|
60
|
+
|
|
61
|
+
async def close(self, conn: T) -> None:
|
|
62
|
+
if self._close:
|
|
63
|
+
await _maybe_await(self._close(conn))
|
|
64
|
+
else:
|
|
65
|
+
await super().close(conn)
|
|
66
|
+
|
|
67
|
+
def is_fatal_error(self, exc: BaseException) -> bool:
|
|
68
|
+
return bool(self._is_fatal and self._is_fatal(exc))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Synchronous factory contract: the ONE seam between the pool and the outside world.
|
|
2
|
+
|
|
3
|
+
To pool *anything* (DB driver, HTTP session, SSH client, socket, gRPC channel...)
|
|
4
|
+
implement ``ConnectionFactory`` - or just pass callables via ``CallableFactory``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Callable, Generic, Optional, TypeVar
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectionFactory(ABC, Generic[T]):
|
|
15
|
+
"""Override ``create``; override the rest only when the default isn't right."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def create(self) -> T:
|
|
19
|
+
"""Open a new connection. Should enforce its own connect timeout."""
|
|
20
|
+
|
|
21
|
+
def validate(self, conn: T) -> bool:
|
|
22
|
+
"""Cheap liveness check (e.g. ``SELECT 1``). Return False/raise = dead."""
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def reset(self, conn: T) -> None:
|
|
26
|
+
"""Called on release to scrub session state (e.g. ``rollback()``)."""
|
|
27
|
+
|
|
28
|
+
def close(self, conn: T) -> None:
|
|
29
|
+
"""Dispose of a connection. Default: call ``conn.close()`` if present."""
|
|
30
|
+
closer = getattr(conn, "close", None)
|
|
31
|
+
if callable(closer):
|
|
32
|
+
closer()
|
|
33
|
+
|
|
34
|
+
def is_fatal_error(self, exc: BaseException) -> bool:
|
|
35
|
+
"""True if ``exc`` (raised by user code using the connection) proves the
|
|
36
|
+
connection is broken, so it is discarded without further checks."""
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CallableFactory(ConnectionFactory[T]):
|
|
41
|
+
"""Build a factory from plain functions."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
create: Callable[[], T],
|
|
46
|
+
*,
|
|
47
|
+
close: Optional[Callable[[T], None]] = None,
|
|
48
|
+
validate: Optional[Callable[[T], bool]] = None,
|
|
49
|
+
reset: Optional[Callable[[T], None]] = None,
|
|
50
|
+
is_fatal_error: Optional[Callable[[BaseException], bool]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._create, self._close, self._validate = create, close, validate
|
|
53
|
+
self._reset, self._is_fatal = reset, is_fatal_error
|
|
54
|
+
|
|
55
|
+
def create(self) -> T:
|
|
56
|
+
return self._create()
|
|
57
|
+
|
|
58
|
+
def validate(self, conn: T) -> bool:
|
|
59
|
+
return True if self._validate is None else bool(self._validate(conn))
|
|
60
|
+
|
|
61
|
+
def reset(self, conn: T) -> None:
|
|
62
|
+
if self._reset:
|
|
63
|
+
self._reset(conn)
|
|
64
|
+
|
|
65
|
+
def close(self, conn: T) -> None:
|
|
66
|
+
if self._close:
|
|
67
|
+
self._close(conn)
|
|
68
|
+
else:
|
|
69
|
+
super().close(conn)
|
|
70
|
+
|
|
71
|
+
def is_fatal_error(self, exc: BaseException) -> bool:
|
|
72
|
+
return bool(self._is_fatal and self._is_fatal(exc))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Observability hooks. Subclass ``PoolListener`` and override what you care about
|
|
2
|
+
(metrics, tracing, logging). Hooks run synchronously on the calling thread/task, outside
|
|
3
|
+
the pool's lock, so keep them fast. Exceptions raised by listeners are logged and
|
|
4
|
+
swallowed - a broken metrics exporter must never break the pool."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Iterable, List
|
|
9
|
+
|
|
10
|
+
from connpool.resilience.breaker import BreakerState
|
|
11
|
+
from connpool.core.connection import CloseReason, PooledConnection
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("connpool")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PoolListener:
|
|
17
|
+
def on_connection_created(self, conn: PooledConnection[Any], duration: float) -> None: ...
|
|
18
|
+
def on_connection_closed(self, conn: PooledConnection[Any], reason: CloseReason) -> None: ...
|
|
19
|
+
def on_create_failed(self, error: BaseException) -> None: ...
|
|
20
|
+
def on_acquired(self, conn: PooledConnection[Any], wait_time: float) -> None: ...
|
|
21
|
+
def on_released(self, conn: PooledConnection[Any], hold_time: float) -> None: ...
|
|
22
|
+
def on_acquire_timeout(self, waited: float) -> None: ...
|
|
23
|
+
def on_connection_leak(self, conn: PooledConnection[Any], held_for: float) -> None: ...
|
|
24
|
+
def on_breaker_state_change(self, old: BreakerState, new: BreakerState) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ListenerGroup:
|
|
28
|
+
def __init__(self, listeners: Iterable[PoolListener]) -> None:
|
|
29
|
+
self._listeners: List[PoolListener] = list(listeners)
|
|
30
|
+
|
|
31
|
+
def dispatch(self, hook: str, *args: object) -> None:
|
|
32
|
+
for listener in self._listeners:
|
|
33
|
+
try:
|
|
34
|
+
getattr(listener, hook)(*args)
|
|
35
|
+
except Exception: # noqa: BLE001 - listeners must never break the pool
|
|
36
|
+
log.exception("pool listener %r failed in %s", listener, hook)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Built-in metrics: a listener that counts events, plus an immutable snapshot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from collections import Counter, deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Deque, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from connpool.core.connection import CloseReason, PooledConnection
|
|
10
|
+
from connpool.observability.events import PoolListener
|
|
11
|
+
|
|
12
|
+
ACQUIRE_WAIT_WINDOW = 1024
|
|
13
|
+
"""How many recent acquire-wait / create-latency samples to keep. ``avg`` and all percentiles
|
|
14
|
+
are computed over this same window (bounded memory), so they always describe the same
|
|
15
|
+
population."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def percentile(sorted_values: List[float], q: float) -> float:
|
|
19
|
+
"""Linear-interpolated percentile of an already-sorted list. ``q`` in [0, 1]."""
|
|
20
|
+
if not sorted_values:
|
|
21
|
+
return 0.0
|
|
22
|
+
k = (len(sorted_values) - 1) * q
|
|
23
|
+
lo = int(k)
|
|
24
|
+
hi = min(lo + 1, len(sorted_values) - 1)
|
|
25
|
+
return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PoolStats:
|
|
30
|
+
name: str
|
|
31
|
+
max_size: int
|
|
32
|
+
size: int # idle + in use + being created
|
|
33
|
+
idle: int # available for checkout right now
|
|
34
|
+
in_use: int # checked out: held by a caller, OR briefly reserved by a background
|
|
35
|
+
# idle health check (validate_idle) while it pings them off-lock
|
|
36
|
+
waiting: int # callers currently blocked in acquire()
|
|
37
|
+
created: int
|
|
38
|
+
create_failures: int
|
|
39
|
+
acquired: int
|
|
40
|
+
released: int
|
|
41
|
+
timeouts: int
|
|
42
|
+
closed: int
|
|
43
|
+
leaks: int = 0 # borrowed connections flagged as possibly leaked
|
|
44
|
+
closed_by_reason: Dict[str, int] = field(default_factory=dict)
|
|
45
|
+
# acquire-wait aggregates, all over the last ACQUIRE_WAIT_WINDOW samples
|
|
46
|
+
avg_acquire_wait: float = 0.0
|
|
47
|
+
p50_acquire_wait: float = 0.0
|
|
48
|
+
p95_acquire_wait: float = 0.0
|
|
49
|
+
p99_acquire_wait: float = 0.0
|
|
50
|
+
# create-latency aggregates (factory.create() duration), same window
|
|
51
|
+
avg_create_latency: float = 0.0
|
|
52
|
+
p50_create_latency: float = 0.0
|
|
53
|
+
p95_create_latency: float = 0.0
|
|
54
|
+
p99_create_latency: float = 0.0
|
|
55
|
+
breaker_state: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
58
|
+
return dict(self.__dict__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StatsCollector(PoolListener):
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._n: Counter[str] = Counter()
|
|
65
|
+
self._by_reason: Counter[str] = Counter()
|
|
66
|
+
self._waits: Deque[float] = deque(maxlen=ACQUIRE_WAIT_WINDOW)
|
|
67
|
+
self._creates: Deque[float] = deque(maxlen=ACQUIRE_WAIT_WINDOW)
|
|
68
|
+
|
|
69
|
+
def _inc(self, key: str) -> None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._n[key] += 1
|
|
72
|
+
|
|
73
|
+
def on_connection_created(self, conn: PooledConnection[Any], duration: float) -> None:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._n["created"] += 1
|
|
76
|
+
self._creates.append(duration)
|
|
77
|
+
|
|
78
|
+
def on_create_failed(self, error: BaseException) -> None:
|
|
79
|
+
self._inc("create_failures")
|
|
80
|
+
|
|
81
|
+
def on_connection_closed(self, conn: PooledConnection[Any], reason: CloseReason) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._n["closed"] += 1
|
|
84
|
+
self._by_reason[reason.value] += 1
|
|
85
|
+
|
|
86
|
+
def on_acquired(self, conn: PooledConnection[Any], wait_time: float) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._n["acquired"] += 1
|
|
89
|
+
self._waits.append(wait_time)
|
|
90
|
+
|
|
91
|
+
def on_released(self, conn: PooledConnection[Any], hold_time: float) -> None:
|
|
92
|
+
self._inc("released")
|
|
93
|
+
|
|
94
|
+
def on_acquire_timeout(self, waited: float) -> None:
|
|
95
|
+
self._inc("timeouts")
|
|
96
|
+
|
|
97
|
+
def on_connection_leak(self, conn: PooledConnection[Any], held_for: float) -> None:
|
|
98
|
+
self._inc("leaks")
|
|
99
|
+
|
|
100
|
+
def snapshot(self, **gauges: Any) -> PoolStats:
|
|
101
|
+
with self._lock:
|
|
102
|
+
n = self._n
|
|
103
|
+
waits = sorted(self._waits)
|
|
104
|
+
creates = sorted(self._creates)
|
|
105
|
+
return PoolStats(
|
|
106
|
+
created=n["created"], create_failures=n["create_failures"], acquired=n["acquired"],
|
|
107
|
+
released=n["released"], timeouts=n["timeouts"], closed=n["closed"],
|
|
108
|
+
leaks=n["leaks"], closed_by_reason=dict(self._by_reason),
|
|
109
|
+
avg_acquire_wait=(sum(waits) / len(waits)) if waits else 0.0,
|
|
110
|
+
p50_acquire_wait=percentile(waits, 0.50),
|
|
111
|
+
p95_acquire_wait=percentile(waits, 0.95),
|
|
112
|
+
p99_acquire_wait=percentile(waits, 0.99),
|
|
113
|
+
avg_create_latency=(sum(creates) / len(creates)) if creates else 0.0,
|
|
114
|
+
p50_create_latency=percentile(creates, 0.50),
|
|
115
|
+
p95_create_latency=percentile(creates, 0.95),
|
|
116
|
+
p99_create_latency=percentile(creates, 0.99),
|
|
117
|
+
**gauges,
|
|
118
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pool configuration: one immutable, validated object."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from connpool.resilience.breaker import BreakerConfig
|
|
9
|
+
from connpool.core.errors import PoolConfigError
|
|
10
|
+
from connpool.resilience.retry import RetryPolicy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IdleStrategy(str, enum.Enum):
|
|
14
|
+
LIFO = "lifo" # reuse the hottest connection; cold ones age out and get evicted
|
|
15
|
+
FIFO = "fifo" # reuse the oldest idle connection; spreads load / rotates evenly
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class PoolConfig:
|
|
20
|
+
# --- sizing ---
|
|
21
|
+
min_size: int = 0 # kept warm by open() and the maintenance pass
|
|
22
|
+
max_size: int = 10 # hard cap (idle + in use + being created)
|
|
23
|
+
|
|
24
|
+
# --- timeouts (seconds; None = no limit) ---
|
|
25
|
+
acquire_timeout: Optional[float] = 30.0 # default wait in acquire()
|
|
26
|
+
create_timeout: Optional[float] = 10.0 # enforced by AsyncConnectionPool only; sync
|
|
27
|
+
# factories must enforce their own connect timeout
|
|
28
|
+
max_waiters: Optional[int] = None # backpressure: cap callers blocked in acquire()
|
|
29
|
+
# (None = unlimited; 0 = never wait when full)
|
|
30
|
+
|
|
31
|
+
# --- rotation / recycling ---
|
|
32
|
+
max_lifetime: Optional[float] = 1800.0 # retire connections after this age
|
|
33
|
+
lifetime_jitter: float = 0.1 # shave up to 10% off each lifetime so a
|
|
34
|
+
# whole pool never expires at the same instant
|
|
35
|
+
max_idle_time: Optional[float] = 600.0 # evict idle connections (down to min_size)
|
|
36
|
+
max_uses: Optional[int] = None # retire after N checkouts
|
|
37
|
+
idle_strategy: IdleStrategy = IdleStrategy.LIFO
|
|
38
|
+
|
|
39
|
+
# --- health ---
|
|
40
|
+
validate_after_idle: Optional[float] = 30.0 # validate on checkout if idle >= this
|
|
41
|
+
# (0 = always, None = never)
|
|
42
|
+
maintenance_interval: Optional[float] = 30.0 # background sweep period (None = off)
|
|
43
|
+
leak_detection_timeout: Optional[float] = None # warn when a borrowed connection is held
|
|
44
|
+
# longer than this (None = off); needs open()
|
|
45
|
+
validate_idle: bool = False # validate idle connections during the maintenance
|
|
46
|
+
# sweep and evict dead ones (needs open(); adds I/O)
|
|
47
|
+
|
|
48
|
+
# --- resilience ---
|
|
49
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
50
|
+
breaker: Optional[BreakerConfig] = field(default_factory=BreakerConfig) # None = off
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
def need(ok: bool, msg: str) -> None:
|
|
54
|
+
if not ok:
|
|
55
|
+
raise PoolConfigError(msg)
|
|
56
|
+
|
|
57
|
+
need(self.max_size >= 1, "max_size must be >= 1")
|
|
58
|
+
need(0 <= self.min_size <= self.max_size, "min_size must be within [0, max_size]")
|
|
59
|
+
need(self.acquire_timeout is None or self.acquire_timeout >= 0, "acquire_timeout must be >= 0")
|
|
60
|
+
for name in ("create_timeout", "max_lifetime", "max_idle_time", "maintenance_interval",
|
|
61
|
+
"leak_detection_timeout"):
|
|
62
|
+
value = getattr(self, name)
|
|
63
|
+
need(value is None or value > 0, f"{name} must be > 0 or None")
|
|
64
|
+
need(self.validate_after_idle is None or self.validate_after_idle >= 0,
|
|
65
|
+
"validate_after_idle must be >= 0 or None")
|
|
66
|
+
need(0.0 <= self.lifetime_jitter <= 1.0, "lifetime_jitter must be within [0, 1]")
|
|
67
|
+
need(self.max_uses is None or self.max_uses >= 1, "max_uses must be >= 1 or None")
|
|
68
|
+
need(self.max_waiters is None or self.max_waiters >= 0, "max_waiters must be >= 0 or None")
|
|
69
|
+
|
|
70
|
+
def replace(self, **changes: Any) -> "PoolConfig":
|
|
71
|
+
"""Return a modified copy (the config itself is immutable)."""
|
|
72
|
+
return replace(self, **changes)
|