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/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