sqlalchemy-foundation-kit 0.4.0__tar.gz → 0.5.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 (53) hide show
  1. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/CHANGELOG.md +17 -7
  2. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/PKG-INFO +4 -2
  3. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/README.md +1 -1
  4. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/pyproject.toml +2 -0
  5. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/__init__.py +3 -1
  6. sqlalchemy_foundation_kit-0.5.0/sqlalchemy_foundation_kit/__version__.py +1 -0
  7. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/engine.py +12 -13
  8. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/config/postgres.py +1 -1
  9. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +31 -10
  10. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +17 -5
  11. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/protocols/__init__.py +2 -0
  12. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/protocols/metrics.py +60 -7
  13. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/builder.py +7 -5
  14. sqlalchemy_foundation_kit-0.5.0/sqlalchemy_foundation_kit/session/connection.py +40 -0
  15. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/factories.py +5 -2
  16. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/manager.py +86 -14
  17. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/protocols.py +2 -2
  18. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +46 -23
  19. sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/__version__.py +0 -1
  20. sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/session/connection.py +0 -33
  21. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/.gitignore +0 -0
  22. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/LICENSE +0 -0
  23. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
  24. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
  25. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/_optional.py +0 -0
  26. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
  27. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
  28. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/serialization.py +0 -0
  29. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
  30. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
  31. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
  32. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
  33. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
  34. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
  35. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -0
  36. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
  37. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
  38. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
  39. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
  40. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -0
  41. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
  42. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
  43. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
  44. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
  45. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
  46. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
  47. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
  48. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/py.typed +0 -0
  49. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
  50. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/locks.py +0 -0
  51. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
  52. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
  53. {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.4.0...sqlalchemy-foundation-kit-v0.5.0) (2026-10-16)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * BasePostgresConfig.use_orjson_serialization now defaults to False, matching AsyncSessionManager and the builder. A service that has orjson installed, kept the default, and writes values only orjson serializes (datetime, UUID, Decimal, non-str keys) into JSON columns now gets StatementError (TypeError: Object of type datetime is not JSON serializable). Set use_orjson_serialization=True to keep the old behaviour.
