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/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""pgcommon — opinionated async PostgreSQL access layer for platform services.
|
|
2
|
+
|
|
3
|
+
Public re-exports only. Anything under `pgcommon._internal` is private and must never be
|
|
4
|
+
imported by consuming services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pgcommon.config import Config, ConfigWarning, config_from_env
|
|
10
|
+
from pgcommon.errors import (
|
|
11
|
+
DSNRequiredError,
|
|
12
|
+
EmptyTenantRoleError,
|
|
13
|
+
InconsistentGUCSetError,
|
|
14
|
+
InvalidMaxConnsError,
|
|
15
|
+
InvalidMinConnsError,
|
|
16
|
+
InvalidSavepointNameError,
|
|
17
|
+
InvalidTenantRoleError,
|
|
18
|
+
MigrationDirtyError,
|
|
19
|
+
MinConnsExceedsMaxError,
|
|
20
|
+
NoMigrationsFoundError,
|
|
21
|
+
PgCommonError,
|
|
22
|
+
PoolClosedError,
|
|
23
|
+
constraint_name,
|
|
24
|
+
is_check_violation,
|
|
25
|
+
is_deadlock,
|
|
26
|
+
is_foreign_key_violation,
|
|
27
|
+
is_not_null_violation,
|
|
28
|
+
is_serialization_failure,
|
|
29
|
+
is_unique_violation,
|
|
30
|
+
)
|
|
31
|
+
from pgcommon.guc import (
|
|
32
|
+
GUCSet,
|
|
33
|
+
guc_set_from_context,
|
|
34
|
+
inject_gucs,
|
|
35
|
+
with_guc_set,
|
|
36
|
+
with_validated_guc_set,
|
|
37
|
+
)
|
|
38
|
+
from pgcommon.logger import Logger, StdlibLogger, StructlogLogger
|
|
39
|
+
from pgcommon.metrics import Metrics, OTelQueryRecorder
|
|
40
|
+
from pgcommon.metrics import init as pgmetrics_init
|
|
41
|
+
from pgcommon.metrics import init_with_registry as pgmetrics_init_with_registry
|
|
42
|
+
from pgcommon.pool import HealthStatus, Pool, Stat
|
|
43
|
+
from pgcommon.retry import RetryOptions, run_in_tx_with_retry, run_in_tx_with_retry_opts
|
|
44
|
+
from pgcommon.tracer import SlowQueryTracer
|
|
45
|
+
from pgcommon.tx import run_in_savepoint, run_in_tx
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Config",
|
|
49
|
+
"ConfigWarning",
|
|
50
|
+
"DSNRequiredError",
|
|
51
|
+
"EmptyTenantRoleError",
|
|
52
|
+
"GUCSet",
|
|
53
|
+
"HealthStatus",
|
|
54
|
+
"InconsistentGUCSetError",
|
|
55
|
+
"InvalidMaxConnsError",
|
|
56
|
+
"InvalidMinConnsError",
|
|
57
|
+
"InvalidSavepointNameError",
|
|
58
|
+
"InvalidTenantRoleError",
|
|
59
|
+
"Logger",
|
|
60
|
+
"Metrics",
|
|
61
|
+
"MigrationDirtyError",
|
|
62
|
+
"MinConnsExceedsMaxError",
|
|
63
|
+
"NoMigrationsFoundError",
|
|
64
|
+
"OTelQueryRecorder",
|
|
65
|
+
"PgCommonError",
|
|
66
|
+
"Pool",
|
|
67
|
+
"PoolClosedError",
|
|
68
|
+
"RetryOptions",
|
|
69
|
+
"SlowQueryTracer",
|
|
70
|
+
"Stat",
|
|
71
|
+
"StdlibLogger",
|
|
72
|
+
"StructlogLogger",
|
|
73
|
+
"config_from_env",
|
|
74
|
+
"constraint_name",
|
|
75
|
+
"guc_set_from_context",
|
|
76
|
+
"inject_gucs",
|
|
77
|
+
"is_check_violation",
|
|
78
|
+
"is_deadlock",
|
|
79
|
+
"is_foreign_key_violation",
|
|
80
|
+
"is_not_null_violation",
|
|
81
|
+
"is_serialization_failure",
|
|
82
|
+
"is_unique_violation",
|
|
83
|
+
"pgmetrics_init",
|
|
84
|
+
"pgmetrics_init_with_registry",
|
|
85
|
+
"run_in_savepoint",
|
|
86
|
+
"run_in_tx",
|
|
87
|
+
"run_in_tx_with_retry",
|
|
88
|
+
"run_in_tx_with_retry_opts",
|
|
89
|
+
"with_guc_set",
|
|
90
|
+
"with_validated_guc_set",
|
|
91
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private helpers for pgcommon. Not part of the public API — do not import from outside this package."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# Matches the password segment of a URI-style DSN: scheme://user:PASSWORD@host
|
|
6
|
+
_URI_PASSWORD = re.compile(r"(://[^:/?#@]*:)([^@/?#]*)(@)")
|
|
7
|
+
# Matches ` password=... ` or `password='...'` in keyword/value DSNs.
|
|
8
|
+
_KV_PASSWORD = re.compile(r"(?i)(\bpassword\s*=\s*)('(?:[^'\\]|\\.)*'|\S+)")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def mask_dsn(dsn: str) -> str:
|
|
12
|
+
"""Replace the password segment of a DSN with '***' so it is safe to include in error messages."""
|
|
13
|
+
masked = _URI_PASSWORD.sub(r"\1***\3", dsn)
|
|
14
|
+
masked = _KV_PASSWORD.sub(r"\1***", masked)
|
|
15
|
+
return masked
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
_TOKEN = re.compile(r"([0-9]*\.?[0-9]+)(ns|us|µs|ms|s|m|h)")
|
|
7
|
+
_UNIT_SECONDS: dict[str, float] = {
|
|
8
|
+
"ns": 1e-9,
|
|
9
|
+
"us": 1e-6,
|
|
10
|
+
"µs": 1e-6,
|
|
11
|
+
"ms": 1e-3,
|
|
12
|
+
"s": 1.0,
|
|
13
|
+
"m": 60.0,
|
|
14
|
+
"h": 3600.0,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_duration(value: str) -> timedelta:
|
|
19
|
+
"""Parse a Go `time.ParseDuration`-style string ("200ms", "1h30m") into a `timedelta`."""
|
|
20
|
+
text = value.strip()
|
|
21
|
+
if not text:
|
|
22
|
+
raise ValueError("empty duration string")
|
|
23
|
+
|
|
24
|
+
pos = 0
|
|
25
|
+
total_seconds = 0.0
|
|
26
|
+
matched_any = False
|
|
27
|
+
for m in _TOKEN.finditer(text):
|
|
28
|
+
if m.start() != pos:
|
|
29
|
+
raise ValueError(f"invalid duration {value!r}")
|
|
30
|
+
pos = m.end()
|
|
31
|
+
num, unit = m.groups()
|
|
32
|
+
total_seconds += float(num) * _UNIT_SECONDS[unit]
|
|
33
|
+
matched_any = True
|
|
34
|
+
|
|
35
|
+
if not matched_any or pos != len(text):
|
|
36
|
+
raise ValueError(f"invalid duration {value!r}")
|
|
37
|
+
|
|
38
|
+
return timedelta(seconds=total_seconds)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class QueryEvent(Protocol):
|
|
9
|
+
"""Structural shape of asyncpg's `Connection.add_query_logger` callback record.
|
|
10
|
+
|
|
11
|
+
asyncpg has no pre/post-query hook pair like pgx's `QueryTracer` — instead a single
|
|
12
|
+
callback fires after each query completes, carrying elapsed time and any exception. Spans
|
|
13
|
+
are reconstructed after the fact using `elapsed` to back-date `start_time`; see
|
|
14
|
+
`pgcommon.metrics.OTelQueryRecorder`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
query: str
|
|
18
|
+
args: Sequence[object] | None
|
|
19
|
+
elapsed: float
|
|
20
|
+
exception: BaseException | None
|
|
21
|
+
conn_addr: tuple[str, int] | str | None
|
|
22
|
+
conn_params: object
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
_KNOWN_VERBS = frozenset(
|
|
4
|
+
{
|
|
5
|
+
"SELECT",
|
|
6
|
+
"INSERT",
|
|
7
|
+
"UPDATE",
|
|
8
|
+
"DELETE",
|
|
9
|
+
"BEGIN",
|
|
10
|
+
"COMMIT",
|
|
11
|
+
"ROLLBACK",
|
|
12
|
+
"SAVEPOINT",
|
|
13
|
+
"RELEASE",
|
|
14
|
+
"CREATE",
|
|
15
|
+
"ALTER",
|
|
16
|
+
"DROP",
|
|
17
|
+
"TRUNCATE",
|
|
18
|
+
"WITH",
|
|
19
|
+
"EXPLAIN",
|
|
20
|
+
"COPY",
|
|
21
|
+
"SET",
|
|
22
|
+
"GRANT",
|
|
23
|
+
"REVOKE",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sql_operation(sql: str) -> str:
|
|
29
|
+
"""Extract the leading SQL verb from a statement, for use as a bounded-cardinality metric/span label.
|
|
30
|
+
|
|
31
|
+
Never returns raw SQL — this is what keeps `pgcommon_query_total{operation}` and
|
|
32
|
+
`db.statement` (when full statements are not allowed) free of PII and unbounded cardinality.
|
|
33
|
+
"""
|
|
34
|
+
stripped = sql.lstrip()
|
|
35
|
+
first_word = stripped.split(None, 1)[0].upper() if stripped else ""
|
|
36
|
+
return first_word if first_word in _KNOWN_VERBS else "other"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
UNIQUE_VIOLATION = "23505"
|
|
4
|
+
FOREIGN_KEY_VIOLATION = "23503"
|
|
5
|
+
NOT_NULL_VIOLATION = "23502"
|
|
6
|
+
CHECK_VIOLATION = "23514"
|
|
7
|
+
DEADLOCK_DETECTED = "40P01"
|
|
8
|
+
SERIALIZATION_FAILURE = "40001"
|
|
9
|
+
|
|
10
|
+
RETRYABLE = frozenset({DEADLOCK_DETECTED, SERIALIZATION_FAILURE})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sqlstate_of(exc: BaseException) -> str | None:
|
|
14
|
+
"""Return the SQLSTATE code carried by a PostgresError-like exception, if any."""
|
|
15
|
+
return getattr(exc, "sqlstate", None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def constraint_name_of(exc: BaseException) -> str:
|
|
19
|
+
"""Return the offending constraint name carried by a PostgresError-like exception, or ""."""
|
|
20
|
+
return getattr(exc, "constraint_name", None) or ""
|
pgcommon/config.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Pool configuration: the `Config` dataclass, `config_from_env()`, and `ConfigWarning`.
|
|
2
|
+
|
|
3
|
+
Pure and I/O-free except for `config_from_env`, which reads `os.environ` — mirroring the Go
|
|
4
|
+
repo's `internal/config.Load()`. No network or database access happens here; `Pool.create`
|
|
5
|
+
is what fails fast against a live connection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from pgcommon._internal.duration import parse_duration
|
|
17
|
+
from pgcommon.errors import (
|
|
18
|
+
DSNRequiredError,
|
|
19
|
+
InvalidMaxConnsError,
|
|
20
|
+
InvalidMinConnsError,
|
|
21
|
+
MinConnsExceedsMaxError,
|
|
22
|
+
)
|
|
23
|
+
from pgcommon.guc import GUCSet
|
|
24
|
+
from pgcommon.logger import Logger
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from opentelemetry.trace import Tracer
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Config",
|
|
31
|
+
"ConfigWarning",
|
|
32
|
+
"config_from_env",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_DEFAULT_MAX_CONNS = 10
|
|
36
|
+
_DEFAULT_MIN_CONNS = 2
|
|
37
|
+
_DEFAULT_MAX_CONN_LIFETIME = timedelta(hours=1)
|
|
38
|
+
_DEFAULT_MAX_CONN_LIFETIME_JITTER = timedelta(0)
|
|
39
|
+
_DEFAULT_MAX_CONN_IDLE_TIME = timedelta(minutes=30)
|
|
40
|
+
_DEFAULT_HEALTH_CHECK_PERIOD = timedelta(minutes=1)
|
|
41
|
+
_DEFAULT_SLOW_QUERY_THRESHOLD = timedelta(milliseconds=200)
|
|
42
|
+
_INSECURE_SSLMODES = frozenset({"disable", "allow", "prefer"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class Config:
|
|
47
|
+
"""Pool configuration. `dsn` is the only required field."""
|
|
48
|
+
|
|
49
|
+
dsn: str
|
|
50
|
+
max_conns: int = _DEFAULT_MAX_CONNS
|
|
51
|
+
min_conns: int = _DEFAULT_MIN_CONNS
|
|
52
|
+
max_conn_lifetime: timedelta = _DEFAULT_MAX_CONN_LIFETIME
|
|
53
|
+
max_conn_lifetime_jitter: timedelta = _DEFAULT_MAX_CONN_LIFETIME_JITTER
|
|
54
|
+
max_conn_idle_time: timedelta = _DEFAULT_MAX_CONN_IDLE_TIME
|
|
55
|
+
health_check_period: timedelta = _DEFAULT_HEALTH_CHECK_PERIOD
|
|
56
|
+
slow_query_threshold: timedelta = _DEFAULT_SLOW_QUERY_THRESHOLD
|
|
57
|
+
logger: Logger | None = None
|
|
58
|
+
tracer: Tracer | None = None
|
|
59
|
+
guc_provider: Callable[[], GUCSet | None] | None = None
|
|
60
|
+
pgbouncer_mode: bool = False
|
|
61
|
+
log_tenant_id: bool = False
|
|
62
|
+
allow_full_statements: bool = False
|
|
63
|
+
|
|
64
|
+
def validate(self) -> None:
|
|
65
|
+
"""Raise `DSNRequiredError` / `InvalidMaxConnsError` / `InvalidMinConnsError` /
|
|
66
|
+
`MinConnsExceedsMaxError`.
|
|
67
|
+
|
|
68
|
+
Called by `Pool.create` before any connection is attempted — misconfiguration is a
|
|
69
|
+
startup error, never a runtime surprise.
|
|
70
|
+
"""
|
|
71
|
+
if not self.dsn:
|
|
72
|
+
raise DSNRequiredError("Config.dsn is required")
|
|
73
|
+
if self.max_conns < 0:
|
|
74
|
+
raise InvalidMaxConnsError(f"max_conns must be >= 0, got {self.max_conns}")
|
|
75
|
+
if self.min_conns < 0:
|
|
76
|
+
raise InvalidMinConnsError(f"min_conns must be >= 0, got {self.min_conns}")
|
|
77
|
+
if self.min_conns > self.max_conns:
|
|
78
|
+
raise MinConnsExceedsMaxError(
|
|
79
|
+
f"min_conns ({self.min_conns}) exceeds max_conns ({self.max_conns})"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class ConfigWarning:
|
|
85
|
+
"""A non-fatal configuration issue surfaced by `config_from_env` — log these at startup."""
|
|
86
|
+
|
|
87
|
+
key: str
|
|
88
|
+
reason: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_positive_int(env_var: str, default: int) -> tuple[int, ConfigWarning | None]:
|
|
92
|
+
raw = os.environ.get(env_var)
|
|
93
|
+
if raw is None:
|
|
94
|
+
return default, None
|
|
95
|
+
try:
|
|
96
|
+
parsed = int(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return default, ConfigWarning(
|
|
99
|
+
key=env_var, reason=f"invalid integer {raw!r}, using default {default}"
|
|
100
|
+
)
|
|
101
|
+
if parsed < 0:
|
|
102
|
+
return default, ConfigWarning(
|
|
103
|
+
key=env_var, reason=f"value {parsed} must be >= 0, using default {default}"
|
|
104
|
+
)
|
|
105
|
+
return parsed, None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_duration_with_warning(
|
|
109
|
+
env_var: str, default: timedelta
|
|
110
|
+
) -> tuple[timedelta, ConfigWarning | None]:
|
|
111
|
+
raw = os.environ.get(env_var)
|
|
112
|
+
if raw is None:
|
|
113
|
+
return default, None
|
|
114
|
+
try:
|
|
115
|
+
return parse_duration(raw), None
|
|
116
|
+
except ValueError:
|
|
117
|
+
return default, ConfigWarning(
|
|
118
|
+
key=env_var, reason=f"invalid duration {raw!r}, using default {default}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_bool(env_var: str, default: bool) -> bool:
|
|
123
|
+
raw = os.environ.get(env_var)
|
|
124
|
+
if raw is None:
|
|
125
|
+
return default
|
|
126
|
+
return raw.strip().lower() in ("1", "true", "t", "yes", "y", "on")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_dsn() -> tuple[str, ConfigWarning | None]:
|
|
130
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
131
|
+
if database_url:
|
|
132
|
+
return database_url, None
|
|
133
|
+
|
|
134
|
+
host = os.environ.get("PG_HOST", "localhost")
|
|
135
|
+
port = os.environ.get("PG_PORT", "5432")
|
|
136
|
+
user = os.environ.get("PG_USER", "")
|
|
137
|
+
password = os.environ.get("PG_PASSWORD", "")
|
|
138
|
+
dbname = os.environ.get("PG_DBNAME", "")
|
|
139
|
+
sslmode = os.environ.get("PG_SSLMODE", "require")
|
|
140
|
+
|
|
141
|
+
userinfo = f"{user}:{password}@" if user or password else ""
|
|
142
|
+
dsn = f"postgres://{userinfo}{host}:{port}/{dbname}?sslmode={sslmode}"
|
|
143
|
+
return dsn, None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _sslmode_of(dsn: str) -> str | None:
|
|
147
|
+
if "sslmode=" not in dsn:
|
|
148
|
+
return None
|
|
149
|
+
tail = dsn.split("sslmode=", 1)[1]
|
|
150
|
+
for sep in ("&", " "):
|
|
151
|
+
tail = tail.split(sep, 1)[0]
|
|
152
|
+
return tail
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def config_from_env() -> tuple[Config, list[ConfigWarning]]:
|
|
156
|
+
"""Build a `Config` from `DATABASE_URL` / `PG_*` environment variables.
|
|
157
|
+
|
|
158
|
+
Mirrors the Go repo's `internal/config.Load()`: invalid integers/durations fall back to
|
|
159
|
+
their defaults with a `ConfigWarning` rather than raising, and an insecure `sslmode`
|
|
160
|
+
(`disable`, `allow`, `prefer`) always produces a warning. Log every returned warning at
|
|
161
|
+
startup so accidental TLS disablement or a typo'd tuning value is visible.
|
|
162
|
+
"""
|
|
163
|
+
warnings: list[ConfigWarning] = []
|
|
164
|
+
|
|
165
|
+
dsn, dsn_warning = _build_dsn()
|
|
166
|
+
if dsn_warning:
|
|
167
|
+
warnings.append(dsn_warning)
|
|
168
|
+
|
|
169
|
+
max_conns, w = _parse_positive_int("PG_MAX_CONNS", _DEFAULT_MAX_CONNS)
|
|
170
|
+
if w:
|
|
171
|
+
warnings.append(w)
|
|
172
|
+
min_conns, w = _parse_positive_int("PG_MIN_CONNS", _DEFAULT_MIN_CONNS)
|
|
173
|
+
if w:
|
|
174
|
+
warnings.append(w)
|
|
175
|
+
max_conn_lifetime, w = _parse_duration_with_warning(
|
|
176
|
+
"PG_MAX_CONN_LIFETIME", _DEFAULT_MAX_CONN_LIFETIME
|
|
177
|
+
)
|
|
178
|
+
if w:
|
|
179
|
+
warnings.append(w)
|
|
180
|
+
max_conn_lifetime_jitter, w = _parse_duration_with_warning(
|
|
181
|
+
"PG_MAX_CONN_LIFETIME_JITTER", _DEFAULT_MAX_CONN_LIFETIME_JITTER
|
|
182
|
+
)
|
|
183
|
+
if w:
|
|
184
|
+
warnings.append(w)
|
|
185
|
+
max_conn_idle_time, w = _parse_duration_with_warning(
|
|
186
|
+
"PG_MAX_CONN_IDLE_TIME", _DEFAULT_MAX_CONN_IDLE_TIME
|
|
187
|
+
)
|
|
188
|
+
if w:
|
|
189
|
+
warnings.append(w)
|
|
190
|
+
health_check_period, w = _parse_duration_with_warning(
|
|
191
|
+
"PG_HEALTH_CHECK_PERIOD", _DEFAULT_HEALTH_CHECK_PERIOD
|
|
192
|
+
)
|
|
193
|
+
if w:
|
|
194
|
+
warnings.append(w)
|
|
195
|
+
slow_query_threshold, w = _parse_duration_with_warning(
|
|
196
|
+
"PG_SLOW_QUERY_THRESHOLD", _DEFAULT_SLOW_QUERY_THRESHOLD
|
|
197
|
+
)
|
|
198
|
+
if w:
|
|
199
|
+
warnings.append(w)
|
|
200
|
+
pgbouncer_mode = _parse_bool("PG_BOUNCER_MODE", False)
|
|
201
|
+
|
|
202
|
+
sslmode = os.environ.get("PG_SSLMODE") or _sslmode_of(dsn)
|
|
203
|
+
if sslmode and sslmode.lower() in _INSECURE_SSLMODES:
|
|
204
|
+
warnings.append(
|
|
205
|
+
ConfigWarning(
|
|
206
|
+
key="PG_SSLMODE",
|
|
207
|
+
reason=f"sslmode={sslmode!r} does not enforce TLS — use 'require' or stronger in production",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
cfg = Config(
|
|
212
|
+
dsn=dsn,
|
|
213
|
+
max_conns=max_conns,
|
|
214
|
+
min_conns=min_conns,
|
|
215
|
+
max_conn_lifetime=max_conn_lifetime,
|
|
216
|
+
max_conn_lifetime_jitter=max_conn_lifetime_jitter,
|
|
217
|
+
max_conn_idle_time=max_conn_idle_time,
|
|
218
|
+
health_check_period=health_check_period,
|
|
219
|
+
slow_query_threshold=slow_query_threshold,
|
|
220
|
+
pgbouncer_mode=pgbouncer_mode,
|
|
221
|
+
)
|
|
222
|
+
return cfg, warnings
|
pgcommon/errors.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Sentinel exceptions and PostgreSQL error-classification helpers.
|
|
2
|
+
|
|
3
|
+
Mirrors the Go repo's `domain.Err*` sentinels (check with `isinstance`, the Python
|
|
4
|
+
equivalent of `errors.Is`) and the `Is*Violation` / `ConstraintName` helper functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pgcommon._internal.sqlstate import (
|
|
10
|
+
CHECK_VIOLATION,
|
|
11
|
+
DEADLOCK_DETECTED,
|
|
12
|
+
FOREIGN_KEY_VIOLATION,
|
|
13
|
+
NOT_NULL_VIOLATION,
|
|
14
|
+
SERIALIZATION_FAILURE,
|
|
15
|
+
UNIQUE_VIOLATION,
|
|
16
|
+
constraint_name_of,
|
|
17
|
+
sqlstate_of,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DSNRequiredError",
|
|
22
|
+
"EmptyTenantRoleError",
|
|
23
|
+
"InconsistentGUCSetError",
|
|
24
|
+
"InvalidMaxConnsError",
|
|
25
|
+
"InvalidMinConnsError",
|
|
26
|
+
"InvalidSavepointNameError",
|
|
27
|
+
"InvalidTenantRoleError",
|
|
28
|
+
"MigrationDirtyError",
|
|
29
|
+
"MinConnsExceedsMaxError",
|
|
30
|
+
"NoMigrationsFoundError",
|
|
31
|
+
"PgCommonError",
|
|
32
|
+
"PoolClosedError",
|
|
33
|
+
"constraint_name",
|
|
34
|
+
"is_check_violation",
|
|
35
|
+
"is_deadlock",
|
|
36
|
+
"is_foreign_key_violation",
|
|
37
|
+
"is_not_null_violation",
|
|
38
|
+
"is_serialization_failure",
|
|
39
|
+
"is_unique_violation",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PgCommonError(Exception):
|
|
44
|
+
"""Base class for every sentinel error raised by pgcommon."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DSNRequiredError(PgCommonError):
|
|
48
|
+
"""Raised by `Pool.create` / `Config` validation when no DSN is configured."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PoolClosedError(PgCommonError):
|
|
52
|
+
"""Raised by `with_conn` / `health` / `ping` after the pool has been closed."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvalidMaxConnsError(PgCommonError):
|
|
56
|
+
"""Raised when `Config.max_conns` is negative."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InvalidMinConnsError(PgCommonError):
|
|
60
|
+
"""Raised when `Config.min_conns` is negative."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MinConnsExceedsMaxError(PgCommonError):
|
|
64
|
+
"""Raised when `Config.min_conns > Config.max_conns` after defaults are applied."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InvalidSavepointNameError(PgCommonError):
|
|
68
|
+
"""Raised by `run_in_savepoint` when the name fails `^[A-Za-z_][A-Za-z0-9_]*$`."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InconsistentGUCSetError(PgCommonError):
|
|
72
|
+
"""Raised by `GUCSet.validate()` when `user_id` is set but `tenant_id` is empty."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidTenantRoleError(PgCommonError):
|
|
76
|
+
"""Raised by `GUCSet.validate()` when a tenant role contains a comma (the GUC separator)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EmptyTenantRoleError(PgCommonError):
|
|
80
|
+
"""Raised by `GUCSet.validate()` when a tenant role is the empty string."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MigrationDirtyError(PgCommonError):
|
|
84
|
+
"""Raised when the schema is left dirty by a previously failed migration run."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NoMigrationsFoundError(PgCommonError):
|
|
88
|
+
"""Raised when the migration source directory has no matching `*.sql` files."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_unique_violation(exc: BaseException) -> bool:
|
|
92
|
+
"""True when `exc` carries SQLSTATE 23505 (duplicate value in a UNIQUE / PRIMARY KEY column)."""
|
|
93
|
+
return sqlstate_of(exc) == UNIQUE_VIOLATION
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_foreign_key_violation(exc: BaseException) -> bool:
|
|
97
|
+
"""True when `exc` carries SQLSTATE 23503 (referenced row does not exist)."""
|
|
98
|
+
return sqlstate_of(exc) == FOREIGN_KEY_VIOLATION
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_not_null_violation(exc: BaseException) -> bool:
|
|
102
|
+
"""True when `exc` carries SQLSTATE 23502 (NULL inserted into a NOT NULL column)."""
|
|
103
|
+
return sqlstate_of(exc) == NOT_NULL_VIOLATION
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_check_violation(exc: BaseException) -> bool:
|
|
107
|
+
"""True when `exc` carries SQLSTATE 23514 (value failed a CHECK constraint)."""
|
|
108
|
+
return sqlstate_of(exc) == CHECK_VIOLATION
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_deadlock(exc: BaseException) -> bool:
|
|
112
|
+
"""True when `exc` carries SQLSTATE 40P01 (deadlock detected between transactions)."""
|
|
113
|
+
return sqlstate_of(exc) == DEADLOCK_DETECTED
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_serialization_failure(exc: BaseException) -> bool:
|
|
117
|
+
"""True when `exc` carries SQLSTATE 40001 (SERIALIZABLE / REPEATABLE READ write conflict)."""
|
|
118
|
+
return sqlstate_of(exc) == SERIALIZATION_FAILURE
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def constraint_name(exc: BaseException) -> str:
|
|
122
|
+
"""Return the offending constraint name for a PostgreSQL error, or "" if not applicable."""
|
|
123
|
+
return constraint_name_of(exc)
|