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.
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/CHANGELOG.md +17 -7
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/PKG-INFO +4 -2
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/README.md +1 -1
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/pyproject.toml +2 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/__init__.py +3 -1
- sqlalchemy_foundation_kit-0.5.0/sqlalchemy_foundation_kit/__version__.py +1 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/engine.py +12 -13
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/config/postgres.py +1 -1
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +31 -10
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +17 -5
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/protocols/__init__.py +2 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/protocols/metrics.py +60 -7
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/builder.py +7 -5
- sqlalchemy_foundation_kit-0.5.0/sqlalchemy_foundation_kit/session/connection.py +40 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/factories.py +5 -2
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/manager.py +86 -14
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/protocols.py +2 -2
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +46 -23
- sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/__version__.py +0 -1
- sqlalchemy_foundation_kit-0.4.0/sqlalchemy_foundation_kit/session/connection.py +0 -33
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/.gitignore +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/LICENSE +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/_optional.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/serialization.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/py.typed +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/locks.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
- {sqlalchemy_foundation_kit-0.4.0 → sqlalchemy_foundation_kit-0.5.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
- {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.
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
174
|
+
"""Record the wait for a connection from the pool, and count a timeout acquiring it.
|
|
158
175
|
|
|
159
|
-
A pool checkout timeout
|
|
160
|
-
|
|
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.
|
|
72
|
-
raise ValueError(
|
|
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:
|
|
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=
|
|
137
|
-
description="Use orjson for JSON serialization (requires orjson
|
|
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:
|
|
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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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=
|
|
149
|
-
pool_checked_out=(pool
|
|
150
|
-
pool_overflow=
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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 =
|
|
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
|
|
248
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
#
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
#
|
|
481
|
+
# Rolled back on exit -- there is nothing to commit
|
|
460
482
|
|
|
461
483
|
Note:
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|