pgcommon 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → pgcommon-0.2.0}/.github/workflows/ci.yml +13 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/ARCHITECTURE.md +3 -3
- pgcommon-0.2.0/CHANGELOG.md +61 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/PKG-INFO +5 -3
- {pgcommon-0.1.0 → pgcommon-0.2.0}/README.md +4 -2
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/runintx-lifecycle.mmd +1 -1
- {pgcommon-0.1.0 → pgcommon-0.2.0}/pyproject.toml +1 -1
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/config.py +47 -19
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/errors.py +9 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/runner.py +31 -9
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/retry.py +4 -1
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fake_migrate_conn.py +35 -5
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_migrate_runner_integration.py +5 -3
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_config.py +69 -3
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_migrate_runner.py +60 -1
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_retry.py +11 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/uv.lock +1 -1
- pgcommon-0.1.0/CHANGELOG.md +0 -29
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.env-example +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/CODEOWNERS +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/SECURITY.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/dependabot.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/pull_request_template.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/quality.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/release.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/test.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.github/workflows/validate.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/.gitignore +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/CONTRIBUTING.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/Dockerfile +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/LICENSE +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/Makefile +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/SECURITY.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/VERSIONING.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docker-compose.yml +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/Architecture.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/MigrationGuide.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-001-contextvars-guc-propagation.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-002-cancellation-and-rollback-safety.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-003-guc-injection-timing.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/adr/ADR-004-migration-runner-design.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/README.md +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/consuming-service-wiring.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/documentation-assets.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/layer-model.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/migration-runner.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/observability-stack.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/pool-create-hook-composition.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/withconn-standard-mode.mmd +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/migrations/0001_create_example_table.down.sql +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/migrations/0001_create_example_table.up.sql +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/dsn.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/duration.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/query_event.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/sql_verb.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/_internal/sqlstate.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/guc.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/integrations/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/integrations/fastapi.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/logger.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/metrics.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/__main__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/cli.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/migrate/errors.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/pool.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/py.typed +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/tracer.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/src/pgcommon/tx.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/conftest.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/e2e/test_rls_pgbouncer_e2e.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fake_asyncpg.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/fixtures/fakes.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/conftest.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_concurrency_stress.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_error_helpers_real_violations.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_pool_lifecycle.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_retry_real_serialization_failure.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_rls_guc_injection.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_tx_and_savepoint.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/__init__.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_errors.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_guc.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_integrations_fastapi.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_internal.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_logger.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_metrics.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_migrate_cli.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_pool.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_tracer.py +0 -0
- {pgcommon-0.1.0 → pgcommon-0.2.0}/tests/unit/test_tx.py +0 -0
|
@@ -78,6 +78,19 @@ jobs:
|
|
|
78
78
|
path: dist/
|
|
79
79
|
if-no-files-found: error
|
|
80
80
|
|
|
81
|
+
interop:
|
|
82
|
+
name: Cross-language compatibility
|
|
83
|
+
needs: build
|
|
84
|
+
uses: BCBP-SOLUTIONS-FZC-LLC/platform-interop-tests/.github/workflows/interop.yml@main
|
|
85
|
+
with:
|
|
86
|
+
# This repo's own PR/branch ref; the other five default to their main branch (see
|
|
87
|
+
# platform-interop-tests/README.md for why).
|
|
88
|
+
platform_pgcommon_py_ref: ${{ github.head_ref || github.ref_name }}
|
|
89
|
+
secrets:
|
|
90
|
+
# Reuses the org's existing GO_PRIVATE_TOKEN (Contents:Read across the six sibling
|
|
91
|
+
# repos) rather than provisioning a separate GH_PRIVATE_TOKEN for this repo alone.
|
|
92
|
+
gh_private_token: ${{ secrets.GO_PRIVATE_TOKEN }}
|
|
93
|
+
|
|
81
94
|
lint-dockerfile:
|
|
82
95
|
name: Lint Dockerfile
|
|
83
96
|
runs-on: ubuntu-latest
|
|
@@ -277,7 +277,7 @@ sequenceDiagram
|
|
|
277
277
|
run_in_tx ->> pool: pool._acquire() — shared acquire primitive (also used by with_conn)
|
|
278
278
|
|
|
279
279
|
opt pgbouncer_mode AND guc_provider set and GUCSet non-zero
|
|
280
|
-
run_in_tx ->> run_in_tx: guc_provider() -> GUCSet
|
|
280
|
+
run_in_tx ->> run_in_tx: guc_provider() -> GUCSet, guc_set.validate() [before BEGIN]
|
|
281
281
|
end
|
|
282
282
|
|
|
283
283
|
run_in_tx ->>+ DB: conn.transaction(isolation=..., readonly=...) — BEGIN
|
|
@@ -359,7 +359,7 @@ flowchart TD
|
|
|
359
359
|
T -- "up/down/steps" --> B["_load_migrations(source)\nparse NNNN_description.{up,down}.sql\nraise NoMigrationsFoundError if none match"]
|
|
360
360
|
B --> C1["connect() -> SET lock_timeout = '30s'"]
|
|
361
361
|
C1 --> D1["SELECT pg_advisory_lock(key)"]
|
|
362
|
-
D1 --> E1["CREATE TABLE IF NOT EXISTS
|
|
362
|
+
D1 --> E1["CREATE TABLE IF NOT EXISTS pgcommon_migrations\n(only ever run while the lock is held —\nconcurrent CREATE TABLE IF NOT EXISTS is not race-safe in Postgres)"]
|
|
363
363
|
E1 --> F{"dirty flag set\nfrom a previous run?"}
|
|
364
364
|
F -- yes --> G([raise MigrationDirtyError])
|
|
365
365
|
F -- no --> H["apply/roll back the requested migrations,\none at a time, each in its own transaction"]
|
|
@@ -372,7 +372,7 @@ flowchart TD
|
|
|
372
372
|
|
|
373
373
|
T -- "version" --> C2["connect() -> SET lock_timeout = '30s'\n(never calls _load_migrations — cannot raise NoMigrationsFoundError)"]
|
|
374
374
|
C2 --> D2["SELECT pg_advisory_lock(key)"]
|
|
375
|
-
D2 --> E2["CREATE TABLE IF NOT EXISTS
|
|
375
|
+
D2 --> E2["CREATE TABLE IF NOT EXISTS pgcommon_migrations\n(same race-avoidance as the up/down/steps path)"]
|
|
376
376
|
E2 --> M["read (version, dirty) — returned as-is,\nno dirty check, never raises MigrationDirtyError"]
|
|
377
377
|
M --> K2["SELECT pg_advisory_unlock(key) [always, in a finally block]"]
|
|
378
378
|
K2 --> L
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [semantic versioning](VERSIONING.md).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-07-27
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `migrate.Runner` accepts an optional `table` constructor parameter (default
|
|
12
|
+
`"pgcommon_migrations"`) naming the version-tracking table, validated against
|
|
13
|
+
`^[A-Za-z_][A-Za-z0-9_]*$`. Lets a library that ships its own migrations (e.g.
|
|
14
|
+
`eventcommon`'s outbox schema) track its version state in an isolated table, independent of
|
|
15
|
+
a consuming service's own `schema_migrations`/dirty-state. New `InvalidMigrationsTableError`
|
|
16
|
+
sentinel raised for an invalid name.
|
|
17
|
+
|
|
18
|
+
### Fixed — cross-language compatibility with the Go `platform-pgcommon` library
|
|
19
|
+
|
|
20
|
+
- `migrate.Runner`'s default tracking table is `"pgcommon_migrations"`, not `"schema_migrations"`
|
|
21
|
+
— matching the Go library's own default, which is deliberately not `schema_migrations` so this
|
|
22
|
+
runner never collides with a consuming service's own `golang-migrate`-tracked table. Pass
|
|
23
|
+
`table="schema_migrations"` explicitly to adopt an existing `golang-migrate` deployment.
|
|
24
|
+
- `config_from_env()` now warns (`key="PG_USER/PG_DBNAME"`) and leaves `Config.dsn` empty when
|
|
25
|
+
`PG_USER`/`PG_DBNAME` are missing and `DATABASE_URL` is unset, instead of silently building an
|
|
26
|
+
unusable DSN — matching Go's `internal/config.Load()`.
|
|
27
|
+
- `PG_MAX_CONNS`/`PG_MIN_CONNS` now reject `0` (not just negative values), matching Go's
|
|
28
|
+
`parsePositiveInt32`.
|
|
29
|
+
- The insecure-`sslmode` warning is now keyed `"DATABASE_URL"` (and sourced only from the DSN's
|
|
30
|
+
own `sslmode` query parameter) when `DATABASE_URL` is set, matching Go's mutually-exclusive
|
|
31
|
+
branching — previously it was always keyed `"PG_SSLMODE"` and could miss a warning Go would
|
|
32
|
+
have emitted, or emit one Go wouldn't.
|
|
33
|
+
- `PG_BOUNCER_MODE` now matches only the exact string `"true"` (case-sensitive, no trimming),
|
|
34
|
+
matching Go's `os.Getenv("PG_BOUNCER_MODE") == "true"` — previously accepted a wider set of
|
|
35
|
+
truthy spellings (`"1"`, `"yes"`, `"on"`, ...) that Go's own config loader does not.
|
|
36
|
+
- `RetryOptions.multiplier < 1` is now clamped up to `1` (constant wait) instead of producing a
|
|
37
|
+
shrinking backoff, matching Go's `backoffWait`.
|
|
38
|
+
|
|
39
|
+
## [0.1.0] - 2026-07-26
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Licensed under Apache-2.0 (was `Proprietary`) and published to public PyPI as `pgcommon`
|
|
44
|
+
via Trusted Publishing — the source repository itself stays private. See
|
|
45
|
+
[VERSIONING.md § Publishing to PyPI](VERSIONING.md#publishing-to-pypi).
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- Initial Python port of `platform-pgcommon` (Go) for `asyncio`/FastAPI services, built on `asyncpg`.
|
|
50
|
+
- `Config`, `config_from_env`, `ConfigWarning` (`pgcommon.config`).
|
|
51
|
+
- Sentinel errors and SQLSTATE helpers (`pgcommon.errors`).
|
|
52
|
+
- `GUCSet`, contextvars-based propagation, `inject_gucs` (`pgcommon.guc`).
|
|
53
|
+
- `Pool` — fail-fast construction, `with_conn`, `health`, `stat`, `drain_and_close` (`pgcommon.pool`).
|
|
54
|
+
- `run_in_tx`, `run_in_savepoint` (`pgcommon.tx`).
|
|
55
|
+
- `RetryOptions`, `run_in_tx_with_retry`, `run_in_tx_with_retry_opts` (`pgcommon.retry`).
|
|
56
|
+
- `SlowQueryTracer` (`pgcommon.tracer`).
|
|
57
|
+
- Prometheus metrics + `OTelQueryRecorder` (`pgcommon.metrics`).
|
|
58
|
+
- Dependency-light `.sql` migration `Runner` + `pgcommon-migrate` console script (`pgcommon.migrate`).
|
|
59
|
+
- Optional FastAPI integration: lifespan pool wiring, `GUCAuthMiddleware`, `/healthz` helper (`pgcommon.integrations.fastapi`, `pgcommon[fastapi]` extra).
|
|
60
|
+
- Optional `structlog` `Logger` adapter (`pgcommon[structlog]` extra).
|
|
61
|
+
- Unit, integration (testcontainers Postgres), and e2e (testcontainers Postgres + PgBouncer) test suites.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgcommon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Opinionated async PostgreSQL access layer for platform services — RLS tenancy, retries, and observability by default.
|
|
5
5
|
Project-URL: Homepage, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
|
|
6
6
|
Project-URL: Repository, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
|
|
@@ -835,7 +835,7 @@ try:
|
|
|
835
835
|
await runner.up() # apply all pending migrations
|
|
836
836
|
except MigrationDirtyError:
|
|
837
837
|
# A previous migration failed mid-run. Fix the migration and manually
|
|
838
|
-
# clear the
|
|
838
|
+
# clear the pgcommon_migrations.dirty flag before retrying.
|
|
839
839
|
raise
|
|
840
840
|
|
|
841
841
|
version, dirty = await runner.version() # report current schema version
|
|
@@ -843,10 +843,12 @@ await runner.down(1) # roll back one step
|
|
|
843
843
|
await runner.steps(2) # apply exactly two steps forward (or -2 to roll back two)
|
|
844
844
|
```
|
|
845
845
|
|
|
846
|
-
Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `
|
|
846
|
+
Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `pgcommon_migrations(version bigint, dirty boolean)` table, created automatically if absent. The default is deliberately *not* `schema_migrations` — matching the Go `pgcommon` library's own default — so this runner never collides with a consuming service's own `golang-migrate`-tracked `schema_migrations` table.
|
|
847
847
|
|
|
848
848
|
> **Custom migration source:** pass any directory path as `Runner`'s second argument — it is read fresh on every `up`/`down`/`steps` call, so no code changes are needed to add migration files.
|
|
849
849
|
|
|
850
|
+
> **Isolated tracking table:** pass `table="my_table"` to track versions in a table other than the default — e.g. a library that ships its own migrations (bundled with the package, applied via its own `Runner` instance) uses a dedicated table so its version/dirty state never collides with the consuming service's own domain migrations run against the same database. Pass `table="schema_migrations"` explicitly to adopt an existing `golang-migrate`-based deployment's tracking table instead.
|
|
851
|
+
|
|
850
852
|
---
|
|
851
853
|
|
|
852
854
|
## Observability — Prometheus and OTel
|
|
@@ -809,7 +809,7 @@ try:
|
|
|
809
809
|
await runner.up() # apply all pending migrations
|
|
810
810
|
except MigrationDirtyError:
|
|
811
811
|
# A previous migration failed mid-run. Fix the migration and manually
|
|
812
|
-
# clear the
|
|
812
|
+
# clear the pgcommon_migrations.dirty flag before retrying.
|
|
813
813
|
raise
|
|
814
814
|
|
|
815
815
|
version, dirty = await runner.version() # report current schema version
|
|
@@ -817,10 +817,12 @@ await runner.down(1) # roll back one step
|
|
|
817
817
|
await runner.steps(2) # apply exactly two steps forward (or -2 to roll back two)
|
|
818
818
|
```
|
|
819
819
|
|
|
820
|
-
Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `
|
|
820
|
+
Migration files follow the `NNNN_description.up.sql` / `NNNN_description.down.sql` naming convention (see [`migrations/`](migrations/) for the shipped example pair). Applied versions are tracked in a `pgcommon_migrations(version bigint, dirty boolean)` table, created automatically if absent. The default is deliberately *not* `schema_migrations` — matching the Go `pgcommon` library's own default — so this runner never collides with a consuming service's own `golang-migrate`-tracked `schema_migrations` table.
|
|
821
821
|
|
|
822
822
|
> **Custom migration source:** pass any directory path as `Runner`'s second argument — it is read fresh on every `up`/`down`/`steps` call, so no code changes are needed to add migration files.
|
|
823
823
|
|
|
824
|
+
> **Isolated tracking table:** pass `table="my_table"` to track versions in a table other than the default — e.g. a library that ships its own migrations (bundled with the package, applied via its own `Runner` instance) uses a dedicated table so its version/dirty state never collides with the consuming service's own domain migrations run against the same database. Pass `table="schema_migrations"` explicitly to adopt an existing `golang-migrate`-based deployment's tracking table instead.
|
|
825
|
+
|
|
824
826
|
---
|
|
825
827
|
|
|
826
828
|
## Observability — Prometheus and OTel
|
|
@@ -7,7 +7,7 @@ sequenceDiagram
|
|
|
7
7
|
run_in_tx ->> pool: pool._acquire() — shared acquire primitive (also used by with_conn)
|
|
8
8
|
|
|
9
9
|
opt pgbouncer_mode AND guc_provider set and GUCSet non-zero
|
|
10
|
-
run_in_tx ->> run_in_tx: guc_provider() -> GUCSet
|
|
10
|
+
run_in_tx ->> run_in_tx: guc_provider() -> GUCSet, guc_set.validate() [before BEGIN]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
run_in_tx ->>+ DB: conn.transaction(isolation=..., readonly=...) — BEGIN
|
|
@@ -98,9 +98,9 @@ def _parse_positive_int(env_var: str, default: int) -> tuple[int, ConfigWarning
|
|
|
98
98
|
return default, ConfigWarning(
|
|
99
99
|
key=env_var, reason=f"invalid integer {raw!r}, using default {default}"
|
|
100
100
|
)
|
|
101
|
-
if parsed
|
|
101
|
+
if parsed <= 0:
|
|
102
102
|
return default, ConfigWarning(
|
|
103
|
-
key=env_var, reason=f"value {parsed} must be
|
|
103
|
+
key=env_var, reason=f"value {parsed} must be positive, using default {default}"
|
|
104
104
|
)
|
|
105
105
|
return parsed, None
|
|
106
106
|
|
|
@@ -119,17 +119,32 @@ def _parse_duration_with_warning(
|
|
|
119
119
|
)
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
def _is_pgbouncer_mode() -> bool:
|
|
123
|
+
# Exact-string match, no trimming/case-folding — mirrors Go's `os.Getenv(...) == "true"`
|
|
124
|
+
# precisely, since a shared .env file/Helm value must behave identically for either language.
|
|
125
|
+
return os.environ.get("PG_BOUNCER_MODE") == "true"
|
|
126
|
+
|
|
127
127
|
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class _DSNResult:
|
|
130
|
+
dsn: str
|
|
131
|
+
warning: ConfigWarning | None
|
|
132
|
+
# sslmode + the warning key to use if it's insecure — kept alongside the DSN because Go's
|
|
133
|
+
# two DSN-construction branches (DATABASE_URL vs PG_* parts) each have their own,
|
|
134
|
+
# mutually-exclusive sslmode source and warning key; there's no single global sslmode check.
|
|
135
|
+
sslmode: str | None
|
|
136
|
+
sslmode_warning_key: str
|
|
128
137
|
|
|
129
|
-
|
|
138
|
+
|
|
139
|
+
def _build_dsn() -> _DSNResult:
|
|
130
140
|
database_url = os.environ.get("DATABASE_URL")
|
|
131
141
|
if database_url:
|
|
132
|
-
return
|
|
142
|
+
return _DSNResult(
|
|
143
|
+
dsn=database_url,
|
|
144
|
+
warning=None,
|
|
145
|
+
sslmode=_sslmode_of(database_url),
|
|
146
|
+
sslmode_warning_key="DATABASE_URL",
|
|
147
|
+
)
|
|
133
148
|
|
|
134
149
|
host = os.environ.get("PG_HOST", "localhost")
|
|
135
150
|
port = os.environ.get("PG_PORT", "5432")
|
|
@@ -138,9 +153,21 @@ def _build_dsn() -> tuple[str, ConfigWarning | None]:
|
|
|
138
153
|
dbname = os.environ.get("PG_DBNAME", "")
|
|
139
154
|
sslmode = os.environ.get("PG_SSLMODE", "require")
|
|
140
155
|
|
|
141
|
-
|
|
156
|
+
if not user or not dbname:
|
|
157
|
+
return _DSNResult(
|
|
158
|
+
dsn="",
|
|
159
|
+
warning=ConfigWarning(
|
|
160
|
+
key="PG_USER/PG_DBNAME",
|
|
161
|
+
reason="both PG_USER and PG_DBNAME are required to build a DSN; database "
|
|
162
|
+
"connections will fail",
|
|
163
|
+
),
|
|
164
|
+
sslmode=sslmode,
|
|
165
|
+
sslmode_warning_key="PG_SSLMODE",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
userinfo = f"{user}:{password}@"
|
|
142
169
|
dsn = f"postgres://{userinfo}{host}:{port}/{dbname}?sslmode={sslmode}"
|
|
143
|
-
return dsn, None
|
|
170
|
+
return _DSNResult(dsn=dsn, warning=None, sslmode=sslmode, sslmode_warning_key="PG_SSLMODE")
|
|
144
171
|
|
|
145
172
|
|
|
146
173
|
def _sslmode_of(dsn: str) -> str | None:
|
|
@@ -162,9 +189,10 @@ def config_from_env() -> tuple[Config, list[ConfigWarning]]:
|
|
|
162
189
|
"""
|
|
163
190
|
warnings: list[ConfigWarning] = []
|
|
164
191
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
dsn_result = _build_dsn()
|
|
193
|
+
dsn = dsn_result.dsn
|
|
194
|
+
if dsn_result.warning:
|
|
195
|
+
warnings.append(dsn_result.warning)
|
|
168
196
|
|
|
169
197
|
max_conns, w = _parse_positive_int("PG_MAX_CONNS", _DEFAULT_MAX_CONNS)
|
|
170
198
|
if w:
|
|
@@ -197,14 +225,14 @@ def config_from_env() -> tuple[Config, list[ConfigWarning]]:
|
|
|
197
225
|
)
|
|
198
226
|
if w:
|
|
199
227
|
warnings.append(w)
|
|
200
|
-
pgbouncer_mode =
|
|
228
|
+
pgbouncer_mode = _is_pgbouncer_mode()
|
|
201
229
|
|
|
202
|
-
sslmode
|
|
203
|
-
if sslmode and sslmode.lower() in _INSECURE_SSLMODES:
|
|
230
|
+
if dsn_result.sslmode and dsn_result.sslmode.lower() in _INSECURE_SSLMODES:
|
|
204
231
|
warnings.append(
|
|
205
232
|
ConfigWarning(
|
|
206
|
-
key=
|
|
207
|
-
reason=f"sslmode={sslmode!r} does not enforce TLS — use 'require' or
|
|
233
|
+
key=dsn_result.sslmode_warning_key,
|
|
234
|
+
reason=f"sslmode={dsn_result.sslmode!r} does not enforce TLS — use 'require' or "
|
|
235
|
+
"stronger in production",
|
|
208
236
|
)
|
|
209
237
|
)
|
|
210
238
|
|
|
@@ -22,6 +22,7 @@ __all__ = [
|
|
|
22
22
|
"EmptyTenantRoleError",
|
|
23
23
|
"InconsistentGUCSetError",
|
|
24
24
|
"InvalidMaxConnsError",
|
|
25
|
+
"InvalidMigrationsTableError",
|
|
25
26
|
"InvalidMinConnsError",
|
|
26
27
|
"InvalidSavepointNameError",
|
|
27
28
|
"InvalidTenantRoleError",
|
|
@@ -84,6 +85,14 @@ class MigrationDirtyError(PgCommonError):
|
|
|
84
85
|
"""Raised when the schema is left dirty by a previously failed migration run."""
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
class InvalidMigrationsTableError(PgCommonError):
|
|
89
|
+
"""Raised by `migrate.Runner` when `table` fails `^[A-Za-z_][A-Za-z0-9_]*$`.
|
|
90
|
+
|
|
91
|
+
The table name is interpolated directly into SQL (identifiers cannot be bound as query
|
|
92
|
+
parameters), so it is validated up front rather than escaped.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
87
96
|
class NoMigrationsFoundError(PgCommonError):
|
|
88
97
|
"""Raised when the migration source directory has no matching `*.sql` files."""
|
|
89
98
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Deliberately not built on Alembic — Alembic assumes SQLAlchemy models, which this library does
|
|
4
4
|
not have. This is a from-scratch, `golang-migrate`-equivalent runner: it reads
|
|
5
5
|
`NNNN_description.up.sql` / `NNNN_description.down.sql` pairs from a directory, tracks the
|
|
6
|
-
applied version in a
|
|
7
|
-
|
|
8
|
-
don't race each other.
|
|
6
|
+
applied version in a tracking table (default `pgcommon_migrations`, see `Runner.__init__`'s
|
|
7
|
+
`table` param), and takes a Postgres advisory lock for the duration of the run so two runners
|
|
8
|
+
started concurrently (e.g. two replicas booting at once) don't race each other.
|
|
9
9
|
|
|
10
10
|
⚠️ **Never run migrations from every replica's boot path simultaneously.** The advisory lock
|
|
11
11
|
here only serializes the runs — it does not make it safe for the application to start against
|
|
@@ -26,7 +26,7 @@ from typing import TypeVar
|
|
|
26
26
|
|
|
27
27
|
import asyncpg
|
|
28
28
|
|
|
29
|
-
from pgcommon.errors import MigrationDirtyError, NoMigrationsFoundError
|
|
29
|
+
from pgcommon.errors import InvalidMigrationsTableError, MigrationDirtyError, NoMigrationsFoundError
|
|
30
30
|
from pgcommon.logger import Logger
|
|
31
31
|
|
|
32
32
|
__all__ = ["Migration", "Runner"]
|
|
@@ -34,6 +34,7 @@ __all__ = ["Migration", "Runner"]
|
|
|
34
34
|
T = TypeVar("T")
|
|
35
35
|
|
|
36
36
|
_FILENAME_RE = re.compile(r"^(?P<version>\d+)_(?P<description>.+)\.(?P<direction>up|down)\.sql$")
|
|
37
|
+
_TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
37
38
|
_ADVISORY_LOCK_KEY = zlib.crc32(b"pgcommon-migrate")
|
|
38
39
|
_LOCK_TIMEOUT_SECONDS = 30
|
|
39
40
|
|
|
@@ -90,12 +91,33 @@ class Runner:
|
|
|
90
91
|
|
|
91
92
|
`source` defaults are the caller's responsibility — pass an absolute or relative path to a
|
|
92
93
|
directory containing `NNNN_description.up.sql` / `NNNN_description.down.sql` pairs.
|
|
94
|
+
|
|
95
|
+
`table` names the version-tracking table (default `"pgcommon_migrations"` — deliberately not
|
|
96
|
+
`"schema_migrations"`, so this runner never collides with a consuming service's own
|
|
97
|
+
`golang-migrate`/`schema_migrations`-based migrations, matching the Go `pgcommon` library's
|
|
98
|
+
default). Pass a distinct name to run an isolated set of migrations against the same database
|
|
99
|
+
without colliding with another `Runner`'s tracking table or dirty-state — e.g. a library that
|
|
100
|
+
ships its own migrations (like `eventcommon`'s outbox schema) alongside a consuming service's
|
|
101
|
+
own domain migrations, or to explicitly opt into reading/writing an existing
|
|
102
|
+
`"schema_migrations"` table from a prior `golang-migrate` deployment. Must match
|
|
103
|
+
`^[A-Za-z_][A-Za-z0-9_]*$`; it is interpolated directly into SQL since identifiers cannot be
|
|
104
|
+
bound as query parameters.
|
|
93
105
|
"""
|
|
94
106
|
|
|
95
|
-
def __init__(
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
dsn: str,
|
|
110
|
+
source: str | Path,
|
|
111
|
+
*,
|
|
112
|
+
logger: Logger | None = None,
|
|
113
|
+
table: str = "pgcommon_migrations",
|
|
114
|
+
) -> None:
|
|
115
|
+
if not _TABLE_NAME_RE.match(table):
|
|
116
|
+
raise InvalidMigrationsTableError(f"invalid migrations table name: {table!r}")
|
|
96
117
|
self._dsn = dsn
|
|
97
118
|
self._source = Path(source)
|
|
98
119
|
self._logger = logger
|
|
120
|
+
self._table = table
|
|
99
121
|
|
|
100
122
|
async def up(self) -> None:
|
|
101
123
|
"""Apply all pending migrations, in ascending version order."""
|
|
@@ -131,21 +153,21 @@ class Runner:
|
|
|
131
153
|
|
|
132
154
|
async def _ensure_schema_migrations_table(self, conn: asyncpg.Connection) -> None:
|
|
133
155
|
await conn.execute(
|
|
134
|
-
"CREATE TABLE IF NOT EXISTS
|
|
156
|
+
f"CREATE TABLE IF NOT EXISTS {self._table} ("
|
|
135
157
|
"version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)"
|
|
136
158
|
)
|
|
137
159
|
|
|
138
160
|
async def _current_version(self, conn: asyncpg.Connection) -> tuple[int, bool]:
|
|
139
|
-
row = await conn.fetchrow("SELECT version, dirty FROM
|
|
161
|
+
row = await conn.fetchrow(f"SELECT version, dirty FROM {self._table} LIMIT 1")
|
|
140
162
|
if row is None:
|
|
141
163
|
return 0, False
|
|
142
164
|
return row["version"], row["dirty"]
|
|
143
165
|
|
|
144
166
|
async def _set_version(self, conn: asyncpg.Connection, version: int, *, dirty: bool) -> None:
|
|
145
167
|
async with conn.transaction():
|
|
146
|
-
await conn.execute("DELETE FROM
|
|
168
|
+
await conn.execute(f"DELETE FROM {self._table}")
|
|
147
169
|
await conn.execute(
|
|
148
|
-
"INSERT INTO
|
|
170
|
+
f"INSERT INTO {self._table} (version, dirty) VALUES ($1, $2)", version, dirty
|
|
149
171
|
)
|
|
150
172
|
|
|
151
173
|
async def _with_advisory_lock(
|
|
@@ -53,7 +53,10 @@ def _retry_reason(exc: BaseException) -> str | None:
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def _compute_delay_seconds(opts: RetryOptions, attempt: int) -> float:
|
|
56
|
-
delay
|
|
56
|
+
# A multiplier < 1 would shrink the delay on every retry instead of growing it — Go's
|
|
57
|
+
# backoffWait clamps this up to 1 (constant wait) rather than allowing it.
|
|
58
|
+
multiplier = max(opts.multiplier, 1.0)
|
|
59
|
+
delay = opts.initial_wait.total_seconds() * (multiplier ** (attempt - 1))
|
|
57
60
|
max_wait_seconds = opts.max_wait.total_seconds()
|
|
58
61
|
if max_wait_seconds > 0:
|
|
59
62
|
delay = min(delay, max_wait_seconds)
|
|
@@ -8,8 +8,15 @@ letting a specific migration's SQL fail on demand) without a database.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import re
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
|
|
14
|
+
_DEFAULT_TABLE = "pgcommon_migrations"
|
|
15
|
+
_CREATE_TABLE_RE = re.compile(r"CREATE TABLE IF NOT EXISTS (\w+)")
|
|
16
|
+
_SELECT_VERSION_RE = re.compile(r"SELECT version, dirty FROM (\w+)")
|
|
17
|
+
_DELETE_RE = re.compile(r"DELETE FROM (\w+)")
|
|
18
|
+
_INSERT_RE = re.compile(r"INSERT INTO (\w+)")
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
class _NoOpTransaction:
|
|
15
22
|
async def __aenter__(self) -> _NoOpTransaction:
|
|
@@ -21,7 +28,15 @@ class _NoOpTransaction:
|
|
|
21
28
|
|
|
22
29
|
@dataclass
|
|
23
30
|
class FakeMigrationConnection:
|
|
31
|
+
"""Tracks per-table schema state so tests can drive two `Runner`s (different `table=`)
|
|
32
|
+
against the same fake connection and assert their version bookkeeping stays independent.
|
|
33
|
+
|
|
34
|
+
`schema_migrations` is kept as a plain attribute (not nested under `tables`) for backward
|
|
35
|
+
compatibility with existing tests that only ever use the default table name.
|
|
36
|
+
"""
|
|
37
|
+
|
|
24
38
|
schema_migrations: dict[str, object] | None = None
|
|
39
|
+
tables: dict[str, dict[str, object] | None] = field(default_factory=dict)
|
|
25
40
|
executed_migration_sql: list[str] = field(default_factory=list)
|
|
26
41
|
lock_calls: list[int] = field(default_factory=list)
|
|
27
42
|
unlock_calls: list[int] = field(default_factory=list)
|
|
@@ -29,12 +44,24 @@ class FakeMigrationConnection:
|
|
|
29
44
|
closed: bool = False
|
|
30
45
|
call_order: list[str] = field(default_factory=list)
|
|
31
46
|
|
|
47
|
+
def _get_table_state(self, table: str) -> dict[str, object] | None:
|
|
48
|
+
if table == _DEFAULT_TABLE:
|
|
49
|
+
return self.schema_migrations
|
|
50
|
+
return self.tables.get(table)
|
|
51
|
+
|
|
52
|
+
def _set_table_state(self, table: str, state: dict[str, object] | None) -> None:
|
|
53
|
+
if table == _DEFAULT_TABLE:
|
|
54
|
+
self.schema_migrations = state
|
|
55
|
+
else:
|
|
56
|
+
self.tables[table] = state
|
|
57
|
+
|
|
32
58
|
async def execute(self, sql: str, *args: object) -> str:
|
|
33
59
|
stripped = sql.strip()
|
|
34
60
|
if stripped.startswith("SET lock_timeout"):
|
|
35
61
|
return "SET"
|
|
36
|
-
if
|
|
62
|
+
if m := _CREATE_TABLE_RE.match(stripped):
|
|
37
63
|
self.call_order.append("create_table")
|
|
64
|
+
self.tables.setdefault(m.group(1), self.tables.get(m.group(1)))
|
|
38
65
|
return "CREATE TABLE"
|
|
39
66
|
if stripped.startswith("SELECT pg_advisory_lock"):
|
|
40
67
|
self.call_order.append("lock")
|
|
@@ -43,12 +70,12 @@ class FakeMigrationConnection:
|
|
|
43
70
|
if stripped.startswith("SELECT pg_advisory_unlock"):
|
|
44
71
|
self.unlock_calls.append(args[0]) # type: ignore[arg-type]
|
|
45
72
|
return "SELECT"
|
|
46
|
-
if
|
|
47
|
-
self.
|
|
73
|
+
if m := _DELETE_RE.match(stripped):
|
|
74
|
+
self._set_table_state(m.group(1), None)
|
|
48
75
|
return "DELETE"
|
|
49
|
-
if
|
|
76
|
+
if m := _INSERT_RE.match(stripped):
|
|
50
77
|
version, dirty = args
|
|
51
|
-
self.
|
|
78
|
+
self._set_table_state(m.group(1), {"version": version, "dirty": dirty})
|
|
52
79
|
return "INSERT"
|
|
53
80
|
|
|
54
81
|
self.executed_migration_sql.append(stripped)
|
|
@@ -57,6 +84,9 @@ class FakeMigrationConnection:
|
|
|
57
84
|
return "SELECT"
|
|
58
85
|
|
|
59
86
|
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
|
87
|
+
stripped = sql.strip()
|
|
88
|
+
if m := _SELECT_VERSION_RE.match(stripped):
|
|
89
|
+
return self._get_table_state(m.group(1))
|
|
60
90
|
return self.schema_migrations
|
|
61
91
|
|
|
62
92
|
def transaction(self, **_kwargs: object) -> _NoOpTransaction:
|
|
@@ -35,9 +35,11 @@ def migrations_dir(tmp_path: Path, table: str) -> Path:
|
|
|
35
35
|
|
|
36
36
|
@pytest.fixture(autouse=True)
|
|
37
37
|
async def _drop_schema_migrations(postgres_dsn: str, pg_conn):
|
|
38
|
-
# Each integration test file shares one Postgres container; keep
|
|
39
|
-
# isolated per test by resetting it before every test in this module.
|
|
40
|
-
|
|
38
|
+
# Each integration test file shares one Postgres container; keep the tracking table's state
|
|
39
|
+
# isolated per test by resetting it before every test in this module. "pgcommon_migrations"
|
|
40
|
+
# is the Runner's default (see runner.py) — deliberately not "schema_migrations", so this
|
|
41
|
+
# library never collides with a consuming service's own golang-migrate-tracked table.
|
|
42
|
+
await pg_conn.execute("DROP TABLE IF EXISTS pgcommon_migrations")
|
|
41
43
|
yield
|
|
42
44
|
|
|
43
45
|
|
|
@@ -97,20 +97,46 @@ def test_config_from_env_builds_dsn_from_pg_vars(monkeypatch: pytest.MonkeyPatch
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def test_config_from_env_defaults_host_and_sslmode(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
# PG_USER/PG_DBNAME are required to build a DSN from parts (see the dedicated warning test
|
|
101
|
+
# below) — set them here so this test isolates the host/port/sslmode defaults.
|
|
102
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
103
|
+
monkeypatch.setenv("PG_DBNAME", "orders")
|
|
104
|
+
cfg, warnings = config_from_env()
|
|
105
|
+
assert cfg.dsn == "postgres://svc:@localhost:5432/orders?sslmode=require"
|
|
106
|
+
assert warnings == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_config_from_env_warns_when_user_or_dbname_missing(
|
|
110
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
111
|
+
) -> None:
|
|
112
|
+
# Matches Go's buildDSN: DSN is left empty (not a syntactically-valid-but-unusable string)
|
|
113
|
+
# when either PG_USER or PG_DBNAME is missing, so Pool.create fails fast with a clear error.
|
|
114
|
+
cfg, warnings = config_from_env()
|
|
115
|
+
assert cfg.dsn == ""
|
|
116
|
+
assert any(w.key == "PG_USER/PG_DBNAME" for w in warnings)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_config_from_env_warns_when_only_dbname_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
120
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
121
|
+
cfg, warnings = config_from_env()
|
|
122
|
+
assert cfg.dsn == ""
|
|
123
|
+
assert any(w.key == "PG_USER/PG_DBNAME" for w in warnings)
|
|
102
124
|
|
|
103
125
|
|
|
104
126
|
@pytest.mark.parametrize("sslmode", ["disable", "allow", "prefer"])
|
|
105
127
|
def test_config_from_env_warns_on_insecure_sslmode(
|
|
106
128
|
monkeypatch: pytest.MonkeyPatch, sslmode: str
|
|
107
129
|
) -> None:
|
|
130
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
131
|
+
monkeypatch.setenv("PG_DBNAME", "orders")
|
|
108
132
|
monkeypatch.setenv("PG_SSLMODE", sslmode)
|
|
109
133
|
_, warnings = config_from_env()
|
|
110
134
|
assert any(w.key == "PG_SSLMODE" for w in warnings)
|
|
111
135
|
|
|
112
136
|
|
|
113
137
|
def test_config_from_env_no_warning_for_require(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
138
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
139
|
+
monkeypatch.setenv("PG_DBNAME", "orders")
|
|
114
140
|
monkeypatch.setenv("PG_SSLMODE", "require")
|
|
115
141
|
_, warnings = config_from_env()
|
|
116
142
|
assert warnings == []
|
|
@@ -121,10 +147,26 @@ def test_config_from_env_warns_on_insecure_sslmode_in_database_url(
|
|
|
121
147
|
) -> None:
|
|
122
148
|
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host:5432/db?sslmode=disable")
|
|
123
149
|
_, warnings = config_from_env()
|
|
124
|
-
|
|
150
|
+
# Once DATABASE_URL is set, Go inspects only the DSN's own sslmode query param (never
|
|
151
|
+
# PG_SSLMODE) and keys the warning "DATABASE_URL", not "PG_SSLMODE".
|
|
152
|
+
assert any(w.key == "DATABASE_URL" for w in warnings)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_config_from_env_ignores_pg_sslmode_env_var_once_database_url_is_set(
|
|
156
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
157
|
+
) -> None:
|
|
158
|
+
# A secure PG_SSLMODE env var must not mask an insecure sslmode inside DATABASE_URL itself —
|
|
159
|
+
# and, symmetrically, a "secure-looking" DATABASE_URL must not be second-guessed by a
|
|
160
|
+
# leftover insecure PG_SSLMODE env var either. DATABASE_URL's own query param always wins.
|
|
161
|
+
monkeypatch.setenv("PG_SSLMODE", "require")
|
|
162
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host:5432/db?sslmode=disable")
|
|
163
|
+
_, warnings = config_from_env()
|
|
164
|
+
assert any(w.key == "DATABASE_URL" for w in warnings)
|
|
125
165
|
|
|
126
166
|
|
|
127
167
|
def test_config_from_env_parses_durations(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
168
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
169
|
+
monkeypatch.setenv("PG_DBNAME", "orders")
|
|
128
170
|
monkeypatch.setenv("PG_MAX_CONN_LIFETIME", "2h")
|
|
129
171
|
monkeypatch.setenv("PG_SLOW_QUERY_THRESHOLD", "500ms")
|
|
130
172
|
cfg, warnings = config_from_env()
|
|
@@ -160,12 +202,34 @@ def test_config_from_env_warns_and_defaults_on_negative_int(
|
|
|
160
202
|
assert any(w.key == "PG_MIN_CONNS" for w in warnings)
|
|
161
203
|
|
|
162
204
|
|
|
205
|
+
def test_config_from_env_warns_and_defaults_on_zero_int(
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
207
|
+
) -> None:
|
|
208
|
+
# Zero must be rejected too, not just negative values — matches Go's parsePositiveInt32,
|
|
209
|
+
# which checks `v <= 0`, not `v < 0`.
|
|
210
|
+
monkeypatch.setenv("PG_MAX_CONNS", "0")
|
|
211
|
+
cfg, warnings = config_from_env()
|
|
212
|
+
assert cfg.max_conns == 10
|
|
213
|
+
assert any(w.key == "PG_MAX_CONNS" for w in warnings)
|
|
214
|
+
|
|
215
|
+
|
|
163
216
|
def test_config_from_env_parses_bouncer_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
164
217
|
monkeypatch.setenv("PG_BOUNCER_MODE", "true")
|
|
165
218
|
cfg, _ = config_from_env()
|
|
166
219
|
assert cfg.pgbouncer_mode is True
|
|
167
220
|
|
|
168
221
|
|
|
222
|
+
@pytest.mark.parametrize("value", ["1", "yes", "True", "TRUE", "on", " true", "true "])
|
|
223
|
+
def test_config_from_env_bouncer_mode_requires_exact_lowercase_true(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch, value: str
|
|
225
|
+
) -> None:
|
|
226
|
+
# Go matches only the exact string "true" (os.Getenv(...) == "true") — no case-folding, no
|
|
227
|
+
# trimming, no other truthy spellings. A shared .env file must behave identically either way.
|
|
228
|
+
monkeypatch.setenv("PG_BOUNCER_MODE", value)
|
|
229
|
+
cfg, _ = config_from_env()
|
|
230
|
+
assert cfg.pgbouncer_mode is False
|
|
231
|
+
|
|
232
|
+
|
|
169
233
|
@pytest.mark.parametrize(
|
|
170
234
|
"env_var",
|
|
171
235
|
[
|
|
@@ -191,6 +255,8 @@ def test_config_from_env_no_warning_when_database_url_has_no_sslmode(
|
|
|
191
255
|
|
|
192
256
|
|
|
193
257
|
def test_config_from_env_min_conns_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
258
|
+
monkeypatch.setenv("PG_USER", "svc")
|
|
259
|
+
monkeypatch.setenv("PG_DBNAME", "orders")
|
|
194
260
|
monkeypatch.setenv("PG_MIN_CONNS", "5")
|
|
195
261
|
cfg, warnings = config_from_env()
|
|
196
262
|
assert cfg.min_conns == 5
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
import pgcommon.migrate.runner as runner_module
|
|
8
|
-
from pgcommon.errors import MigrationDirtyError, NoMigrationsFoundError
|
|
8
|
+
from pgcommon.errors import InvalidMigrationsTableError, MigrationDirtyError, NoMigrationsFoundError
|
|
9
9
|
from pgcommon.migrate.runner import Runner, _load_migrations
|
|
10
10
|
from tests.fixtures.fake_migrate_conn import FakeMigrationConnection
|
|
11
11
|
from tests.fixtures.fakes import FakeLogger
|
|
@@ -363,3 +363,62 @@ def test_load_migrations_skips_subdirectories(tmp_path: Path) -> None:
|
|
|
363
363
|
(d / "0002_not_a_file.up.sql").mkdir() # a directory that happens to match the pattern
|
|
364
364
|
migrations = _load_migrations(d)
|
|
365
365
|
assert set(migrations) == {1}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --- table= (isolated tracking table name) ---
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_default_table_name_is_pgcommon_migrations(migrations_dir: Path) -> None:
|
|
372
|
+
runner = Runner("postgres://localhost/db", migrations_dir)
|
|
373
|
+
assert runner._table == "pgcommon_migrations"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def test_can_override_to_schema_migrations_for_golang_migrate_compat(
|
|
377
|
+
migrations_dir: Path, patch_connect: FakeMigrationConnection
|
|
378
|
+
) -> None:
|
|
379
|
+
# golang-migrate (and any pre-existing golang-migrate-based deployment) tracks versions in
|
|
380
|
+
# "schema_migrations" — a caller adopting that table explicitly opts in via table=.
|
|
381
|
+
runner = Runner("postgres://localhost/db", migrations_dir, table="schema_migrations")
|
|
382
|
+
await runner.up()
|
|
383
|
+
assert patch_connect.tables["schema_migrations"] == {"version": 3, "dirty": False}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def test_custom_table_name_is_used_for_tracking(
|
|
387
|
+
migrations_dir: Path, patch_connect: FakeMigrationConnection
|
|
388
|
+
) -> None:
|
|
389
|
+
runner = Runner("postgres://localhost/db", migrations_dir, table="outbox_migrations")
|
|
390
|
+
await runner.up()
|
|
391
|
+
assert patch_connect.schema_migrations is None
|
|
392
|
+
assert patch_connect.tables["outbox_migrations"] == {"version": 3, "dirty": False}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def test_two_runners_with_different_tables_track_versions_independently(
|
|
396
|
+
migrations_dir: Path, patch_connect: FakeMigrationConnection
|
|
397
|
+
) -> None:
|
|
398
|
+
domain_runner = Runner("postgres://localhost/db", migrations_dir)
|
|
399
|
+
outbox_runner = Runner("postgres://localhost/db", migrations_dir, table="outbox_migrations")
|
|
400
|
+
|
|
401
|
+
await domain_runner.up()
|
|
402
|
+
assert patch_connect.schema_migrations == {"version": 3, "dirty": False}
|
|
403
|
+
assert patch_connect.tables.get("outbox_migrations") is None
|
|
404
|
+
|
|
405
|
+
patch_connect.executed_migration_sql.clear()
|
|
406
|
+
await outbox_runner.up()
|
|
407
|
+
assert patch_connect.tables["outbox_migrations"] == {"version": 3, "dirty": False}
|
|
408
|
+
# Domain runner's version is untouched by the outbox runner's run.
|
|
409
|
+
assert patch_connect.schema_migrations == {"version": 3, "dirty": False}
|
|
410
|
+
|
|
411
|
+
domain_version, domain_dirty = await domain_runner.version()
|
|
412
|
+
outbox_version, outbox_dirty = await outbox_runner.version()
|
|
413
|
+
assert (domain_version, domain_dirty) == (3, False)
|
|
414
|
+
assert (outbox_version, outbox_dirty) == (3, False)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@pytest.mark.parametrize("bad_table", ["", "1abc", "has space", "has-dash", "drop table;--"])
|
|
418
|
+
def test_invalid_table_name_raises(bad_table: str, tmp_path: Path) -> None:
|
|
419
|
+
with pytest.raises(InvalidMigrationsTableError):
|
|
420
|
+
Runner("postgres://localhost/db", tmp_path, table=bad_table)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_valid_table_name_with_underscore_and_digits_is_accepted(tmp_path: Path) -> None:
|
|
424
|
+
Runner("postgres://localhost/db", tmp_path, table="outbox_migrations_v2")
|
|
@@ -204,3 +204,14 @@ async def test_jitter_produces_varying_delays_within_bounds(patch_create_pool) -
|
|
|
204
204
|
assert len(delays) > 1
|
|
205
205
|
for d in delays:
|
|
206
206
|
assert 0.05 <= d <= 0.15 + 1e-9
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_multiplier_below_one_is_clamped_to_constant_wait() -> None:
|
|
210
|
+
# A multiplier < 1 would shrink the delay on every retry — Go's backoffWait clamps this up
|
|
211
|
+
# to 1 (a constant wait) rather than allowing a shrinking backoff.
|
|
212
|
+
from pgcommon.retry import _compute_delay_seconds
|
|
213
|
+
|
|
214
|
+
opts = RetryOptions(initial_wait=timedelta(milliseconds=50), multiplier=0.5)
|
|
215
|
+
assert _compute_delay_seconds(opts, 1) == pytest.approx(0.05)
|
|
216
|
+
assert _compute_delay_seconds(opts, 2) == pytest.approx(0.05)
|
|
217
|
+
assert _compute_delay_seconds(opts, 3) == pytest.approx(0.05)
|
pgcommon-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [semantic versioning](VERSIONING.md).
|
|
4
|
-
|
|
5
|
-
## [Unreleased]
|
|
6
|
-
|
|
7
|
-
## [0.1.0] - 2026-07-26
|
|
8
|
-
|
|
9
|
-
### Changed
|
|
10
|
-
|
|
11
|
-
- Licensed under Apache-2.0 (was `Proprietary`) and published to public PyPI as `pgcommon`
|
|
12
|
-
via Trusted Publishing — the source repository itself stays private. See
|
|
13
|
-
[VERSIONING.md § Publishing to PyPI](VERSIONING.md#publishing-to-pypi).
|
|
14
|
-
|
|
15
|
-
### Added
|
|
16
|
-
|
|
17
|
-
- Initial Python port of `platform-pgcommon` (Go) for `asyncio`/FastAPI services, built on `asyncpg`.
|
|
18
|
-
- `Config`, `config_from_env`, `ConfigWarning` (`pgcommon.config`).
|
|
19
|
-
- Sentinel errors and SQLSTATE helpers (`pgcommon.errors`).
|
|
20
|
-
- `GUCSet`, contextvars-based propagation, `inject_gucs` (`pgcommon.guc`).
|
|
21
|
-
- `Pool` — fail-fast construction, `with_conn`, `health`, `stat`, `drain_and_close` (`pgcommon.pool`).
|
|
22
|
-
- `run_in_tx`, `run_in_savepoint` (`pgcommon.tx`).
|
|
23
|
-
- `RetryOptions`, `run_in_tx_with_retry`, `run_in_tx_with_retry_opts` (`pgcommon.retry`).
|
|
24
|
-
- `SlowQueryTracer` (`pgcommon.tracer`).
|
|
25
|
-
- Prometheus metrics + `OTelQueryRecorder` (`pgcommon.metrics`).
|
|
26
|
-
- Dependency-light `.sql` migration `Runner` + `pgcommon-migrate` console script (`pgcommon.migrate`).
|
|
27
|
-
- Optional FastAPI integration: lifespan pool wiring, `GUCAuthMiddleware`, `/healthz` helper (`pgcommon.integrations.fastapi`, `pgcommon[fastapi]` extra).
|
|
28
|
-
- Optional `structlog` `Logger` adapter (`pgcommon[structlog]` extra).
|
|
29
|
-
- Unit, integration (testcontainers Postgres), and e2e (testcontainers Postgres + PgBouncer) test suites.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgcommon-0.1.0 → pgcommon-0.2.0}/docs/architecture/mermaid/pool-create-hook-composition.mmd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgcommon-0.1.0 → pgcommon-0.2.0}/tests/integration/test_retry_real_serialization_failure.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|