9
+ * uow.query() now opens its transaction READ ONLY. A write inside it (INSERT, UPDATE, DELETE, DDL, nextval()) fails with SQLSTATE 25006 instead of being rolled back silently, or persisted when followed by an explicit session.commit(). Move writes to uow.transaction().
10
+
11
+ ### Bug Fixes
12
+
13
+ * build null and static pools from PoolSettings, and stop recommending the sync queue pool ([#61](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/61)) ([608d327](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/608d32747361321895f6fc43f5e77da6937375ff))
14
+ * count connect timeouts, not statement timeouts, in connection_timeouts_total ([#63](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/63)) ([961e0e8](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/961e0e862cb540525cda99b5ecc79ee12cbab2e4))
15
+ * default use_orjson_serialization to False, so the [settings] quick start runs ([#66](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/66)) ([3addfdd](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/3addfdd50c98657966867b680121facd596b6810))
16
+ * read the pool gauges when they are scraped, and never export a negative overflow ([#62](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/62)) ([e85ac96](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/e85ac96bd875ee47729f029c8046ea85be48f712))
17
+ * run query() in a READ ONLY transaction, as documented ([#64](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/64)) ([cb77404](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/cb7740468cc86d1d7e9f7bda80d56b27b0300739))
18
+ * stop logging a failed flush's parameters and row values ([#65](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/65)) ([6dd88ba](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/6dd88ba34de6f6fa3306f7e9002788ab57f7797c))
19
+
3
20
  ## [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
21
 
5
22
 
@@ -51,10 +68,3 @@
51
68
  ### Documentation
52
69
 
53
70
  * **foundation-kit:** add comprehensive documentation for all features ([8d10f84](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/8d10f84179273a8cc40dc1fe326bddab84379d32))
54
-
55
- ## Changelog
56
-
57
- All notable changes to this project will be documented in this file.
58
-
59
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
60
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlalchemy-foundation-kit
3
- Version: 0.4.0
3
+ Version: 0.5.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/
@@ -216,6 +216,8 @@ Classifier: License :: OSI Approved :: Apache Software License
216
216
  Classifier: Programming Language :: Python :: 3
217
217
  Classifier: Programming Language :: Python :: 3.11
218
218
  Classifier: Programming Language :: Python :: 3.12
219
+ Classifier: Programming Language :: Python :: 3.13
220
+ Classifier: Programming Language :: Python :: 3.14
219
221
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
220
222
  Classifier: Typing :: Typed
221
223
  Requires-Python: >=3.11
@@ -397,7 +399,7 @@ class PostgresConfig:
397
399
  query: QuerySettingsProtocol = MyQuerySettings()
398
400
  application_name: str = "my-service"
399
401
  db_schema: str | None = None
400
- use_orjson_serialization: bool = True
402
+ use_orjson_serialization: bool = False
401
403
  jit: str | None = None
402
404
 
403
405
  def to_dsn(self) -> str:
@@ -146,7 +146,7 @@ class PostgresConfig:
146
146
  query: QuerySettingsProtocol = MyQuerySettings()
147
147
  application_name: str = "my-service"
148
148
  db_schema: str | None = None
149
- use_orjson_serialization: bool = True
149
+ use_orjson_serialization: bool = False
150
150
  jit: str | None = None
151
151
 
152
152
  def to_dsn(self) -> str:
@@ -25,6 +25,8 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3",
26
26
  "Programming Language :: Python :: 3.11",
27
27
  "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
28
30
  "Topic :: Software Development :: Libraries :: Python Modules",
29
31
  "Typing :: Typed",
30
32
  ]
@@ -33,6 +33,7 @@ from .protocols import (
33
33
  CheckoutRecorder,
34
34
  CheckoutWaitRecorder,
35
35
  ErrorRecorder,
36
+ PoolStatsFunctionRecorder,
36
37
  PoolStatsRecorder,
37
38
  PostgresMetricsProtocol,
38
39
  )
@@ -65,7 +66,7 @@ from .uow import (
65
66
  try:
66
67
  __version__ = version("sqlalchemy-foundation-kit")
67
68
  except PackageNotFoundError: # pragma: no cover
68
- __version__ = "0.4.0"
69
+ __version__ = "0.5.0"
69
70
 
70
71
  __all__ = [ # noqa: RUF022
71
72
  # Base ORM
@@ -92,6 +93,7 @@ __all__ = [ # noqa: RUF022
92
93
  "CheckoutRecorder",
93
94
  "CheckoutWaitRecorder",
94
95
  "ErrorRecorder",
96
+ "PoolStatsFunctionRecorder",
95
97
  "PoolStatsRecorder",
96
98
  "PostgresMetricsProtocol",
97
99
  # Session Management
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -224,26 +224,25 @@ def _apply_pool_settings(
224
224
  Maps pool settings to SQLAlchemy's expected ``pool_*`` keyword arguments.
225
225
  Note: pool_pre_ping is already set in build_engine_kwargs, so not applied here.
226
226
 
227
- Only applies pool size/overflow/recycle/timeout for pool classes that support them.
228
- Checks if the pool class accepts these parameters via hasattr to avoid passing
229
- unsupported kwargs to pools like NullPool or StaticPool.
227
+ ``pool_recycle`` applies to every pool class. ``pool_size``, ``max_overflow`` and
228
+ ``pool_timeout`` describe a queue of connections and exist only on ``QueuePool``
229
+ and its subclasses -- the async queue pools, a registered subclass, the class
230
+ ``instrument_pool_class`` builds. ``create_async_engine`` rejects them for any other
231
+ pool (``NullPool``, ``StaticPool``), so they are left out there.
230
232
 
231
233
  Args:
232
234
  engine_kwargs: Dictionary to update with pool configuration.
233
235
  poolclass: Pool class being configured.
234
236
  pool_settings: Pool configuration settings.
235
237
  """
236
- # Build pool parameters dict
237
- params = {
238
- "pool_size": pool_settings.size,
239
- "max_overflow": pool_settings.max_overflow,
240
- "pool_recycle": pool_settings.recycle,
241
- "pool_timeout": pool_settings.timeout,
242
- }
238
+ params: dict[str, object] = {"pool_recycle": pool_settings.recycle}
239
+ if issubclass(poolclass, QueuePool):
240
+ params.update(
241
+ pool_size=pool_settings.size,
242
+ max_overflow=pool_settings.max_overflow,
243
+ pool_timeout=pool_settings.timeout,
244
+ )
243
245
 
244
- # Apply only non-None parameters
245
- # SQLAlchemy pool classes that don't support these params will ignore them
246
- # or raise TypeError if passed, so we rely on the pool class itself to validate
247
246
  engine_kwargs.update({k: v for k, v in params.items() if v is not None})
248
247
 
249
248
 
@@ -143,7 +143,7 @@ class PostgresSettingsProtocol(Protocol):
143
143
  ... query: QuerySettingsProtocol
144
144
  ... application_name: str = "my-app"
145
145
  ... db_schema: str | None = None
146
- ... use_orjson_serialization: bool = True
146
+ ... use_orjson_serialization: bool = False
147
147
  ... jit: str | None = None
148
148
  ...
149
149
  ... def to_dsn(self) -> str:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from collections.abc import Callable
6
7
 
7
8
  try:
8
9
  from prometheus_client import Counter, Gauge, Histogram
@@ -59,19 +60,24 @@ class PostgresMetrics:
59
60
  """Postgres connection pool metrics.
60
61
 
61
62
  Metrics:
62
- - postgres_db_pool_size: Current database connection pool size.
63
+ - postgres_db_pool_size: Configured pool_size of the connection pool.
63
64
  - postgres_db_pool_checked_out: Number of connections currently checked out.
64
- - postgres_db_pool_overflow: Number of connections over pool_size (within max_overflow).
65
+ - postgres_db_pool_overflow: Number of open connections over pool_size (within max_overflow).
65
66
  - postgres_db_connection_checkout_wait_seconds: Time a caller waited for a connection.
66
67
  - postgres_db_connection_held_duration_seconds: Time a connection was held by its caller.
67
68
  - postgres_db_connection_checkout_duration_seconds: Deprecated alias of the held duration,
68
69
  published unchanged for one more minor release.
69
- - postgres_db_connection_timeouts_total: Number of connection checkout timeouts.
70
+ - postgres_db_connection_timeouts_total: Number of times acquiring a connection timed out:
71
+ pool checkout timeouts and connect timeouts. Statement timeouts are not counted here.
70
72
  - postgres_db_connection_errors_total: Number of connection errors.
71
73
 
72
74
  Labels:
73
75
  - error_type: Type of connection error (for errors_total).
74
76
 
77
+ The three pool gauges are read from the pool when the registry is collected, not
78
+ sampled from pool events, so an idle pool reports what it holds. One instance reports
79
+ one engine: attached to a second engine, the gauges follow the second.
80
+
75
81
  The wait histogram and the timeout counter are fed by the instrumented pool class the
76
82
  session manager builds, so they only move on an engine created by
77
83
  ``AsyncSessionManager`` (or one whose pool went through ``instrument_pool_class``).
@@ -90,7 +96,7 @@ class PostgresMetrics:
90
96
 
91
97
  self.pool_size = Gauge(
92
98
  _make_metric_name("postgres_db_pool_size", prefix),
93
- "Current database connection pool size",
99
+ "Configured pool_size of the database connection pool",
94
100
  )
95
101
  self.pool_checked_out = Gauge(
96
102
  _make_metric_name("postgres_db_pool_checked_out", prefix),
@@ -98,7 +104,7 @@ class PostgresMetrics:
98
104
  )
99
105
  self.pool_overflow = Gauge(
100
106
  _make_metric_name("postgres_db_pool_overflow", prefix),
101
- "Number of connections over pool_size (within max_overflow)",
107
+ "Number of open connections over pool_size (within max_overflow)",
102
108
  )
103
109
  self.connection_checkout_wait = Histogram(
104
110
  _make_metric_name("postgres_db_connection_checkout_wait_seconds", prefix),
@@ -117,7 +123,7 @@ class PostgresMetrics:
117
123
  )
118
124
  self.connection_timeouts_total = Counter(
119
125
  _make_metric_name("postgres_db_connection_timeouts_total", prefix),
120
- "Number of connection checkout timeouts",
126
+ "Number of times acquiring a connection timed out: pool checkout timeouts and connect timeouts",
121
127
  )
122
128
  self.connection_errors_total = Counter(
123
129
  _make_metric_name("postgres_db_connection_errors_total", prefix),
@@ -136,6 +142,17 @@ class PostgresMetrics:
136
142
  self.pool_checked_out.set(float(pool_checked_out))
137
143
  self.pool_overflow.set(float(pool_overflow))
138
144
 
145
+ def set_pool_stats_functions(
146
+ self,
147
+ pool_size: Callable[[], float],
148
+ pool_checked_out: Callable[[], float],
149
+ pool_overflow: Callable[[], float],
150
+ ) -> None:
151
+ """Read the pool gauges from these functions whenever the registry is collected."""
152
+ self.pool_size.set_function(pool_size)
153
+ self.pool_checked_out.set_function(pool_checked_out)
154
+ self.pool_overflow.set_function(pool_overflow)
155
+
139
156
  def record_checkout(
140
157
  self,
141
158
  duration: float,
@@ -154,10 +171,10 @@ class PostgresMetrics:
154
171
  duration: float,
155
172
  timed_out: bool = False,
156
173
  ) -> None:
157
- """Record the wait for a connection from the pool, and count a pool timeout.
174
+ """Record the wait for a connection from the pool, and count a timeout acquiring it.
158
175
 
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.
176
+ A pool checkout timeout and a connect timeout never reach the engine's
177
+ ``handle_error`` listener; this is the only place either is counted.
161
178
  """
162
179
  self.connection_checkout_wait.observe(duration)
163
180
  if timed_out:
@@ -168,7 +185,11 @@ class PostgresMetrics:
168
185
  error_type: str,
169
186
  is_timeout: bool = False,
170
187
  ) -> None:
171
- """Record a database connection or execution error."""
188
+ """Record a database connection or execution error.
189
+
190
+ ``is_timeout`` is kept for compatibility and still counts a timeout when a custom
191
+ caller sets it; the library itself no longer does.
192
+ """
172
193
  self.connection_errors_total.labels(error_type=error_type).inc()
173
194
  if is_timeout:
174
195
  self.connection_timeouts_total.inc()
@@ -47,8 +47,15 @@ class PoolSettings(BaseModel):
47
47
 
48
48
  Groups all connection pool-related parameters for SQLAlchemy engine.
49
49
 
50
+ ``size``, ``max_overflow`` and ``timeout`` apply to the queue pools
51
+ (``async_adapted_queue``, ``fallback_async_adapted_queue``) and are not passed to any
52
+ other pool. ``static`` is SQLAlchemy's ``StaticPool``: one connection shared by every
53
+ session, which concurrent sessions cannot use safely -- for tests, never as a
54
+ fixed-size pool. A fixed-size pool is ``async_adapted_queue`` with ``max_overflow=0``.
55
+
50
56
  Examples:
51
57
  >>> pool = PoolSettings(size=10, max_overflow=20)
58
+ >>> fixed = PoolSettings(size=10, max_overflow=0)
52
59
  """
53
60
 
54
61
  kind: PoolClassStr = Field(
@@ -68,8 +75,11 @@ class PoolSettings(BaseModel):
68
75
  @model_validator(mode="after")
69
76
  def _validate_pool_settings(self) -> PoolSettings:
70
77
  """Validate pool configuration constraints."""
71
- if self.kind == "static" and self.max_overflow > 0:
72
- raise ValueError(f"max_overflow must be 0 for static pool, got {self.max_overflow}")
78
+ if self.kind == "static" and self.size > 1:
79
+ raise ValueError(
80
+ f"size must be 1 for a static pool, got {self.size}: a static pool is one connection "
81
+ "shared by every session. For a fixed-size pool use kind='async_adapted_queue' with max_overflow=0"
82
+ )
73
83
  return self
74
84
 
75
85
 
@@ -100,7 +110,9 @@ class BasePostgresConfig(BaseSettings):
100
110
  application_name: Application name for connection identification.
101
111
  db_schema: Optional PostgreSQL ``search_path`` — a schema, or a comma-separated
102
112
  list — applied to every transaction with ``SET LOCAL`` semantics.
103
- use_orjson_serialization: Use orjson for JSON serialization (requires orjson).
113
+ use_orjson_serialization: Serialize JSON / JSONB columns with orjson. Off by default:
114
+ it needs the ``orjson`` extra, and orjson serializes some values (``datetime``,
115
+ ``UUID``, ``Decimal``, non-``str`` keys) that ``json.dumps`` rejects.
104
116
  jit: JIT compilation setting (off/on), sent as a startup parameter only when set.
105
117
  ``None`` (the default) sends nothing and leaves the server's own setting; a
106
118
  transaction-mode PgBouncer rejects the parameter unless it tracks it.
@@ -133,8 +145,8 @@ class BasePostgresConfig(BaseSettings):
133
145
  description="PostgreSQL search_path (a schema or a comma-separated list), applied to every transaction",
134
146
  )
135
147
  use_orjson_serialization: bool = Field(
136
- default=True,
137
- description="Use orjson for JSON serialization (requires orjson installed)",
148
+ default=False,
149
+ description="Use orjson for JSON serialization (requires the orjson extra)",
138
150
  )
139
151
  jit: PostgresJit | None = Field(
140
152
  default=None,
@@ -10,6 +10,7 @@ from .metrics import (
10
10
  CheckoutRecorder,
11
11
  CheckoutWaitRecorder,
12
12
  ErrorRecorder,
13
+ PoolStatsFunctionRecorder,
13
14
  PoolStatsRecorder,
14
15
  PostgresMetricsProtocol,
15
16
  )
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "CheckoutRecorder",
19
20
  "CheckoutWaitRecorder",
20
21
  "ErrorRecorder",
22
+ "PoolStatsFunctionRecorder",
21
23
  "PoolStatsRecorder",
22
24
  "PostgresMetricsProtocol",
23
25
  ]
@@ -7,6 +7,7 @@ the narrow protocols selectively (e.g., only record errors), and
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import Callable
10
11
  from typing import Protocol, runtime_checkable
11
12
 
12
13
 
@@ -21,10 +22,51 @@ class PoolStatsRecorder(Protocol):
21
22
  ) -> None:
22
23
  """Record database connection pool statistics.
23
24
 
25
+ Called from the pool's ``checkout`` and ``checkin`` events, so each call describes
26
+ the pool as that event saw it -- and ``checkin`` fires before the connection is
27
+ back, so the last call of a burst still counts it as checked out. A recorder that
28
+ also implements :class:`PoolStatsFunctionRecorder` reads the pool when it is
29
+ collected instead, and this method is then not called.
30
+
24
31
  Args:
25
- pool_size: Current total number of connections in the pool.
32
+ pool_size: The configured ``pool_size`` of a queue pool, 0 for any other pool.
26
33
  pool_checked_out: Number of connections currently in use.
27
- pool_overflow: Number of connections over the configured pool_size.
34
+ pool_overflow: Number of open connections over ``pool_size``, within
35
+ ``max_overflow``; never negative.
36
+ """
37
+ ...
38
+
39
+
40
+ @runtime_checkable
41
+ class PoolStatsFunctionRecorder(Protocol):
42
+ """Capability protocol for reading pool statistics when the metrics are collected.
43
+
44
+ A gauge sampled from pool events goes stale: the ``checkin`` event fires before the
45
+ connection is returned, and nothing fires once the pool is idle, so an idle pool keeps
46
+ reporting the last burst. A recorder that implements this protocol is handed
47
+ functions that read the pool on demand -- ``prometheus_client``'s
48
+ ``Gauge.set_function`` -- and :class:`PoolStatsRecorder` is then not used for it.
49
+
50
+ Not part of :class:`PostgresMetricsProtocol`, so implementations written without it
51
+ stay valid; the session manager tests for it with ``isinstance``.
52
+ """
53
+
54
+ def set_pool_stats_functions(
55
+ self,
56
+ pool_size: Callable[[], float],
57
+ pool_checked_out: Callable[[], float],
58
+ pool_overflow: Callable[[], float],
59
+ ) -> None:
60
+ """Read the pool statistics from these functions whenever they are collected.
61
+
62
+ Each function reads the engine's current pool, so the pool that ``dispose()``
63
+ creates is the one reported. Values mean the same as in
64
+ :meth:`PoolStatsRecorder.record_pool_stats`.
65
+
66
+ Args:
67
+ pool_size: Returns the configured ``pool_size``.
68
+ pool_checked_out: Returns the number of connections currently in use.
69
+ pool_overflow: Returns the number of open connections over ``pool_size``.
28
70
  """
29
71
  ...
30
72
 
@@ -60,22 +102,32 @@ class CheckoutWaitRecorder(Protocol):
60
102
  Args:
61
103
  duration: Seconds spent inside ``pool.connect()`` — the queue wait, and the
62
104
  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``.
105
+ timed_out: True if the wait ended in a timeout rather than in a connection:
106
+ the pool checkout timeout (``sqlalchemy.exc.TimeoutError``) or the
107
+ driver's connect timeout (a builtin ``TimeoutError`` while the pool opens
108
+ a connection). Neither reaches the engine's ``handle_error``.
66
109
  """
67
110
  ...
68
111
 
69
112
 
113
+ @runtime_checkable
70
114
  class ErrorRecorder(Protocol):
71
- """Capability protocol for recording database errors."""
115
+ """Capability protocol for recording database errors.
116
+
117
+ Called for every error the engine's ``handle_error`` sees -- errors on an established
118
+ connection, a statement timeout among them -- and, from the instrumented pool, for a
119
+ connection the pool could not open. ``runtime_checkable`` so the instrumented pool can
120
+ tell whether its recorder takes errors too.
121
+ """
72
122
 
73
123
  def record_error(self, error_type: str, is_timeout: bool = False) -> None:
74
124
  """Record a database connection or execution error.
75
125
 
76
126
  Args:
77
127
  error_type: The type of error that occurred (e.g., "OperationalError").
78
- is_timeout: True if this error was specifically a connection checkout timeout.
128
+ is_timeout: Kept for compatibility; the library no longer sets it. Timeouts
129
+ acquiring a connection are reported through
130
+ :meth:`CheckoutWaitRecorder.record_checkout_wait`.
79
131
  """
80
132
  ...
81
133
 
@@ -104,6 +156,7 @@ __all__ = [
104
156
  "CheckoutRecorder",
105
157
  "CheckoutWaitRecorder",
106
158
  "ErrorRecorder",
159
+ "PoolStatsFunctionRecorder",
107
160
  "PoolStatsRecorder",
108
161
  "PostgresMetricsProtocol",
109
162
  ]
@@ -32,7 +32,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
32
32
  Basic usage:
33
33
  >>> manager = (
34
34
  ... AsyncSessionManagerBuilder("postgresql+asyncpg://...")
35
- ... .with_pool("queue")
35
+ ... .with_pool("async_adapted_queue")
36
36
  ... .with_echo(True)
37
37
  ... .build()
38
38
  ... )
@@ -41,7 +41,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
41
41
  >>> manager = (
42
42
  ... AsyncSessionManagerBuilder[CustomSession]("postgresql+asyncpg://...")
43
43
  ... .with_session_class(CustomSession)
44
- ... .with_pool("queue", pool_settings=settings.pool)
44
+ ... .with_pool("async_adapted_queue", pool_settings=settings.pool)
45
45
  ... .with_metrics(metrics)
46
46
  ... .with_callbacks(on_engine_created=instrument_engine)
47
47
  ... .with_json_serialization(orjson=True)
@@ -52,7 +52,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
52
52
  Reusable configuration:
53
53
  >>> builder = (
54
54
  ... AsyncSessionManagerBuilder("postgresql+asyncpg://...")
55
- ... .with_pool("queue")
55
+ ... .with_pool("async_adapted_queue")
56
56
  ... .with_metrics(metrics)
57
57
  ... )
58
58
  >>> manager1 = builder.with_echo(True).build()
@@ -100,8 +100,10 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
100
100
  """Configure connection pool.
101
101
 
102
102
  Args:
103
- poolclass: Pool class name or type (e.g., "queue", "null").
104
- pool_settings: Optional pool configuration settings.
103
+ poolclass: Pool class name or type: ``"async_adapted_queue"`` for a service,
104
+ ``"null"`` (the default when this is not called) for tests and scripts.
105
+ pool_settings: Optional pool configuration settings. ``size``,
106
+ ``max_overflow`` and ``timeout`` apply to the queue pools only.
105
107
 
106
108
  Returns:
107
109
  Self for method chaining.
@@ -0,0 +1,40 @@
1
+ """asyncpg connection class with uuid prepared-statement names."""
2
+
3
+ import uuid
4
+
5
+ import asyncpg
6
+
7
+
8
+ class AsyncCConnection(asyncpg.Connection):
9
+ """asyncpg connection whose prepared statements are named with a uuid.
10
+
11
+ Overrides only the private ``_get_unique_id``, so statement names are unique across
12
+ every connection rather than counted per connection. The only failure that prevents
13
+ is two clients colliding on a name on a shared server connection
14
+ (https://github.com/sqlalchemy/sqlalchemy/issues/6467). That needs a transaction-mode
15
+ PgBouncer that does not track prepared statements, where a named statement fails
16
+ anyway with ``prepared statement ... does not exist``.
17
+
18
+ It is not what makes the library work behind PgBouncer. With
19
+ ``statement_cache_size=0`` (the default) asyncpg sends unnamed statements and asks for
20
+ no name at all; with ``statement_cache_size > 0`` PgBouncer has to track prepared
21
+ statements itself (1.21+ with ``max_prepared_statements > 0``, the default from 1.24),
22
+ and then rewrites the names per server connection. Kept as the default connection
23
+ class of :func:`create_async_session_manager` because it is harmless.
24
+ """
25
+
26
+ def _get_unique_id(self, prefix: str) -> str:
27
+ """Generate unique ID for prepared statements.
28
+
29
+ Args:
30
+ prefix: Prefix for the unique ID.
31
+
32
+ Returns:
33
+ A unique string ID including the prefix and a UUID.
34
+ """
35
+ return f"__asyncpg_{prefix}_{uuid.uuid4()}__"
36
+
37
+
38
+ __all__ = [
39
+ "AsyncCConnection",
40
+ ]
@@ -44,8 +44,11 @@ def create_async_session_manager(
44
44
  metrics: Optional metrics collector for connection pool monitoring.
45
45
  on_engine_created: Optional callback invoked with the AsyncEngine right after creation.
46
46
  Use it to attach OpenTelemetry instrumentation, custom listeners, etc.
47
- connection_class: Custom asyncpg Connection subclass. Defaults to ``AsyncCConnection``
48
- which provides pgbouncer transaction-mode compatibility.
47
+ connection_class: Custom asyncpg Connection subclass. Defaults to ``AsyncCConnection``,
48
+ which names statements with a uuid. PgBouncer compatibility does not depend on
49
+ it: with ``statement_cache_size=0`` (the default) asyncpg sends unnamed
50
+ statements that any PgBouncer carries, and ``statement_cache_size > 0`` needs
51
+ PgBouncer 1.21+ with ``max_prepared_statements > 0`` whatever the class.
49
52
  extra_server_settings: Additional PostgreSQL ``server_settings`` to merge with defaults
50
53
  (e.g., ``{"statement_timeout": "30000", "timezone": "UTC"}``). User-provided keys
51
54
  override library defaults. These are startup parameters: through PgBouncer only
@@ -21,10 +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
+ from ..protocols import CheckoutWaitRecorder, ErrorRecorder, PoolStatsFunctionRecorder
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from sqlalchemy.engine import Connection
28
+ from sqlalchemy.pool import Pool
28
29
 
29
30
  from ..config import PoolSettingsProtocol
30
31
  from ..protocols import PostgresMetricsProtocol
@@ -54,8 +55,27 @@ def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
54
55
  logger.exception(error_msg)
55
56
 
56
57
 
58
+ def _pool_size(pool: Pool) -> int:
59
+ """The configured ``pool_size`` of a queue pool, 0 for a pool without one."""
60
+ return int(pool.size()) if hasattr(pool, "size") else 0
61
+
62
+
63
+ def _pool_checked_out(pool: Pool) -> int:
64
+ """Connections currently in use."""
65
+ return int(pool.checkedout()) if hasattr(pool, "checkedout") else 0
66
+
67
+
68
+ def _pool_overflow(pool: Pool) -> int:
69
+ """Open connections over ``pool_size``.
70
+
71
+ ``QueuePool.overflow()`` starts at ``-pool_size`` and counts every connection the pool
72
+ opens, so it is negative until the pool has grown past ``pool_size``.
73
+ """
74
+ return max(int(pool.overflow()), 0) if hasattr(pool, "overflow") else 0
75
+
76
+
57
77
  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.
78
+ """Build a pool class that times the wait for a connection and counts timeouts acquiring one.
59
79
 
60
80
  SQLAlchemy has no event for "a checkout was requested", only for one that succeeded, so
61
81
  the time a caller spends queued for a connection cannot be observed from a listener.
@@ -66,6 +86,15 @@ def instrument_pool_class(pool_class: type, metrics: CheckoutWaitRecorder) -> ty
66
86
  ``await_only`` inside that call, so the timer spans the whole suspension and measures
67
87
  the time the caller really waited.
68
88
 
89
+ Every checkout records its wait, however it ends. Two endings count as a timeout: the
90
+ pool's own ``sqlalchemy.exc.TimeoutError`` (the queue outlasted ``pool_timeout``) and
91
+ a builtin ``TimeoutError`` from the driver while the pool opens a new connection
92
+ (asyncpg's connect ``timeout``). Any other exception is a connection the pool could
93
+ not open, and is also recorded as an error when ``metrics`` implements
94
+ ``ErrorRecorder`` -- unless it is the driver's DBAPI error, which the engine's
95
+ ``handle_error`` reports itself. asyncpg's connect errors are not: none of them reaches
96
+ ``handle_error``, so this is the only place a database refusing connections shows up.
97
+
69
98
  ``_do_get`` is the wrong hook: ``QueuePool`` recurses into it when the non-blocking get
70
99
  comes back empty and the race for an overflow slot is lost, which is exactly the
71
100
  contended case worth measuring, and the wait would be observed twice.
@@ -86,6 +115,7 @@ def instrument_pool_class(pool_class: type, metrics: CheckoutWaitRecorder) -> ty
86
115
  >>> poolclass = instrument_pool_class(resolve_pool_class("async_adapted_queue"), metrics)
87
116
  >>> engine = create_async_engine(url, poolclass=poolclass)
88
117
  """
118
+ error_recorder = metrics if isinstance(metrics, ErrorRecorder) else None
89
119
 
90
120
  class InstrumentedPool(pool_class): # type: ignore[misc, valid-type]
91
121
  """Pool that reports how long each checkout waited."""
@@ -94,7 +124,7 @@ def instrument_pool_class(pool_class: type, metrics: CheckoutWaitRecorder) -> ty
94
124
  started = time.perf_counter()
95
125
  try:
96
126
  connection = super().connect()
97
- except SATimeoutError:
127
+ except (SATimeoutError, TimeoutError):
98
128
  _safe_metric_call(
99
129
  lambda: metrics.record_checkout_wait(
100
130
  duration=time.perf_counter() - started,
@@ -103,12 +133,29 @@ def instrument_pool_class(pool_class: type, metrics: CheckoutWaitRecorder) -> ty
103
133
  "Failed to record database checkout timeout",
104
134
  )
105
135
  raise
136
+ except Exception as exc:
137
+ _safe_metric_call(
138
+ lambda: metrics.record_checkout_wait(duration=time.perf_counter() - started),
139
+ "Failed to record database checkout wait",
140
+ )
141
+ if error_recorder is not None and not self._reported_by_handle_error(exc):
142
+ error_type = type(exc).__name__
143
+ _safe_metric_call(
144
+ lambda: error_recorder.record_error(error_type=error_type),
145
+ "Failed to record database connect error",
146
+ )
147
+ raise
106
148
  _safe_metric_call(
107
149
  lambda: metrics.record_checkout_wait(duration=time.perf_counter() - started),
108
150
  "Failed to record database checkout wait",
109
151
  )
110
152
  return connection
111
153
 
154
+ def _reported_by_handle_error(self, exc: Exception) -> bool:
155
+ """Whether the engine turns ``exc`` into a ``handle_error`` event itself."""
156
+ dbapi = getattr(self._dialect, "loaded_dbapi", None)
157
+ return dbapi is not None and isinstance(exc, dbapi.Error)
158
+
112
159
  setattr(InstrumentedPool, _WAIT_RECORDER_ATTRIBUTE, metrics)
113
160
  InstrumentedPool.__name__ = f"Instrumented{pool_class.__name__}"
114
161
  InstrumentedPool.__qualname__ = InstrumentedPool.__name__
@@ -121,6 +168,12 @@ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> Non
121
168
  Registers event handlers for connection checkout, checkin, and error events
122
169
  to collect pool statistics and connection metrics.
123
170
 
171
+ The pool gauges come from the pool itself: a ``metrics`` that implements
172
+ ``PoolStatsFunctionRecorder`` is handed functions that read ``engine.pool`` whenever
173
+ the metrics are collected, so an idle pool reports what it holds rather than its last
174
+ burst, and a pool recreated by ``dispose()`` is the one read. One that only implements
175
+ ``record_pool_stats`` is sampled from the ``checkout`` and ``checkin`` events instead.
176
+
124
177
  The checkout wait and the pool checkout timeout are **not** listeners — no pool event
125
178
  fires for either — and come from the pool class :func:`instrument_pool_class` builds.
126
179
  If ``metrics`` records the wait but the engine's pool was built without it, that is
@@ -142,12 +195,24 @@ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> Non
142
195
  type(pool).__name__,
143
196
  )
144
197
 
198
+ reads_pool_on_collect = False
199
+ if isinstance(metrics, PoolStatsFunctionRecorder):
200
+ reads_pool_on_collect = True
201
+ # engine.pool is looked up on every read: dispose() replaces the pool object.
202
+ metrics.set_pool_stats_functions(
203
+ pool_size=lambda: _pool_size(engine.pool),
204
+ pool_checked_out=lambda: _pool_checked_out(engine.pool),
205
+ pool_overflow=lambda: _pool_overflow(engine.pool),
206
+ )
207
+
145
208
  def record_pool_stats() -> None:
209
+ if reads_pool_on_collect:
210
+ return
146
211
  _safe_metric_call(
147
212
  lambda: metrics.record_pool_stats(
148
- pool_size=pool.size() if hasattr(pool, "size") else 0,
149
- pool_checked_out=(pool.checkedout() if hasattr(pool, "checkedout") else 0),
150
- pool_overflow=pool.overflow() if hasattr(pool, "overflow") else 0,
213
+ pool_size=_pool_size(pool),
214
+ pool_checked_out=_pool_checked_out(pool),
215
+ pool_overflow=_pool_overflow(pool),
151
216
  ),
152
217
  "Failed to record database pool stats",
153
218
  )
@@ -170,11 +235,11 @@ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> Non
170
235
 
171
236
  def on_error(exception_context: Any) -> None:
172
237
  """Handle database error event."""
173
- exc = exception_context.original_exception
174
- error_type = type(exc).__name__
175
- is_timeout = isinstance(exc, (TimeoutError, SATimeoutError))
238
+ # Only errors on an established connection get here -- a statement timeout among
239
+ # them. Timeouts acquiring a connection are counted by the instrumented pool.
240
+ error_type = type(exception_context.original_exception).__name__
176
241
  _safe_metric_call(
177
- lambda: metrics.record_error(error_type=error_type, is_timeout=is_timeout),
242
+ lambda: metrics.record_error(error_type=error_type),
178
243
  "Failed to record database error metric",
179
244
  )
180
245
 
@@ -226,7 +291,7 @@ class AsyncSessionManager(Generic[SessionT]):
226
291
  self,
227
292
  url: str,
228
293
  echo: bool = False,
229
- poolclass: str | type = "null",
294
+ poolclass: str | type | None = None,
230
295
  session_class: type[SessionT] | None = None,
231
296
  expire_on_commit: bool = False,
232
297
  connect_args: dict[str, object] | None = None,
@@ -244,13 +309,18 @@ class AsyncSessionManager(Generic[SessionT]):
244
309
  Args:
245
310
  url: Database connection URL (required).
246
311
  echo: If True, SQLAlchemy will log all SQL statements (default: False).
247
- poolclass: SQLAlchemy pool class or name (default: "null").
248
- Use "queue" for production, "null" for testing.
312
+ poolclass: SQLAlchemy pool class or name. Defaults to ``pool_settings.kind``
313
+ when ``pool_settings`` is given, otherwise to ``"null"`` -- a new connection
314
+ for every checkout, which suits tests and scripts. A service wants
315
+ ``"async_adapted_queue"``. The sync pools (``"queue"``,
316
+ ``"singleton_thread"``) cannot run on an async engine.
249
317
  session_class: Custom ``AsyncSession`` subclass (default: ``AsyncSession``).
250
318
  expire_on_commit: If True, objects expire after commit (default: False).
251
319
  connect_args: Arguments passed to the database driver (default: None).
252
320
  isolation_level: Default transaction isolation level (default: None).
253
- pool_settings: Pool configuration settings (default: None).
321
+ pool_settings: Pool configuration settings (default: None). ``size``,
322
+ ``max_overflow`` and ``timeout`` apply to the queue pools only; ``recycle``
323
+ and ``pre_ping`` apply to every pool.
254
324
  use_orjson: If True, use orjson for JSON serialization (default: False).
255
325
  metrics: Optional metrics collector (default: None). One that also implements
256
326
  ``CheckoutWaitRecorder`` gets the pool class wrapped by
@@ -270,6 +340,8 @@ class AsyncSessionManager(Generic[SessionT]):
270
340
  self._closed = False
271
341
  self._close_lock = asyncio.Lock()
272
342
  self._dispose_timeout = dispose_timeout
343
+ if poolclass is None:
344
+ poolclass = pool_settings.kind if pool_settings is not None else "null"
273
345
  resolved_poolclass = resolve_pool_class(poolclass)
274
346
  if metrics is not None and isinstance(metrics, CheckoutWaitRecorder):
275
347
  # The wait for a connection is not observable from a pool event, so it has to
@@ -56,7 +56,7 @@ class AsyncUnitOfWork(Protocol, Generic[T_co]):
56
56
  Provides three modes of operation:
57
57
  - ``transaction()``: For write operations with automatic commit/rollback.
58
58
  - ``managed_session()``: For write operations with **manual** commit/rollback control.
59
- - ``query()``: For read-only operations without transaction management.
59
+ - ``query()``: For reads, in one ``READ ONLY`` transaction that is rolled back on exit.
60
60
  """
61
61
 
62
62
  def transaction(
@@ -95,4 +95,4 @@ class AsyncUnitOfWork(Protocol, Generic[T_co]):
95
95
  """
96
96
 
97
97
  def query(self, isolation_level: str | None = None) -> AbstractAsyncContextManager[T_co]:
98
- """Create a read-only query context without transaction management."""
98
+ """Create a read-only query context: one ``READ ONLY`` transaction, rolled back on exit."""
@@ -3,12 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import logging
7
6
  from collections.abc import AsyncIterator, Callable
8
7
  from contextlib import asynccontextmanager
9
8
  from typing import Generic
10
9
 
11
- from sqlalchemy.exc import SQLAlchemyError
10
+ from sqlalchemy import text
12
11
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
13
12
 
14
13
  from .._typing import T
@@ -16,8 +15,6 @@ from ..session.locks import try_advisory_xact_lock
16
15
  from .enums import IsolationLevel
17
16
  from .protocols import AsyncUnitOfWork, AsyncUowTransaction
18
17
 
19
- logger = logging.getLogger(__name__)
20
-
21
18
  # Cache valid isolation levels for performance (avoid recreating set on each call)
22
19
  _VALID_ISOLATION_LEVELS: frozenset[str] = frozenset(item.value for item in IsolationLevel)
23
20
 
@@ -111,6 +108,23 @@ async def apply_isolation_level(
111
108
  await session.connection(execution_options={"isolation_level": normalized})
112
109
 
113
110
 
111
+ async def _begin_read_only(session: AsyncSession) -> None:
112
+ """Make the session's transaction ``READ ONLY``.
113
+
114
+ The ``postgresql_readonly`` execution option is applied when a connection is checked
115
+ out, so on a session that has not checked one out yet it goes with that checkout:
116
+ asyncpg then opens the transaction ``READ ONLY`` with no extra round trip, and the
117
+ dialect resets the flag when the connection returns to the pool. When ``open_session()``
118
+ has already started the transaction -- an isolation level was applied, or an override
119
+ ran a statement -- ``SET TRANSACTION READ ONLY`` marks it instead; PostgreSQL accepts
120
+ that at any point in a transaction.
121
+ """
122
+ if session.in_transaction():
123
+ await session.execute(text("SET TRANSACTION READ ONLY"))
124
+ else:
125
+ await session.connection(execution_options={"postgresql_readonly": True})
126
+
127
+
114
128
  @asynccontextmanager
115
129
  async def _owned_transaction(session: AsyncSession) -> AsyncIterator[None]:
116
130
  """Run a block inside a transaction, committing on success and rolling back on failure.
@@ -231,7 +245,7 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
231
245
 
232
246
  Methods:
233
247
  transaction(): For write operations with automatic commit/rollback.
234
- query(): For read-only operations without transaction management.
248
+ query(): For reads, in one READ ONLY transaction that is rolled back on exit.
235
249
  """
236
250
 
237
251
  def __init__(
@@ -314,9 +328,11 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
314
328
  isolation_level: Optional transaction isolation level.
315
329
  Can be an IsolationLevel enum member or a string value.
316
330
  Supported values: "READ_COMMITTED", "REPEATABLE_READ", "SERIALIZABLE", "READ_UNCOMMITTED".
317
- flush_before_commit: If True, flush the session before commit to surface
318
- constraint violations while still inside the transaction. If ``None`` (default),
319
- falls back to the value passed to the constructor (``True`` unless overridden).
331
+ flush_before_commit: If True, flush the session explicitly before the commit.
332
+ The commit flushes as well, so a constraint violation raises the same
333
+ exception through the same rollback either way, as the block exits. If
334
+ ``None`` (default), falls back to the value passed to the constructor
335
+ (``True`` unless overridden).
320
336
 
321
337
  Raises:
322
338
  ValueError: If isolation_level is not supported.
@@ -334,13 +350,10 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
334
350
  uow = self._transaction_factory(session)
335
351
  yield uow
336
352
  if flush_before_commit:
337
- # Flush changes before commit to catch constraint violations early
338
- # while still inside the transaction context.
339
- try:
340
- await session.flush()
341
- except SQLAlchemyError as e:
342
- logger.warning("Database flush failed", extra={"error": str(e)})
343
- raise
353
+ # A failure propagates to the caller as is, and is not logged here: its
354
+ # message carries the statement's parameters and PostgreSQL's DETAIL line,
355
+ # which holds the conflicting row's values.
356
+ await session.flush()
344
357
 
345
358
  @asynccontextmanager
346
359
  async def managed_session(
@@ -437,11 +450,20 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
437
450
  self,
438
451
  isolation_level: IsolationLevel | str | None = None,
439
452
  ) -> AsyncIterator[T]:
440
- """Create a read-only query context without transaction management.
453
+ """Create a read-only query context: one ``READ ONLY`` transaction, never committed.
454
+
455
+ The block runs in a single transaction from its first statement until it exits,
456
+ when the transaction is rolled back. PostgreSQL enforces ``READ ONLY``: an
457
+ ``INSERT``, ``UPDATE``, ``DELETE`` or DDL statement, and functions such as
458
+ ``nextval()``, fail with SQLSTATE 25006. Reads, ``SET LOCAL`` / ``set_config`` and
459
+ advisory locks work.
441
460
 
442
- This method is designed for read-only operations and does not start a transaction
443
- or perform any commit/rollback. It's semantically clearer than managed_session()
444
- for read operations.
461
+ It is still a transaction, with what that costs: every lock it takes -- the
462
+ ``AccessShareLock`` on each table read, an advisory xact lock -- is held until the
463
+ block exits, ``now()`` is fixed at the first statement, a slow ``await`` inside the
464
+ block leaves the connection ``idle in transaction``, and behind a transaction-mode
465
+ pooler the block holds one server connection from its first statement to exit.
466
+ Keep it short.
445
467
 
446
468
  Args:
447
469
  isolation_level: Optional transaction isolation level.
@@ -456,13 +478,14 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
456
478
  async with uow.query() as qx:
457
479
  users = await qx.users.list_all()
458
480
  user = await qx.users.get_by_id(user_id)
459
- # No commit/rollback - just closes session
481
+ # Rolled back on exit -- there is nothing to commit
460
482
 
461
483
  Note:
462
- While this method is intended for read-only operations, SQLAlchemy does not enforce
463
- this at the session level. It's up to the caller to ensure only read operations are performed.
484
+ Only the block's first transaction is read-only. Calling
485
+ ``session.commit()`` inside ``query()`` ends it, and the session starts an
486
+ ordinary one on its next statement. Write in :meth:`transaction` instead.
464
487
  """
465
488
  async with self.open_session(isolation_level) as session:
466
- # No transaction begin/commit - just yield the session
489
+ await _begin_read_only(session)
467
490
  uow = self._transaction_factory(session)
468
491
  yield uow
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"
@@ -1,33 +0,0 @@
1
- """Custom connection class for pgbouncer compatibility (async)."""
2
-
3
- import uuid
4
-
5
- import asyncpg
6
-
7
-
8
- class AsyncCConnection(asyncpg.Connection):
9
- """Custom async connection class for pgbouncer magic.
10
-
11
- This subclass overrides only the private method _get_unique_id so that
12
- prepared statement identifiers are unique per connection. That is required
13
- when using pgbouncer in transaction mode, where the same server connection
14
- may be reused for different logical connections.
15
-
16
- See: https://github.com/sqlalchemy/sqlalchemy/issues/6467
17
- """
18
-
19
- def _get_unique_id(self, prefix: str) -> str:
20
- """Generate unique ID for prepared statements.
21
-
22
- Args:
23
- prefix: Prefix for the unique ID.
24
-
25
- Returns:
26
- A unique string ID including the prefix and a UUID.
27
- """
28
- return f"__asyncpg_{prefix}_{uuid.uuid4()}__"
29
-
30
-
31
- __all__ = [
32
- "AsyncCConnection",
33
- ]