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.
@@ -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