sqlalchemy-foundation-kit 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/CHANGELOG.md +7 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/PKG-INFO +2 -2
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/README.md +1 -1
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/__init__.py +3 -1
- sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/__version__.py +1 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +60 -4
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/__init__.py +2 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/metrics.py +36 -4
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/manager.py +89 -1
- sqlalchemy_foundation_kit-0.3.0/sqlalchemy_foundation_kit/__version__.py +0 -1
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/.gitignore +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/LICENSE +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/pyproject.toml +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/_optional.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/engine.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/serialization.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/py.typed +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/builder.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/connection.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/factories.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/locks.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/protocols.py +0 -0
- {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.3.0...sqlalchemy-foundation-kit-v0.4.0) (2026-09-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* measure the wait for a pool connection, and count pool checkout timeouts ([#27](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/27)) ([78c9d7e](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/78c9d7e2c05052090276bce08219047849cd6dbb))
|
|
9
|
+
|
|
3
10
|
## [0.3.0](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.2.1...sqlalchemy-foundation-kit-v0.3.0) (2026-09-07)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlalchemy-foundation-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Foundation layer for SQLAlchemy-based services with UoW, session management, and observability
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/sqlalchemy-foundation-kit
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/sqlalchemy-foundation-kit/
|
|
@@ -494,7 +494,7 @@ from sqlalchemy_foundation_kit.contrib.settings import (
|
|
|
494
494
|
from sqlalchemy_foundation_kit.contrib.metrics import PostgresMetrics
|
|
495
495
|
```
|
|
496
496
|
- `PostgresMetrics` — Prometheus metrics for connection pool
|
|
497
|
-
- Tracks: pool size, checked out connections, checkout duration, errors
|
|
497
|
+
- Tracks: pool size, checked out connections, checkout wait, held duration, timeouts, errors
|
|
498
498
|
|
|
499
499
|
#### `contrib.di` (requires `[dishka]`)
|
|
500
500
|
```python
|
|
@@ -243,7 +243,7 @@ from sqlalchemy_foundation_kit.contrib.settings import (
|
|
|
243
243
|
from sqlalchemy_foundation_kit.contrib.metrics import PostgresMetrics
|
|
244
244
|
```
|
|
245
245
|
- `PostgresMetrics` — Prometheus metrics for connection pool
|
|
246
|
-
- Tracks: pool size, checked out connections, checkout duration, errors
|
|
246
|
+
- Tracks: pool size, checked out connections, checkout wait, held duration, timeouts, errors
|
|
247
247
|
|
|
248
248
|
#### `contrib.di` (requires `[dishka]`)
|
|
249
249
|
```python
|
|
@@ -31,6 +31,7 @@ from .config import (
|
|
|
31
31
|
# Protocols
|
|
32
32
|
from .protocols import (
|
|
33
33
|
CheckoutRecorder,
|
|
34
|
+
CheckoutWaitRecorder,
|
|
34
35
|
ErrorRecorder,
|
|
35
36
|
PoolStatsRecorder,
|
|
36
37
|
PostgresMetricsProtocol,
|
|
@@ -64,7 +65,7 @@ from .uow import (
|
|
|
64
65
|
try:
|
|
65
66
|
__version__ = version("sqlalchemy-foundation-kit")
|
|
66
67
|
except PackageNotFoundError: # pragma: no cover
|
|
67
|
-
__version__ = "0.
|
|
68
|
+
__version__ = "0.4.0"
|
|
68
69
|
|
|
69
70
|
__all__ = [ # noqa: RUF022
|
|
70
71
|
# Base ORM
|
|
@@ -89,6 +90,7 @@ __all__ = [ # noqa: RUF022
|
|
|
89
90
|
"QuerySettingsProtocol",
|
|
90
91
|
# Protocols
|
|
91
92
|
"CheckoutRecorder",
|
|
93
|
+
"CheckoutWaitRecorder",
|
|
92
94
|
"ErrorRecorder",
|
|
93
95
|
"PoolStatsRecorder",
|
|
94
96
|
"PostgresMetricsProtocol",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -11,7 +11,7 @@ try:
|
|
|
11
11
|
except ImportError:
|
|
12
12
|
HAS_PROMETHEUS = False
|
|
13
13
|
|
|
14
|
-
# Default buckets for connection
|
|
14
|
+
# Default buckets for how long a connection was held (seconds)
|
|
15
15
|
CONNECTION_CHECKOUT_BUCKETS: tuple[float, ...] = (
|
|
16
16
|
0.001,
|
|
17
17
|
0.005,
|
|
@@ -26,6 +26,25 @@ CONNECTION_CHECKOUT_BUCKETS: tuple[float, ...] = (
|
|
|
26
26
|
5.0,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
+
# Default buckets for the wait for a connection from the pool (seconds). The range runs to
|
|
30
|
+
# 30 s, the default ``pool_timeout``, so a saturated pool fills the top buckets instead of
|
|
31
|
+
# collapsing into +Inf just when the histogram is worth reading.
|
|
32
|
+
CONNECTION_WAIT_BUCKETS: tuple[float, ...] = (
|
|
33
|
+
0.001,
|
|
34
|
+
0.005,
|
|
35
|
+
0.01,
|
|
36
|
+
0.025,
|
|
37
|
+
0.05,
|
|
38
|
+
0.1,
|
|
39
|
+
0.25,
|
|
40
|
+
0.5,
|
|
41
|
+
1.0,
|
|
42
|
+
2.5,
|
|
43
|
+
5.0,
|
|
44
|
+
10.0,
|
|
45
|
+
30.0,
|
|
46
|
+
)
|
|
47
|
+
|
|
29
48
|
|
|
30
49
|
def _check_prometheus() -> None:
|
|
31
50
|
"""Check if prometheus-client is installed."""
|
|
@@ -43,12 +62,19 @@ class PostgresMetrics:
|
|
|
43
62
|
- postgres_db_pool_size: Current database connection pool size.
|
|
44
63
|
- postgres_db_pool_checked_out: Number of connections currently checked out.
|
|
45
64
|
- postgres_db_pool_overflow: Number of connections over pool_size (within max_overflow).
|
|
46
|
-
-
|
|
65
|
+
- postgres_db_connection_checkout_wait_seconds: Time a caller waited for a connection.
|
|
66
|
+
- postgres_db_connection_held_duration_seconds: Time a connection was held by its caller.
|
|
67
|
+
- postgres_db_connection_checkout_duration_seconds: Deprecated alias of the held duration,
|
|
68
|
+
published unchanged for one more minor release.
|
|
47
69
|
- postgres_db_connection_timeouts_total: Number of connection checkout timeouts.
|
|
48
70
|
- postgres_db_connection_errors_total: Number of connection errors.
|
|
49
71
|
|
|
50
72
|
Labels:
|
|
51
73
|
- error_type: Type of connection error (for errors_total).
|
|
74
|
+
|
|
75
|
+
The wait histogram and the timeout counter are fed by the instrumented pool class the
|
|
76
|
+
session manager builds, so they only move on an engine created by
|
|
77
|
+
``AsyncSessionManager`` (or one whose pool went through ``instrument_pool_class``).
|
|
52
78
|
"""
|
|
53
79
|
|
|
54
80
|
def __init__(self, prefix: str | None = None) -> None:
|
|
@@ -74,9 +100,19 @@ class PostgresMetrics:
|
|
|
74
100
|
_make_metric_name("postgres_db_pool_overflow", prefix),
|
|
75
101
|
"Number of connections over pool_size (within max_overflow)",
|
|
76
102
|
)
|
|
103
|
+
self.connection_checkout_wait = Histogram(
|
|
104
|
+
_make_metric_name("postgres_db_connection_checkout_wait_seconds", prefix),
|
|
105
|
+
"Time a caller waited to acquire a connection from the pool",
|
|
106
|
+
buckets=list(CONNECTION_WAIT_BUCKETS),
|
|
107
|
+
)
|
|
108
|
+
self.connection_held_duration = Histogram(
|
|
109
|
+
_make_metric_name("postgres_db_connection_held_duration_seconds", prefix),
|
|
110
|
+
"Time a connection was held by its caller, from checkout to checkin",
|
|
111
|
+
buckets=list(CONNECTION_CHECKOUT_BUCKETS),
|
|
112
|
+
)
|
|
77
113
|
self.connection_checkout_duration = Histogram(
|
|
78
114
|
_make_metric_name("postgres_db_connection_checkout_duration_seconds", prefix),
|
|
79
|
-
"
|
|
115
|
+
"Deprecated: held duration, published under its old name. Use postgres_db_connection_held_duration_seconds",
|
|
80
116
|
buckets=list(CONNECTION_CHECKOUT_BUCKETS),
|
|
81
117
|
)
|
|
82
118
|
self.connection_timeouts_total = Counter(
|
|
@@ -104,9 +140,29 @@ class PostgresMetrics:
|
|
|
104
140
|
self,
|
|
105
141
|
duration: float,
|
|
106
142
|
) -> None:
|
|
107
|
-
"""Record a
|
|
143
|
+
"""Record how long a connection was held, from checkout to checkin.
|
|
144
|
+
|
|
145
|
+
Observed into ``postgres_db_connection_held_duration_seconds`` and, until the
|
|
146
|
+
deprecated name is dropped, into ``postgres_db_connection_checkout_duration_seconds``
|
|
147
|
+
as well.
|
|
148
|
+
"""
|
|
149
|
+
self.connection_held_duration.observe(duration)
|
|
108
150
|
self.connection_checkout_duration.observe(duration)
|
|
109
151
|
|
|
152
|
+
def record_checkout_wait(
|
|
153
|
+
self,
|
|
154
|
+
duration: float,
|
|
155
|
+
timed_out: bool = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Record the wait for a connection from the pool, and count a pool timeout.
|
|
158
|
+
|
|
159
|
+
A pool checkout timeout is raised before any DBAPI call, so it never reaches the
|
|
160
|
+
engine's ``handle_error`` listener; this is the only place it is counted.
|
|
161
|
+
"""
|
|
162
|
+
self.connection_checkout_wait.observe(duration)
|
|
163
|
+
if timed_out:
|
|
164
|
+
self.connection_timeouts_total.inc()
|
|
165
|
+
|
|
110
166
|
def record_error(
|
|
111
167
|
self,
|
|
112
168
|
error_type: str,
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from .metrics import (
|
|
10
10
|
CheckoutRecorder,
|
|
11
|
+
CheckoutWaitRecorder,
|
|
11
12
|
ErrorRecorder,
|
|
12
13
|
PoolStatsRecorder,
|
|
13
14
|
PostgresMetricsProtocol,
|
|
@@ -15,6 +16,7 @@ from .metrics import (
|
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
18
|
"CheckoutRecorder",
|
|
19
|
+
"CheckoutWaitRecorder",
|
|
18
20
|
"ErrorRecorder",
|
|
19
21
|
"PoolStatsRecorder",
|
|
20
22
|
"PostgresMetricsProtocol",
|
|
@@ -7,7 +7,7 @@ the narrow protocols selectively (e.g., only record errors), and
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import Protocol
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class PoolStatsRecorder(Protocol):
|
|
@@ -30,13 +30,39 @@ class PoolStatsRecorder(Protocol):
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class CheckoutRecorder(Protocol):
|
|
33
|
-
"""Capability protocol for recording connection
|
|
33
|
+
"""Capability protocol for recording how long a connection was held."""
|
|
34
34
|
|
|
35
35
|
def record_checkout(self, duration: float) -> None:
|
|
36
|
-
"""Record a database connection checkout
|
|
36
|
+
"""Record a completed database connection checkout.
|
|
37
37
|
|
|
38
38
|
Args:
|
|
39
|
-
duration: Time
|
|
39
|
+
duration: Time between the connection leaving the pool and coming back to it,
|
|
40
|
+
in seconds — how long the caller *held* it, which is the query time seen
|
|
41
|
+
from the pool. The time a caller spent *waiting* for it is
|
|
42
|
+
:class:`CheckoutWaitRecorder`.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class CheckoutWaitRecorder(Protocol):
|
|
49
|
+
"""Capability protocol for recording the wait for a connection from the pool.
|
|
50
|
+
|
|
51
|
+
Deliberately not part of :class:`PostgresMetricsProtocol`: a metrics object written
|
|
52
|
+
against the three older capabilities stays valid and simply publishes no wait series.
|
|
53
|
+
The session manager tests for this protocol with ``isinstance`` and instruments the
|
|
54
|
+
pool class only when it is satisfied, which is what ``runtime_checkable`` is for here.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def record_checkout_wait(self, duration: float, timed_out: bool = False) -> None:
|
|
58
|
+
"""Record the time a caller spent acquiring a connection from the pool.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
duration: Seconds spent inside ``pool.connect()`` — the queue wait, and the
|
|
62
|
+
pre-ping round trip and connect handshake when the pool had to grow.
|
|
63
|
+
timed_out: True if the wait ended in ``sqlalchemy.exc.TimeoutError`` rather
|
|
64
|
+
than in a connection. This is the pool checkout timeout; it is raised
|
|
65
|
+
before any DBAPI call and so never reaches the engine's ``handle_error``.
|
|
40
66
|
"""
|
|
41
67
|
...
|
|
42
68
|
|
|
@@ -60,10 +86,15 @@ class PostgresMetricsProtocol(PoolStatsRecorder, CheckoutRecorder, ErrorRecorder
|
|
|
60
86
|
Aggregates the narrow capability protocols for convenience. Implementations
|
|
61
87
|
that only need a subset can implement the individual protocols directly.
|
|
62
88
|
|
|
89
|
+
:class:`CheckoutWaitRecorder` is intentionally left out so that implementations
|
|
90
|
+
written before it keep satisfying this protocol; add ``record_checkout_wait`` to get
|
|
91
|
+
the checkout wait histogram and the pool timeout counter as well.
|
|
92
|
+
|
|
63
93
|
Examples:
|
|
64
94
|
>>> class MyMetrics:
|
|
65
95
|
... def record_pool_stats(self, pool_size: int, pool_checked_out: int, pool_overflow: int) -> None: ...
|
|
66
96
|
... def record_checkout(self, duration: float) -> None: ...
|
|
97
|
+
... def record_checkout_wait(self, duration: float, timed_out: bool = False) -> None: ...
|
|
67
98
|
... def record_error(self, error_type: str, is_timeout: bool = False) -> None: ...
|
|
68
99
|
>>> metrics: PostgresMetricsProtocol = MyMetrics()
|
|
69
100
|
"""
|
|
@@ -71,6 +102,7 @@ class PostgresMetricsProtocol(PoolStatsRecorder, CheckoutRecorder, ErrorRecorder
|
|
|
71
102
|
|
|
72
103
|
__all__ = [
|
|
73
104
|
"CheckoutRecorder",
|
|
105
|
+
"CheckoutWaitRecorder",
|
|
74
106
|
"ErrorRecorder",
|
|
75
107
|
"PoolStatsRecorder",
|
|
76
108
|
"PostgresMetricsProtocol",
|
|
@@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import (
|
|
|
21
21
|
|
|
22
22
|
from .._typing import SessionT
|
|
23
23
|
from ..base import build_engine_kwargs, resolve_pool_class
|
|
24
|
+
from ..protocols import CheckoutWaitRecorder
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from sqlalchemy.engine import Connection
|
|
@@ -32,6 +33,10 @@ logger = logging.getLogger(__name__)
|
|
|
32
33
|
|
|
33
34
|
DEFAULT_DISPOSE_TIMEOUT_SECONDS: float = 30.0
|
|
34
35
|
|
|
36
|
+
# Set on the pool subclass instrument_pool_class() builds, so attach_metrics() can tell an
|
|
37
|
+
# engine whose pool records the checkout wait from one that silently will not.
|
|
38
|
+
_WAIT_RECORDER_ATTRIBUTE = "_checkout_wait_recorder"
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
|
|
37
42
|
"""Call a metric-recording function and swallow exceptions.
|
|
@@ -49,18 +54,94 @@ def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
|
|
|
49
54
|
logger.exception(error_msg)
|
|
50
55
|
|
|
51
56
|
|
|
57
|
+
def instrument_pool_class(pool_class: type, metrics: CheckoutWaitRecorder) -> type:
|
|
58
|
+
"""Build a pool class that times the wait for a connection and counts pool timeouts.
|
|
59
|
+
|
|
60
|
+
SQLAlchemy has no event for "a checkout was requested", only for one that succeeded, so
|
|
61
|
+
the time a caller spends queued for a connection cannot be observed from a listener.
|
|
62
|
+
The returned subclass wraps :meth:`sqlalchemy.pool.Pool.connect`, which runs exactly
|
|
63
|
+
once per checkout — for a sync and an async engine alike, since
|
|
64
|
+
``Engine.raw_connection()`` goes through it — and through which the pool's
|
|
65
|
+
``TimeoutError`` passes exactly once. Under an async pool the queue wait is an
|
|
66
|
+
``await_only`` inside that call, so the timer spans the whole suspension and measures
|
|
67
|
+
the time the caller really waited.
|
|
68
|
+
|
|
69
|
+
``_do_get`` is the wrong hook: ``QueuePool`` recurses into it when the non-blocking get
|
|
70
|
+
comes back empty and the race for an overflow slot is lost, which is exactly the
|
|
71
|
+
contended case worth measuring, and the wait would be observed twice.
|
|
72
|
+
|
|
73
|
+
The recorder lives on the class, so a pool produced by ``recreate()`` after
|
|
74
|
+
``dispose()`` keeps recording.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
pool_class: Pool class to instrument — anything :func:`resolve_pool_class` returns,
|
|
78
|
+
including a class registered with :func:`register_pool_class`.
|
|
79
|
+
metrics: Recorder to call once per checkout. A raising recorder is logged and
|
|
80
|
+
swallowed; the checkout itself is never affected.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A subclass of ``pool_class`` suitable for ``create_async_engine(poolclass=...)``.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
>>> poolclass = instrument_pool_class(resolve_pool_class("async_adapted_queue"), metrics)
|
|
87
|
+
>>> engine = create_async_engine(url, poolclass=poolclass)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
class InstrumentedPool(pool_class): # type: ignore[misc, valid-type]
|
|
91
|
+
"""Pool that reports how long each checkout waited."""
|
|
92
|
+
|
|
93
|
+
def connect(self) -> Any:
|
|
94
|
+
started = time.perf_counter()
|
|
95
|
+
try:
|
|
96
|
+
connection = super().connect()
|
|
97
|
+
except SATimeoutError:
|
|
98
|
+
_safe_metric_call(
|
|
99
|
+
lambda: metrics.record_checkout_wait(
|
|
100
|
+
duration=time.perf_counter() - started,
|
|
101
|
+
timed_out=True,
|
|
102
|
+
),
|
|
103
|
+
"Failed to record database checkout timeout",
|
|
104
|
+
)
|
|
105
|
+
raise
|
|
106
|
+
_safe_metric_call(
|
|
107
|
+
lambda: metrics.record_checkout_wait(duration=time.perf_counter() - started),
|
|
108
|
+
"Failed to record database checkout wait",
|
|
109
|
+
)
|
|
110
|
+
return connection
|
|
111
|
+
|
|
112
|
+
setattr(InstrumentedPool, _WAIT_RECORDER_ATTRIBUTE, metrics)
|
|
113
|
+
InstrumentedPool.__name__ = f"Instrumented{pool_class.__name__}"
|
|
114
|
+
InstrumentedPool.__qualname__ = InstrumentedPool.__name__
|
|
115
|
+
return InstrumentedPool
|
|
116
|
+
|
|
117
|
+
|
|
52
118
|
def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> None:
|
|
53
119
|
"""Attach metrics event listeners to a SQLAlchemy engine.
|
|
54
120
|
|
|
55
121
|
Registers event handlers for connection checkout, checkin, and error events
|
|
56
122
|
to collect pool statistics and connection metrics.
|
|
57
123
|
|
|
124
|
+
The checkout wait and the pool checkout timeout are **not** listeners — no pool event
|
|
125
|
+
fires for either — and come from the pool class :func:`instrument_pool_class` builds.
|
|
126
|
+
If ``metrics`` records the wait but the engine's pool was built without it, that is
|
|
127
|
+
logged as a warning rather than left as a series that never moves.
|
|
128
|
+
|
|
58
129
|
Args:
|
|
59
130
|
engine: SQLAlchemy ``AsyncEngine`` to attach listeners to.
|
|
60
131
|
metrics: Metrics collector implementing ``PostgresMetricsProtocol``.
|
|
61
132
|
"""
|
|
62
133
|
pool = engine.pool
|
|
63
134
|
|
|
135
|
+
if isinstance(metrics, CheckoutWaitRecorder) and getattr(type(pool), _WAIT_RECORDER_ATTRIBUTE, None) is None:
|
|
136
|
+
logger.warning(
|
|
137
|
+
"%s records the connection checkout wait, but the engine's pool (%s) was not built by "
|
|
138
|
+
"instrument_pool_class(), so no wait and no pool checkout timeout will ever be recorded. "
|
|
139
|
+
"Build the engine through AsyncSessionManager, or pass "
|
|
140
|
+
"instrument_pool_class(pool_class, metrics) as poolclass to create_async_engine().",
|
|
141
|
+
type(metrics).__name__,
|
|
142
|
+
type(pool).__name__,
|
|
143
|
+
)
|
|
144
|
+
|
|
64
145
|
def record_pool_stats() -> None:
|
|
65
146
|
_safe_metric_call(
|
|
66
147
|
lambda: metrics.record_pool_stats(
|
|
@@ -171,7 +252,10 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
171
252
|
isolation_level: Default transaction isolation level (default: None).
|
|
172
253
|
pool_settings: Pool configuration settings (default: None).
|
|
173
254
|
use_orjson: If True, use orjson for JSON serialization (default: False).
|
|
174
|
-
metrics: Optional metrics collector (default: None).
|
|
255
|
+
metrics: Optional metrics collector (default: None). One that also implements
|
|
256
|
+
``CheckoutWaitRecorder`` gets the pool class wrapped by
|
|
257
|
+
:func:`instrument_pool_class`, which is what feeds the checkout wait and
|
|
258
|
+
the pool checkout timeout — neither is reachable from a pool event.
|
|
175
259
|
on_engine_created: Optional callback invoked with ``AsyncEngine`` after creation.
|
|
176
260
|
Use for OpenTelemetry instrumentation, custom event listeners, etc.
|
|
177
261
|
dispose_timeout: Maximum seconds to wait for engine disposal in :meth:`aclose`
|
|
@@ -187,6 +271,10 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
187
271
|
self._close_lock = asyncio.Lock()
|
|
188
272
|
self._dispose_timeout = dispose_timeout
|
|
189
273
|
resolved_poolclass = resolve_pool_class(poolclass)
|
|
274
|
+
if metrics is not None and isinstance(metrics, CheckoutWaitRecorder):
|
|
275
|
+
# The wait for a connection is not observable from a pool event, so it has to
|
|
276
|
+
# be timed inside the pool -- which means deciding the class before the engine.
|
|
277
|
+
resolved_poolclass = instrument_pool_class(resolved_poolclass, metrics)
|
|
190
278
|
engine_kwargs = build_engine_kwargs(
|
|
191
279
|
echo=echo,
|
|
192
280
|
poolclass=resolved_poolclass,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|