sqlalchemy-foundation-kit 0.2.1__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.2.1 → sqlalchemy_foundation_kit-0.4.0}/CHANGELOG.md +14 -0
  2. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/PKG-INFO +4 -4
  3. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/README.md +3 -3
  4. {sqlalchemy_foundation_kit-0.2.1 → 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.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/postgres.py +3 -3
  7. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +60 -4
  8. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +13 -4
  9. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/__init__.py +2 -0
  10. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/protocols/metrics.py +36 -4
  11. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/builder.py +18 -0
  12. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/factories.py +12 -3
  13. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/manager.py +129 -2
  14. sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/__version__.py +0 -1
  15. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/.gitignore +0 -0
  16. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/LICENSE +0 -0
  17. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/pyproject.toml +0 -0
  18. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
  19. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
  20. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/_optional.py +0 -0
  21. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/engine.py +0 -0
  22. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
  23. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
  24. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/serialization.py +0 -0
  25. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
  26. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
  27. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
  28. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
  29. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
  30. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
  31. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -0
  32. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
  33. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
  34. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
  35. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
  36. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -0
  37. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
  38. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
  39. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
  40. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
  41. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
  42. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
  43. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
  44. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/py.typed +0 -0
  45. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
  46. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/connection.py +0 -0
  47. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/locks.py +0 -0
  48. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
  49. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
  50. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
  51. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/protocols.py +0 -0
  52. {sqlalchemy_foundation_kit-0.2.1 → sqlalchemy_foundation_kit-0.4.0}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +0 -0
@@ -1,5 +1,19 @@
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
+
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)
11
+
12
+
13
+ ### Features
14
+
15
+ * stop sending jit and search_path as startup parameters ([#24](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/24)) ([704c343](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/704c343b932b15f0c6ee434f2fc80ab4732b0f80))
16
+
3
17
  ## [0.2.1](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.2.0...sqlalchemy-foundation-kit-v0.2.1) (2026-09-06)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlalchemy-foundation-kit
3
- Version: 0.2.1
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/
@@ -295,7 +295,7 @@ Only `sqlalchemy[asyncio]`, `pydantic` and `asyncpg` are required by default —
295
295
  ✅ **Single dependency** — All foundation pieces in one place
296
296
  ✅ **Unit of Work pattern** — Transactional consistency with automatic commit/rollback
297
297
  ✅ **Connection pool management** — `AsyncSessionManager` with metrics and health checks
298
- ✅ **pgbouncer compatible** — Custom connection class for transaction mode
298
+ ✅ **PgBouncer compatible** — A startup packet PgBouncer accepts, `search_path` per transaction, unique statement names
299
299
  ✅ **Observability built-in** — Prometheus metrics + OpenTelemetry tracing
300
300
  ✅ **Type-safe configuration** — Pydantic settings with validation
301
301
  ✅ **Base ORM models** — Pre-configured `Base` with naming conventions and mixins
@@ -398,7 +398,7 @@ class PostgresConfig:
398
398
  application_name: str = "my-service"
399
399
  db_schema: str | None = None
400
400
  use_orjson_serialization: bool = True
401
- jit: str | None = "off"
401
+ jit: str | None = None
402
402
 
403
403
  def to_dsn(self) -> str:
404
404
  return f"postgresql+asyncpg://{self.connection.user}@{self.connection.host}:{self.connection.port}/{self.connection.database}"
@@ -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
@@ -44,7 +44,7 @@ Only `sqlalchemy[asyncio]`, `pydantic` and `asyncpg` are required by default —
44
44
  ✅ **Single dependency** — All foundation pieces in one place
45
45
  ✅ **Unit of Work pattern** — Transactional consistency with automatic commit/rollback
46
46
  ✅ **Connection pool management** — `AsyncSessionManager` with metrics and health checks
47
- ✅ **pgbouncer compatible** — Custom connection class for transaction mode
47
+ ✅ **PgBouncer compatible** — A startup packet PgBouncer accepts, `search_path` per transaction, unique statement names
48
48
  ✅ **Observability built-in** — Prometheus metrics + OpenTelemetry tracing
49
49
  ✅ **Type-safe configuration** — Pydantic settings with validation
50
50
  ✅ **Base ORM models** — Pre-configured `Base` with naming conventions and mixins
@@ -147,7 +147,7 @@ class PostgresConfig:
147
147
  application_name: str = "my-service"
148
148
  db_schema: str | None = None
149
149
  use_orjson_serialization: bool = True
150
- jit: str | None = "off"
150
+ jit: str | None = None
151
151
 
152
152
  def to_dsn(self) -> str:
153
153
  return f"postgresql+asyncpg://{self.connection.user}@{self.connection.host}:{self.connection.port}/{self.connection.database}"
@@ -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.2.1"
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"
@@ -131,9 +131,9 @@ class PostgresSettingsProtocol(Protocol):
131
131
  pool: Connection pool settings.
132
132
  query: Query execution and transaction settings.
133
133
  application_name: Application identifier for connections.
134
- db_schema: Optional PostgreSQL schema name.
134
+ db_schema: Optional PostgreSQL ``search_path``, applied to every transaction.
135
135
  use_orjson_serialization: Enable orjson for JSON operations.
136
- jit: JIT compilation setting (PgBouncer compatibility).
136
+ jit: JIT compilation setting, sent as a startup parameter only when not ``None``.
137
137
 
138
138
  Examples:
139
139
  Implementing the protocol:
@@ -144,7 +144,7 @@ class PostgresSettingsProtocol(Protocol):
144
144
  ... application_name: str = "my-app"
145
145
  ... db_schema: str | None = None
146
146
  ... use_orjson_serialization: bool = True
147
- ... jit: str | None = "off"
147
+ ... jit: str | None = None
148
148
  ...
149
149
  ... def to_dsn(self) -> str:
150
150
  ... return f"postgresql://{self.connection.user}@{self.connection.host}..."
@@ -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,
@@ -98,9 +98,12 @@ class BasePostgresConfig(BaseSettings):
98
98
  pool: Connection pool configuration (size, overflow, timeouts).
99
99
  query: Query execution settings (echo, caching, isolation level).
100
100
  application_name: Application name for connection identification.
101
- db_schema: Optional PostgreSQL schema name.
101
+ db_schema: Optional PostgreSQL ``search_path`` — a schema, or a comma-separated
102
+ list — applied to every transaction with ``SET LOCAL`` semantics.
102
103
  use_orjson_serialization: Use orjson for JSON serialization (requires orjson).
103
- jit: JIT compilation setting (off/on) for PgBouncer compatibility.
104
+ jit: JIT compilation setting (off/on), sent as a startup parameter only when set.
105
+ ``None`` (the default) sends nothing and leaves the server's own setting; a
106
+ transaction-mode PgBouncer rejects the parameter unless it tracks it.
104
107
  metrics_enabled: Enable connection pool metrics collection.
105
108
 
106
109
  Examples:
@@ -125,12 +128,18 @@ class BasePostgresConfig(BaseSettings):
125
128
 
126
129
  # Top-level settings
127
130
  application_name: str = Field(description="Application name for PostgreSQL")
128
- db_schema: str | None = Field(default=None, description="PostgreSQL schema name")
131
+ db_schema: str | None = Field(
132
+ default=None,
133
+ description="PostgreSQL search_path (a schema or a comma-separated list), applied to every transaction",
134
+ )
129
135
  use_orjson_serialization: bool = Field(
130
136
  default=True,
131
137
  description="Use orjson for JSON serialization (requires orjson installed)",
132
138
  )
133
- jit: PostgresJit | None = Field(default="off", description="JIT setting (off/on)")
139
+ jit: PostgresJit | None = Field(
140
+ default=None,
141
+ description="JIT setting (off/on), sent as a startup parameter only when set",
142
+ )
134
143
  metrics_enabled: bool = Field(default=False, description="Enable PostgreSQL metrics")
135
144
 
136
145
  def __repr__(self) -> 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",
@@ -77,6 +77,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
77
77
  self._metrics: PostgresMetricsProtocol | None = None
78
78
  self._on_engine_created: Callable[[AsyncEngine], None] | None = None
79
79
  self._dispose_timeout: float | None = None
80
+ self._search_path: str | None = None
80
81
  self._extra_kwargs: dict[str, object] = {}
81
82
 
82
83
  def with_echo(self, echo: bool = True) -> AsyncSessionManagerBuilder[SessionT]:
@@ -258,6 +259,22 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
258
259
  self._dispose_timeout = timeout
259
260
  return self
260
261
 
262
+ def with_search_path(self, search_path: str) -> AsyncSessionManagerBuilder[SessionT]:
263
+ """Apply a PostgreSQL ``search_path`` to every transaction.
264
+
265
+ Issued with ``SET LOCAL`` semantics at the start of each transaction — the one
266
+ scope a transaction-mode pooler such as PgBouncer honours. See
267
+ :func:`~sqlalchemy_foundation_kit.session.manager.attach_search_path`.
268
+
269
+ Args:
270
+ search_path: A schema, or a comma-separated list such as ``"tenant_7, public"``.
271
+
272
+ Returns:
273
+ Self for method chaining.
274
+ """
275
+ self._search_path = search_path
276
+ return self
277
+
261
278
  def build(self) -> AsyncSessionManager[SessionT]:
262
279
  """Build AsyncSessionManager instance with configured parameters.
263
280
 
@@ -284,6 +301,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
284
301
  "use_orjson": self._use_orjson,
285
302
  "metrics": self._metrics,
286
303
  "on_engine_created": self._on_engine_created,
304
+ "search_path": self._search_path,
287
305
  }
288
306
  if self._dispose_timeout is not None:
289
307
  kwargs["dispose_timeout"] = self._dispose_timeout
@@ -31,6 +31,13 @@ def create_async_session_manager(
31
31
  ) -> AsyncSessionManager[AsyncSession]:
32
32
  """Create async session manager with PostgreSQL-specific configuration.
33
33
 
34
+ The startup packet carries ``application_name`` and, only when the config sets it,
35
+ ``jit``; nothing else, because a transaction-mode pooler such as PgBouncer rejects
36
+ startup parameters it does not track (``unsupported startup parameter: jit``) or,
37
+ with ``ignore_startup_parameters``, silently drops them. ``db_schema`` therefore
38
+ travels as the manager's ``search_path`` and is applied with ``SET LOCAL`` semantics
39
+ at the start of every transaction, which is the one scope such a pooler honours.
40
+
34
41
  Args:
35
42
  postgres_config: PostgreSQL configuration implementing PostgresSettingsProtocol.
36
43
  application_name: Optional custom application name. If None, uses postgres_config.application_name.
@@ -41,7 +48,8 @@ def create_async_session_manager(
41
48
  which provides pgbouncer transaction-mode compatibility.
42
49
  extra_server_settings: Additional PostgreSQL ``server_settings`` to merge with defaults
43
50
  (e.g., ``{"statement_timeout": "30000", "timezone": "UTC"}``). User-provided keys
44
- override library defaults.
51
+ override library defaults. These are startup parameters: through PgBouncer only
52
+ the ones it tracks arrive (``track_extra_parameters``).
45
53
  extra_connect_args: Additional asyncpg ``connect_args`` to merge with defaults
46
54
  (e.g., ``{"command_timeout": 60}``). User-provided keys override library defaults.
47
55
  **kwargs: Additional keyword arguments passed to AsyncSessionManager.
@@ -73,11 +81,11 @@ def create_async_session_manager(
73
81
  """
74
82
  app_name = application_name or postgres_config.application_name
75
83
 
76
- # Build server settings with optional overrides
84
+ # Build server settings with optional overrides. These are startup parameters, so
85
+ # search_path is deliberately not among them -- see the docstring.
77
86
  server_settings: dict[str, str] = {
78
87
  "application_name": app_name,
79
88
  **({"jit": postgres_config.jit} if postgres_config.jit is not None else {}),
80
- **({"search_path": postgres_config.db_schema} if postgres_config.db_schema is not None else {}),
81
89
  **(extra_server_settings or {}),
82
90
  }
83
91
 
@@ -100,5 +108,6 @@ def create_async_session_manager(
100
108
  use_orjson=postgres_config.use_orjson_serialization,
101
109
  metrics=metrics,
102
110
  on_engine_created=on_engine_created,
111
+ search_path=postgres_config.db_schema,
103
112
  **kwargs,
104
113
  )
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from types import TracebackType
11
11
  from typing import TYPE_CHECKING, Any, Generic, cast
12
12
 
13
- from sqlalchemy import event
13
+ from sqlalchemy import event, text
14
14
  from sqlalchemy.exc import TimeoutError as SATimeoutError
15
15
  from sqlalchemy.ext.asyncio import (
16
16
  AsyncEngine,
@@ -21,8 +21,11 @@ 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:
27
+ from sqlalchemy.engine import Connection
28
+
26
29
  from ..config import PoolSettingsProtocol
27
30
  from ..protocols import PostgresMetricsProtocol
28
31
 
@@ -30,6 +33,10 @@ logger = logging.getLogger(__name__)
30
33
 
31
34
  DEFAULT_DISPOSE_TIMEOUT_SECONDS: float = 30.0
32
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
+
33
40
 
34
41
  def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
35
42
  """Call a metric-recording function and swallow exceptions.
@@ -47,18 +54,94 @@ def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
47
54
  logger.exception(error_msg)
48
55
 
49
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
+
50
118
  def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> None:
51
119
  """Attach metrics event listeners to a SQLAlchemy engine.
52
120
 
53
121
  Registers event handlers for connection checkout, checkin, and error events
54
122
  to collect pool statistics and connection metrics.
55
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
+
56
129
  Args:
57
130
  engine: SQLAlchemy ``AsyncEngine`` to attach listeners to.
58
131
  metrics: Metrics collector implementing ``PostgresMetricsProtocol``.
59
132
  """
60
133
  pool = engine.pool
61
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
+
62
145
  def record_pool_stats() -> None:
63
146
  _safe_metric_call(
64
147
  lambda: metrics.record_pool_stats(
@@ -100,6 +183,35 @@ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> Non
100
183
  event.listen(engine.sync_engine, "handle_error", on_error)
101
184
 
102
185
 
186
+ def attach_search_path(engine: AsyncEngine, search_path: str) -> None:
187
+ """Apply ``search_path`` to every transaction the engine begins.
188
+
189
+ Registers a ``begin`` listener that issues ``set_config('search_path', …, true)`` —
190
+ the function form of ``SET LOCAL``, with the value as a bind parameter — as the first
191
+ statement of each transaction, so the setting lives exactly as long as the transaction
192
+ does. That is the one scope a transaction-mode pooler such as PgBouncer honours: a
193
+ startup parameter is rejected or dropped before it reaches PostgreSQL, and a plain
194
+ ``SET`` on a server connection leaks to whichever client is handed it next.
195
+
196
+ Under the asyncpg adapter ``BEGIN`` is sent lazily with the first statement, so the
197
+ ``set_config`` call lands inside the transaction rather than ahead of it. Every
198
+ transaction is covered — a session's autobegin, ``session.begin()``, a raw
199
+ ``engine.connect()``, the one that follows a ``commit()`` — but a statement run with
200
+ ``isolation_level="AUTOCOMMIT"`` begins none and runs with the server's default.
201
+
202
+ Args:
203
+ engine: SQLAlchemy ``AsyncEngine`` to attach the listener to.
204
+ search_path: Value for ``search_path`` — a schema, or a comma-separated list such
205
+ as ``"tenant_7, public"``.
206
+ """
207
+ statement = text("SELECT set_config('search_path', :search_path, true)")
208
+
209
+ def on_begin(conn: Connection) -> None:
210
+ conn.execute(statement, {"search_path": search_path})
211
+
212
+ event.listen(engine.sync_engine, "begin", on_begin)
213
+
214
+
103
215
  class AsyncSessionManager(Generic[SessionT]):
104
216
  """Manages async database sessions with configurable connection pooling.
105
217
 
@@ -124,6 +236,7 @@ class AsyncSessionManager(Generic[SessionT]):
124
236
  metrics: PostgresMetricsProtocol | None = None,
125
237
  on_engine_created: Callable[[AsyncEngine], None] | None = None,
126
238
  dispose_timeout: float = DEFAULT_DISPOSE_TIMEOUT_SECONDS,
239
+ search_path: str | None = None,
127
240
  **kwargs: object,
128
241
  ) -> None:
129
242
  """Initialize session manager with direct configuration.
@@ -139,18 +252,29 @@ class AsyncSessionManager(Generic[SessionT]):
139
252
  isolation_level: Default transaction isolation level (default: None).
140
253
  pool_settings: Pool configuration settings (default: None).
141
254
  use_orjson: If True, use orjson for JSON serialization (default: False).
142
- 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.
143
259
  on_engine_created: Optional callback invoked with ``AsyncEngine`` after creation.
144
260
  Use for OpenTelemetry instrumentation, custom event listeners, etc.
145
261
  dispose_timeout: Maximum seconds to wait for engine disposal in :meth:`aclose`
146
262
  (default: 30.0). Lower this in tests or short-lived environments; raise it
147
263
  if you have long-running transactions that need more time to settle.
264
+ search_path: PostgreSQL ``search_path`` applied to every transaction with
265
+ ``SET LOCAL`` semantics, which is what survives a transaction-mode pooler
266
+ (default: None — nothing is sent, the server default applies). See
267
+ :func:`attach_search_path`.
148
268
  **kwargs: Additional keyword arguments for ``create_async_engine``.
149
269
  """
150
270
  self._closed = False
151
271
  self._close_lock = asyncio.Lock()
152
272
  self._dispose_timeout = dispose_timeout
153
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)
154
278
  engine_kwargs = build_engine_kwargs(
155
279
  echo=echo,
156
280
  poolclass=resolved_poolclass,
@@ -174,6 +298,9 @@ class AsyncSessionManager(Generic[SessionT]):
174
298
  if metrics:
175
299
  attach_metrics(self._engine, metrics)
176
300
 
301
+ if search_path is not None:
302
+ attach_search_path(self._engine, search_path)
303
+
177
304
  if on_engine_created is not None:
178
305
  on_engine_created(self._engine)
179
306
 
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"