pgcommon 0.1.0__py3-none-any.whl
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.
- pgcommon/__init__.py +91 -0
- pgcommon/_internal/__init__.py +1 -0
- pgcommon/_internal/dsn.py +15 -0
- pgcommon/_internal/duration.py +38 -0
- pgcommon/_internal/query_event.py +22 -0
- pgcommon/_internal/sql_verb.py +36 -0
- pgcommon/_internal/sqlstate.py +20 -0
- pgcommon/config.py +222 -0
- pgcommon/errors.py +123 -0
- pgcommon/guc.py +142 -0
- pgcommon/integrations/__init__.py +5 -0
- pgcommon/integrations/fastapi.py +151 -0
- pgcommon/logger.py +71 -0
- pgcommon/metrics.py +179 -0
- pgcommon/migrate/__init__.py +8 -0
- pgcommon/migrate/__main__.py +6 -0
- pgcommon/migrate/cli.py +78 -0
- pgcommon/migrate/errors.py +12 -0
- pgcommon/migrate/runner.py +261 -0
- pgcommon/pool.py +346 -0
- pgcommon/py.typed +0 -0
- pgcommon/retry.py +121 -0
- pgcommon/tracer.py +85 -0
- pgcommon/tx.py +98 -0
- pgcommon-0.1.0.dist-info/METADATA +1369 -0
- pgcommon-0.1.0.dist-info/RECORD +29 -0
- pgcommon-0.1.0.dist-info/WHEEL +4 -0
- pgcommon-0.1.0.dist-info/entry_points.txt +2 -0
- pgcommon-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""A minimal, dependency-light migration runner over plain `.sql` files.
|
|
2
|
+
|
|
3
|
+
Deliberately not built on Alembic — Alembic assumes SQLAlchemy models, which this library does
|
|
4
|
+
not have. This is a from-scratch, `golang-migrate`-equivalent runner: it reads
|
|
5
|
+
`NNNN_description.up.sql` / `NNNN_description.down.sql` pairs from a directory, tracks the
|
|
6
|
+
applied version in a `schema_migrations` table, and takes a Postgres advisory lock for the
|
|
7
|
+
duration of the run so two runners started concurrently (e.g. two replicas booting at once)
|
|
8
|
+
don't race each other.
|
|
9
|
+
|
|
10
|
+
⚠️ **Never run migrations from every replica's boot path simultaneously.** The advisory lock
|
|
11
|
+
here only serializes the runs — it does not make it safe for the application to start against
|
|
12
|
+
a half-migrated schema, nor does it make concurrent runners fast or free of readiness-probe
|
|
13
|
+
timeouts. Use an init container, a pre-deploy job, or a leader-only-on-boot pattern, and prefer
|
|
14
|
+
additive (expand/contract) migrations so an old app version tolerates the new schema during a
|
|
15
|
+
rolling deploy. See the README's migration deployment-pattern table.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import zlib
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TypeVar
|
|
26
|
+
|
|
27
|
+
import asyncpg
|
|
28
|
+
|
|
29
|
+
from pgcommon.errors import MigrationDirtyError, NoMigrationsFoundError
|
|
30
|
+
from pgcommon.logger import Logger
|
|
31
|
+
|
|
32
|
+
__all__ = ["Migration", "Runner"]
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
_FILENAME_RE = re.compile(r"^(?P<version>\d+)_(?P<description>.+)\.(?P<direction>up|down)\.sql$")
|
|
37
|
+
_ADVISORY_LOCK_KEY = zlib.crc32(b"pgcommon-migrate")
|
|
38
|
+
_LOCK_TIMEOUT_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Migration:
|
|
43
|
+
"""One migration version, with up and/or down SQL loaded from disk."""
|
|
44
|
+
|
|
45
|
+
version: int
|
|
46
|
+
description: str
|
|
47
|
+
up_sql: str | None
|
|
48
|
+
down_sql: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_migrations(source: Path) -> dict[int, Migration]:
|
|
52
|
+
migrations: dict[int, Migration] = {}
|
|
53
|
+
try:
|
|
54
|
+
entries = sorted(source.iterdir())
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise NoMigrationsFoundError(f"migration source {source} is not readable: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
for path in entries:
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
continue
|
|
61
|
+
m = _FILENAME_RE.match(path.name)
|
|
62
|
+
if not m:
|
|
63
|
+
continue
|
|
64
|
+
version = int(m.group("version"))
|
|
65
|
+
description = m.group("description")
|
|
66
|
+
direction = m.group("direction")
|
|
67
|
+
sql = path.read_text()
|
|
68
|
+
|
|
69
|
+
existing = migrations.get(version)
|
|
70
|
+
up_sql = existing.up_sql if existing else None
|
|
71
|
+
down_sql = existing.down_sql if existing else None
|
|
72
|
+
if direction == "up":
|
|
73
|
+
up_sql = sql
|
|
74
|
+
else:
|
|
75
|
+
down_sql = sql
|
|
76
|
+
migrations[version] = Migration(
|
|
77
|
+
version=version,
|
|
78
|
+
description=existing.description if existing else description,
|
|
79
|
+
up_sql=up_sql,
|
|
80
|
+
down_sql=down_sql,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if not migrations:
|
|
84
|
+
raise NoMigrationsFoundError(f"no *.up.sql / *.down.sql files found under {source}")
|
|
85
|
+
return migrations
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Runner:
|
|
89
|
+
"""Wraps a directory of SQL migrations and a target database.
|
|
90
|
+
|
|
91
|
+
`source` defaults are the caller's responsibility — pass an absolute or relative path to a
|
|
92
|
+
directory containing `NNNN_description.up.sql` / `NNNN_description.down.sql` pairs.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, dsn: str, source: str | Path, *, logger: Logger | None = None) -> None:
|
|
96
|
+
self._dsn = dsn
|
|
97
|
+
self._source = Path(source)
|
|
98
|
+
self._logger = logger
|
|
99
|
+
|
|
100
|
+
async def up(self) -> None:
|
|
101
|
+
"""Apply all pending migrations, in ascending version order."""
|
|
102
|
+
await self._run(direction="up", steps=None)
|
|
103
|
+
|
|
104
|
+
async def down(self, n: int) -> None:
|
|
105
|
+
"""Roll back the `n` most recently applied migrations, in descending version order."""
|
|
106
|
+
await self._run(direction="down", steps=n)
|
|
107
|
+
|
|
108
|
+
async def steps(self, n: int) -> None:
|
|
109
|
+
"""Apply `n` migrations forward if `n >= 0`, or roll back `-n` if `n < 0`."""
|
|
110
|
+
if n >= 0:
|
|
111
|
+
await self._run(direction="up", steps=n)
|
|
112
|
+
else:
|
|
113
|
+
await self._run(direction="down", steps=-n)
|
|
114
|
+
|
|
115
|
+
async def version(self) -> tuple[int, bool]:
|
|
116
|
+
"""Return `(version, dirty)` for the schema's current migration state."""
|
|
117
|
+
conn = await self._connect()
|
|
118
|
+
try:
|
|
119
|
+
return await self._with_advisory_lock(conn, lambda: self._version_locked(conn))
|
|
120
|
+
finally:
|
|
121
|
+
await conn.close()
|
|
122
|
+
|
|
123
|
+
async def _version_locked(self, conn: asyncpg.Connection) -> tuple[int, bool]:
|
|
124
|
+
await self._ensure_schema_migrations_table(conn)
|
|
125
|
+
return await self._current_version(conn)
|
|
126
|
+
|
|
127
|
+
async def _connect(self) -> asyncpg.Connection:
|
|
128
|
+
conn = await asyncpg.connect(self._dsn)
|
|
129
|
+
await conn.execute(f"SET lock_timeout = '{_LOCK_TIMEOUT_SECONDS}s'")
|
|
130
|
+
return conn
|
|
131
|
+
|
|
132
|
+
async def _ensure_schema_migrations_table(self, conn: asyncpg.Connection) -> None:
|
|
133
|
+
await conn.execute(
|
|
134
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
|
135
|
+
"version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def _current_version(self, conn: asyncpg.Connection) -> tuple[int, bool]:
|
|
139
|
+
row = await conn.fetchrow("SELECT version, dirty FROM schema_migrations LIMIT 1")
|
|
140
|
+
if row is None:
|
|
141
|
+
return 0, False
|
|
142
|
+
return row["version"], row["dirty"]
|
|
143
|
+
|
|
144
|
+
async def _set_version(self, conn: asyncpg.Connection, version: int, *, dirty: bool) -> None:
|
|
145
|
+
async with conn.transaction():
|
|
146
|
+
await conn.execute("DELETE FROM schema_migrations")
|
|
147
|
+
await conn.execute(
|
|
148
|
+
"INSERT INTO schema_migrations (version, dirty) VALUES ($1, $2)", version, dirty
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _with_advisory_lock(
|
|
152
|
+
self, conn: asyncpg.Connection, fn: Callable[[], Awaitable[T]]
|
|
153
|
+
) -> T:
|
|
154
|
+
await conn.execute("SELECT pg_advisory_lock($1)", _ADVISORY_LOCK_KEY)
|
|
155
|
+
try:
|
|
156
|
+
return await fn()
|
|
157
|
+
finally:
|
|
158
|
+
await conn.execute("SELECT pg_advisory_unlock($1)", _ADVISORY_LOCK_KEY)
|
|
159
|
+
|
|
160
|
+
async def _apply_sql(
|
|
161
|
+
self, conn: asyncpg.Connection, sql: str, *, failing_version: int, success_version: int
|
|
162
|
+
) -> None:
|
|
163
|
+
try:
|
|
164
|
+
async with conn.transaction():
|
|
165
|
+
await conn.execute(sql)
|
|
166
|
+
except Exception:
|
|
167
|
+
await self._set_version(conn, failing_version, dirty=True)
|
|
168
|
+
raise
|
|
169
|
+
await self._set_version(conn, success_version, dirty=False)
|
|
170
|
+
|
|
171
|
+
async def _run(self, *, direction: str, steps: int | None) -> None:
|
|
172
|
+
migrations = _load_migrations(self._source)
|
|
173
|
+
conn = await self._connect()
|
|
174
|
+
try:
|
|
175
|
+
await self._with_advisory_lock(
|
|
176
|
+
conn, lambda: self._run_locked(conn, migrations, direction, steps)
|
|
177
|
+
)
|
|
178
|
+
finally:
|
|
179
|
+
await conn.close()
|
|
180
|
+
|
|
181
|
+
async def _run_locked(
|
|
182
|
+
self,
|
|
183
|
+
conn: asyncpg.Connection,
|
|
184
|
+
migrations: dict[int, Migration],
|
|
185
|
+
direction: str,
|
|
186
|
+
steps: int | None,
|
|
187
|
+
) -> None:
|
|
188
|
+
# CREATE TABLE IF NOT EXISTS is not safe against true concurrent creation in Postgres —
|
|
189
|
+
# two sessions racing this can both pass the "not exists" check and collide on the
|
|
190
|
+
# underlying pg_type entry. Only ever run it while holding the advisory lock.
|
|
191
|
+
await self._ensure_schema_migrations_table(conn)
|
|
192
|
+
current_version, dirty = await self._current_version(conn)
|
|
193
|
+
if dirty:
|
|
194
|
+
raise MigrationDirtyError(
|
|
195
|
+
f"schema is dirty at version {current_version} — a previous migration failed "
|
|
196
|
+
"mid-run; fix the migration and manually clear the dirty flag before retrying"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if direction == "up":
|
|
200
|
+
await self._run_up(conn, migrations, current_version, steps)
|
|
201
|
+
else:
|
|
202
|
+
assert steps is not None, "down/steps(negative) callers always pass an int"
|
|
203
|
+
await self._run_down(conn, migrations, current_version, steps)
|
|
204
|
+
|
|
205
|
+
async def _run_up(
|
|
206
|
+
self,
|
|
207
|
+
conn: asyncpg.Connection,
|
|
208
|
+
migrations: dict[int, Migration],
|
|
209
|
+
current_version: int,
|
|
210
|
+
steps: int | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
pending = sorted(v for v in migrations if v > current_version)
|
|
213
|
+
if steps is not None:
|
|
214
|
+
pending = pending[:steps]
|
|
215
|
+
|
|
216
|
+
if not pending:
|
|
217
|
+
if self._logger is not None:
|
|
218
|
+
self._logger.info("no pending migrations", version=current_version)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
for version in pending:
|
|
222
|
+
migration = migrations[version]
|
|
223
|
+
if migration.up_sql is None:
|
|
224
|
+
raise NoMigrationsFoundError(f"missing *.up.sql for version {version}")
|
|
225
|
+
await self._apply_sql(
|
|
226
|
+
conn, migration.up_sql, failing_version=version, success_version=version
|
|
227
|
+
)
|
|
228
|
+
if self._logger is not None:
|
|
229
|
+
self._logger.info("applied migration", version=version, direction="up")
|
|
230
|
+
|
|
231
|
+
async def _run_down(
|
|
232
|
+
self,
|
|
233
|
+
conn: asyncpg.Connection,
|
|
234
|
+
migrations: dict[int, Migration],
|
|
235
|
+
current_version: int,
|
|
236
|
+
steps: int,
|
|
237
|
+
) -> None:
|
|
238
|
+
applied = sorted(v for v in migrations if v <= current_version)
|
|
239
|
+
to_roll_back = applied[-steps:] if steps > 0 else []
|
|
240
|
+
if not to_roll_back:
|
|
241
|
+
if self._logger is not None:
|
|
242
|
+
self._logger.info("no migrations to roll back", version=current_version)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# `to_roll_back` is the top (highest-version) contiguous suffix of `applied`. Walk it
|
|
246
|
+
# from the highest version down, and after rolling back applied[idx], the new current
|
|
247
|
+
# version is its predecessor in the *full* applied list — applied[idx - 1], or 0 once
|
|
248
|
+
# nothing is left.
|
|
249
|
+
idx = len(applied) - 1
|
|
250
|
+
for version in reversed(to_roll_back):
|
|
251
|
+
assert applied[idx] == version
|
|
252
|
+
migration = migrations[version]
|
|
253
|
+
if migration.down_sql is None:
|
|
254
|
+
raise NoMigrationsFoundError(f"missing *.down.sql for version {version}")
|
|
255
|
+
idx -= 1
|
|
256
|
+
new_version = applied[idx] if idx >= 0 else 0
|
|
257
|
+
await self._apply_sql(
|
|
258
|
+
conn, migration.down_sql, failing_version=version, success_version=new_version
|
|
259
|
+
)
|
|
260
|
+
if self._logger is not None:
|
|
261
|
+
self._logger.info("rolled back migration", version=version, direction="down")
|
pgcommon/pool.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""The connection pool — the only sanctioned entry point to PostgreSQL.
|
|
2
|
+
|
|
3
|
+
Wraps `asyncpg.Pool`. There is intentionally no public API that exposes the underlying
|
|
4
|
+
`asyncpg.Pool` or a raw `asyncpg.Connection` outside a `with_conn` / `run_in_tx` callback —
|
|
5
|
+
bypassing these helpers silently disables GUC injection, retry logic, and observability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import TypeVar
|
|
18
|
+
|
|
19
|
+
import asyncpg
|
|
20
|
+
|
|
21
|
+
from pgcommon import metrics as metrics_module
|
|
22
|
+
from pgcommon._internal.dsn import mask_dsn
|
|
23
|
+
from pgcommon._internal.query_event import QueryEvent
|
|
24
|
+
from pgcommon.config import Config
|
|
25
|
+
from pgcommon.errors import PoolClosedError
|
|
26
|
+
from pgcommon.guc import _inject_gucs_local, inject_gucs
|
|
27
|
+
from pgcommon.metrics import OTelQueryRecorder
|
|
28
|
+
from pgcommon.tracer import SlowQueryTracer
|
|
29
|
+
|
|
30
|
+
__all__ = ["HealthStatus", "Pool", "Stat"]
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
_DEFAULT_HEALTH_PING_TIMEOUT = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Stat:
|
|
39
|
+
"""Snapshot of pool sizing and acquire counters — see `Pool.stat()`."""
|
|
40
|
+
|
|
41
|
+
size: int
|
|
42
|
+
idle_size: int
|
|
43
|
+
min_size: int
|
|
44
|
+
max_size: int
|
|
45
|
+
acquired_size: int
|
|
46
|
+
acquire_count: int
|
|
47
|
+
acquire_duration: timedelta
|
|
48
|
+
empty_acquire_count: int
|
|
49
|
+
canceled_acquire_count: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class HealthStatus:
|
|
54
|
+
"""Point-in-time health snapshot returned by `Pool.health()` — call per-request, not once."""
|
|
55
|
+
|
|
56
|
+
healthy: bool
|
|
57
|
+
total_conns: int
|
|
58
|
+
idle_conns: int
|
|
59
|
+
acquired_conns: int
|
|
60
|
+
max_conns: int
|
|
61
|
+
utilization: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Pool:
|
|
65
|
+
"""Construct via `await Pool.create(config)` — never call `Pool()` directly."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, config: Config, asyncpg_pool: asyncpg.Pool) -> None:
|
|
68
|
+
self._config = config
|
|
69
|
+
self._pool = asyncpg_pool
|
|
70
|
+
self._closed = False
|
|
71
|
+
self._acquire_count = 0
|
|
72
|
+
self._empty_acquire_count = 0
|
|
73
|
+
self._canceled_acquire_count = 0
|
|
74
|
+
self._total_acquire_seconds = 0.0
|
|
75
|
+
self._background_tasks: list[asyncio.Task[None]] = []
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
async def create(cls, config: Config) -> Pool:
|
|
79
|
+
"""Validate `config`, open the pool, and ping it once so misconfiguration fails fast."""
|
|
80
|
+
config.validate()
|
|
81
|
+
|
|
82
|
+
slow_query_tracer: SlowQueryTracer | None = None
|
|
83
|
+
if config.logger is not None and config.slow_query_threshold > timedelta(0):
|
|
84
|
+
slow_query_tracer = SlowQueryTracer(
|
|
85
|
+
config.slow_query_threshold,
|
|
86
|
+
config.logger,
|
|
87
|
+
log_tenant_id=config.log_tenant_id,
|
|
88
|
+
allow_full_statements=config.allow_full_statements,
|
|
89
|
+
guc_provider=config.guc_provider,
|
|
90
|
+
on_slow_query=lambda: _inc_slow_query_total(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
otel_recorder: OTelQueryRecorder | None = None
|
|
94
|
+
if config.tracer is not None:
|
|
95
|
+
otel_recorder = OTelQueryRecorder(
|
|
96
|
+
config.tracer,
|
|
97
|
+
allow_full_statements=config.allow_full_statements,
|
|
98
|
+
metrics=metrics_module.current(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _inc_slow_query_total() -> None:
|
|
102
|
+
m = metrics_module.current()
|
|
103
|
+
if m is not None:
|
|
104
|
+
m.slow_query_total.inc()
|
|
105
|
+
|
|
106
|
+
def _on_query(record: QueryEvent) -> None:
|
|
107
|
+
if slow_query_tracer is not None:
|
|
108
|
+
slow_query_tracer(record)
|
|
109
|
+
if otel_recorder is not None:
|
|
110
|
+
otel_recorder(record)
|
|
111
|
+
|
|
112
|
+
has_query_hooks = slow_query_tracer is not None or otel_recorder is not None
|
|
113
|
+
|
|
114
|
+
async def _init(conn: asyncpg.Connection) -> None:
|
|
115
|
+
if has_query_hooks:
|
|
116
|
+
conn.add_query_logger(_on_query)
|
|
117
|
+
|
|
118
|
+
async def _setup(conn: asyncpg.pool.PoolConnectionProxy) -> None:
|
|
119
|
+
if config.guc_provider is not None and not config.pgbouncer_mode:
|
|
120
|
+
guc = config.guc_provider()
|
|
121
|
+
if guc is not None:
|
|
122
|
+
await inject_gucs(conn, guc)
|
|
123
|
+
|
|
124
|
+
connect_kwargs: dict[str, object] = {}
|
|
125
|
+
if config.pgbouncer_mode:
|
|
126
|
+
# PgBouncer transaction pooling can hand back a different backend session on
|
|
127
|
+
# every acquire; asyncpg's client-side statement cache must be disabled or it
|
|
128
|
+
# may reuse a prepared statement name the new backend never actually prepared.
|
|
129
|
+
connect_kwargs["statement_cache_size"] = 0
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
asyncpg_pool = await asyncpg.create_pool(
|
|
133
|
+
dsn=config.dsn,
|
|
134
|
+
min_size=config.min_conns,
|
|
135
|
+
max_size=config.max_conns,
|
|
136
|
+
max_inactive_connection_lifetime=config.max_conn_idle_time.total_seconds(),
|
|
137
|
+
init=_init,
|
|
138
|
+
setup=_setup,
|
|
139
|
+
**connect_kwargs,
|
|
140
|
+
)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise ConnectionError(
|
|
143
|
+
f"failed to create pool for dsn={mask_dsn(config.dsn)}: {mask_dsn(str(exc))}"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
if asyncpg_pool is None: # pragma: no cover - asyncpg always returns a Pool here
|
|
147
|
+
raise ConnectionError("asyncpg.create_pool unexpectedly returned None")
|
|
148
|
+
|
|
149
|
+
pool = cls(config=config, asyncpg_pool=asyncpg_pool)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
await pool.ping()
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
await asyncpg_pool.close()
|
|
155
|
+
raise ConnectionError(
|
|
156
|
+
f"initial ping failed for dsn={mask_dsn(config.dsn)}: {mask_dsn(str(exc))}"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
pool._start_background_tasks()
|
|
160
|
+
return pool
|
|
161
|
+
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def _acquire(self) -> AsyncIterator[asyncpg.pool.PoolConnectionProxy]:
|
|
164
|
+
"""Acquire a raw connection with acquire counters/metrics recorded, releasing on exit.
|
|
165
|
+
|
|
166
|
+
Shared primitive behind `with_conn` and `pgcommon.tx.run_in_tx` — deliberately does
|
|
167
|
+
*not* perform any GUC injection itself, since the two callers scope GUCs differently
|
|
168
|
+
(an implicit wrapping transaction for `with_conn`, the caller-controlled transaction
|
|
169
|
+
for `run_in_tx`).
|
|
170
|
+
"""
|
|
171
|
+
if self._closed:
|
|
172
|
+
raise PoolClosedError("pool is closed")
|
|
173
|
+
|
|
174
|
+
was_empty = self._pool.get_idle_size() == 0
|
|
175
|
+
start = time.monotonic()
|
|
176
|
+
cm = self._pool.acquire()
|
|
177
|
+
try:
|
|
178
|
+
conn = await cm.__aenter__()
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
self._canceled_acquire_count += 1
|
|
181
|
+
self._record_acquire_metric(start, status="error")
|
|
182
|
+
raise
|
|
183
|
+
except BaseException:
|
|
184
|
+
self._record_acquire_metric(start, status="error")
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
self._record_acquire_success(start, was_empty)
|
|
188
|
+
try:
|
|
189
|
+
yield conn
|
|
190
|
+
finally:
|
|
191
|
+
# PoolAcquireContext.__aexit__ ignores exception info and only releases the
|
|
192
|
+
# connection, so it's always safe to call unconditionally here.
|
|
193
|
+
await cm.__aexit__(None, None, None)
|
|
194
|
+
|
|
195
|
+
async def with_conn(self, fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]]) -> T:
|
|
196
|
+
"""Acquire a connection, inject GUCs, run `fn`, then release — the only safe entry point.
|
|
197
|
+
|
|
198
|
+
In PgBouncer mode, when a `GUCSet` is active, `fn` runs inside an implicit wrapping
|
|
199
|
+
transaction used to scope the injected GUCs as `is_local=true`. Do not call
|
|
200
|
+
`conn.transaction()` yourself inside `fn` in that case — see ARCHITECTURE.md.
|
|
201
|
+
"""
|
|
202
|
+
async with self._acquire() as conn:
|
|
203
|
+
if self._config.pgbouncer_mode and self._config.guc_provider is not None:
|
|
204
|
+
guc = self._config.guc_provider()
|
|
205
|
+
if guc is not None and not guc.is_zero():
|
|
206
|
+
guc.validate()
|
|
207
|
+
async with conn.transaction():
|
|
208
|
+
await _inject_gucs_local(conn, guc)
|
|
209
|
+
return await fn(conn)
|
|
210
|
+
return await fn(conn)
|
|
211
|
+
|
|
212
|
+
async def run_in_tx(
|
|
213
|
+
self,
|
|
214
|
+
fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]],
|
|
215
|
+
*,
|
|
216
|
+
isolation: str | None = None,
|
|
217
|
+
read_only: bool = False,
|
|
218
|
+
) -> T:
|
|
219
|
+
"""Convenience method form of `pgcommon.tx.run_in_tx(pool, fn, ...)`."""
|
|
220
|
+
from pgcommon.tx import run_in_tx as _run_in_tx
|
|
221
|
+
|
|
222
|
+
return await _run_in_tx(self, fn, isolation=isolation, read_only=read_only)
|
|
223
|
+
|
|
224
|
+
def _record_acquire_metric(self, start: float, *, status: str) -> None:
|
|
225
|
+
elapsed = time.monotonic() - start
|
|
226
|
+
self._total_acquire_seconds += elapsed
|
|
227
|
+
m = metrics_module.current()
|
|
228
|
+
if m is not None:
|
|
229
|
+
m.pool_acquire_total.labels(status=status).inc()
|
|
230
|
+
m.pool_acquire_duration.observe(elapsed)
|
|
231
|
+
|
|
232
|
+
def _record_acquire_success(self, start: float, was_empty: bool) -> None:
|
|
233
|
+
self._acquire_count += 1
|
|
234
|
+
if was_empty:
|
|
235
|
+
self._empty_acquire_count += 1
|
|
236
|
+
self._record_acquire_metric(start, status="success")
|
|
237
|
+
|
|
238
|
+
async def ping(self) -> None:
|
|
239
|
+
"""Verify database connectivity. Raises `PoolClosedError` if the pool has been closed."""
|
|
240
|
+
if self._closed:
|
|
241
|
+
raise PoolClosedError("pool is closed")
|
|
242
|
+
async with self._pool.acquire() as conn:
|
|
243
|
+
await conn.execute("SELECT 1")
|
|
244
|
+
|
|
245
|
+
async def health(self, *, timeout: float = _DEFAULT_HEALTH_PING_TIMEOUT) -> HealthStatus:
|
|
246
|
+
"""Return a point-in-time `HealthStatus`. Never raises — ping failures set `healthy=False`."""
|
|
247
|
+
stat = self.stat()
|
|
248
|
+
try:
|
|
249
|
+
await asyncio.wait_for(self.ping(), timeout=timeout)
|
|
250
|
+
healthy = True
|
|
251
|
+
except Exception:
|
|
252
|
+
healthy = False
|
|
253
|
+
|
|
254
|
+
max_conns = self._config.max_conns
|
|
255
|
+
utilization = (stat.acquired_size / max_conns) if max_conns > 0 else 0.0
|
|
256
|
+
return HealthStatus(
|
|
257
|
+
healthy=healthy,
|
|
258
|
+
total_conns=stat.size,
|
|
259
|
+
idle_conns=stat.idle_size,
|
|
260
|
+
acquired_conns=stat.acquired_size,
|
|
261
|
+
max_conns=max_conns,
|
|
262
|
+
utilization=utilization,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def stat(self) -> Stat:
|
|
266
|
+
"""Return a snapshot of pool sizing and acquire counters."""
|
|
267
|
+
size = self._pool.get_size()
|
|
268
|
+
idle_size = self._pool.get_idle_size()
|
|
269
|
+
return Stat(
|
|
270
|
+
size=size,
|
|
271
|
+
idle_size=idle_size,
|
|
272
|
+
min_size=self._pool.get_min_size(),
|
|
273
|
+
max_size=self._pool.get_max_size(),
|
|
274
|
+
acquired_size=size - idle_size,
|
|
275
|
+
acquire_count=self._acquire_count,
|
|
276
|
+
acquire_duration=timedelta(seconds=self._total_acquire_seconds),
|
|
277
|
+
empty_acquire_count=self._empty_acquire_count,
|
|
278
|
+
canceled_acquire_count=self._canceled_acquire_count,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
"""Immediately invalidate the pool. Prefer `drain_and_close` during graceful shutdown."""
|
|
283
|
+
self._closed = True
|
|
284
|
+
self._cancel_background_tasks()
|
|
285
|
+
self._pool.terminate()
|
|
286
|
+
|
|
287
|
+
async def drain_and_close(self, *, timeout: float = 30.0) -> None:
|
|
288
|
+
"""Mark the pool closed, wait for in-flight callbacks to finish, then force-close.
|
|
289
|
+
|
|
290
|
+
New `with_conn` / `ping` / `health` calls raise `PoolClosedError` immediately. Already
|
|
291
|
+
in-flight callbacks are allowed to finish; if they have not all finished by `timeout`
|
|
292
|
+
seconds, the pool is force-closed.
|
|
293
|
+
"""
|
|
294
|
+
self._closed = True
|
|
295
|
+
self._cancel_background_tasks()
|
|
296
|
+
try:
|
|
297
|
+
await asyncio.wait_for(self._pool.close(), timeout=timeout)
|
|
298
|
+
except TimeoutError:
|
|
299
|
+
self._pool.terminate()
|
|
300
|
+
|
|
301
|
+
def _start_background_tasks(self) -> None:
|
|
302
|
+
if self._config.max_conn_lifetime > timedelta(0):
|
|
303
|
+
self._background_tasks.append(
|
|
304
|
+
asyncio.ensure_future(self._recycle_connections_periodically())
|
|
305
|
+
)
|
|
306
|
+
if self._config.health_check_period > timedelta(0):
|
|
307
|
+
self._background_tasks.append(asyncio.ensure_future(self._probe_health_periodically()))
|
|
308
|
+
|
|
309
|
+
def _cancel_background_tasks(self) -> None:
|
|
310
|
+
for task in self._background_tasks:
|
|
311
|
+
task.cancel()
|
|
312
|
+
self._background_tasks.clear()
|
|
313
|
+
|
|
314
|
+
async def _recycle_connections_periodically(self) -> None:
|
|
315
|
+
"""Recycle the whole pool generation every `max_conn_lifetime` (+ jitter).
|
|
316
|
+
|
|
317
|
+
This is a deliberate simplification from pgx's per-connection independent aging:
|
|
318
|
+
asyncpg has no public hook to force-expire a single physical connection without
|
|
319
|
+
surfacing an error to whichever caller currently holds it. `expire_connections()`
|
|
320
|
+
instead bumps a pool-wide generation counter so every connection is transparently
|
|
321
|
+
replaced the next time it is idle and reacquired. The jitter still serves its
|
|
322
|
+
purpose — staggering *when* different service instances recycle their pools — even
|
|
323
|
+
though it no longer staggers individual connections within one pool.
|
|
324
|
+
"""
|
|
325
|
+
base = self._config.max_conn_lifetime.total_seconds()
|
|
326
|
+
jitter = self._config.max_conn_lifetime_jitter.total_seconds()
|
|
327
|
+
try:
|
|
328
|
+
while True:
|
|
329
|
+
delay = base + (random.uniform(0, jitter) if jitter > 0 else 0.0)
|
|
330
|
+
await asyncio.sleep(delay)
|
|
331
|
+
await self._pool.expire_connections()
|
|
332
|
+
except asyncio.CancelledError:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
async def _probe_health_periodically(self) -> None:
|
|
336
|
+
interval = self._config.health_check_period.total_seconds()
|
|
337
|
+
try:
|
|
338
|
+
while True:
|
|
339
|
+
await asyncio.sleep(interval)
|
|
340
|
+
try:
|
|
341
|
+
await self.ping()
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
if self._config.logger is not None:
|
|
344
|
+
self._config.logger.warn("pool health check failed", error=str(exc))
|
|
345
|
+
except asyncio.CancelledError:
|
|
346
|
+
return
|
pgcommon/py.typed
ADDED
|
File without changes
|