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.
Files changed (52) hide show
  1. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/CHANGELOG.md +7 -0
  2. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/PKG-INFO +2 -2
  3. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/README.md +1 -1
  4. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/__init__.py +3 -1
  5. sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/__version__.py +1 -0
  6. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +60 -4
  7. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/__init__.py +2 -0
  8. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/metrics.py +36 -4
  9. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/manager.py +89 -1
  10. sqlalchemy_foundation_kit-0.3.0/sqlalchemy_foundation_kit/__version__.py +0 -1
  11. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/.gitignore +0 -0
  12. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/LICENSE +0 -0
  13. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/pyproject.toml +0 -0
  14. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
  15. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
  16. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/_optional.py +0 -0
  17. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/engine.py +0 -0
  18. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
  19. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
  20. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/serialization.py +0 -0
  21. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
  22. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
  23. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/postgres.py +0 -0
  24. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
  25. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
  26. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
  27. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
  28. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -0
  29. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
  30. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
  31. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
  32. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
  33. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -0
  34. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
  35. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
  36. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
  37. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
  38. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +0 -0
  39. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
  40. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
  41. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
  42. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/py.typed +0 -0
  43. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
  44. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/builder.py +0 -0
  45. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/connection.py +0 -0
  46. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/factories.py +0 -0
  47. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/locks.py +0 -0
  48. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
  49. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
  50. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
  51. {sqlalchemy_foundation_kit-0.3.0 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/protocols.py +0 -0
  52. {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.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.3.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 checkout duration (seconds)
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
- - postgres_db_connection_checkout_duration_seconds: Time to acquire connection from pool.
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
- "Time to acquire connection from pool",
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 database connection checkout from the pool."""
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 checkout duration."""
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 from the pool.
36
+ """Record a completed database connection checkout.
37
37
 
38
38
  Args:
39
- duration: Time taken to acquire the connection from the pool, in seconds.
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"