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
pgcommon/retry.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Retry helpers for transient serialization failures and deadlocks.
|
|
2
|
+
|
|
3
|
+
⚠️ **Retry does NOT make an operation safe — it re-runs it.** These helpers re-execute `fn`
|
|
4
|
+
from the top, up to `max_attempts` times. `fn` must be idempotent: the transaction rolls back
|
|
5
|
+
between attempts, undoing *database* writes, but it cannot undo a side effect that already
|
|
6
|
+
happened outside the transaction (an email send, a payment charge, a webhook POST). Do all
|
|
7
|
+
non-database I/O outside the transaction, or make it idempotent with a key. See the README's
|
|
8
|
+
"Retry does NOT make an operation safe" section before using these on a write path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import random
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import timedelta
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import asyncpg
|
|
21
|
+
|
|
22
|
+
from pgcommon import metrics as metrics_module
|
|
23
|
+
from pgcommon.errors import is_deadlock, is_serialization_failure
|
|
24
|
+
from pgcommon.tx import run_in_tx
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pgcommon.pool import Pool
|
|
28
|
+
|
|
29
|
+
__all__ = ["RetryOptions", "run_in_tx_with_retry", "run_in_tx_with_retry_opts"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class RetryOptions:
|
|
34
|
+
"""Configures the backoff schedule for `run_in_tx_with_retry_opts`.
|
|
35
|
+
|
|
36
|
+
delay = min(initial_wait * multiplier**(attempt-1), max_wait) # when max_wait > 0
|
|
37
|
+
delay ± jitter_fraction * delay # randomized ± spread
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
max_attempts: int = 3
|
|
41
|
+
initial_wait: timedelta = timedelta(0)
|
|
42
|
+
max_wait: timedelta = timedelta(0)
|
|
43
|
+
multiplier: float = 1.0
|
|
44
|
+
jitter_fraction: float = 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _retry_reason(exc: BaseException) -> str | None:
|
|
48
|
+
if is_deadlock(exc):
|
|
49
|
+
return "deadlock"
|
|
50
|
+
if is_serialization_failure(exc):
|
|
51
|
+
return "serialization"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _compute_delay_seconds(opts: RetryOptions, attempt: int) -> float:
|
|
56
|
+
delay = opts.initial_wait.total_seconds() * (opts.multiplier ** (attempt - 1))
|
|
57
|
+
max_wait_seconds = opts.max_wait.total_seconds()
|
|
58
|
+
if max_wait_seconds > 0:
|
|
59
|
+
delay = min(delay, max_wait_seconds)
|
|
60
|
+
if opts.jitter_fraction > 0:
|
|
61
|
+
delay += random.uniform(-opts.jitter_fraction, opts.jitter_fraction) * delay
|
|
62
|
+
return max(delay, 0.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def run_in_tx_with_retry[T](
|
|
66
|
+
pool: Pool,
|
|
67
|
+
fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]],
|
|
68
|
+
max_attempts: int,
|
|
69
|
+
*,
|
|
70
|
+
isolation: str | None = None,
|
|
71
|
+
read_only: bool = False,
|
|
72
|
+
) -> T:
|
|
73
|
+
"""Retry on deadlock (40P01) / serialization failure (40001), up to `max_attempts`.
|
|
74
|
+
|
|
75
|
+
Retries immediately, with no delay between attempts. Non-retryable errors are returned to
|
|
76
|
+
the caller unchanged on the first occurrence.
|
|
77
|
+
"""
|
|
78
|
+
return await run_in_tx_with_retry_opts(
|
|
79
|
+
pool,
|
|
80
|
+
fn,
|
|
81
|
+
RetryOptions(max_attempts=max_attempts),
|
|
82
|
+
isolation=isolation,
|
|
83
|
+
read_only=read_only,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def run_in_tx_with_retry_opts[T](
|
|
88
|
+
pool: Pool,
|
|
89
|
+
fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]],
|
|
90
|
+
opts: RetryOptions,
|
|
91
|
+
*,
|
|
92
|
+
isolation: str | None = None,
|
|
93
|
+
read_only: bool = False,
|
|
94
|
+
) -> T:
|
|
95
|
+
"""Like `run_in_tx_with_retry`, with configurable exponential backoff and jitter.
|
|
96
|
+
|
|
97
|
+
`asyncio.CancelledError` is never treated as retryable — it propagates immediately,
|
|
98
|
+
whether raised during the transaction attempt or during the backoff sleep.
|
|
99
|
+
"""
|
|
100
|
+
attempt = 1
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
return await run_in_tx(pool, fn, isolation=isolation, read_only=read_only)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
reason = _retry_reason(exc)
|
|
106
|
+
if reason is None:
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
m = metrics_module.current()
|
|
110
|
+
if m is not None:
|
|
111
|
+
m.retry_total.labels(reason=reason).inc()
|
|
112
|
+
|
|
113
|
+
if attempt >= opts.max_attempts:
|
|
114
|
+
if m is not None:
|
|
115
|
+
m.retry_exhausted_total.labels(reason=reason).inc()
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
delay = _compute_delay_seconds(opts, attempt)
|
|
119
|
+
if delay > 0:
|
|
120
|
+
await asyncio.sleep(delay)
|
|
121
|
+
attempt += 1
|
pgcommon/tracer.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Slow-query detection and structured logging.
|
|
2
|
+
|
|
3
|
+
Mirrors the Go repo's `SlowQueryTracer`. pgx exposes a `QueryTracer` interface with paired
|
|
4
|
+
`TraceQueryStart` / `TraceQueryEnd` hooks; asyncpg's closest analog is
|
|
5
|
+
`Connection.add_query_logger`, a single post-query callback carrying `elapsed` and
|
|
6
|
+
`exception`. `SlowQueryTracer` is written as a callable so it can be registered directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
|
|
15
|
+
from pgcommon._internal.query_event import QueryEvent
|
|
16
|
+
from pgcommon._internal.sql_verb import sql_operation
|
|
17
|
+
from pgcommon.guc import GUCSet
|
|
18
|
+
from pgcommon.logger import Logger
|
|
19
|
+
|
|
20
|
+
__all__ = ["SlowQueryTracer"]
|
|
21
|
+
|
|
22
|
+
_RATE_LIMIT_SECONDS = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlowQueryTracer:
|
|
26
|
+
"""Logs `WARN` for any query exceeding `threshold`, rate-limited to 1 log/second.
|
|
27
|
+
|
|
28
|
+
The `pgcommon_slow_query_total` counter (wired via `on_slow_query`) fires on every
|
|
29
|
+
detection regardless of the log rate limit — only the log line itself is throttled.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
threshold: timedelta,
|
|
35
|
+
logger: Logger,
|
|
36
|
+
*,
|
|
37
|
+
log_tenant_id: bool = False,
|
|
38
|
+
allow_full_statements: bool = False,
|
|
39
|
+
guc_provider: Callable[[], GUCSet | None] | None = None,
|
|
40
|
+
on_slow_query: Callable[[], None] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._threshold_seconds = threshold.total_seconds()
|
|
43
|
+
self._logger = logger
|
|
44
|
+
self._log_tenant_id = log_tenant_id
|
|
45
|
+
self._allow_full_statements = allow_full_statements
|
|
46
|
+
self._guc_provider = guc_provider
|
|
47
|
+
self._on_slow_query = on_slow_query
|
|
48
|
+
self._last_log_monotonic = float("-inf")
|
|
49
|
+
|
|
50
|
+
def set_log_tenant_id(self, value: bool) -> None:
|
|
51
|
+
"""Opt in (or back out) of including `tenant_id` in slow-query log entries."""
|
|
52
|
+
self._log_tenant_id = value
|
|
53
|
+
|
|
54
|
+
def set_allow_full_statements(self, value: bool) -> None:
|
|
55
|
+
"""Opt in (or back out) of logging full SQL text instead of just the verb."""
|
|
56
|
+
self._allow_full_statements = value
|
|
57
|
+
|
|
58
|
+
def set_slow_query_hook(self, hook: Callable[[], None] | None) -> None:
|
|
59
|
+
"""Set (or clear) the callback invoked on every slow-query detection."""
|
|
60
|
+
self._on_slow_query = hook
|
|
61
|
+
|
|
62
|
+
def __call__(self, record: QueryEvent) -> None:
|
|
63
|
+
"""Register this as an asyncpg query logger: `conn.add_query_logger(tracer)`."""
|
|
64
|
+
if record.elapsed < self._threshold_seconds:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if self._on_slow_query is not None:
|
|
68
|
+
self._on_slow_query()
|
|
69
|
+
|
|
70
|
+
now = time.monotonic()
|
|
71
|
+
if now - self._last_log_monotonic < _RATE_LIMIT_SECONDS:
|
|
72
|
+
return
|
|
73
|
+
self._last_log_monotonic = now
|
|
74
|
+
|
|
75
|
+
fields: dict[str, object] = {
|
|
76
|
+
"query": record.query if self._allow_full_statements else sql_operation(record.query),
|
|
77
|
+
"args_count": len(record.args) if record.args is not None else 0,
|
|
78
|
+
"duration_ms": round(record.elapsed * 1000, 3),
|
|
79
|
+
}
|
|
80
|
+
if self._log_tenant_id and self._guc_provider is not None:
|
|
81
|
+
guc = self._guc_provider()
|
|
82
|
+
if guc is not None and guc.tenant_id:
|
|
83
|
+
fields["tenant_id"] = guc.tenant_id
|
|
84
|
+
|
|
85
|
+
self._logger.warn("slow query", **fields)
|
pgcommon/tx.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Transaction helpers: `run_in_tx` and `run_in_savepoint`.
|
|
2
|
+
|
|
3
|
+
`run_in_tx` commits on success and rolls back on exception, including `asyncio.CancelledError`
|
|
4
|
+
— handled by asyncpg's own `conn.transaction()` context manager, whose `__aexit__` sends
|
|
5
|
+
`ROLLBACK` when any exception (cancellation included) propagates out of the `async with` block.
|
|
6
|
+
|
|
7
|
+
This is a deliberately different mechanism from the Go repo's "rollback uses
|
|
8
|
+
`context.Background()`" trick, not just a different spelling of the same one — see
|
|
9
|
+
ARCHITECTURE.md's Cancellation section for why Go's workaround doesn't have a direct Python
|
|
10
|
+
analog: Go's `context.Context` is checked by the driver *before* it will even attempt an I/O
|
|
11
|
+
call, so a already-cancelled context can suppress the ROLLBACK from being sent at all. asyncio
|
|
12
|
+
cancellation instead interrupts exactly one suspended `await`; once that single
|
|
13
|
+
`CancelledError` has been delivered and is being handled in cleanup code, further `await`s
|
|
14
|
+
(including the one sending `ROLLBACK`) run to completion normally unless cancelled a second
|
|
15
|
+
time. That is why no `asyncio.shield()` or "background context" equivalent is needed here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
import asyncpg
|
|
25
|
+
|
|
26
|
+
from pgcommon.errors import InvalidSavepointNameError
|
|
27
|
+
from pgcommon.guc import _inject_gucs_local
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pgcommon.pool import Pool
|
|
31
|
+
|
|
32
|
+
__all__ = ["run_in_savepoint", "run_in_tx"]
|
|
33
|
+
|
|
34
|
+
_SAVEPOINT_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def run_in_tx[T](
|
|
38
|
+
pool: Pool,
|
|
39
|
+
fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]],
|
|
40
|
+
*,
|
|
41
|
+
isolation: str | None = None,
|
|
42
|
+
read_only: bool = False,
|
|
43
|
+
) -> T:
|
|
44
|
+
"""Run `fn` inside a transaction: commit on success, rollback on exception or cancellation.
|
|
45
|
+
|
|
46
|
+
`isolation` is passed straight through to `asyncpg.Connection.transaction()` — one of
|
|
47
|
+
`"read_committed"`, `"repeatable_read"`, `"serializable"`, or `None` for the session
|
|
48
|
+
default.
|
|
49
|
+
|
|
50
|
+
In PgBouncer mode, when a `GUCSet` is active on the current context, GUCs are re-injected
|
|
51
|
+
(`is_local=true`) inside this same transaction before `fn` runs, so they are automatically
|
|
52
|
+
cleared on commit/rollback rather than leaking into the next client that gets this backend
|
|
53
|
+
connection from PgBouncer.
|
|
54
|
+
"""
|
|
55
|
+
async with pool._acquire() as conn:
|
|
56
|
+
config = pool._config
|
|
57
|
+
guc = None
|
|
58
|
+
if config.pgbouncer_mode and config.guc_provider is not None:
|
|
59
|
+
candidate = config.guc_provider()
|
|
60
|
+
if candidate is not None and not candidate.is_zero():
|
|
61
|
+
candidate.validate()
|
|
62
|
+
guc = candidate
|
|
63
|
+
|
|
64
|
+
tx_kwargs: dict[str, object] = {}
|
|
65
|
+
if isolation is not None:
|
|
66
|
+
tx_kwargs["isolation"] = isolation
|
|
67
|
+
if read_only:
|
|
68
|
+
tx_kwargs["readonly"] = True
|
|
69
|
+
|
|
70
|
+
async with conn.transaction(**tx_kwargs):
|
|
71
|
+
if guc is not None:
|
|
72
|
+
await _inject_gucs_local(conn, guc)
|
|
73
|
+
return await fn(conn)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def run_in_savepoint[T](
|
|
77
|
+
conn: asyncpg.pool.PoolConnectionProxy,
|
|
78
|
+
name: str,
|
|
79
|
+
fn: Callable[[asyncpg.pool.PoolConnectionProxy], Awaitable[T]],
|
|
80
|
+
) -> T:
|
|
81
|
+
"""Run `fn` inside a named `SAVEPOINT` nested in the caller's already-open transaction.
|
|
82
|
+
|
|
83
|
+
Safe to call from inside a `run_in_tx` callback. On success: `RELEASE SAVEPOINT`. On
|
|
84
|
+
exception: `ROLLBACK TO SAVEPOINT` — the outer transaction is unaffected and the caller may
|
|
85
|
+
continue using it. `name` must match `^[A-Za-z_][A-Za-z0-9_]*$`.
|
|
86
|
+
"""
|
|
87
|
+
if not _SAVEPOINT_NAME_RE.match(name):
|
|
88
|
+
raise InvalidSavepointNameError(f"invalid savepoint name: {name!r}")
|
|
89
|
+
|
|
90
|
+
await conn.execute(f"SAVEPOINT {name}")
|
|
91
|
+
try:
|
|
92
|
+
result = await fn(conn)
|
|
93
|
+
except BaseException:
|
|
94
|
+
await conn.execute(f"ROLLBACK TO SAVEPOINT {name}")
|
|
95
|
+
raise
|
|
96
|
+
else:
|
|
97
|
+
await conn.execute(f"RELEASE SAVEPOINT {name}")
|
|
98
|
+
return result
|