pgcommon 0.2.0__tar.gz → 0.3.1__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.3.1/.claude/CLAUDE.md +122 -0
- pgcommon-0.3.1/.claude/api-reference.md +163 -0
- pgcommon-0.3.1/.claude/architecture.md +123 -0
- pgcommon-0.3.1/.claude/database-schema.md +85 -0
- pgcommon-0.3.1/.claude/operations.md +101 -0
- pgcommon-0.3.1/.claude/request-flows.md +100 -0
- pgcommon-0.3.1/.claude/settings.json +18 -0
- pgcommon-0.3.1/.dockerignore +12 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/.env-example +6 -0
- pgcommon-0.3.1/.githooks/pre-commit +41 -0
- pgcommon-0.3.1/.github/CODEOWNERS +44 -0
- pgcommon-0.3.1/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
- pgcommon-0.3.1/.github/ISSUE_TEMPLATE/feature_request.md +45 -0
- pgcommon-0.3.1/.github/SECURITY.md +111 -0
- pgcommon-0.3.1/.github/pull_request_template.md +77 -0
- pgcommon-0.3.1/.github/scripts/api-compat.sh +136 -0
- pgcommon-0.3.1/.github/scripts/arch-lint.sh +10 -0
- pgcommon-0.3.1/.github/scripts/changelog-section.sh +44 -0
- pgcommon-0.3.1/.github/scripts/coverage-gate.sh +38 -0
- pgcommon-0.3.1/.github/scripts/create-github-release.sh +45 -0
- pgcommon-0.3.1/.github/scripts/detect-changes.sh +114 -0
- pgcommon-0.3.1/.github/scripts/detect-changes_test.sh +158 -0
- pgcommon-0.3.1/.github/scripts/docs-mermaid-sync.sh +96 -0
- pgcommon-0.3.1/.github/scripts/export-image-provenance.sh +31 -0
- pgcommon-0.3.1/.github/scripts/extract-release-notes.sh +51 -0
- pgcommon-0.3.1/.github/scripts/image-fingerprint.sh +65 -0
- pgcommon-0.3.1/.github/scripts/log-image-digest.sh +14 -0
- pgcommon-0.3.1/.github/scripts/pr-summary.js +121 -0
- pgcommon-0.3.1/.github/scripts/prepare-release-dist.sh +43 -0
- pgcommon-0.3.1/.github/scripts/release-checksums.sh +50 -0
- pgcommon-0.3.1/.github/scripts/release-image-tags.sh +83 -0
- pgcommon-0.3.1/.github/scripts/release_guard.py +112 -0
- pgcommon-0.3.1/.github/scripts/set-build-cache-targets.sh +30 -0
- pgcommon-0.3.1/.github/scripts/set-source-date-epoch.sh +8 -0
- pgcommon-0.3.1/.github/scripts/smoke-tests.sh +115 -0
- pgcommon-0.3.1/.github/scripts/verify-changelog-entry.sh +16 -0
- pgcommon-0.3.1/.github/scripts/verify-release-tag.sh +63 -0
- pgcommon-0.3.1/.github/workflows/changelog-check.yml +56 -0
- pgcommon-0.3.1/.github/workflows/ci.yml +587 -0
- pgcommon-0.3.1/.github/workflows/docs.yml +59 -0
- pgcommon-0.3.1/.github/workflows/release.yml +736 -0
- pgcommon-0.3.1/.github/workflows/validate-quality.yml +161 -0
- pgcommon-0.3.1/.github/workflows/validate-test.yml +187 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/.gitignore +17 -0
- pgcommon-0.3.1/ARCHITECTURE.md +1562 -0
- pgcommon-0.3.1/CHANGELOG.md +771 -0
- pgcommon-0.3.1/CONTRIBUTING.md +497 -0
- pgcommon-0.3.1/Dockerfile +77 -0
- pgcommon-0.3.1/Makefile +585 -0
- pgcommon-0.3.1/PKG-INFO +2305 -0
- pgcommon-0.3.1/README.md +2279 -0
- pgcommon-0.3.1/SECURITY.md +14 -0
- pgcommon-0.3.1/VERSIONING.md +371 -0
- pgcommon-0.3.1/build-constraints.txt +25 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/docker-compose.yml +4 -2
- pgcommon-0.3.1/docs/MigrationGuide.md +87 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/docs/adr/ADR-001-contextvars-guc-propagation.md +14 -11
- {pgcommon-0.2.0 → pgcommon-0.3.1}/docs/adr/ADR-002-cancellation-and-rollback-safety.md +23 -1
- {pgcommon-0.2.0 → pgcommon-0.3.1}/docs/adr/ADR-003-guc-injection-timing.md +28 -1
- {pgcommon-0.2.0 → pgcommon-0.3.1}/docs/adr/ADR-004-migration-runner-design.md +24 -1
- pgcommon-0.3.1/docs/architecture/README.md +31 -0
- pgcommon-0.3.1/docs/architecture/mermaid/consuming-service-wiring.mmd +30 -0
- pgcommon-0.3.1/docs/architecture/mermaid/data-model.mmd +15 -0
- pgcommon-0.3.1/docs/architecture/mermaid/deployment.mmd +45 -0
- pgcommon-0.3.1/docs/architecture/mermaid/documentation-assets.mmd +48 -0
- pgcommon-0.3.1/docs/architecture/mermaid/error-classification.mmd +26 -0
- pgcommon-0.3.1/docs/architecture/mermaid/failure-domains.mmd +37 -0
- pgcommon-0.3.1/docs/architecture/mermaid/layer-model.mmd +71 -0
- pgcommon-0.3.1/docs/architecture/mermaid/migration-runner.mmd +28 -0
- pgcommon-0.3.1/docs/architecture/mermaid/no-identity-flow.mmd +32 -0
- pgcommon-0.3.1/docs/architecture/mermaid/observability-stack.mmd +34 -0
- pgcommon-0.3.1/docs/architecture/mermaid/package-dependencies.mmd +57 -0
- pgcommon-0.3.1/docs/architecture/mermaid/pool-create-hook-composition.mmd +15 -0
- pgcommon-0.3.1/docs/architecture/mermaid/pool-lifecycle.mmd +43 -0
- pgcommon-0.3.1/docs/architecture/mermaid/query-flow.mmd +45 -0
- pgcommon-0.3.1/docs/architecture/mermaid/retry-flow.mmd +14 -0
- pgcommon-0.3.1/docs/architecture/mermaid/rls-guc-flow.mmd +30 -0
- pgcommon-0.3.1/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +32 -0
- pgcommon-0.3.1/docs/architecture/mermaid/runintx-lifecycle.mmd +46 -0
- pgcommon-0.3.1/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +47 -0
- pgcommon-0.3.1/docs/architecture/mermaid/withconn-standard-mode.mmd +55 -0
- pgcommon-0.3.1/docs/lld/platform-lld-pgcommon-py.md +1842 -0
- pgcommon-0.3.1/docs/observability/README.md +147 -0
- pgcommon-0.3.1/docs/observability/metrics-registry.md +169 -0
- pgcommon-0.3.1/pyproject.toml +175 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/__init__.py +69 -9
- pgcommon-0.3.1/src/pgcommon/_internal/dsn.py +607 -0
- pgcommon-0.3.1/src/pgcommon/_internal/duration.py +127 -0
- pgcommon-0.3.1/src/pgcommon/_internal/goquote.py +13 -0
- pgcommon-0.3.1/src/pgcommon/_internal/sql_verb.py +95 -0
- pgcommon-0.3.1/src/pgcommon/_internal/sqlstate.py +43 -0
- pgcommon-0.3.1/src/pgcommon/_internal/statement_scope.py +80 -0
- pgcommon-0.3.1/src/pgcommon/_internal/strconv.py +112 -0
- pgcommon-0.3.1/src/pgcommon/_internal/txshape.py +357 -0
- pgcommon-0.3.1/src/pgcommon/_internal/wrap_tx.py +297 -0
- pgcommon-0.3.1/src/pgcommon/config.py +877 -0
- pgcommon-0.3.1/src/pgcommon/errors.py +575 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/guc.py +68 -38
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/integrations/fastapi.py +35 -18
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/logger.py +31 -5
- pgcommon-0.3.1/src/pgcommon/metrics.py +918 -0
- pgcommon-0.3.1/src/pgcommon/metrics_registry.py +393 -0
- pgcommon-0.3.1/src/pgcommon/migrate/__init__.py +35 -0
- pgcommon-0.3.1/src/pgcommon/migrate/cli.py +339 -0
- pgcommon-0.3.1/src/pgcommon/migrate/errors.py +35 -0
- pgcommon-0.3.1/src/pgcommon/migrate/runner.py +946 -0
- pgcommon-0.3.1/src/pgcommon/pool.py +1419 -0
- pgcommon-0.3.1/src/pgcommon/retry.py +176 -0
- pgcommon-0.3.1/src/pgcommon/tracer.py +131 -0
- pgcommon-0.3.1/src/pgcommon/tx.py +628 -0
- pgcommon-0.3.1/tests/conftest.py +24 -0
- pgcommon-0.3.1/tests/e2e/test_core_invariants_e2e.py +145 -0
- pgcommon-0.3.1/tests/e2e/test_pgbouncer_callback_escape_e2e.py +93 -0
- pgcommon-0.3.1/tests/fixtures/fake_asyncpg.py +366 -0
- pgcommon-0.3.1/tests/fixtures/fake_migrate_conn.py +129 -0
- pgcommon-0.3.1/tests/fixtures/go_pgmetrics_vectors.go.txt +93 -0
- pgcommon-0.3.1/tests/fixtures/go_pgmetrics_vectors.json +450 -0
- pgcommon-0.3.1/tests/integration/test_connection_loss.py +188 -0
- pgcommon-0.3.1/tests/integration/test_core_invariants_integration.py +414 -0
- pgcommon-0.3.1/tests/integration/test_failover.py +240 -0
- pgcommon-0.3.1/tests/integration/test_migrate_invariants_integration.py +566 -0
- pgcommon-0.3.1/tests/integration/test_migrate_runner_integration.py +372 -0
- pgcommon-0.3.1/tests/integration/test_platform_metrics.py +157 -0
- pgcommon-0.3.1/tests/integration/test_pool_hardening.py +511 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_pool_lifecycle.py +3 -1
- pgcommon-0.3.1/tests/integration/test_pool_regressions.py +350 -0
- pgcommon-0.3.1/tests/integration/test_tx_escape.py +494 -0
- pgcommon-0.3.1/tests/unit/test_config.py +466 -0
- pgcommon-0.3.1/tests/unit/test_config_parity.py +377 -0
- pgcommon-0.3.1/tests/unit/test_core_invariants.py +1038 -0
- pgcommon-0.3.1/tests/unit/test_core_redaction_and_config.py +1009 -0
- pgcommon-0.3.1/tests/unit/test_errors.py +163 -0
- pgcommon-0.3.1/tests/unit/test_fastapi_invariants.py +366 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/test_guc.py +18 -18
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/test_integrations_fastapi.py +38 -0
- pgcommon-0.3.1/tests/unit/test_internal.py +405 -0
- pgcommon-0.3.1/tests/unit/test_internal_parity.py +240 -0
- pgcommon-0.3.1/tests/unit/test_logger.py +81 -0
- pgcommon-0.3.1/tests/unit/test_metrics.py +188 -0
- pgcommon-0.3.1/tests/unit/test_metrics_identity.py +142 -0
- pgcommon-0.3.1/tests/unit/test_metrics_invariants.py +574 -0
- pgcommon-0.3.1/tests/unit/test_metrics_inventory.py +135 -0
- pgcommon-0.3.1/tests/unit/test_metrics_lifecycle.py +569 -0
- pgcommon-0.3.1/tests/unit/test_metrics_standard.py +424 -0
- pgcommon-0.3.1/tests/unit/test_migrate_cli.py +612 -0
- pgcommon-0.3.1/tests/unit/test_migrate_cli_invariants.py +235 -0
- pgcommon-0.3.1/tests/unit/test_migrate_invariants.py +880 -0
- pgcommon-0.3.1/tests/unit/test_migrate_runner.py +1329 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/test_pool.py +114 -23
- pgcommon-0.3.1/tests/unit/test_pool_hardening.py +622 -0
- pgcommon-0.3.1/tests/unit/test_pool_metrics.py +399 -0
- pgcommon-0.3.1/tests/unit/test_pool_parity.py +790 -0
- pgcommon-0.3.1/tests/unit/test_pool_resilience.py +621 -0
- pgcommon-0.3.1/tests/unit/test_public_api.py +165 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/test_retry.py +99 -29
- pgcommon-0.3.1/tests/unit/test_tx.py +852 -0
- pgcommon-0.3.1/tests/unit/test_txshape.py +217 -0
- pgcommon-0.3.1/tests/unit/test_txshape_go_oracle.py +217 -0
- pgcommon-0.3.1/tests/unit/test_wrap_tx.py +487 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/uv.lock +151 -8
- pgcommon-0.2.0/.github/CODEOWNERS +0 -10
- pgcommon-0.2.0/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- pgcommon-0.2.0/.github/ISSUE_TEMPLATE/feature_request.md +0 -28
- pgcommon-0.2.0/.github/SECURITY.md +0 -34
- pgcommon-0.2.0/.github/pull_request_template.md +0 -62
- pgcommon-0.2.0/.github/workflows/ci.yml +0 -332
- pgcommon-0.2.0/.github/workflows/quality.yml +0 -49
- pgcommon-0.2.0/.github/workflows/release.yml +0 -211
- pgcommon-0.2.0/.github/workflows/test.yml +0 -43
- pgcommon-0.2.0/.github/workflows/validate.yml +0 -33
- pgcommon-0.2.0/ARCHITECTURE.md +0 -604
- pgcommon-0.2.0/CHANGELOG.md +0 -61
- pgcommon-0.2.0/CONTRIBUTING.md +0 -199
- pgcommon-0.2.0/Dockerfile +0 -21
- pgcommon-0.2.0/Makefile +0 -174
- pgcommon-0.2.0/PKG-INFO +0 -1371
- pgcommon-0.2.0/README.md +0 -1345
- pgcommon-0.2.0/SECURITY.md +0 -74
- pgcommon-0.2.0/VERSIONING.md +0 -215
- pgcommon-0.2.0/docs/Architecture.md +0 -9
- pgcommon-0.2.0/docs/MigrationGuide.md +0 -61
- pgcommon-0.2.0/docs/architecture/README.md +0 -20
- pgcommon-0.2.0/docs/architecture/mermaid/consuming-service-wiring.mmd +0 -27
- pgcommon-0.2.0/docs/architecture/mermaid/documentation-assets.mmd +0 -38
- pgcommon-0.2.0/docs/architecture/mermaid/layer-model.mmd +0 -56
- pgcommon-0.2.0/docs/architecture/mermaid/migration-runner.mmd +0 -23
- pgcommon-0.2.0/docs/architecture/mermaid/observability-stack.mmd +0 -15
- pgcommon-0.2.0/docs/architecture/mermaid/pool-create-hook-composition.mmd +0 -16
- pgcommon-0.2.0/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +0 -23
- pgcommon-0.2.0/docs/architecture/mermaid/runintx-lifecycle.mmd +0 -30
- pgcommon-0.2.0/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +0 -31
- pgcommon-0.2.0/docs/architecture/mermaid/withconn-standard-mode.mmd +0 -23
- pgcommon-0.2.0/pyproject.toml +0 -110
- pgcommon-0.2.0/src/pgcommon/_internal/dsn.py +0 -15
- pgcommon-0.2.0/src/pgcommon/_internal/duration.py +0 -38
- pgcommon-0.2.0/src/pgcommon/_internal/sql_verb.py +0 -36
- pgcommon-0.2.0/src/pgcommon/_internal/sqlstate.py +0 -20
- pgcommon-0.2.0/src/pgcommon/config.py +0 -250
- pgcommon-0.2.0/src/pgcommon/errors.py +0 -132
- pgcommon-0.2.0/src/pgcommon/metrics.py +0 -179
- pgcommon-0.2.0/src/pgcommon/migrate/__init__.py +0 -8
- pgcommon-0.2.0/src/pgcommon/migrate/cli.py +0 -78
- pgcommon-0.2.0/src/pgcommon/migrate/errors.py +0 -12
- pgcommon-0.2.0/src/pgcommon/migrate/runner.py +0 -283
- pgcommon-0.2.0/src/pgcommon/pool.py +0 -346
- pgcommon-0.2.0/src/pgcommon/retry.py +0 -124
- pgcommon-0.2.0/src/pgcommon/tracer.py +0 -85
- pgcommon-0.2.0/src/pgcommon/tx.py +0 -98
- pgcommon-0.2.0/tests/fixtures/fake_asyncpg.py +0 -146
- pgcommon-0.2.0/tests/fixtures/fake_migrate_conn.py +0 -96
- pgcommon-0.2.0/tests/integration/test_migrate_runner_integration.py +0 -110
- pgcommon-0.2.0/tests/unit/test_config.py +0 -263
- pgcommon-0.2.0/tests/unit/test_errors.py +0 -71
- pgcommon-0.2.0/tests/unit/test_internal.py +0 -76
- pgcommon-0.2.0/tests/unit/test_logger.py +0 -54
- pgcommon-0.2.0/tests/unit/test_metrics.py +0 -121
- pgcommon-0.2.0/tests/unit/test_migrate_cli.py +0 -136
- pgcommon-0.2.0/tests/unit/test_migrate_runner.py +0 -424
- pgcommon-0.2.0/tests/unit/test_tx.py +0 -236
- {pgcommon-0.2.0 → pgcommon-0.3.1}/.github/dependabot.yml +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/LICENSE +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/migrations/0001_create_example_table.down.sql +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/migrations/0001_create_example_table.up.sql +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/_internal/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/_internal/query_event.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/integrations/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/migrate/__main__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/src/pgcommon/py.typed +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/e2e/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/e2e/conftest.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/e2e/test_rls_pgbouncer_e2e.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/fixtures/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/fixtures/fakes.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/conftest.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_concurrency_stress.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_error_helpers_real_violations.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_retry_real_serialization_failure.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_rls_guc_injection.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/integration/test_tx_and_savepoint.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/__init__.py +0 -0
- {pgcommon-0.2.0 → pgcommon-0.3.1}/tests/unit/test_tracer.py +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code when working with **platform-pgcommon-py**, the shared PostgreSQL access library every Python platform service imports (Tender Management SaaS platform). It is the asyncio port of the Go `platform-pgcommon`. Detailed reference lives in the sibling files listed under [See Also](#see-also). This file is what must always be in context.
|
|
4
|
+
|
|
5
|
+
## What This Repo Is
|
|
6
|
+
|
|
7
|
+
`platform-pgcommon-py` is the **Python package `pgcommon`** (`pyproject.toml`: version `0.3.0`, `requires-python >=3.12`, CI on 3.12 / 3.13 / 3.14; runtime dependencies `asyncpg>=0.30,<0.32`, `prometheus-client>=0.20`, `opentelemetry-api>=1.24`; extras `fastapi`, `structlog`; managed with **uv**, `uv.lock` committed). It is published to PyPI as `pgcommon` (Apache-2.0; the repository is private) and is **never deployed**. Its one shipped artefact besides the package is the `pgcommon-migrate` CLI (`pgcommon.migrate.cli:main`), published as a container image `ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`. It tracks Go **v2.0.1**. Consumers: `platform-eventcommon` (outbox / inbox / DLQ; still pins `pgcommon>=0.2.0,<0.3.0`), the FastAPI services built on the platform, and `platform-interop-tests`' Python probe (CI `interop` job).
|
|
8
|
+
|
|
9
|
+
**Status:** 0.3.0 was released on 2026-10-15 (`CHANGELOG.md` `[0.3.0]`; breaking against 0.2.0). New work goes under `[Unreleased]`. Release process: [`operations.md`](operations.md) § Release state.
|
|
10
|
+
|
|
11
|
+
**Key responsibilities (the library is the sole implementation of):**
|
|
12
|
+
- **Connection pool** — `Pool.create(config)` wraps `asyncpg.create_pool`. It validates the config and the DSN before connecting (fail fast, then one ping), resolves each setting **explicit `Config` field ▸ DSN `pool_*` ▸ default** (max 10; min 2 capped at max, 0 in PgBouncer mode; per-connection lifetime 1 h + up to lifetime ÷ 10 jitter; idle 30 m; health check 1 m; `acquire_timeout` 30 s), and exposes **no** raw `asyncpg.Pool` or connection outside a callback.
|
|
13
|
+
- **RLS identity injection** — `app.user_id` / `app.tenant_id` / `app.tenant_roles` from the request's `GUCSet` (`Config.guc_provider = guc_set_from_context`, a `ContextVar`). Validated **before** any connection is waited for, in both modes. Direct mode: session GUCs injected on every acquire (`Pool._take_conn`), cleared by asyncpg's `RESET ALL` on release (a failed reset terminates the connection). PgBouncer mode: transaction-local only, in the transaction's setup statement.
|
|
14
|
+
- **Transactions** — `run_in_tx` / `Pool.run_in_tx` / `Pool.with_tx` (pgcommon sends `COMMIT` itself and reads its status; rollback on exception or cancellation, every cleanup bounded to 5 s), `TxEndedInCallbackError` / `TxCommitRollbackError`, `run_in_tx_with_retry(_opts)` (only `40P01` / `40001`), `run_in_savepoint`, transaction-local `statement_timeout` / `lock_timeout`, the prepared-transaction marker (`prepared_xact_guard`).
|
|
15
|
+
- **Error classification** — the `is_*` SQLSTATE helpers, `sql_state`, `constraint_name`, `is_pg_error`, `is_pool_closed`, Python-only `is_connection_lost`; they look through `__cause__`, exception groups and `callback_error`.
|
|
16
|
+
- **Migration runner** — `pgcommon.migrate.Runner` + `pgcommon-migrate`: a from-scratch asyncio runner sharing golang-migrate's tracking table, dirty protocol and advisory-lock key with the Go runner. The library ships **no** migrations.
|
|
17
|
+
- **Database-client telemetry** — the 10 Tier 1 `platform_db_*` metrics (**Proposed**; no legacy family), OTel `db.query` spans, the rate-limited slow-query log.
|
|
18
|
+
|
|
19
|
+
**Does NOT own:** consumers' tables, RLS policies, roles or migrations; authentication (the service decides the `GUCSet`); the logger, OTel SDK or `CollectorRegistry` (passed in); HTTP status mapping; PgBouncer / RDS Proxy; outbox / inbox (`platform-eventcommon`); alert rules, dashboards, runbook (Go's apply unchanged); metric ratification.
|
|
20
|
+
|
|
21
|
+
## Common Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
make setup # uv sync + .env-example → .env (never overwrites) + make install-hooks (run once)
|
|
25
|
+
make help # every target, with one line each
|
|
26
|
+
make format / format-check # ruff format + ruff check --fix / ruff format --check (aliases fmt, fmt-check)
|
|
27
|
+
make lint / typecheck # ruff check / mypy --strict
|
|
28
|
+
make arch-lint # import-linter contracts in pyproject.toml (.github/scripts/arch-lint.sh)
|
|
29
|
+
make lock-check / pip-check / vulncheck # uv lock --check / uv pip check / pip-audit
|
|
30
|
+
make metrics-lint # observability-standard conformance + generated inventory in sync
|
|
31
|
+
make metrics-doc # regenerate docs/observability/metrics-registry.md
|
|
32
|
+
make docs-check # every docs/architecture/mermaid/*.mmd embedded byte-identically in ARCHITECTURE.md
|
|
33
|
+
make ci-scripts-test # regression tests for .github/scripts/detect-changes.sh
|
|
34
|
+
make test # unit + integration + e2e in one run (Docker; alias test-all)
|
|
35
|
+
make test-unit / test-int / test-e2e # no Docker / Postgres / Postgres + PgBouncer
|
|
36
|
+
make test-ci / cover-check / cover / cover-func # CI test gate / + ≥ 97% gate / + HTML / per-file report
|
|
37
|
+
make rls-check # the RLS isolation tests in both modes (own containers)
|
|
38
|
+
make build / build-constraints # wheel + sdist + twine + console-script check / re-lock the build backend
|
|
39
|
+
make ci # lock-check + format-check + lint + typecheck + arch-lint + metrics-lint + docs-check + ci-scripts-test → test-ci → coverage gate → build
|
|
40
|
+
make api-compat / release-guard TAG=vX.Y.Z # griffe vs the last release tag / pre-tag version + CHANGELOG check
|
|
41
|
+
make docker-build / lint-docker / scan / smoke-test # migrate image, hadolint, Trivy, CI's smoke-tests.sh
|
|
42
|
+
make docker-up / docker-up-all / docker-down / docker-reset / docker-ps / docker-logs # local stack
|
|
43
|
+
make pgbouncer-up / pgbouncer-down / migrate-up / migrate-down / migrate-status / psql / show-config
|
|
44
|
+
make pin-base-images / docs-serve / clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run a single test (always `--no-cov`: `pyproject.toml`'s pytest `addopts` collect coverage and enforce `fail_under = 97`, so a subset fails on coverage even when it passes):
|
|
48
|
+
```bash
|
|
49
|
+
uv run pytest tests/unit/test_pool.py -k test_with_conn_raises_when_closed -v --no-cov
|
|
50
|
+
uv run pytest tests/integration/test_rls_guc_injection.py -v --no-cov
|
|
51
|
+
uv run pytest tests/e2e/test_rls_pgbouncer_e2e.py -v --no-cov
|
|
52
|
+
uv run pytest -m "not integration and not e2e" # no Docker; the unit suite alone clears the gate
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Coverage note:** line + branch coverage of `src/pgcommon` over all three suites. Gate **≥ 97%** (`[tool.coverage.report] fail_under`, checked by `.github/scripts/coverage-gate.sh`). Current: **100%** line and branch, **2375 passed, 1 skipped** (full run, 2026-10-15). The few unreachable guards are marked `# pragma: no branch` / `# pragma: no cover` with a comment saying why — don't add new ones without that justification.
|
|
56
|
+
|
|
57
|
+
**Testcontainers note:** integration and e2e start their own `postgres:16-alpine` / `edoburu/pgbouncer` containers (Docker must run); they never use the `docker-compose.yml` stack. The compose host ports are overridable: `POSTGRES_HOST_PORT` (5432), `PGBOUNCER_HOST_PORT` (6432).
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
One flat package `src/pgcommon/`: public modules on top, a private `_internal/` package of dependency sinks below; one composition root, the CLI. The import-linter contracts in `pyproject.toml` ([tool.importlinter]) are the rules: `_internal` imports no public module; `errors` depends only on `_internal.sqlstate`; the core never imports `pgcommon.migrate` or `pgcommon.integrations`; those two are independent. Layout, dependency rules and how consumers wire it are in **[`.claude/architecture.md`](architecture.md)**; the narrative with 20 diagrams is [`ARCHITECTURE.md`](../ARCHITECTURE.md); the invariant catalogue (`PGPY-*`) is the LLD.
|
|
62
|
+
|
|
63
|
+
## Critical Rules (do not break)
|
|
64
|
+
|
|
65
|
+
- **Tenant isolation is the reason this library exists.** The identity on a connection is *this* request's, or none. Never add a "same identity, skip" cache (identity is re-sent on every acquire); never inject through an asyncpg `setup` hook (it closes the connection on any error, cancellation included); validate before waiting.
|
|
66
|
+
- **Never bypass `Pool`.** No method or attribute may expose the `asyncpg.Pool`; the only connection a caller sees is a callback's `conn`. The one sanctioned non-pool connection is the migration runner's own `asyncpg.connect`.
|
|
67
|
+
- **No session-level `SET app.*` in `src/`** (CI grep in `validate-quality.yml`). Identity is set only with `set_config` — session-scoped at acquire (direct) or `is_local=true` in a transaction.
|
|
68
|
+
- **Release never raises** (`Pool._release`): a failed reset terminates the connection and logs WARN; the callback's result stands. Every acquire is bounded (`acquire_timeout`).
|
|
69
|
+
- **Password redaction:** every error that can quote a DSN goes through `_internal/dsn.redact` and `sanitize_error` (whole chain); raise outside `except` blocks so no unredacted `__context__` attaches. `ConfigWarning`s never quote `DATABASE_URL`.
|
|
70
|
+
- **Lock-key parity with Go:** the migration advisory-lock key (`_advisory_lock_id`, `Runner._database_name`, `_go_url_path`) must equal Go's for every DSN — Go and Python runners on one table must serialise. Pinned by `test_advisory_lock_id_matches_go_runner` and the `_database_name` tests. Never change it.
|
|
71
|
+
- **Go is the behavioural source of truth.** Match Go v2.0.1; every deliberate divergence is listed in `ARCHITECTURE.md` § Divergences from Go (and LLD §18.5). A metric changes in Go first.
|
|
72
|
+
- **Required check names:** `Validate / Test / test` and `Validate / Quality / quality` (the reusable workflows skip their *inner* job on a docs-only change so these still report). Don't rename jobs; the other `ci.yml` check names (`Build image (cache)`, `Trivy CVE scan`, `Smoke tests`, `PR summary`, …) mirror Go's and should stay stable too.
|
|
73
|
+
- **CHANGELOG entry required** for any change to `src/`, `pyproject.toml`, `uv.lock`, `build-constraints.txt`, `Dockerfile` (`changelog-check.yml`); add it under `[Unreleased]`.
|
|
74
|
+
- **Diagrams:** edit a `docs/architecture/mermaid/*.mmd` and its embedded block in `ARCHITECTURE.md` together (`make docs-check`). No `<word>` in labels.
|
|
75
|
+
- **Coverage gate ≥ 97%** — and new code is expected to stay at 100%.
|
|
76
|
+
|
|
77
|
+
## Conventions
|
|
78
|
+
|
|
79
|
+
- `mypy --strict` over `pgcommon`; `ruff` (line length 100, rules in `pyproject.toml`); `TID251` bans `pgcommon._internal` outside `src/` and `tests/`.
|
|
80
|
+
- Public API = `__all__` of `pgcommon`, `pgcommon.config`, `pgcommon.migrate`, `pgcommon.metrics`, `pgcommon.metrics_registry`, `pgcommon.integrations.fastapi` (`tests/unit/test_public_api.py`). Anything new must be exported deliberately and documented.
|
|
81
|
+
- Exceptions keep their type (Go's `%w` becomes notes, `__cause__` or `callback_error`); every sentinel subclasses `PgCommonError`.
|
|
82
|
+
- Tests: unit tests use the fakes in `tests/fixtures/` (`fake_asyncpg.py`, `fake_migrate_conn.py`, `fakes.py`); anything touching GUC injection, transactions or PgBouncer mode also needs an integration or e2e test through a non-superuser `FORCE ROW LEVEL SECURITY` role.
|
|
83
|
+
- Never run `ruff format .` repo-wide in a review-only change; format only what you touched.
|
|
84
|
+
|
|
85
|
+
## Data Model
|
|
86
|
+
|
|
87
|
+
The library owns **no application table**. Its footprint: golang-migrate's tracking table (`pgcommon_migrations` by default), the GUC contract (`app.user_id`, `app.tenant_id`, `app.tenant_roles`, comma-joined, written together, `''` when reset), the migration advisory lock, and the transaction marker lock. Policies must read `NULLIF(current_setting('app.tenant_id', true), '')::uuid`. Details: **[`.claude/database-schema.md`](database-schema.md)**.
|
|
88
|
+
|
|
89
|
+
## Public API
|
|
90
|
+
|
|
91
|
+
`pgcommon` (pool, transactions, identity, errors, config, logger, tracer; `MetricsIdentity` / `pgmetrics_init_with_identity` aliases), `pgcommon.migrate` (`Runner`, `Migration`, errors), `pgcommon.metrics` (`init_with_identity`, `Identity`, `OTelQueryRecorder`), `pgcommon.metrics_registry`, `pgcommon.integrations.fastapi`. Every sentinel is importable from `pgcommon` except `DuplicateMigrationError` (`pgcommon.migrate`). Catalogue: **[`.claude/api-reference.md`](api-reference.md)**.
|
|
92
|
+
|
|
93
|
+
## Runtime Flows
|
|
94
|
+
|
|
95
|
+
`with_conn` (direct / PgBouncer), `run_in_tx`, retry, savepoints, migrations, dead-connection replacement, `health()`, `drain_and_close()`: **[`.claude/request-flows.md`](request-flows.md)**.
|
|
96
|
+
|
|
97
|
+
## Operations
|
|
98
|
+
|
|
99
|
+
Local stack, migrations, CI workflows, release process and guard, troubleshooting, observability: **[`.claude/operations.md`](operations.md)**.
|
|
100
|
+
|
|
101
|
+
## Extending the Library
|
|
102
|
+
|
|
103
|
+
- **New GUC field:** extend `GUCSet` (`is_zero`, `validate`, a new sentinel in `IDENTITY_ERRORS`), `_guc_columns` / `_guc_args` in `guc.py` (direct and transaction-local paths share them); integration test in `tests/integration/test_rls_guc_injection.py`, e2e in `tests/e2e/test_rls_pgbouncer_e2e.py`; mirror Go.
|
|
104
|
+
- **New `Config` field / env var:** `config.py` (`Config`, `resolve`, `config_from_env`), the README configuration table, LLD §12, `.env-example`.
|
|
105
|
+
- **New metric:** Go first; then `metrics_registry.py` (Proposed), registration in `MetricSet`, `make metrics-doc` + `make metrics-lint`; regenerate `tests/fixtures/go_pgmetrics_vectors.json` from Go.
|
|
106
|
+
- **New module:** add it to the import-linter contracts in `pyproject.toml`.
|
|
107
|
+
- **asyncpg upgrade:** pgcommon relies on non-public asyncpg behaviour (LLD §3.1); widen the `<0.32` cap only with the `asyncpg-compat` matrix in `validate-test.yml` extended.
|
|
108
|
+
|
|
109
|
+
## See Also
|
|
110
|
+
|
|
111
|
+
Detailed reference in `.claude/`:
|
|
112
|
+
- [`architecture.md`](architecture.md) — package layout, dependency contracts, acquire path, GUC modes, transaction and pool lifecycle
|
|
113
|
+
- [`database-schema.md`](database-schema.md) — tracking table, advisory-lock key (Go `databasePath`), GUC contract, RLS policy contract, marker lock
|
|
114
|
+
- [`api-reference.md`](api-reference.md) — public API, `Config` + env vars + defaults, errors and helpers, metrics, CLI
|
|
115
|
+
- [`request-flows.md`](request-flows.md) — step-by-step flows and failure scenarios
|
|
116
|
+
- [`operations.md`](operations.md) — local dev, CI, release, troubleshooting, observability
|
|
117
|
+
|
|
118
|
+
Repository docs:
|
|
119
|
+
- **`README.md`** — consumer guide, local development, testing RLS locally
|
|
120
|
+
- **`ARCHITECTURE.md`** — narrative with 20 diagrams, divergences from Go, threat model
|
|
121
|
+
- **`docs/lld/platform-lld-pgcommon-py.md`** — low-level design (invariant IDs `PGPY-*`, §16 open-questions register). Keep it in sync when behaviour changes
|
|
122
|
+
- **`docs/adr/`**, **`docs/MigrationGuide.md`**, **`docs/observability/`**, **`CHANGELOG.md`**, **`VERSIONING.md`**, **`CONTRIBUTING.md`**, **`.github/SECURITY.md`**
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
The public surface is the `__all__` of `pgcommon`, `pgcommon.config`, `pgcommon.migrate`, `pgcommon.metrics`, `pgcommon.metrics_registry` and `pgcommon.integrations.fastapi` (`tests/unit/test_public_api.py` pins it: every name importable, private-free, documented). `pgcommon._internal` is never public. Signatures are authoritative in the docstrings and type hints (`py.typed`, `mypy --strict`; browse with `make docs-serve`). This file records semantics, contracts and gotchas; the LLD (§5, §17) is the long form.
|
|
4
|
+
|
|
5
|
+
## 1. API Conventions
|
|
6
|
+
|
|
7
|
+
- **Every database call takes a callback.** `await pool.with_conn(fn)` / `await run_in_tx(pool, fn)` with `async def fn(conn: Conn) -> T`. The pool acquires, sets the identity, runs `fn`, and commits / rolls back / releases. No method returns the `asyncpg.Pool` or an unmanaged connection. `Conn` is asyncpg's `PoolConnectionProxy`.
|
|
8
|
+
- **No `ctx` parameter.** The identity travels in a `ContextVar` (`with_guc_set` / `with_validated_guc_set`, context managers), read by `Config.guc_provider = guc_set_from_context`. Cancellation is the task's `asyncio.CancelledError`; deadlines are `acquire_timeout`, `command_timeout`, `statement_timeout` or the caller's `asyncio.timeout`.
|
|
9
|
+
- **Cleanup is bounded, not detached:** every rollback, savepoint cleanup, `ROLLBACK PREPARED` and release reset is bounded to 5 s (`tx.CLEANUP_TIMEOUT`); an interrupted statement's connection is terminated instead of waited on (ADR-002).
|
|
10
|
+
- **Exceptions keep their type.** Go's `%w` becomes a note, a new instance of the same class, `__cause__`, or `callback_error`; `except asyncpg.SerializationError` keeps working, and the `is_*` helpers look through chains.
|
|
11
|
+
- **Every error that could quote a DSN** is redacted and its chain sanitized (`_internal/dsn.redact`, `sanitize_error`).
|
|
12
|
+
- **`None` means default.** Every `Config` field but `dsn` is optional; `0` / `timedelta(0)` also means default (Go parity). An explicit `min_conns=0` is honoured. Privacy options default off.
|
|
13
|
+
- **One `Pool` per event loop and process.** Safe across tasks on that loop, not across threads or loops.
|
|
14
|
+
|
|
15
|
+
## 2. Usage Rules (what reviewers enforce in consumers)
|
|
16
|
+
|
|
17
|
+
| Rule | Why |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Every tenant-serving pool sets `guc_provider=guc_set_from_context` | Otherwise no identity is injected and RLS returns nothing |
|
|
20
|
+
| Cross-tenant work uses a **separate** `Pool` with **no** `guc_provider` on a `BYPASSRLS` role | Never widen the app pool |
|
|
21
|
+
| No session-level `SET app.*` / `set_config(…, false)` in application code; `inject_gucs` only on direct connections | It outlives the transaction behind PgBouncer |
|
|
22
|
+
| `fn` passed to a retry helper is idempotent, with no external side effects | It re-runs whole on `40P01` / `40001` |
|
|
23
|
+
| No `BEGIN` / `COMMIT` / `ROLLBACK` (incl. `AND CHAIN`), `END`, `ABORT`, `PREPARE TRANSACTION` inside an identity-bearing PgBouncer-mode `with_conn`; don't end the transaction in a `run_in_tx` callback | `TxEndedInCallbackError` (never retried), open transaction rolled back; use `with_tx`, `run_in_savepoint` or a nested `conn.transaction()` |
|
|
24
|
+
| `fn` raises to roll back; don't swallow a failed statement | pgcommon raises `TxCommitRollbackError` instead of reporting success |
|
|
25
|
+
| Don't keep `conn` after the callback, or release / close it inside | The pool releases it when `fn` returns |
|
|
26
|
+
| `Config.query_logger` and its `trace_*` hooks are fast and non-blocking | They run on the event loop for every statement; exceptions are logged, never raised |
|
|
27
|
+
| Shutdown uses `drain_and_close(timeout=…)`, not `close()` / `aclose()` | `close()` cuts work without cancelling it server-side; `aclose()` waits with no bound |
|
|
28
|
+
| `init_with_identity` runs **before** `Pool.create` | Pool gauges register only on an identity-initialised set |
|
|
29
|
+
| Every `ContextVar.set()` pairs with `reset()`; long-lived workers start from the lifespan or a fresh `contextvars.Context()` | A task copies the context: a worker started inside a request keeps that tenant forever (ADR-001) |
|
|
30
|
+
| Set `command_timeout` (`PG_COMMAND_TIMEOUT`) above `statement_timeout` | Nothing else bounds a statement on a server that stops answering |
|
|
31
|
+
|
|
32
|
+
## 3. Catalogue
|
|
33
|
+
|
|
34
|
+
### `pgcommon.Config` (`None` / zero = default) and its environment variables
|
|
35
|
+
|
|
36
|
+
| Field | Default | Env (`config_from_env`) | Notes |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `dsn` | — | `DATABASE_URL`, else built from `PG_HOST` (`localhost`) / `PG_PORT` (`5432`) / `PG_USER` / `PG_PASSWORD` / `PG_DBNAME` / `PG_SSLMODE` (`require`) | Required (`DSNRequiredError`). URL or keyword/value. Its `pool_max_conns`, `pool_min_conns`, `pool_max_conn_lifetime`, `pool_max_conn_lifetime_jitter`, `pool_max_conn_idle_time`, `pool_health_check_period`, `connect_timeout` configure the pool and are never sent to the server |
|
|
39
|
+
| `max_conns` / `min_conns` | 10 / 2 capped at max (0 in PgBouncer mode) | `PG_MAX_CONNS` / `PG_MIN_CONNS` | Negative → `InvalidMaxConnsError` / `InvalidMinConnsError`; `min > max` after DSN + defaults → `MinConnsExceedsMaxError`. An env *default* never overrides a DSN `pool_*`; an explicit variable does |
|
|
40
|
+
| `max_conn_lifetime` / `max_conn_lifetime_jitter` | 1 h / lifetime ÷ 10 | `PG_MAX_CONN_LIFETIME` / `PG_MAX_CONN_LIFETIME_JITTER` | Per connection. Negative jitter (or `off`) disables it; negative lifetime → `NegativeDurationError` |
|
|
41
|
+
| `max_conn_idle_time` / `health_check_period` | 30 m / 1 m | `PG_MAX_CONN_IDLE_TIME` / `PG_HEALTH_CHECK_PERIOD` | Negative → `NegativeDurationError` |
|
|
42
|
+
| `slow_query_threshold` | 200 ms **when `logger` is set** | `PG_SLOW_QUERY_THRESHOLD` | Negative / `off` disables |
|
|
43
|
+
| `statement_timeout` / `lock_timeout` | unset | `PG_STATEMENT_TIMEOUT` / `PG_LOCK_TIMEOUT` | `set_config(…, true)` in every `run_in_tx` and PgBouncer wrapping transaction, whole ms rounded up (`pg_millis`); above INT_MAX ms → `TimeoutOutOfRangeError` |
|
|
44
|
+
| `acquire_timeout` | 30 s | `PG_ACQUIRE_TIMEOUT` | Bounds every acquire incl. preparation and retries → `AcquireTimeoutError`; negative / `off` = none. Python-only |
|
|
45
|
+
| `command_timeout` | unset | `PG_COMMAND_TIMEOUT` | asyncpg's client-side per-statement limit. Python-only |
|
|
46
|
+
| `saturated_alive_window` | 10 s | `PG_SATURATED_ALIVE_WINDOW` | `health()` skips the ping on a saturated pool when the server answered within it; `off` = always ping |
|
|
47
|
+
| `prepared_xact_guard` | `True` | `PG_PREPARED_XACT_GUARD` | Transaction marker that proves which prepared transaction is pgcommon's |
|
|
48
|
+
| `pgbouncer_mode` | `False` | `PG_BOUNCER_MODE` | Go `ParseBool` spellings; else warning + `false` |
|
|
49
|
+
| `pool_name` | `"default"` | `PG_POOL_NAME` | `^[a-z][a-z0-9_]{0,31}$` (`InvalidPoolNameError`); the `pool` label |
|
|
50
|
+
| `logger` / `tracer` / `query_logger` / `guc_provider` | `None` | — | `Logger` protocol / `opentelemetry.trace.Tracer` / extra asyncpg query logger (may implement `AcquireTracer` / `ReleaseTracer`) / `Callable[[], GUCSet \| None]` |
|
|
51
|
+
| `log_tenant_id` / `allow_full_statements` | `False` | — | Opt-in `tenant_id` in the slow-query log / full SQL in logs, `db.query.text`, span error messages |
|
|
52
|
+
|
|
53
|
+
`Config.validate()` runs the DSN-free checks; `Pool.create` runs them plus `resolve()`. `config_from_env() -> (Config, list[ConfigWarning])` never raises and never quotes `DATABASE_URL`; it warns on insecure or missing `sslmode` (resolved as asyncpg would: DSN, service file, `PGSSLMODE`), unparsable values, `PG_HOST` / `PG_PORT` conflicts, missing `PG_USER` / `PG_DBNAME`. `migration_dsn_from_env()` = `MIGRATION_DATABASE_URL`, else the app DSN. Constants: `DEFAULT_*`, `DISABLED`, `MAX_PG_TIMEOUT`, `default_min_conns_for`.
|
|
54
|
+
|
|
55
|
+
### `pgcommon` — pool, transactions, identity
|
|
56
|
+
|
|
57
|
+
| Symbol | Contract |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `await Pool.create(config)` | `resolve` → `asyncpg.create_pool(…, init=_init_connection, setup=None)` → untracked ping → metrics → health-check task. Raises the config sentinels, `InvalidDSNError`, or `ConnectionError("pgcommon: failed to create pool: …" / "pgcommon: initial ping failed: …")`, redacted, sanitized `__cause__` |
|
|
60
|
+
| `await pool.with_conn(fn)` | `PoolClosedError` once closed. Direct: identity injected on acquire, no transaction. PgBouncer + identity: `fn` inside a wrapping transaction with transaction-local GUCs and timeouts (`TxEndedInCallbackError`, `TxCommitRollbackError`). No wrapping transaction → no timeouts |
|
|
61
|
+
| `await run_in_tx(pool, fn, *, isolation=None, read_only=False, deferrable=False)` = `pool.run_in_tx` = `pool.with_tx` | `isolation` ∈ `read_committed` / `repeatable_read` / `serializable`. Commit on return; rollback on exception (re-raised unchanged) or cancellation |
|
|
62
|
+
| `await run_in_tx_with_retry(pool, fn, max_attempts, …)` | `max(max_attempts, 1)` immediate attempts, only on `40P01` / `40001`, never on `TxEndedInCallbackError` |
|
|
63
|
+
| `await run_in_tx_with_retry_opts(pool, fn, RetryOptions(...), …)` | `RetryOptions(max_attempts=3, initial_wait, max_wait, multiplier=1.0, jitter_fraction=0.0)`; saturating backoff. Exhaustion re-raises the last error with note `pgcommon: transaction failed after N attempts`; a cancelled sleep raises `CancelledError` with the last error as `__cause__` |
|
|
64
|
+
| `await run_in_savepoint(conn, name, fn)` | `^[A-Za-z_][A-Za-z0-9_]*$` else `InvalidSavepointNameError`. Error → `ROLLBACK TO` + `RELEASE`; failed cleanup → `SavepointCleanupError` (`callback_error`) |
|
|
65
|
+
| `GUCSet(user_id="", tenant_id="", tenant_roles=())` · `is_zero()` · `validate()` | Frozen dataclass. `validate` order: `InconsistentGUCSetError` (user without tenant), `TenantRolesWithoutTenantError`, `InvalidGUCValueError` (NUL / not UTF-8 encodable), per role `EmptyTenantRoleError`, `InvalidTenantRoleError` (`,`), `InvalidGUCValueError` |
|
|
66
|
+
| `with_guc_set` / `with_validated_guc_set` / `guc_set_from_context` | Store / validate then store / read (`None` when unset) |
|
|
67
|
+
| `await inject_gucs(conn, guc_set)` | Manual session-scoped injection; direct connections only |
|
|
68
|
+
| `await pool.health(*, timeout=5.0)` | Never raises. `HealthStatus(healthy, total_conns, idle_conns, acquired_conns, max_conns, utilization, saturated)` |
|
|
69
|
+
| `await pool.drain_and_close(*, timeout=30.0)` | Bounded (≈ `timeout` + 1 s); on timeout cancels running statements server-side, terminates, raises `DrainTimeoutError` |
|
|
70
|
+
| `await pool.aclose()` · `pool.close()` | Go `Close` (unbounded wait) · synchronous hard stop. Both idempotent |
|
|
71
|
+
| `await pool.ping()` · `pool.stat()` · `pool.name` · `pool.metrics_auto_registered` | Untracked `SELECT 1` within `acquire_timeout` · `Stat` counters snapshot · `pool_name` · whether `create` registered the gauges |
|
|
72
|
+
| `sql_operation(sql)` | Verb from a closed 38-word allowlist, leading comments skipped, else `"other"` |
|
|
73
|
+
| `SlowQueryTracer(threshold, logger, *, log_tenant_id=False, allow_full_statements=False, guc_provider=None, on_slow_query=None)`; protocols `AcquireTracer`, `ReleaseTracer` | Standalone asyncpg query logger; `Pool.create` wires one |
|
|
74
|
+
| `Logger`, `StdlibLogger`, `StructlogLogger` | `debug` / `info` / `warn` / `error(msg, /, **fields)`; `StructlogLogger` needs the `structlog` extra |
|
|
75
|
+
|
|
76
|
+
### `pgcommon.migrate`
|
|
77
|
+
|
|
78
|
+
`Runner(dsn, source, *, logger=None, table=None, lock_timeout=None)`; `source` is a path or an `importlib.resources` `Traversable` (top level only, `{version}_{title}.up|down.{ext}`). Every method takes `stop: asyncio.Event | None`.
|
|
79
|
+
|
|
80
|
+
| Method | Contract |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `await up()` | Apply all pending; nothing pending → no error |
|
|
83
|
+
| `await down(n)` | `n > 0` (`ValueError`); fewer than `n` applied → rolls back all, `ShortLimitError` (`.short`) |
|
|
84
|
+
| `await steps(n)` | By sign; `0` only checks the recorded version |
|
|
85
|
+
| `await version() -> (int \| None, bool)` | `(None, False)` = none; `(None, True)` = dirty nil version; `VersionNotInSourceError` when the source lacks it. **Not read-only** (creates the table, waits for the lock) |
|
|
86
|
+
| `await force(version)` | `version >= -1`; a target ≥ 0 must exist in the source |
|
|
87
|
+
|
|
88
|
+
`up` / `down` / `steps` raise `MigrationDirtyError` / `VersionNotInSourceError` (`.version`, `.dirty`) / `MigrationStoppedError`. Also: `Migration`, `DEFAULT_MIGRATIONS_TABLE = "pgcommon_migrations"`, `DEFAULT_LOCK_TIMEOUT` (30 s), `DEFAULT_CONNECT_TIMEOUT` (10 s), `DuplicateMigrationError`, `InvalidMigrationsTableError`, `NoMigrationsFoundError`. DSN `x-` options: `x-migrations-table`, `x-migrations-table-quoted`, `x-statement-timeout`, `x-multi-statement`, `x-multi-statement-max-size` (unknown ones ignored with a warning).
|
|
89
|
+
|
|
90
|
+
### `pgcommon-migrate` CLI
|
|
91
|
+
|
|
92
|
+
`pgcommon-migrate up | down [N] | status | force <V> | version` (also `python -m pgcommon.migrate`, `--version`, `-h` / `--help`). Usage is checked before any env var. Environment: `MIGRATION_DATABASE_URL` → `DATABASE_URL` → `PG_*` (the fallback refused when `PG_BOUNCER_MODE` is true), `MIGRATIONS_DIR` (required), `MIGRATIONS_TABLE`, `MIGRATIONS_LOCK_TIMEOUT` (Go duration; unset / `0` = 30 s; negative = server setting). Progress goes to stderr; stdout carries only the result. SIGINT / SIGTERM stop between migrations.
|
|
93
|
+
|
|
94
|
+
| Exit | Meaning |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `0` (`EXIT_OK`) | Success / no change; `version`; `down N` with fewer than N applied |
|
|
97
|
+
| `1` (`EXIT_ERROR`) | Usage, configuration, connection or migration error; a stopped run (unless its last pending migration was applied) |
|
|
98
|
+
| `3` (`EXIT_DIRTY`) | Dirty schema (`status`, or `up` / `down` hitting `MigrationDirtyError`) |
|
|
99
|
+
| `4` (`EXIT_VERSION_NOT_IN_SOURCE`) | Recorded version not in `MIGRATIONS_DIR` |
|
|
100
|
+
|
|
101
|
+
### `pgcommon.metrics` / `pgcommon.metrics_registry`
|
|
102
|
+
|
|
103
|
+
| Symbol | Contract |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `init_with_identity(identity, registry=None) -> list[RegistrationWarning]` | The only init. Empty `environment` ← `APP_ENV` → `ENVIRONMENT` → `"dev"` (`DEFAULT_ENVIRONMENT`). `None` → prometheus_client's default registry. Invalid identity (`^[a-z][a-z0-9_-]{0,62}$`) → `InvalidIdentityError`, nothing changes. A refused `platform_db_*` name → `RegistrationWarning(metric, error)`, that metric disabled |
|
|
106
|
+
| `Identity(domain, service, environment)` | No version label |
|
|
107
|
+
| `identity_from_env(domain, service="")` / `identity_from_labels(mapping)` / `environment_from_env()` | `APP_NAME` for an empty service / from const labels / env resolution only |
|
|
108
|
+
| `OTelQueryRecorder(tracer, *, allow_full_statements=False)`, `.allow_full_sql()` | `db.query` spans; usable standalone |
|
|
109
|
+
| `registry()`, `lookup(name)`, `RegistryEntry`, `RegistryStatus` | The 10 Proposed entries (identical to Go's) |
|
|
110
|
+
| `MetricSet`, `PoolStats`, `current_set`, `replace_current`, `attach_pool_stats`, `remember_pool`, `live_pools` | Lifecycle primitives used by `Pool`; `replace_current` exists for tests |
|
|
111
|
+
|
|
112
|
+
### `pgcommon.integrations.fastapi` (extra `fastapi`)
|
|
113
|
+
|
|
114
|
+
`create_lifespan(config)` (`Pool.create` → `app.state.pool`; `drain_and_close()` at shutdown) · `get_pool(request)` · `GUCAuthMiddleware(app, identity_extractor=...)` (`IDENTITY_ERRORS` → `401 {"detail": "invalid identity"}`; else `with with_guc_set(g): await call_next(request)`) · `IdentityExtractor` protocol · `healthz(*, utilization_threshold=0.9)` (503 when unhealthy or over the threshold).
|
|
115
|
+
|
|
116
|
+
## 4. Exceptions
|
|
117
|
+
|
|
118
|
+
Every sentinel subclasses `PgCommonError`; check with `isinstance`. All are importable from `pgcommon` except `DuplicateMigrationError` (`pgcommon.migrate`).
|
|
119
|
+
|
|
120
|
+
| Exception (extra base) | Raised by | Typical mapping |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `DSNRequiredError` · `InvalidDSNError` (`ValueError`) · `InvalidMaxConnsError` · `InvalidMinConnsError` · `MinConnsExceedsMaxError` · `InvalidPoolNameError` · `NegativeDurationError` (`ValueError`) · `TimeoutOutOfRangeError` (`ValueError`) | `Config.validate`, `Pool.create` (`TimeoutOutOfRangeError` also `Runner()`) | Startup error: exit |
|
|
123
|
+
| `InvalidIdentityError` (`ValueError`) | `init_with_identity` | Startup error |
|
|
124
|
+
| `PoolClosedError` | `with_conn`, `run_in_tx`, `ping` | 503, final (`is_pool_closed`) |
|
|
125
|
+
| `AcquireTimeoutError` (`TimeoutError`) | acquire | 503 |
|
|
126
|
+
| `DrainTimeoutError` (`TimeoutError`) | `drain_and_close` | Log at shutdown |
|
|
127
|
+
| `TxEndedInCallbackError` | `run_in_tx` / `with_tx`, PgBouncer `with_conn` | 500; never retried; `callback_error` = fn's exception |
|
|
128
|
+
| `TxCommitRollbackError` | same | 500 |
|
|
129
|
+
| `SavepointCleanupError` · `InvalidSavepointNameError` | `run_in_savepoint` | 500 |
|
|
130
|
+
| `IDENTITY_ERRORS` = `InconsistentGUCSetError`, `TenantRolesWithoutTenantError`, `EmptyTenantRoleError`, `InvalidTenantRoleError`, `InvalidGUCValueError` | `validate`, `with_validated_guc_set`, acquire (`pgcommon: invalid GUC identity: …`), `inject_gucs` | 400 / 401 |
|
|
131
|
+
| `MigrationDirtyError` · `VersionNotInSourceError` · `NoMigrationsFoundError` · `DuplicateMigrationError` · `ShortLimitError` · `MigrationStoppedError` · `InvalidMigrationsTableError` | `Runner` | CLI exits 3 / 4 / 1 |
|
|
132
|
+
|
|
133
|
+
## 5. SQLSTATE Helpers
|
|
134
|
+
|
|
135
|
+
All accept `None` and look through `__cause__`, exception groups and `callback_error`. Only server-sent errors count.
|
|
136
|
+
|
|
137
|
+
| Helper | Match |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `is_unique_violation` (+ `constraint_name`) · `is_foreign_key_violation` · `is_not_null_violation` · `is_check_violation` | `23505` · `23503` · `23502` · `23514` |
|
|
140
|
+
| `is_deadlock` · `is_serialization_failure` | `40P01` · `40001` (retried) |
|
|
141
|
+
| `is_lock_not_available` · `is_query_canceled` | `55P03` · `57014` |
|
|
142
|
+
| `is_connection_exception` · `is_insufficient_resources` · `is_operator_intervention` · `is_system_error` | class `08` · `53` · `57` except `57014` · `58` |
|
|
143
|
+
| `is_pg_error` · `sql_state` | any server error · the raw code (`""` otherwise) |
|
|
144
|
+
| `is_pool_closed` | `PoolClosedError`, asyncpg's closed / closing pool, `POOL_INTERRUPTED_NOTE` |
|
|
145
|
+
| `is_connection_lost` (Python-only) | class `08`, `57P01`–`57P03`, built-in `ConnectionError`, asyncpg's `ConnectionDoesNotExistError`, `CONNECTION_LOST_NOTE`; **not** a `command_timeout`. Never retry |
|
|
146
|
+
|
|
147
|
+
## 6. Metric Catalogue
|
|
148
|
+
|
|
149
|
+
Registered by `init_with_identity`; `{domain, service, environment}` bound on every series. All **Tier 1, Proposed**, identical to Go's (name, type, labels, buckets, help text byte for byte). No legacy `pgcommon_*` family, no `*_created` samples.
|
|
150
|
+
|
|
151
|
+
| Metric | Type | Labels |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `platform_db_queries_total` | counter | `pool`, `operation`, `outcome` |
|
|
154
|
+
| `platform_db_query_duration_seconds` | histogram (`.005`…`10`) | `pool`, `operation` |
|
|
155
|
+
| `platform_db_pool_acquires_total` | counter | `pool`, `outcome` |
|
|
156
|
+
| `platform_db_pool_acquire_duration_seconds` | histogram (`.001`…`10`) | `pool` |
|
|
157
|
+
| `platform_db_slow_queries_total` | counter | `pool` |
|
|
158
|
+
| `platform_db_transaction_retries_total` / `_exhausted_total` | counter | `pool`, `reason` |
|
|
159
|
+
| `platform_db_pool_connections` | gauge (scrape time) | `pool`, `state` (`idle`, `acquired`; never `constructing`) |
|
|
160
|
+
| `platform_db_pool_max_connections` | gauge | `pool` |
|
|
161
|
+
| `platform_db_pool_empty_acquires_total` | counter (scrape time) | `pool` |
|
|
162
|
+
|
|
163
|
+
Invariants: counters pre-created at 0 per pool; internal statements counted only when they fail; cancelled / invalid-identity / post-drain acquires record only their wait; replaced dead connections are not query errors; a statement asyncpg dropped with a dying connection is recorded once as `operation="other"`; a refused registration disables only that metric. Spans: `db.query` (kind `CLIENT`), `db.system.name`, `db.operation.name`, `db.namespace`, `server.address`, `server.port`; failures carry only the SQLSTATE unless full statements are allowed.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The Go module's Clean Architecture layers become one flat package under `src/pgcommon/`: public modules on top, a private `_internal/` package of dependency sinks below. Python has no `internal/` rule, so `_internal` is private by convention (leading underscore, never re-exported or documented) and by the import-linter contracts. There is one composition root, the console script `pgcommon-migrate` (`pgcommon.migrate.cli:main`). Everything else is a library. The narrative with 20 diagrams is in [`ARCHITECTURE.md`](../ARCHITECTURE.md); the invariant catalogue is [`docs/lld/platform-lld-pgcommon-py.md`](../docs/lld/platform-lld-pgcommon-py.md).
|
|
4
|
+
|
|
5
|
+
## Package Layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
platform-pgcommon-py/
|
|
9
|
+
├── src/pgcommon/
|
|
10
|
+
│ ├── __init__.py # public re-exports + sql_operation, MetricsIdentity, pgmetrics_init_with_identity,
|
|
11
|
+
│ │ # OTelQueryRecorder, RegistrationWarning (tests/unit/test_public_api.py pins __all__)
|
|
12
|
+
│ ├── config.py # Config, ResolvedConfig, resolve() (validate → DSN → DSN pool_* → defaults), ConfigWarning,
|
|
13
|
+
│ │ # config_from_env(), migration_dsn_from_env(), default_min_conns_for, DEFAULT_*, DISABLED,
|
|
14
|
+
│ │ # MAX_PG_TIMEOUT
|
|
15
|
+
│ ├── pool.py # Pool (create, with_conn, run_in_tx = with_tx, ping, health, stat, aclose, close,
|
|
16
|
+
│ │ # drain_and_close, name, metrics_auto_registered), Stat, HealthStatus, Conn;
|
|
17
|
+
│ │ # _acquire / _acquire_conn / _take_conn / _prepare_failed / _release; _init_connection
|
|
18
|
+
│ │ # (asyncpg init hook: lifetime deadline, query logger, keepalive); _on_query (every sink);
|
|
19
|
+
│ │ # _force_close; health-check task: _close_expired_idle, ping, _top_up
|
|
20
|
+
│ ├── tx.py # run_in_tx, run_in_savepoint, begin_tx, _set_up_tx (setup statement), run_wrapped_tx,
|
|
21
|
+
│ │ # acquire_and_run_wrapped, _commit, _rollback, _resolve_prepared, pg_millis, CLEANUP_TIMEOUT
|
|
22
|
+
│ ├── retry.py # RetryOptions, run_in_tx_with_retry, run_in_tx_with_retry_opts
|
|
23
|
+
│ ├── guc.py # GUCSet (is_zero, validate), with_guc_set, with_validated_guc_set, guc_set_from_context,
|
|
24
|
+
│ │ # inject_gucs, _guc_columns / _guc_args (shared by both injection paths)
|
|
25
|
+
│ ├── errors.py # PgCommonError + every sentinel, IDENTITY_ERRORS, is_* helpers, sql_state, constraint_name,
|
|
26
|
+
│ │ # is_connection_lost, CONNECTION_LOST_NOTE, POOL_INTERRUPTED_NOTE
|
|
27
|
+
│ ├── tracer.py # SlowQueryTracer, AcquireTracer / ReleaseTracer protocols
|
|
28
|
+
│ ├── logger.py # Logger protocol, StdlibLogger, StructlogLogger
|
|
29
|
+
│ ├── metrics.py # Identity, init_with_identity, identity_from_env / _labels, environment_from_env, MetricSet,
|
|
30
|
+
│ │ # RegistrationWarning, attach_pool_stats, PoolStats, current_set, replace_current,
|
|
31
|
+
│ │ # remember_pool, live_pools, OTelQueryRecorder
|
|
32
|
+
│ ├── metrics_registry.py # registry(), lookup(), RegistryEntry, RegistryStatus, label vocabularies, buckets
|
|
33
|
+
│ ├── migrate/ # runner.py (Runner, Migration, DEFAULT_*, _advisory_lock_id, _go_url_path, _Session);
|
|
34
|
+
│ │ # cli.py (pgcommon-migrate, EXIT_*); errors.py (DuplicateMigrationError + re-exports); __main__.py
|
|
35
|
+
│ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, IdentityExtractor, healthz (extra `fastapi`)
|
|
36
|
+
│ ├── py.typed
|
|
37
|
+
│ └── _internal/ # PRIVATE — dependency sinks
|
|
38
|
+
│ ├── dsn.py # is_url_dsn, normalize_dsn, dsn_settings, keyword_database, check_asyncpg_url,
|
|
39
|
+
│ │ # has_bad_percent_escape, resolve_connect_timeout, redact, sanitize_error, RedactedError, mask_dsn
|
|
40
|
+
│ ├── wrap_tx.py # arm / flush / note_statement (escape watch), interrupted, terminate, terminated_by_pgcommon
|
|
41
|
+
│ ├── txshape.py # analyze_tx (port of Go sqlutil.AnalyzeTx)
|
|
42
|
+
│ ├── statement_scope.py # internal / untracked statement scopes, acquire_statements()
|
|
43
|
+
│ ├── sql_verb.py # sql_operation, known_operations (port of Go sqlutil.Operation)
|
|
44
|
+
│ └── sqlstate.py · duration.py · strconv.py (go_trim_space, parse_go_*) · goquote.py · query_event.py
|
|
45
|
+
├── tests/
|
|
46
|
+
│ ├── unit/ # no Docker; fakes in tests/fixtures/; parity + invariant suites (test_*_invariants.py,
|
|
47
|
+
│ │ # test_core_redaction_and_config.py, test_txshape_go_oracle.py, test_public_api.py)
|
|
48
|
+
│ ├── integration/ # testcontainers Postgres 16 (RLS, failover, tx escape, migrations, invariants)
|
|
49
|
+
│ ├── e2e/ # Postgres + PgBouncer (transaction mode)
|
|
50
|
+
│ └── fixtures/ # fake_asyncpg.py, fake_migrate_conn.py, fakes.py, go_pgmetrics_vectors.json (+ .go.txt)
|
|
51
|
+
├── migrations/ # example 0001 pair for the CLI image and `make migrate-up`; not in the wheel
|
|
52
|
+
├── docs/ # adr/ (ADR-001..004), architecture/ (README + 20 mermaid/*.mmd), lld/, observability/,
|
|
53
|
+
│ # MigrationGuide.md
|
|
54
|
+
├── .github/ # workflows (ci, validate-test, validate-quality, release, docs, changelog-check), scripts,
|
|
55
|
+
│ # CODEOWNERS, SECURITY.md (canonical), dependabot.yml, templates
|
|
56
|
+
├── .githooks/pre-commit # ruff format --check + ruff check (staged .py), docs-check, lock-check
|
|
57
|
+
├── .claude/ # this reference set
|
|
58
|
+
└── Dockerfile docker-compose.yml Makefile pyproject.toml uv.lock build-constraints.txt .env-example
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Runtime Dependencies (what every consumer inherits)
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
dependencies = [
|
|
65
|
+
"asyncpg>=0.30,<0.32", # private behaviour relied on: see validate-test.yml asyncpg-compat
|
|
66
|
+
"prometheus-client>=0.20",
|
|
67
|
+
"opentelemetry-api>=1.24",
|
|
68
|
+
]
|
|
69
|
+
[project.optional-dependencies]
|
|
70
|
+
fastapi = ["fastapi>=0.110"]
|
|
71
|
+
structlog = ["structlog>=24.1"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Deliberately absent:** a logging framework, the OTel SDK, any web framework outside the `fastapi` extra (importing `pgcommon` never imports `fastapi` / `starlette`), every test tool (`[dependency-groups] dev`). **asyncpg is capped `<0.32`** because pgcommon relies on non-public behaviour (query loggers dispatched with `loop.call_soon` copying the `contextvars` context; `Connection._top_xact`; `_protocol._is_cancelling()`; `Pool._queue` and holder internals for the top-up; the slot asyncpg 0.31 leaks on a protocol error). The floor is 0.30 (0.29's `command_timeout` does not free slots on a frozen server); CI's `asyncpg-compat` job runs the whole suite on 0.30.0. LLD §3.1 has the full table.
|
|
75
|
+
|
|
76
|
+
## Dependency Rules (`make arch-lint`, `.github/scripts/arch-lint.sh`, run in `validate-test.yml`)
|
|
77
|
+
|
|
78
|
+
The import-linter contracts in `pyproject.toml`:
|
|
79
|
+
1. **`_internal` is a dependency sink:** it imports no public `pgcommon` module.
|
|
80
|
+
2. **`errors` depends only on `_internal.sqlstate`.**
|
|
81
|
+
3. **The core never imports `pgcommon.migrate` or `pgcommon.integrations`** (core = `_internal`, `config`, `errors`, `guc`, `logger`, `metrics`, `metrics_registry`, `pool`, `retry`, `tracer`, `tx`).
|
|
82
|
+
4. **The top-level `pgcommon` package** (`__init__` alone) never imports them either.
|
|
83
|
+
5. **`migrate` and `integrations` are independent.**
|
|
84
|
+
|
|
85
|
+
Also: `tx.py` imports `Pool` for type annotations only (`TYPE_CHECKING`); `pool.py` calls into `tx`. A new module needs a place in these contracts. Ruff `TID251` bans `pgcommon._internal`, exempt for `src/pgcommon/**` and `tests/**`.
|
|
86
|
+
|
|
87
|
+
## Database Access Rule
|
|
88
|
+
|
|
89
|
+
All access goes through `Pool.with_conn` / `run_in_tx` / the retry helpers. `Pool` has no attribute or method returning the `asyncpg.Pool` (`tests/integration/test_pool_lifecycle.py::test_pool_helpers_are_the_only_supported_entry_point`). The one sanctioned non-pool connection is the migration runner's own `asyncpg.connect` (DDL on a dedicated session holding a session-scoped advisory lock). **RLS guard (CI, `validate-quality.yml`):** no session-level `SET app.*` in `src/` (case-insensitive; `SET LOCAL` and `set_config` allowed).
|
|
90
|
+
|
|
91
|
+
## The Acquire Path (`Pool._acquire`, shared by every helper)
|
|
92
|
+
|
|
93
|
+
1. `guc_provider()` → `GUCSet`; zero → none. Closed → `PoolClosedError`.
|
|
94
|
+
2. Non-zero identity → `validate()` **before waiting** (both modes). Invalid → a new instance of the same class, `pgcommon: invalid GUC identity: …`, only the acquire wait recorded.
|
|
95
|
+
3. `asyncio.timeout(acquire_timeout)` around a loop of up to `max_conns + 1` attempts: asyncpg `acquire()` → capture the raw connection → past its lifetime (taken from idle) → close and retry → the **acquire stage**: `inject` (direct mode + identity: `SELECT set_config(…, false) ×3`), else `ping` (`SELECT 1`, untracked, idle ≥ 1 s), then `begin` (`BEGIN` + the setup statement) when a transaction is needed.
|
|
96
|
+
4. A stage failure on a session the server/network ended, a `command_timeout`, or a failed ping → terminate, release, retry on another connection (INFO "connection found dead during acquire; replaced"; not a query error). A PostgreSQL error on a live session → release it healthy and raise (WARN for inject). Every try timing out → `AcquireTimeoutError`.
|
|
97
|
+
5. `fn(conn)` → `_release`: closed → `terminate()` (frees asyncpg's slot); interrupted → terminate; past lifetime → close; else asyncpg's reset (`RESET ALL`) bounded to 5 s, failure → terminate + WARN. **Release never raises.**
|
|
98
|
+
|
|
99
|
+
## GUC Modes
|
|
100
|
+
|
|
101
|
+
| | Direct (`pgbouncer_mode=False`) | PgBouncer / RDS Proxy (`True`) |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Identity scope | Session, set per acquire (`is_local=false`) | Transaction-local (`is_local=true`) in the setup statement |
|
|
104
|
+
| Cleared by | asyncpg's `RESET ALL` on release; failed reset → terminate | Server at `COMMIT` / `ROLLBACK` |
|
|
105
|
+
| `with_conn` + identity | `fn` runs with no transaction | `fn` runs inside a wrapping transaction (`run_wrapped_tx`), escape-watched |
|
|
106
|
+
| Statement cache | On | `statement_cache_size=0` |
|
|
107
|
+
| `min_conns` default | 2 (capped at max) | 0 |
|
|
108
|
+
|
|
109
|
+
There is no dirty flag (Go's `pgcommon.guc_dirty`): every released connection is identity-free or terminated.
|
|
110
|
+
|
|
111
|
+
## Transaction Lifecycle (`run_in_tx` → `acquire_and_run_wrapped` → `run_wrapped_tx`)
|
|
112
|
+
|
|
113
|
+
Acquire with the `begin` stage (`begin_tx`: `BEGIN` with isolation / read-only / deferrable, then one setup `SELECT` combining the prepared-transaction marker, PgBouncer identity and `statement_timeout` / `lock_timeout` — each only when it applies) → `wrap_tx.arm` → `fn(conn)` → `flush()` → decide: escaped or idle → roll back, resolve a marked prepared transaction, `TxEndedInCallbackError`; `fn` raised / cancelled → roll back (5 s bound, or terminate if a statement was interrupted), re-raise; a swallowed server error seen → roll back, `TxCommitRollbackError`; else pgcommon's own `COMMIT` on the raw connection, clear `_top_xact`, status `ROLLBACK` → `TxCommitRollbackError`.
|
|
114
|
+
|
|
115
|
+
## Pool Lifecycle
|
|
116
|
+
|
|
117
|
+
`Pool.create`: `resolve` (validation, DSN parsing, merge, defaults) → `asyncpg.create_pool(min_size, max_size, max_inactive_connection_lifetime=0, init=_init_connection, setup=None, …)` → untracked ping → metrics (`init_pool` + `attach_pool_stats`, or `remember_pool`) → health-check task. Maintenance every `health_check_period`: idle expiry (lifetime, or idle time while more than `min_conns` are open), `ping()`, top-up to `min_conns` (also ~0.5 s after a close). Shutdown: `drain_and_close(timeout=30.0)` (bounded; forced path cancels server statements, raises `DrainTimeoutError`), `aclose()` (Go `Close`, unbounded wait), `close()` (synchronous hard stop).
|
|
118
|
+
|
|
119
|
+
## How Platform Libraries Use pgcommon
|
|
120
|
+
|
|
121
|
+
- **`platform-eventcommon`** holds a `pgcommon.Pool`; outbox / inbox / DLQ work runs through `pgcommon.run_in_tx`; `outbox.enqueue` writes with the **caller's** `run_in_tx` `conn` (atomic with the business write); its consumer binds the tenant with `with_guc_set`; it applies its own schema (not `Runner`) and raises `MigrationDirtyError`. It pins `pgcommon<0.3.0` (LLD §16 A1).
|
|
122
|
+
- **FastAPI services:** `init_with_identity(...)` → `config_from_env()` → `guc_provider = guc_set_from_context` → `Pool.create` (or `create_lifespan(config)`) → `GUCAuthMiddleware` with the service's `IdentityExtractor` → repositories call `pool.with_conn` / `run_in_tx`. Cross-tenant work uses a second pool on a `BYPASSRLS` role with **no** `guc_provider`.
|
|
123
|
+
- **`platform-fastapicommon`:** no dependency in either direction; share one `CollectorRegistry` and identity.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Database Schema
|
|
2
|
+
|
|
3
|
+
pgcommon owns **no application tables**. Its database footprint in each consumer's database is (1) golang-migrate's version-tracking table, created by `pgcommon.migrate.Runner`, (2) the **GUC contract** — the three session variables pgcommon writes and consumer RLS policies read — and (3) two kinds of transient advisory-lock state. Everything else (tables, policies, roles, grants) is owned by each consuming service's migrations. The tracking table and lock key are **shared with the Go runner**: Go and Python runners (and golang-migrate itself) can serve one database and serialise on one lock. LLD §4 is the long form.
|
|
4
|
+
|
|
5
|
+
## Tracking Table
|
|
6
|
+
|
|
7
|
+
```sql
|
|
8
|
+
-- created by Runner (golang-migrate's ensureVersionTable), only while the advisory lock is held
|
|
9
|
+
CREATE TABLE IF NOT EXISTS "<schema>"."pgcommon_migrations" (
|
|
10
|
+
version bigint not null primary key,
|
|
11
|
+
dirty boolean not null
|
|
12
|
+
);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Row state | Meaning |
|
|
16
|
+
|---|---|
|
|
17
|
+
| No row | No version recorded (`Runner.version()` → `(None, False)`) |
|
|
18
|
+
| `(v, false)` | Migration `v` is the last applied |
|
|
19
|
+
| `(v, true)` | A migration to **target** `v` started and did not finish: every `up` / `down` / `steps` raises `MigrationDirtyError` (CLI exit 3) until `force` |
|
|
20
|
+
| `(-1, true)` | A failed down of the first migration (`version()` → `(None, True)`): repair, then `force(-1)` |
|
|
21
|
+
|
|
22
|
+
- **Name:** `DEFAULT_MIGRATIONS_TABLE = "pgcommon_migrations"` (deliberately not golang-migrate's `schema_migrations`). Override with `Runner(table=...)`, the CLI's `MIGRATIONS_TABLE`, or a DSN `x-migrations-table` (the DSN wins). The name is **quoted** (case-sensitive; golang-migrate's `quoteIdentifier`: cut at NUL, `"` doubled) and qualified with `current_schema()`, or with the schema of a quoted `x-migrations-table` (`x-migrations-table-quoted=true`, `"schema"."table"`).
|
|
23
|
+
- **Writes** follow golang-migrate's `SetVersion`: `TRUNCATE` then `INSERT` (no row for a clean nil version), in one transaction.
|
|
24
|
+
- **Dirty protocol:** record the target version dirty → run the body (not in an explicit transaction; a single-statement file can use `CREATE INDEX CONCURRENTLY`) → record it clean. A body runs under `asyncio.shield`: never interrupted mid-SQL.
|
|
25
|
+
- **One table per migration set.** Two sets sharing a table collide on version numbers.
|
|
26
|
+
- **The table DDL runs only under the advisory lock**, for every operation including `version` and `force` (concurrent `CREATE TABLE IF NOT EXISTS` races on `pg_type`).
|
|
27
|
+
- **0.2.0 folded tables are refused, not adopted:** if the quoted table is missing but its lower-case folding exists in the same schema, every operation raises `InvalidMigrationsTableError` naming both — unless the name came from an explicitly quoted `x-migrations-table`.
|
|
28
|
+
|
|
29
|
+
## Advisory-Lock Key (must equal Go's)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
key = (crc32(schema + "\x00" + table + "\x00" + database_name) * 1486364155) & 0xFFFFFFFF # _advisory_lock_id
|
|
33
|
+
SELECT pg_advisory_lock(key) -- session lock, held for the whole run; pg_advisory_unlock in a finally
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`database_name` is golang-migrate's `DatabaseName` as Go's runner sets it (`databasePath(dsn, connCfg.Database)`), reproduced by `Runner._database_name`:
|
|
37
|
+
|
|
38
|
+
1. **URL-ness is decided on the Go-`TrimSpace`'d DSN** (`go_trim_space`: Go's `unicode.IsSpace` set, including U+00A0 and U+0085, not Python's `str.strip`). A DSN that starts with `postgres://` / `postgresql://` after trimming is "a URL".
|
|
39
|
+
2. **The path comes from Go's `url.Parse` of the DSN *as written*** (`_go_url_path`): the decoded path (`"/db"`, `"/my db"` for `%20`, `""` without a path, `"/"` for a bare slash). A **trailing space stays** (`"postgresql://h/db "` → `"/db "`; stripping it would key `"/db"`).
|
|
40
|
+
3. **Where Go's `url.Parse` fails**, Go falls back, and so does Python, to `"/" + keyword_database(dsn)` — the database pgx resolves when reading the string as keyword/value (last of `dbname` / `database`, else `PGDATABASE`, else `""`). `url.Parse` fails on: **leading whitespace** (incl. U+00A0) before the scheme; any **ASCII control character** (tab, CR, LF, DEL — Python's `urlsplit` would silently drop them); a **`%` not followed by two hex digits** in the userinfo, path or fragment (`has_bad_percent_escape`); an **unbalanced `[`** in the host.
|
|
41
|
+
4. **A keyword/value DSN** keys `"/" + keyword_database(dsn)`.
|
|
42
|
+
5. `database_name == ""` (a URL without a path) → the session's `CURRENT_DATABASE()` is used, as golang-migrate does.
|
|
43
|
+
|
|
44
|
+
Pinned by `tests/unit/test_migrate_runner.py::test_advisory_lock_id_matches_go_runner` (lock IDs computed by the Go runner), `test_database_name_follows_gos_url_parse`, `test_database_name_falls_back_where_gos_url_parse_fails`, and on a real server by `tests/integration/test_migrate_invariants_integration.py`. **Never change it**: a different key lets a Go and a Python runner migrate one table concurrently. 0.2.0 locked one fixed key — never run a 0.2.0 runner alongside 0.3.0. Not resolved: a database named only in a service file (`service=` / `PGSERVICE`) — both libraries ignore it (LLD §16 D1).
|
|
45
|
+
|
|
46
|
+
## GUC Contract
|
|
47
|
+
|
|
48
|
+
| GUC | Source | Validation (`GUCSet.validate()`) | Written as |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `app.user_id` | `GUCSet.user_id` | requires `tenant_id` → `InconsistentGUCSetError`; NUL / not UTF-8 encodable → `InvalidGUCValueError` | `set_config('app.user_id', $n, is_local)` |
|
|
51
|
+
| `app.tenant_id` | `GUCSet.tenant_id` | `tenant_id` alone is valid; NUL / unencodable → `InvalidGUCValueError` | `set_config('app.tenant_id', $n+1, is_local)` |
|
|
52
|
+
| `app.tenant_roles` | `",".join(GUCSet.tenant_roles)` | requires `tenant_id` → `TenantRolesWithoutTenantError`; empty role → `EmptyTenantRoleError`; role with `,` → `InvalidTenantRoleError`; NUL / unencodable → `InvalidGUCValueError` | `set_config('app.tenant_roles', $n+2, is_local)` |
|
|
53
|
+
|
|
54
|
+
- **Always all three, in one statement**, as **bind parameters** (`_guc_columns`, `_guc_args`). Validation runs **before any SQL and before any connection is waited for**.
|
|
55
|
+
- **Direct mode** (`is_local=false`): injected on every identity-bearing acquire, never cached; removed by asyncpg's `RESET ALL` on release; a connection whose reset fails or exceeds 5 s is terminated. No dirty flag.
|
|
56
|
+
- **PgBouncer mode** (`is_local=true`): written in the transaction's setup statement right after `BEGIN`, for every `run_in_tx` and every identity-bearing `with_conn`; discarded at transaction end. `statement_cache_size=0`.
|
|
57
|
+
- **A zero `GUCSet`** (or `None` from `guc_provider`) writes nothing.
|
|
58
|
+
- **Not tracked:** a raw `SET app.…` or `set_config(…, false)` in application code — behind PgBouncer it reaches the next client of that backend.
|
|
59
|
+
|
|
60
|
+
## RLS Policy Contract (for consumer migrations)
|
|
61
|
+
|
|
62
|
+
```sql
|
|
63
|
+
ALTER TABLE t ENABLE ROW LEVEL SECURITY;
|
|
64
|
+
ALTER TABLE t FORCE ROW LEVEL SECURITY; -- the owner is filtered too
|
|
65
|
+
REVOKE ALL ON t FROM PUBLIC;
|
|
66
|
+
CREATE POLICY tenant_isolation ON t
|
|
67
|
+
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
|
|
68
|
+
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Both empty states must yield NULL → 0 rows:** never set on the session (`current_setting(…, true)` returns NULL), and reset to `''` (after `RESET ALL`, or after a transaction-local set ended; `''::uuid` raises, `NULLIF` turns it into NULL). Roles: `string_to_array(NULLIF(current_setting('app.tenant_roles', true), ''), ',')`.
|
|
72
|
+
|
|
73
|
+
**Role requirements:** the application role is not `BYPASSRLS`, not a superuser, and (without `FORCE`) not the table owner. Cross-tenant work uses a separate pool on another role with **no** `guc_provider`. This repo's isolation tests use a dedicated non-superuser role on `FORCE ROW LEVEL SECURITY` tables and compare `tenant_id` as **text** (`tests/integration/test_rls_guc_injection.py`, `tests/integration/test_core_invariants_integration.py`, `tests/e2e/test_rls_pgbouncer_e2e.py`, `tests/e2e/test_core_invariants_e2e.py` — the last one holds every PgBouncer backend at once and checks none keeps an identity). They do not exercise the `uuid` cast: **consumers must test their own policies.**
|
|
74
|
+
|
|
75
|
+
## Transaction Marker Lock (prepared-transaction guard)
|
|
76
|
+
|
|
77
|
+
| Lock | Taken by | Scope | Purpose |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `pg_try_advisory_xact_lock_shared($1::int, $2::int)` on a random 2 × 31-bit key | The transaction setup statement (`_TX_MARKER_SQL` in `tx.py`), only when `max_prepared_transactions > 0`, the server is not in recovery and `prepared_xact_guard` is on | Transaction; inherited by a prepared transaction | Prove which prepared transaction is this transaction's |
|
|
80
|
+
|
|
81
|
+
If a callback runs `PREPARE TRANSACTION`, `_resolve_prepared` joins `pg_locks` (the marker held by a prepared-transaction pseudo-backend, `pid IS NULL`) with `pg_prepared_xacts` and rolls back **only** the one holding this transaction's marker (`ROLLBACK PREPARED '<gid>'`, bounded to 5 s). Every other prepared transaction is named in `TxEndedInCallbackError` for manual resolution. On a server that cannot prepare transactions (`max_prepared_transactions = 0`, the default, or a standby) the first marker returns NULL and the connection is not marked again.
|
|
82
|
+
|
|
83
|
+
## Data Model Entities NOT Owned Here
|
|
84
|
+
|
|
85
|
+
Every application table, policy, role, grant, enum and trigger in consumer databases; the outbox, inbox and dead-letter tables and their tracking tables (`platform-eventcommon`, which applies its own schema without `Runner`); consumer idempotency ledgers. The repository's `migrations/0001_create_example_table.*` pair is an example for the CLI image and `make migrate-up`, not part of the wheel.
|