pgcommon 0.3.1__tar.gz → 0.4.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.3.1 → pgcommon-0.4.0}/.claude/CLAUDE.md +3 -3
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/api-reference.md +3 -1
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/architecture.md +3 -2
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/operations.md +4 -4
- {pgcommon-0.3.1 → pgcommon-0.4.0}/ARCHITECTURE.md +16 -5
- {pgcommon-0.3.1 → pgcommon-0.4.0}/CHANGELOG.md +51 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/PKG-INFO +97 -16
- {pgcommon-0.3.1 → pgcommon-0.4.0}/README.md +96 -15
- {pgcommon-0.3.1 → pgcommon-0.4.0}/VERSIONING.md +7 -6
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/lld/platform-lld-pgcommon-py.md +40 -22
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/observability/README.md +5 -0
- pgcommon-0.4.0/docs/problem-details.md +221 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/pyproject.toml +1 -1
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/sqlstate.py +20 -0
- pgcommon-0.4.0/src/pgcommon/integrations/_problem.py +149 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/integrations/fastapi.py +153 -16
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/metrics.py +19 -4
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/pool.py +9 -6
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/tx.py +3 -2
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_core_invariants.py +1 -1
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_fastapi_invariants.py +4 -3
- pgcommon-0.4.0/tests/unit/test_fastapi_problem_details.py +423 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_integrations_fastapi.py +6 -3
- pgcommon-0.4.0/tests/unit/test_logged_error_text.py +237 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics.py +78 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_pool.py +2 -2
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_tx.py +4 -1
- {pgcommon-0.3.1 → pgcommon-0.4.0}/uv.lock +1 -1
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/database-schema.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/request-flows.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.claude/settings.json +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.dockerignore +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.env-example +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.githooks/pre-commit +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/CODEOWNERS +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/SECURITY.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/dependabot.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/pull_request_template.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/api-compat.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/arch-lint.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/changelog-section.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/coverage-gate.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/create-github-release.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/detect-changes.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/detect-changes_test.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/docs-mermaid-sync.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/export-image-provenance.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/extract-release-notes.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/image-fingerprint.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/log-image-digest.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/pr-summary.js +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/prepare-release-dist.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/release-checksums.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/release-image-tags.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/release_guard.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/set-build-cache-targets.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/set-source-date-epoch.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/smoke-tests.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/verify-changelog-entry.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/scripts/verify-release-tag.sh +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/changelog-check.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/ci.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/docs.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/release.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/validate-quality.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.github/workflows/validate-test.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/.gitignore +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/CONTRIBUTING.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/Dockerfile +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/LICENSE +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/Makefile +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/SECURITY.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/build-constraints.txt +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docker-compose.yml +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/MigrationGuide.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/adr/ADR-001-contextvars-guc-propagation.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/adr/ADR-002-cancellation-and-rollback-safety.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/adr/ADR-003-guc-injection-timing.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/adr/ADR-004-migration-runner-design.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/README.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/consuming-service-wiring.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/data-model.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/deployment.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/documentation-assets.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/error-classification.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/failure-domains.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/layer-model.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/migration-runner.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/no-identity-flow.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/observability-stack.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/package-dependencies.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/pool-create-hook-composition.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/pool-lifecycle.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/query-flow.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/retry-flow.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/rls-guc-flow.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/runintx-lifecycle.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/architecture/mermaid/withconn-standard-mode.mmd +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/docs/observability/metrics-registry.md +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/migrations/0001_create_example_table.down.sql +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/migrations/0001_create_example_table.up.sql +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/dsn.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/duration.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/goquote.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/query_event.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/sql_verb.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/statement_scope.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/strconv.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/txshape.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/_internal/wrap_tx.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/config.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/errors.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/guc.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/integrations/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/logger.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/metrics_registry.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/migrate/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/migrate/__main__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/migrate/cli.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/migrate/errors.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/migrate/runner.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/py.typed +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/retry.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/src/pgcommon/tracer.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/conftest.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/e2e/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/e2e/conftest.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/e2e/test_core_invariants_e2e.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/e2e/test_pgbouncer_callback_escape_e2e.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/e2e/test_rls_pgbouncer_e2e.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/fake_asyncpg.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/fake_migrate_conn.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/fakes.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/go_pgmetrics_vectors.go.txt +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/fixtures/go_pgmetrics_vectors.json +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/conftest.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_concurrency_stress.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_connection_loss.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_core_invariants_integration.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_error_helpers_real_violations.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_failover.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_migrate_invariants_integration.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_migrate_runner_integration.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_platform_metrics.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_pool_hardening.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_pool_lifecycle.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_pool_regressions.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_retry_real_serialization_failure.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_rls_guc_injection.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_tx_and_savepoint.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/integration/test_tx_escape.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/__init__.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_config.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_config_parity.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_core_redaction_and_config.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_errors.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_guc.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_internal.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_internal_parity.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_logger.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics_identity.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics_invariants.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics_inventory.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics_lifecycle.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_metrics_standard.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_migrate_cli.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_migrate_cli_invariants.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_migrate_invariants.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_migrate_runner.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_pool_hardening.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_pool_metrics.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_pool_parity.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_pool_resilience.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_public_api.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_retry.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_tracer.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_txshape.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_txshape_go_oracle.py +0 -0
- {pgcommon-0.3.1 → pgcommon-0.4.0}/tests/unit/test_wrap_tx.py +0 -0
|
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code when working with **platform-pgcommon
|
|
|
4
4
|
|
|
5
5
|
## What This Repo Is
|
|
6
6
|
|
|
7
|
-
`platform-pgcommon-py` is the **Python package `pgcommon`** (`pyproject.toml`: version `0.
|
|
7
|
+
`platform-pgcommon-py` is the **Python package `pgcommon`** (`pyproject.toml`: version `0.4.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; `pgcommon>=0.2.0,<0.5.0`), the FastAPI services built on the platform, and `platform-interop-tests`' Python probe (CI `interop` job).
|
|
8
8
|
|
|
9
|
-
**Status:** 0.
|
|
9
|
+
**Status:** 0.4.0 was released on 2026-10-16 (`CHANGELOG.md` `[0.4.0]`; breaking for HTTP clients of the FastAPI integration's 401 / 503); before it 0.3.1 and 0.3.0 (2026-10-15, breaking against 0.2.0). New work goes under `[Unreleased]`. Release process: [`operations.md`](operations.md) § Release state.
|
|
10
10
|
|
|
11
11
|
**Key responsibilities (the library is the sole implementation of):**
|
|
12
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.
|
|
@@ -16,7 +16,7 @@ This file provides guidance to Claude Code when working with **platform-pgcommon
|
|
|
16
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
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
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.
|
|
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 (beyond the FastAPI integration's own 401 / 503 RFC 9457 problems — `docs/problem-details.md`); PgBouncer / RDS Proxy; outbox / inbox (`platform-eventcommon`); alert rules, dashboards, runbook (Go's apply unchanged); metric ratification.
|
|
20
20
|
|
|
21
21
|
## Common Commands
|
|
22
22
|
|
|
@@ -111,7 +111,9 @@ The public surface is the `__all__` of `pgcommon`, `pgcommon.config`, `pgcommon.
|
|
|
111
111
|
|
|
112
112
|
### `pgcommon.integrations.fastapi` (extra `fastapi`)
|
|
113
113
|
|
|
114
|
-
`create_lifespan(config)` (`Pool.create` → `app.state.pool`; `drain_and_close()` at shutdown) · `get_pool(request)` · `GUCAuthMiddleware(app, identity_extractor
|
|
114
|
+
`create_lifespan(config)` (`Pool.create` → `app.state.pool`; `drain_and_close()` at shutdown) · `get_pool(request)` · `GUCAuthMiddleware(app, identity_extractor=..., *, problem_type_base=..., problem_factory=None, logger=None, service="")` (`IDENTITY_ERRORS` → 401 RFC 9457 problem `{base}/platform/unauthorized`, `errorCode` `UNAUTHORIZED`, generic `detail`, never a `tenantId`; else `with with_guc_set(g): await call_next(request)`) · `IdentityExtractor` protocol · `healthz(*, utilization_threshold=0.9, problem_type_base=..., problem_factory=None, logger=None, service="")` (503 problem `{base}/platform/service-unavailable`, `errorCode` `DATABASE_UNAVAILABLE` / `SERVICE_OVERLOADED`; 200 body unchanged) · `ProblemSpec` (frozen: `type`, `title`, `status`, `error_code`, `detail`, `instance`, `trace_id`, `tenant_id`) · `ProblemFactory = Callable[[ProblemSpec, Request], Response]` (replaces the built-in builder) · `DEFAULT_PROBLEM_TYPE_BASE` (`https://errors.company.com`) · `PROBLEM_MEDIA_TYPE`.
|
|
115
|
+
|
|
116
|
+
Problems (private builder `integrations/_problem.py`): `content-type: application/problem+json`; fields `type`, `title`, `status`, `detail`, `instance` (path only), `traceId`, `errorCode`, `timestamp` (RFC 3339 UTC, seconds), `tenantId` only with a trusted tenant context. `traceId`: valid `traceparent` ▸ sanitized `x-request-id` (≤ 128 chars of `[A-Za-z0-9._:-]`, else ignored) ▸ active OTel span ▸ `secrets.token_hex(16)`; body only, no response header. Logged once at WARN (`pgcommon: problem response`: `traceId`, `type`, `status`, `errorCode`, `service`, `tenantId`) through `logger`, else the lifespan pool's `Config.logger`; `service` = argument, else `metrics.current_set().identity.service`. `detail` is always a fixed sentence: never `str(exc)`, SQL or identity values. Recommended mapping of database errors for services: `docs/problem-details.md`.
|
|
115
117
|
|
|
116
118
|
## 4. Exceptions
|
|
117
119
|
|
|
@@ -32,7 +32,8 @@ platform-pgcommon-py/
|
|
|
32
32
|
│ ├── metrics_registry.py # registry(), lookup(), RegistryEntry, RegistryStatus, label vocabularies, buckets
|
|
33
33
|
│ ├── migrate/ # runner.py (Runner, Migration, DEFAULT_*, _advisory_lock_id, _go_url_path, _Session);
|
|
34
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`)
|
|
35
|
+
│ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, IdentityExtractor, healthz, ProblemSpec / ProblemFactory (extra `fastapi`)
|
|
36
|
+
│ ├── integrations/_problem.py # private, framework-free RFC 9457 body / trace-id / log helpers for fastapi.py
|
|
36
37
|
│ ├── py.typed
|
|
37
38
|
│ └── _internal/ # PRIVATE — dependency sinks
|
|
38
39
|
│ ├── dsn.py # is_url_dsn, normalize_dsn, dsn_settings, keyword_database, check_asyncpg_url,
|
|
@@ -119,5 +120,5 @@ Acquire with the `begin` stage (`begin_tx`: `BEGIN` with isolation / read-only /
|
|
|
119
120
|
## How Platform Libraries Use pgcommon
|
|
120
121
|
|
|
121
122
|
- **`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
|
|
123
|
+
- **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` (its 401 and `healthz`'s 503s are RFC 9457 problems; pass the service's `problem_factory` / `problem_type_base` so they match its own) → repositories call `pool.with_conn` / `run_in_tx`, and the service maps database errors to problems as in `docs/problem-details.md` (never `str(exc)` in a response). Cross-tenant work uses a second pool on a `BYPASSRLS` role with **no** `guc_provider`.
|
|
123
124
|
- **`platform-fastapicommon`:** no dependency in either direction; share one `CollectorRegistry` and identity.
|
|
@@ -52,10 +52,10 @@ Trunk-based on `main`; SemVer, pre-1.0 (a MINOR may break). Full procedure: `VER
|
|
|
52
52
|
2. Locally: `make ci`, `make release-guard TAG=vX.Y.Z` (`release_guard.py`: pyproject version = tag, PEP 440; `verify-changelog-entry.sh`: the section exists), `make api-compat API_NEW_VERSION=vX.Y.Z` (griffe; breaking needs a major — or a minor while 0.y).
|
|
53
53
|
3. `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z` → **`release.yml`**: `verify` (`verify-release-tag.sh`: dispatch ref, tag at HEAD, reachable from `origin/main`; release guard; CHANGELOG; `release-image-tags.sh`) → the same gates as `ci.yml` at the tag (api-compat **blocking**, interop **gating**) → `dist` (reproducible wheel + sdist, `release_guard.py --dist`, `twine check`, `SHA256SUMS`) → `push` (image `X.Y.Z`, forward-only `X.Y` / `X`, no `latest`; Cosign) → `pypi-publish` (Trusted Publishing, environment `pypi`; last irreversible step) → `publish` (GitHub Release with notes from the CHANGELOG section).
|
|
54
54
|
|
|
55
|
-
### Release state (2026-10-
|
|
55
|
+
### Release state (2026-10-16)
|
|
56
56
|
|
|
57
|
-
- **0.
|
|
58
|
-
- **`platform-eventcommon`:**
|
|
57
|
+
- **0.4.0 is the current release** (2026-10-16); before it **0.3.1** and **0.3.0** (2026-10-15), 0.2.0 (2026-07-27) and 0.1.0 (2026-07-26).
|
|
58
|
+
- **`platform-eventcommon`:** admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0` (eventcommon PR #16; until it merges the interop gate fails) (it re-raises its own `TxEndedInCallbackError` when pgcommon wraps it, and its fake pool goes through the public `Pool.create`). The interop gate checks the pair at `platform-interop-tests` `9bd44ca`. LLD §16 A1.
|
|
59
59
|
- **GitHub settings:** grant `python-platfrom` write access (CODEOWNERS); `GO_PRIVATE_TOKEN` must stay readable across the six platform libraries for interop.
|
|
60
60
|
- **Upstream reports:** Go's `dsnutil` has the same redaction gap fixed here in 829d80e (a `?password=` value only redacted decoded; escapes not derived from the decoded password); asyncpg's holder leak on a protocol error (LLD §16 A2).
|
|
61
61
|
|
|
@@ -94,7 +94,7 @@ Trunk-based on `main`; SemVer, pre-1.0 (a MINOR may break). Full procedure: `VER
|
|
|
94
94
|
|
|
95
95
|
| Consumer | On | Notes |
|
|
96
96
|
|---|---|---|
|
|
97
|
-
| `platform-eventcommon` | `pgcommon>=0.2.0,<0.
|
|
97
|
+
| `platform-eventcommon` | `pgcommon>=0.2.0,<0.5.0` | Outbox / inbox / DLQ on `Pool` + `run_in_tx` |
|
|
98
98
|
| `platform-interop-tests/python-probe` | this branch (CI) | `Pool`, `GUCSet`, `with_guc_set`, `with_conn` in both modes |
|
|
99
99
|
| FastAPI services | — | Wiring in architecture.md |
|
|
100
100
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document describes the internal structure, dependency rules, and runtime data flows of `pgcommon` — and, where it matters, exactly how the `asyncio`/`contextvars` translation differs from the Go original's `context.Context`/goroutine model.
|
|
4
4
|
|
|
5
|
-
`pgcommon` (repository `platform-pgcommon-py`) is the **Python sibling of the private Go library `platform-pgcommon`**, currently at **0.
|
|
5
|
+
`pgcommon` (repository `platform-pgcommon-py`) is the **Python sibling of the private Go library `platform-pgcommon`**, currently at **0.4.0** (released 2026-10-16 — see [Versions and release state](#versions-and-release-state)) and tracking Go **v2.0.1**. It is an `asyncio` library on top of **asyncpg** (`asyncpg>=0.30,<0.32`, with `prometheus-client` and `opentelemetry-api` as its only other runtime dependencies; Python ≥ 3.12), published on PyPI as `pgcommon` and imported by every Python platform service that reaches PostgreSQL. It owns the **database access boundary**: the connection pool (an `asyncpg.Pool` underneath, with pgxpool's lifetime, idle, health-check and min-connection semantics rebuilt on top of it), Row-Level Security identity injection (the `app.user_id` / `app.tenant_id` / `app.tenant_roles` GUCs), transactions with deadlock/serialization retry and savepoints, SQLSTATE classification, a golang-migrate-compatible migration runner, and the database-client observability every service emits (Tier 1 `platform_db_*` metrics under the Enterprise Platform Observability Standard, identical to Go's; OTel spans; a slow-query log). It is consumed as a package dependency, not deployed. Today its consumers are `platform-eventcommon` (outbox / inbox / DLQ; `pgcommon>=0.2.0,<0.4.0`), the Python probe of `platform-interop-tests`, and the FastAPI services built on the platform. The one thing it ships as an artefact is **the `pgcommon-migrate` CLI**, published as a container image for running a service's migrations as a deploy job.
|
|
6
6
|
|
|
7
7
|
See [`docs/MigrationGuide.md`](docs/MigrationGuide.md) for the concept mapping between the two languages, [Divergences from Go](#divergences-from-go) for the places where asyncpg cannot match Go, the ADRs under [`docs/adr/`](docs/adr/) for the riskiest translation choices, and the LLD [`docs/lld/platform-lld-pgcommon-py.md`](docs/lld/platform-lld-pgcommon-py.md) for the invariant catalogue (`PGPY-*`).
|
|
8
8
|
|
|
@@ -187,7 +187,7 @@ graph LR
|
|
|
187
187
|
| `pgcommon.metrics_registry` | `registry()`, `lookup()`, `operation_values()`, `RegistryEntry` and the approved label vocabularies — the checked-in Platform Observability Registry entry |
|
|
188
188
|
| `pgcommon.logger` | `Logger` (protocol), `StdlibLogger`, `StructlogLogger` |
|
|
189
189
|
| `pgcommon.migrate` | `Runner`, `Migration`, `DEFAULT_MIGRATIONS_TABLE`, `DEFAULT_LOCK_TIMEOUT`, `DEFAULT_CONNECT_TIMEOUT`, `MigrationDirtyError`, `VersionNotInSourceError`, `NoMigrationsFoundError`, `DuplicateMigrationError`, `ShortLimitError`, `MigrationStoppedError`, `InvalidMigrationsTableError` |
|
|
190
|
-
| `pgcommon.integrations.fastapi` | `create_lifespan`, `get_pool`, `GUCAuthMiddleware`, `IdentityExtractor`, `healthz` (requires `pgcommon[fastapi]`) |
|
|
190
|
+
| `pgcommon.integrations.fastapi` | `create_lifespan`, `get_pool`, `GUCAuthMiddleware`, `IdentityExtractor`, `healthz`, `ProblemSpec`, `ProblemFactory`, `DEFAULT_PROBLEM_TYPE_BASE`, `PROBLEM_MEDIA_TYPE` (requires `pgcommon[fastapi]`); the RFC 9457 helpers live in the private `integrations/_problem.py` |
|
|
191
191
|
|
|
192
192
|
The top-level `pgcommon` package re-exports `Config`, `ConfigWarning`, `config_from_env` and `migration_dsn_from_env` (not the `DEFAULT_*` constants), every public name of `errors` except `CONNECTION_LOST_NOTE` (`DuplicateMigrationError` lives in `pgcommon.migrate` only), and everything public in `guc`, `pool`, `tx`, `retry`, `tracer` and `logger`, plus `sql_operation`, `OTelQueryRecorder`, `RegistrationWarning`, `MetricsIdentity` (`metrics.Identity`) and `pgmetrics_init_with_identity` (`metrics.init_with_identity`).
|
|
193
193
|
|
|
@@ -850,6 +850,8 @@ flowchart TD
|
|
|
850
850
|
|
|
851
851
|
`is_pg_error` is the transport split: did PostgreSQL answer (an `asyncpg.PostgresError`), or did the client or the network fail? asyncpg's own client-side errors that carry a class-level SQLSTATE (`ConnectionDoesNotExistError`, 08003) are not server errors, so `sql_state` and the class helpers ignore them, as Go's do for non-`*pgconn.PgError` errors. `is_connection_lost` is a Python addition: pgx reports most lost connections as non-`PgError` errors that Go services classify with `!IsPgError(err)`, but asyncpg's shapes vary (`ConnectionDoesNotExistError`, `InterfaceError`, `InternalClientError`), so pgcommon adds `CONNECTION_LOST_NOTE` to any error raised on a connection that turned out to be closed by the server or network, and the helper names them all. Class 57 is split on purpose: `57014 query_canceled` is the caller's own `statement_timeout` or cancellation (`is_query_canceled`), while the rest of class 57 (`is_operator_intervention`) means the server is going away, like class 58 (`is_system_error`). The same split drives `health()`'s liveness stamp.
|
|
852
852
|
|
|
853
|
+
**From classification to HTTP.** pgcommon never builds an HTTP response for a database error. The service maps the classification above to an RFC 9457 problem; [docs/problem-details.md](docs/problem-details.md) has the recommended table (409 `platform/conflict` for unique violations and exhausted serialization/deadlock retries, 422 / 400 for constraint violations, 503 `platform/service-unavailable` for the "database is the problem" branches, 500 `platform/internal-error` for the callback bugs and everything else). It also has the rule that matters most: an exception's text (SQL, a constraint's `DETAIL` row values) never goes into a response, only into server-side logs. The FastAPI integration emits only two problems of its own: the 401 and the health 503s ([Consumer conformance checklist](#consumer-conformance-checklist)).
|
|
854
|
+
|
|
853
855
|
Library sentinels are checked with `isinstance` (the Python `errors.Is`): `IDENTITY_ERRORS` (an identity that should have been rejected at the request edge; the pool re-raises it as a new instance of the same class, `pgcommon: invalid GUC identity: …`, original as `__cause__`), `TxEndedInCallbackError` / `TxCommitRollbackError` (callback bugs; fn's own exception is `callback_error`), `AcquireTimeoutError` (a `TimeoutError`), and the runner's `MigrationDirtyError` / `VersionNotInSourceError`, which a service running `up` at startup should treat as "refuse to start". A connection interrupted by a timed-out `drain_and_close` keeps its error's type and gains `POOL_INTERRUPTED_NOTE`, so `is_pool_closed` matches it.
|
|
854
856
|
|
|
855
857
|
### Error-chain sanitization
|
|
@@ -961,7 +963,7 @@ sequenceDiagram
|
|
|
961
963
|
Note over MW,DB: Canonical tests: tests/integration/test_rls_guc_injection.py · test_concurrency_stress.py<br/>test_tx_escape.py · tests/e2e/test_rls_pgbouncer_e2e.py · test_pgbouncer_callback_escape_e2e.py
|
|
962
964
|
```
|
|
963
965
|
|
|
964
|
-
`Config.guc_provider = pgcommon.guc_set_from_context` is the single security-critical wiring line in a consuming service. `with_validated_guc_set` (or `GUCAuthMiddleware`, which validates and answers 401) is preferred over `with_guc_set` in middleware, so an invalid identity is rejected at the request edge with a clear error. A service that needs a cross-tenant connection (reconcilers, exporters) should build a **second pool** on a `BYPASSRLS` role with **no** `guc_provider`, so a cross-tenant query is never confused with an app-pool query that happened to carry an empty identity. Work started from a request (`BackgroundTasks`, `asyncio.create_task`) inherits the request's identity by design; a long-lived worker must be started from the lifespan or in a fresh `contextvars.Context()` (see [`pgcommon/integrations/fastapi.py`](src/pgcommon/integrations/fastapi.py) and [ADR-001](docs/adr/ADR-001-contextvars-guc-propagation.md)).
|
|
966
|
+
`Config.guc_provider = pgcommon.guc_set_from_context` is the single security-critical wiring line in a consuming service. `with_validated_guc_set` (or `GUCAuthMiddleware`, which validates and answers an RFC 9457 401 problem that carries neither the identity's values nor a `tenantId`) is preferred over `with_guc_set` in middleware, so an invalid identity is rejected at the request edge with a clear error. A service that needs a cross-tenant connection (reconcilers, exporters) should build a **second pool** on a `BYPASSRLS` role with **no** `guc_provider`, so a cross-tenant query is never confused with an app-pool query that happened to carry an empty identity. Work started from a request (`BackgroundTasks`, `asyncio.create_task`) inherits the request's identity by design; a long-lived worker must be started from the lifespan or in a fresh `contextvars.Context()` (see [`pgcommon/integrations/fastapi.py`](src/pgcommon/integrations/fastapi.py) and [ADR-001](docs/adr/ADR-001-contextvars-guc-propagation.md)).
|
|
965
967
|
|
|
966
968
|
### Why GUC-based tenancy?
|
|
967
969
|
|
|
@@ -1244,9 +1246,9 @@ Run migrations from a **direct** connection, never through PgBouncer: the adviso
|
|
|
1244
1246
|
|
|
1245
1247
|
### Versions and release state
|
|
1246
1248
|
|
|
1247
|
-
The current release is **0.3.0** (2026-10-15, `CHANGELOG.md` `[0.3.0]`): Go v2.0.1 parity and production hardening — Tier 1 `platform_db_*` metrics only with a mandatory identity, a golang-migrate-compatible runner and Go's `cmd/migrate` CLI, `TxEndedInCallbackError` / `TxCommitRollbackError`, GUC injection moved out of asyncpg's `setup` hook, per-connection lifetime, idle expiry and top-up, `acquire_timeout` / `command_timeout` / TCP keepalive, release that never raises, and the prepared-transaction guard. It is breaking against 0.2.0 (pre-1.0, a minor may break; see [`VERSIONING.md`](VERSIONING.md)). Before
|
|
1249
|
+
The current release is **0.4.0** (2026-10-16, `CHANGELOG.md` `[0.4.0]`): the FastAPI integration's 401 and health 503s are RFC 9457 Problem Details (breaking for HTTP clients that parse the old `{"detail": …}` body), a failed statement's span error text matches Go's without PostgreSQL's DETAIL / HINT lines, no logged `error` field carries row values, and the health-check failure log has the `pgcommon:` prefix. Before it: **0.3.1** (2026-10-15, the `Config` / `ResolvedConfig` repr masks the DSN password) and **0.3.0** (2026-10-15, `CHANGELOG.md` `[0.3.0]`): Go v2.0.1 parity and production hardening — Tier 1 `platform_db_*` metrics only with a mandatory identity, a golang-migrate-compatible runner and Go's `cmd/migrate` CLI, `TxEndedInCallbackError` / `TxCommitRollbackError`, GUC injection moved out of asyncpg's `setup` hook, per-connection lifetime, idle expiry and top-up, `acquire_timeout` / `command_timeout` / TCP keepalive, release that never raises, and the prepared-transaction guard. It is breaking against 0.2.0 (pre-1.0, a minor may break; see [`VERSIONING.md`](VERSIONING.md)). Before that: **0.2.0** (2026-07-27, `Runner(table=)`, default table `pgcommon_migrations`) and **0.1.0** (2026-07-26, the initial port). The CHANGELOG names the Go version each release tracks.
|
|
1248
1250
|
|
|
1249
|
-
**
|
|
1251
|
+
**Consumers and open items** (LLD §16 A1, A5): `platform-eventcommon` admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0` (eventcommon PR #16); the cross-language interop gate, which gates the release's `push`, checks the pair. Two GitHub settings are needed (the `python-platfrom` team needs **write** access for `CODEOWNERS` to apply; `GO_PRIVATE_TOKEN` must stay readable across the platform libraries for interop). Go's `dsnutil` has the redaction gap this port closed (`829d80e`); it is to be reported upstream (LLD §16 A4).
|
|
1250
1252
|
|
|
1251
1253
|
---
|
|
1252
1254
|
|
|
@@ -1328,6 +1330,7 @@ graph LR
|
|
|
1328
1330
|
- [ ] `fn` passed to `run_in_tx_with_retry(_opts)` is safe to re-run and performs no external side effect.
|
|
1329
1331
|
- [ ] No `COMMIT` / `ROLLBACK` / `conn.transaction()` used to end the transaction inside a PgBouncer-mode `with_conn` or a `run_in_tx` callback; use `run_in_savepoint` (or a nested `conn.transaction()`) for partial rollback.
|
|
1330
1332
|
- [ ] Errors are classified with the `is_*` helpers, `isinstance` on the sentinels or `sql_state`, not by matching message strings; `AcquireTimeoutError` and `is_connection_lost` map to 503.
|
|
1333
|
+
- [ ] HTTP errors are RFC 9457 problems mapped as in [docs/problem-details.md](docs/problem-details.md); no response contains `str(exc)`, SQL or a constraint's `DETAIL`. `GUCAuthMiddleware` / `healthz()` get the service's `problem_type_base` (and its `problem_factory`, if it has one), so pgcommon's 401 / 503 problems match the service's own.
|
|
1331
1334
|
|
|
1332
1335
|
**Observability**
|
|
1333
1336
|
- [ ] Dashboards, alerts and recording rules query `platform_db_*` (grouped by `domain, service, environment, pool`) — Go's reference bundle applies unchanged.
|
|
@@ -1462,6 +1465,13 @@ The port matches Go `platform-pgcommon` v2.0.1 everywhere a consumer can observe
|
|
|
1462
1465
|
| Forced drain | `forceClose` closes connections | Acquired connections are first closed gracefully for up to 0.5 s — asyncpg sends a cancel request for a running statement — then the pool is terminated | Closing a socket alone leaves the server running the statement, holding its locks, until it next talks to the client |
|
|
1463
1466
|
| Monitoring bundle | Ships recording rules, alerts, a dashboard and a runbook | Ships none; Go's apply unchanged | The metric families are identical, so one bundle serves both languages |
|
|
1464
1467
|
| DSN redaction of a percent-encoded `?password=` | `dsnutil` redacts the decoded value only, and escapes only the values as written | Also redacts the value exactly as written, and the escapes of the decoded password | A gap in Go (to be reported upstream, LLD §16 A4): an error quoting such a password as written, or re-escaped, would leak it |
|
|
1468
|
+
| `db.query` span kind | `INTERNAL` until [platform-pgcommon#45](https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon/pull/45) is released, which makes them `CLIENT` | `CLIENT` | The OpenTelemetry database semantic conventions make a database call a client span |
|
|
1469
|
+
| Transaction setup spans | One span per setup statement: the transaction-local GUCs and the transaction timeouts are separate `set_config` statements; an identity-less acquire of a connection a previous caller left an identity on gets a GUC-reset span | One span for the single combined setup statement (marker, GUCs, timeouts), plus a span for the prepared-transaction marker statement when `prepared_xact_guard` is on; no GUC-reset span, because asyncpg's `RESET ALL` on release is untracked | One round trip instead of several; the marker is how the prepared-transaction guard identifies its own transaction (no command tag in asyncpg) |
|
|
1470
|
+
| `exception` span event `exception.type` | The Go type (`*pgconn.PgError`) | The qualified Python class name (`asyncpg.exceptions.UniqueViolationError`) | Language-inherent. The event carries the same two attributes, and `exception.message` and the status are Go's `PgError.Error()` text (`ERROR: … (SQLSTATE 23505)`, never DETAIL / HINT) |
|
|
1471
|
+
| `db.query.text` of internal transaction control | pgx's `begin` / `commit` / `rollback` | asyncpg's `BEGIN;` / `COMMIT` / `ROLLBACK;` | Driver-inherent: each driver sends its own spelling of these statements |
|
|
1472
|
+
| Python-only log events | — | `pgcommon: rollback failed` (cleanup of a failed or cancelled transaction; Go logs only `rollback failed during panic recovery`), `pgcommon: topping the pool up to min_conns failed`, `pgcommon: releasing the connection failed; terminated it`, `pgcommon: closing the pool failed; terminated it`, `pgcommon: reading pg_prepared_xacts failed`, `pgcommon: <sink> raised`, `pgcommon: connection found dead during acquire; replaced` (INFO), `pgcommon: preparing a connection timed out …`, `pgcommon: pool health check failed` | Each reports a failure only the asyncpg port handles itself (pgxpool's health check, release and close cannot fail visibly, and Go has no guarded sinks) |
|
|
1473
|
+
| Migration progress logs | Only the summary line (`migrations: applied up to version N`) | Also an INFO line per migration (`applied migration` / `rolled back migration`, with `version`, `direction`); the summary line is Go's | Visible progress through a long run |
|
|
1474
|
+
| Query-logger hook failure log | `pgcommon: Config.QueryTracer hook panicked; recovered so the pool slot is not leaked` | `pgcommon: Config.query_logger hook raised; ignored so the pool slot is not leaked` | The hook has another name and raises rather than panics |
|
|
1465
1475
|
|
|
1466
1476
|
---
|
|
1467
1477
|
|
|
@@ -1481,6 +1491,7 @@ Some judgment calls in this port aren't obvious from the code alone. Each is als
|
|
|
1481
1491
|
10. **pgxpool's maintenance is rebuilt on asyncpg internals.** Per-connection lifetime, idle expiry above `min_conns`, the idle ping, the top-up and the slot recovery restore pgxpool's behaviour after failovers. The top-up and slot recovery use private asyncpg attributes, so the dependency is capped (`<0.32`) and every admitted minor is tested.
|
|
1482
1492
|
11. **Tier 1 metrics only, identical to Go's.** The legacy names were removed with no compatibility period (no release emitting them had been deployed), and one metric vocabulary lets Go's rules, alerts and dashboard serve Python services without a Python-specific bundle.
|
|
1483
1493
|
12. **Library-internal statements are excluded from query metrics unless they fail, and replaced dead connections are not errors.** Otherwise the SLIs would measure pgcommon (and outages would count each replaced connection) rather than the service's own work.
|
|
1494
|
+
13. **The FastAPI integration's errors are RFC 9457 problems, built without a new dependency.** The platform answers every HTTP error with `application/problem+json`. pgcommon emits only two problems (the 401 for an invalid identity, the health 503s), so the builder is a small private module (`integrations/_problem.py`), not a dependency on the shared `platform-problem-details` library: that library would otherwise become a hard dependency of the optional extra. Services that use it plug its factory in through `problem_factory`. The settings are constructor and `healthz()` arguments rather than a module global, so two apps in one process cannot change each other's settings. The trace id follows the platform order (`traceparent`, then `x-request-id`); the active OTel span replaces only the "generate one" step. Both problems log at WARN: a failing readiness probe repeats every few seconds, and the outage behind it is already reported by the pool's logs and metrics. Details: [docs/problem-details.md](docs/problem-details.md).
|
|
1484
1495
|
|
|
1485
1496
|
---
|
|
1486
1497
|
|
|
@@ -11,6 +11,57 @@ public API.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.4.0] - 2026-10-16
|
|
15
|
+
|
|
16
|
+
Breaking for HTTP clients of the FastAPI integration: `GUCAuthMiddleware`'s 401 and
|
|
17
|
+
`healthz()`'s 503s are now RFC 9457 Problem Details (`application/problem+json`; match on
|
|
18
|
+
`errorCode`, not `detail`). Fixed: a failed statement's span error text now matches Go's,
|
|
19
|
+
without PostgreSQL's DETAIL / HINT lines, and no logged `error` field carries row values.
|
|
20
|
+
Changed: the background health check's failure log has the `pgcommon:` prefix.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **BREAKING (HTTP clients that parse the old body):** the two error responses of the FastAPI
|
|
25
|
+
integration are now RFC 9457 Problem Details (`content-type: application/problem+json`).
|
|
26
|
+
The status codes are unchanged. `GUCAuthMiddleware`'s 401 for an invalid identity was
|
|
27
|
+
`{"detail": "invalid identity"}`; it is now a problem with type
|
|
28
|
+
`https://errors.company.com/platform/unauthorized`, `errorCode` `UNAUTHORIZED`, a generic
|
|
29
|
+
`detail` that never echoes the identity, and no `tenantId`. `healthz()`'s 503s were
|
|
30
|
+
`{"detail": "database unavailable"}` / `{"detail": "service overloaded"}`; they are now
|
|
31
|
+
problems with type `https://errors.company.com/platform/service-unavailable` and `errorCode`
|
|
32
|
+
`DATABASE_UNAVAILABLE` / `SERVICE_OVERLOADED`. Every problem has `type`, `title`, `status`,
|
|
33
|
+
`detail`, `instance` (the request path), `traceId`, `errorCode` and `timestamp` (RFC 3339
|
|
34
|
+
UTC), plus `tenantId` only when the request has a trusted tenant context. The `healthz()` 200
|
|
35
|
+
body is unchanged. A client that matched on `detail` should match on `errorCode`. The
|
|
36
|
+
return annotation of the `healthz()` handler widens from `JSONResponse` to `Response`.
|
|
37
|
+
See [docs/problem-details.md](docs/problem-details.md).
|
|
38
|
+
- The background health check's failure log is now `pgcommon: pool health check failed` (was `pool health check failed`, the only pgcommon log message without the `pgcommon:` prefix; Go has no equivalent, pgxpool's health check does not log). Log filters or alerts matching the old text exactly must be updated; the level (WARN) and the `error` field are unchanged.
|
|
39
|
+
- Documented the remaining, acceptable observability differences from Go in `ARCHITECTURE.md` § Divergences from Go, `docs/observability/README.md` and LLD §18.5: span kind (Go's spans become `CLIENT` with platform-pgcommon#45), transaction setup span shape, `exception.type` values, the spelling of internal `BEGIN` / `COMMIT` in `db.query.text`, Python-only log events, per-migration progress lines and the query-logger hook failure wording.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `GUCAuthMiddleware` and `healthz()` take `problem_type_base` (default
|
|
44
|
+
`DEFAULT_PROBLEM_TYPE_BASE`, `https://errors.company.com`), `problem_factory` (a
|
|
45
|
+
`ProblemFactory`, `(ProblemSpec, Request) -> Response`, which replaces the built-in builder,
|
|
46
|
+
e.g. with the shared `platform-problem-details` factory), `logger` and `service`.
|
|
47
|
+
`ProblemSpec`, `ProblemFactory`, `DEFAULT_PROBLEM_TYPE_BASE` and `PROBLEM_MEDIA_TYPE` are new
|
|
48
|
+
exports of `pgcommon.integrations.fastapi`.
|
|
49
|
+
- `traceId` comes from a valid W3C `traceparent`, else a sanitized `x-request-id` (1 to 128
|
|
50
|
+
characters of `[A-Za-z0-9._:-]`, otherwise ignored), else the active OpenTelemetry span,
|
|
51
|
+
else a new 32-hex id.
|
|
52
|
+
- Each problem is logged once at WARN (`pgcommon: problem response`) with `traceId`, `type`,
|
|
53
|
+
`status`, `errorCode`, `service` (the argument, else the metrics identity's `service`) and
|
|
54
|
+
`tenantId` (when present). The logger is the `logger` argument, else the lifespan pool's
|
|
55
|
+
`Config.logger`. pgcommon never logs the detail, the identity or headers.
|
|
56
|
+
- [docs/problem-details.md](docs/problem-details.md): what the integration emits, the
|
|
57
|
+
configuration, and the recommended mapping of pgcommon's database errors (`is_*`
|
|
58
|
+
helpers, sentinels) to problem types for services.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- `OTelQueryRecorder(allow_full_statements=True)`: a failed statement's span status and `exception` event no longer include PostgreSQL's DETAIL / HINT lines, which quote row values (`Key (id)=(1) already exists.`). The status is now Go's `pgconn.PgError.Error()` text — `<Severity>: <Message> (SQLSTATE <code>)`, e.g. `ERROR: duplicate key value violates unique constraint "t_pkey" (SQLSTATE 23505)` — where it was asyncpg's `str()` (message plus DETAIL / HINT, no severity or SQLSTATE). A non-PostgreSQL error keeps its own text (`database error` if it has none). The `exception` event now carries only `exception.type` and `exception.message` (the same text), as Go's `span.RecordError` does: no `exception.stacktrace` or `exception.escaped`. `exception.type` stays the qualified Python class name (Go's is the Go type). Without `allow_full_statements` nothing changes.
|
|
63
|
+
- Log fields no longer carry PostgreSQL's DETAIL / HINT row values either: every `error` field pgcommon logs (health check, rollback and other cleanup failures, releasing or closing a connection, topping the pool up, GUC injection and dead-connection replacement, the `pg_prepared_xacts` lookup, a raising sink) use the same Go `err.Error()` text for a server error (`<Severity>: <Message> (SQLSTATE <code>)`); other errors keep their own text (`repr` where it was before). Shared helper: `pgcommon._internal.sqlstate.error_text`. The `pgcommon-migrate` CLI's `migration error:` line is unchanged and still shows DETAIL: it is operator output, and Go's CLI prints DETAIL too (golang-migrate's `migration failed: <message>, <detail> …`).
|
|
64
|
+
|
|
14
65
|
## [0.3.1] - 2026-10-15
|
|
15
66
|
|
|
16
67
|
### Security
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgcommon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -32,7 +32,7 @@ The platform's **PostgreSQL access layer for Python services**: an `asyncio` lib
|
|
|
32
32
|
|
|
33
33
|
**Repository:** `github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py` (private)
|
|
34
34
|
|
|
35
|
-
**Package:** `pgcommon` 0.
|
|
35
|
+
**Package:** `pgcommon` 0.4.0 ([release status](#versioning-and-releases): released 2026-10-16, on [PyPI](https://pypi.org/project/pgcommon/)) · Apache-2.0 · Python ≥ 3.12 (CI runs 3.12, 3.13 and 3.14) · consumed as a dependency, never deployed on its own. It also ships one artefact, the `pgcommon-migrate` CLI, as a container image (`ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`).
|
|
36
36
|
|
|
37
37
|
**Design:** one `src/pgcommon` package — public modules on top of a private `_internal/` layer, with the layer rules enforced by import-linter in CI. See [ARCHITECTURE.md](ARCHITECTURE.md) for design decisions, layer diagrams, and — importantly — how this port's `asyncio`/`contextvars` model differs from the Go original's `context.Context` model. This README is how to **use** it.
|
|
38
38
|
|
|
@@ -326,8 +326,9 @@ Typical meanings and an example are in [PostgreSQL error helpers](#postgresql-er
|
|
|
326
326
|
|---|---|
|
|
327
327
|
| `create_lifespan(config)` | ASGI lifespan: `Pool.create` at startup (`app.state.pool`), `drain_and_close()` at shutdown |
|
|
328
328
|
| `get_pool` | `Depends()` provider returning `app.state.pool` |
|
|
329
|
-
| `GUCAuthMiddleware(app, identity_extractor
|
|
330
|
-
| `healthz(*, utilization_threshold=0.9)` | A `/healthz` handler: `503` when unhealthy or at the threshold, else pool utilisation |
|
|
329
|
+
| `GUCAuthMiddleware(app, identity_extractor=…, *, problem_type_base=…, problem_factory=None, logger=None, service="")` | Validates the extracted `GUCSet`, answers an RFC 9457 `401` problem for `IDENTITY_ERRORS`, otherwise runs the request under `with_guc_set` |
|
|
330
|
+
| `healthz(*, utilization_threshold=0.9, problem_type_base=…, problem_factory=None, logger=None, service="")` | A `/healthz` handler: a `503` problem when unhealthy or at the threshold, else pool utilisation |
|
|
331
|
+
| `ProblemSpec` · `ProblemFactory` · `DEFAULT_PROBLEM_TYPE_BASE` · `PROBLEM_MEDIA_TYPE` | The problem a factory receives (`type`, `title`, `status`, `error_code`, `detail`, `instance`, `trace_id`, `tenant_id`), the hook type `(ProblemSpec, Request) -> Response`, `https://errors.company.com`, `application/problem+json` ([docs/problem-details.md](docs/problem-details.md)) |
|
|
331
332
|
|
|
332
333
|
### `pgcommon-migrate` CLI (image `ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`)
|
|
333
334
|
|
|
@@ -423,6 +424,7 @@ platform-pgcommon-py/
|
|
|
423
424
|
│ ├── metrics.py · metrics_registry.py # init_with_identity, Identity, pool gauges, OTelQueryRecorder · the governed platform_db_* inventory
|
|
424
425
|
│ ├── migrate/ # Runner and its errors; cli.py (pgcommon-migrate), __main__.py (python -m pgcommon.migrate)
|
|
425
426
|
│ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, healthz (extra: pgcommon[fastapi])
|
|
427
|
+
│ ├── integrations/_problem.py # private: RFC 9457 problem body, trace id, log line for fastapi.py
|
|
426
428
|
│ └── _internal/ # PRIVATE: DSN parsing and redaction, transaction-shape lexer, statement scopes, SQL verb, SQLSTATE, …
|
|
427
429
|
├── tests/ # unit (no Docker) · integration (Postgres) · e2e (Postgres + PgBouncer), via testcontainers; fixtures/
|
|
428
430
|
├── migrations/ # An example 0001 migration pair, for trying the CLI locally (not in the wheel)
|
|
@@ -708,8 +710,25 @@ COMMIT / ROLLBACK — GUCs automatically cleared; connection safely returned to
|
|
|
708
710
|
#### Wiring (recommended — contextvars-based)
|
|
709
711
|
|
|
710
712
|
```python
|
|
713
|
+
from fastapi.responses import JSONResponse
|
|
714
|
+
|
|
711
715
|
from pgcommon import IDENTITY_ERRORS, Config, GUCSet, Pool, guc_set_from_context, with_guc_set
|
|
712
716
|
|
|
717
|
+
|
|
718
|
+
# Errors are RFC 9457 Problem Details (docs/problem-details.md). Your service's problem builder
|
|
719
|
+
# (platform-problem-details) replaces this stand-in; it also adds traceId and timestamp.
|
|
720
|
+
def problem(request, error: str, title: str, status: int, error_code: str, detail: str):
|
|
721
|
+
body = {
|
|
722
|
+
"type": f"https://errors.company.com/platform/{error}",
|
|
723
|
+
"title": title,
|
|
724
|
+
"status": status,
|
|
725
|
+
"detail": detail,
|
|
726
|
+
"instance": request.url.path,
|
|
727
|
+
"errorCode": error_code,
|
|
728
|
+
}
|
|
729
|
+
return JSONResponse(body, status_code=status, media_type="application/problem+json")
|
|
730
|
+
|
|
731
|
+
|
|
713
732
|
# 1. Wire once at startup:
|
|
714
733
|
pool = await Pool.create(Config(dsn=dsn, guc_provider=guc_set_from_context))
|
|
715
734
|
|
|
@@ -723,7 +742,14 @@ async def auth_middleware(request, call_next):
|
|
|
723
742
|
try:
|
|
724
743
|
guc_set.validate()
|
|
725
744
|
except IDENTITY_ERRORS: # every GUCSet.validate() error: a caller error, not a DB failure
|
|
726
|
-
return
|
|
745
|
+
return problem(
|
|
746
|
+
request,
|
|
747
|
+
"unauthorized",
|
|
748
|
+
"Unauthorized",
|
|
749
|
+
401,
|
|
750
|
+
"UNAUTHORIZED",
|
|
751
|
+
"The request identity is invalid.",
|
|
752
|
+
)
|
|
727
753
|
with with_guc_set(guc_set):
|
|
728
754
|
return await call_next(request)
|
|
729
755
|
|
|
@@ -792,6 +818,37 @@ async def list_orders(pool=Depends(get_pool)):
|
|
|
792
818
|
|
|
793
819
|
`GUCAuthMiddleware` validates the extracted identity and responds `401` for any of `IDENTITY_ERRORS`; only that validation is guarded, so an identity error raised later by a handler (or by the pool, for a `GUCSet` a handler set itself) is not turned into this request's 401.
|
|
794
820
|
|
|
821
|
+
#### Error responses (RFC 9457 Problem Details)
|
|
822
|
+
|
|
823
|
+
The 401 and `healthz()`'s two 503s are `application/problem+json` problems. The status codes are the same as in 0.3.x; the bodies changed:
|
|
824
|
+
|
|
825
|
+
```json
|
|
826
|
+
{
|
|
827
|
+
"type": "https://errors.company.com/platform/unauthorized",
|
|
828
|
+
"title": "Unauthorized",
|
|
829
|
+
"status": 401,
|
|
830
|
+
"detail": "The request identity is invalid.",
|
|
831
|
+
"instance": "/orders/7",
|
|
832
|
+
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
833
|
+
"errorCode": "UNAUTHORIZED",
|
|
834
|
+
"timestamp": "2026-10-15T10:45:22Z"
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
The 503s have type `https://errors.company.com/platform/service-unavailable`, title `Service Unavailable` and `errorCode` `DATABASE_UNAVAILABLE` (`The database is unavailable.`) or `SERVICE_OVERLOADED` (`The service is overloaded.`). `detail` is always a fixed sentence: it never contains exception text, SQL or identity values. The 401 never has a `tenantId`; a 503 has one only when the request carries a tenant `GUCSet`. `traceId` is the `traceparent` trace-id, else a sanitized `x-request-id`, else the active OpenTelemetry span's trace id, else a new 32-hex id. Each problem is logged once at WARN (`pgcommon: problem response`) through `logger=`, else the lifespan pool's `Config.logger`.
|
|
839
|
+
|
|
840
|
+
```python
|
|
841
|
+
app.add_middleware(
|
|
842
|
+
GUCAuthMiddleware,
|
|
843
|
+
identity_extractor=extract_identity,
|
|
844
|
+
problem_type_base="https://errors.example.com", # default https://errors.company.com
|
|
845
|
+
problem_factory=my_problem_factory, # optional: (ProblemSpec, Request) -> Response
|
|
846
|
+
)
|
|
847
|
+
app.add_api_route("/healthz", healthz(problem_factory=my_problem_factory), methods=["GET"])
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
Pass `problem_factory` to build both responses with the service's own problem library (e.g. the shared `platform-problem-details`). For the full contract, and for the **recommended mapping of pgcommon's database errors** (`is_unique_violation` → 409 conflict, `AcquireTimeoutError` → 503, …) to problems in your own handlers, see [docs/problem-details.md](docs/problem-details.md).
|
|
851
|
+
|
|
795
852
|
> **contextvars and request isolation.** Go threads an explicit `context.Context` through every call; Python instead relies on `contextvars.ContextVar`, which is copied per `asyncio.Task` at task-creation time. Each inbound ASGI request runs on its own task, so `GUCAuthMiddleware`'s `with_guc_set(...)` — entered around `call_next(request)` after validation and exited (resetting the token) in a `finally` block — cannot leak into a concurrently-running request. The failure mode to avoid: never call the underlying `ContextVar.set()` without capturing and later `reset()`-ing its token in the same middleware invocation. See `pgcommon/integrations/fastapi.py`'s module docstring and ARCHITECTURE.md for the full explanation.
|
|
796
853
|
|
|
797
854
|
> **Work started from a request runs as that request's identity.** A task copies the context when it is created, so Starlette `BackgroundTasks` and `asyncio.create_task(...)` called from a handler run as the requesting user and tenant even after the response — right for per-request work (its data stays scoped to the tenant that asked). The hazard is a **long-lived worker started lazily inside a request** (cache refresher, queue consumer, "start on first use" singleton): it acts as the *first* requester's tenant forever. Start such workers from the app's lifespan (outside any request, so no `GUCSet`), or start them in a fresh context — `asyncio.create_task(worker(), context=contextvars.Context())` — or give the worker an explicit identity with `with with_guc_set(GUCSet()):` (an empty `GUCSet`: no GUC injection).
|
|
@@ -982,17 +1039,24 @@ A ping must acquire a connection, so on a fully checked-out pool it would queue
|
|
|
982
1039
|
|
|
983
1040
|
```python
|
|
984
1041
|
@app.get("/healthz")
|
|
985
|
-
async def healthz(pool: Pool = Depends(get_pool)):
|
|
1042
|
+
async def healthz(request: Request, pool: Pool = Depends(get_pool)):
|
|
986
1043
|
status = await pool.health()
|
|
987
1044
|
if not status.healthy:
|
|
988
|
-
return
|
|
1045
|
+
return problem(
|
|
1046
|
+
request,
|
|
1047
|
+
"service-unavailable",
|
|
1048
|
+
"Service Unavailable",
|
|
1049
|
+
503,
|
|
1050
|
+
"DATABASE_UNAVAILABLE",
|
|
1051
|
+
"The database is unavailable.",
|
|
1052
|
+
)
|
|
989
1053
|
return {
|
|
990
1054
|
"conns": f"{status.acquired_conns}/{status.max_conns}",
|
|
991
1055
|
"utilization": status.utilization,
|
|
992
1056
|
}
|
|
993
1057
|
```
|
|
994
1058
|
|
|
995
|
-
(`pgcommon.integrations.fastapi.healthz()` builds exactly this handler, with utilization-based shedding included — see [FastAPI integration](#fastapi-integration).)
|
|
1059
|
+
(`problem()` is the stand-in from [Wiring](#wiring-recommended--contextvars-based). `pgcommon.integrations.fastapi.healthz()` builds exactly this handler, with utilization-based shedding, `traceId` and `timestamp` included — see [FastAPI integration](#fastapi-integration).)
|
|
996
1060
|
|
|
997
1061
|
#### Utilization-based load shedding
|
|
998
1062
|
|
|
@@ -1002,12 +1066,26 @@ async def db_health_middleware(request, call_next):
|
|
|
1002
1066
|
|
|
1003
1067
|
# Fail fast when the database is unreachable.
|
|
1004
1068
|
if not status.healthy:
|
|
1005
|
-
return
|
|
1069
|
+
return problem(
|
|
1070
|
+
request,
|
|
1071
|
+
"service-unavailable",
|
|
1072
|
+
"Service Unavailable",
|
|
1073
|
+
503,
|
|
1074
|
+
"DATABASE_UNAVAILABLE",
|
|
1075
|
+
"The database is unavailable.",
|
|
1076
|
+
)
|
|
1006
1077
|
|
|
1007
1078
|
# Shed load when the pool is 90%+ saturated to protect the last connections
|
|
1008
1079
|
# for in-flight requests — prevents cascading timeouts under traffic spikes.
|
|
1009
1080
|
if status.utilization >= 0.9:
|
|
1010
|
-
return
|
|
1081
|
+
return problem(
|
|
1082
|
+
request,
|
|
1083
|
+
"service-unavailable",
|
|
1084
|
+
"Service Unavailable",
|
|
1085
|
+
503,
|
|
1086
|
+
"SERVICE_OVERLOADED",
|
|
1087
|
+
"The service is overloaded.",
|
|
1088
|
+
)
|
|
1011
1089
|
|
|
1012
1090
|
return await call_next(request)
|
|
1013
1091
|
```
|
|
@@ -1081,6 +1159,8 @@ async def create_user(pool, email):
|
|
|
1081
1159
|
| `sql_state` | — | The raw SQLSTATE (e.g. `"23505"`), or `""`; for codes no helper covers, or logging |
|
|
1082
1160
|
| `is_pool_closed` | `PoolClosedError`, asyncpg's closed-pool error, or a query a timed-out `drain_and_close` interrupted | Shutting down: final, do not retry |
|
|
1083
1161
|
|
|
1162
|
+
For the HTTP side, [docs/problem-details.md](docs/problem-details.md) gives the recommended RFC 9457 problem type for each helper. Never return `str(exc)`: pgcommon's exception text can carry SQL and row values (a constraint's `DETAIL`); keep `sql_state` / `constraint_name` for server-side logs.
|
|
1163
|
+
|
|
1084
1164
|
`is_pg_error`, `sql_state` and the class helpers ignore asyncpg's client-side errors that inherit a SQLSTATE but were never sent by a server (e.g. `ConnectionDoesNotExistError`, raised when the connection is lost mid-query), as Go's do for errors that are not a `*pgconn.PgError`.
|
|
1085
1165
|
|
|
1086
1166
|
### Slow-query logging
|
|
@@ -2000,7 +2080,7 @@ Each statement produces a `db.query` span (kind `CLIENT`) with the stable OpenTe
|
|
|
2000
2080
|
| `server.address` / `server.port` | The server the connection is attached to |
|
|
2001
2081
|
| `db.query.text` | The full SQL — **only** with `Config.allow_full_statements = True` |
|
|
2002
2082
|
|
|
2003
|
-
A failed statement records only its SQLSTATE — `db.response.status_code`, `error.type` (the code), `db.response.severity` and the status `SQLSTATE <code>` — because PostgreSQL error messages quote input values. A non-PostgreSQL error gets `error.type` `context_canceled` (cancellation), `context_deadline_exceeded` (timeout) or `_OTHER`, and the status `database error`. The error message and an exception event are added only with `allow_full_statements
|
|
2083
|
+
A failed statement records only its SQLSTATE — `db.response.status_code`, `error.type` (the code), `db.response.severity` and the status `SQLSTATE <code>` — because PostgreSQL error messages quote input values. A non-PostgreSQL error gets `error.type` `context_canceled` (cancellation), `context_deadline_exceeded` (timeout) or `_OTHER`, and the status `database error`. The error message and an exception event are added only with `allow_full_statements`: the status is then Go's text, `ERROR: <message> (SQLSTATE <code>)` for a PostgreSQL error (never its DETAIL or HINT lines, which quote row values), and the event carries `exception.type` and `exception.message` only, with no stack trace.
|
|
2004
2084
|
|
|
2005
2085
|
> **SQL privacy:** by default spans carry only the SQL verb and the SQLSTATE, to prevent PII leakage to tracing backends. Set `Config.allow_full_statements = True` to add the full SQL and error messages — only do this when query text contains no sensitive data.
|
|
2006
2086
|
|
|
@@ -2040,8 +2120,8 @@ pgcommon itself is released to PyPI, and services adopt it by bumping their pin;
|
|
|
2040
2120
|
Pin an exact version in production ([VERSIONING.md § Consume a release](VERSIONING.md#consume-a-release) has the pin styles):
|
|
2041
2121
|
|
|
2042
2122
|
```bash
|
|
2043
|
-
pip install "pgcommon==0.
|
|
2044
|
-
uv add "pgcommon==0.
|
|
2123
|
+
pip install "pgcommon==0.4.0" # or "pgcommon[fastapi]==0.4.0" / "pgcommon[structlog]==0.4.0"
|
|
2124
|
+
uv add "pgcommon==0.4.0"
|
|
2045
2125
|
```
|
|
2046
2126
|
|
|
2047
2127
|
The files on PyPI are byte-identical to the GitHub Release's wheel and sdist: the release builds them once and uploads the same files to both.
|
|
@@ -2059,7 +2139,7 @@ Two-stage `Dockerfile`. The build stage (`python:3.14-slim` with uv `0.11.32`, b
|
|
|
2059
2139
|
Needs [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) ≥ 2.4. A tag-triggered release is signed by `release.yml@refs/tags/<tag>`; a re-run dispatched from `main` by `release.yml@refs/heads/main`, which the regular expression below also accepts.
|
|
2060
2140
|
|
|
2061
2141
|
```bash
|
|
2062
|
-
TAG=v0.
|
|
2142
|
+
TAG=v0.4.0 # the release to verify
|
|
2063
2143
|
REPO=BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
|
|
2064
2144
|
IDENTITY="^https://github\.com/${REPO}/\.github/workflows/release\.yml@refs/(tags/${TAG}|heads/main)$"
|
|
2065
2145
|
|
|
@@ -2085,7 +2165,7 @@ Run migrations as a separate init container or pre-deploy job, to completion, ov
|
|
|
2085
2165
|
|
|
2086
2166
|
### Versioning and releases
|
|
2087
2167
|
|
|
2088
|
-
**Release status:** 0.
|
|
2168
|
+
**Release status:** 0.4.0 was released on 2026-10-16 (`CHANGELOG.md` `[0.4.0]`). It is breaking for HTTP clients of the FastAPI integration: `GUCAuthMiddleware`'s 401 and `healthz()`'s 503s are RFC 9457 Problem Details ([docs/problem-details.md](docs/problem-details.md)); a client that matched on `detail` should match on `errorCode`. It also fixes span error text to match Go's (no DETAIL / HINT row values) and keeps row values out of logged errors. Before it: 0.3.1 (2026-10-15, the `Config` repr masks the DSN password) and 0.3.0 (2026-10-15, Go v2.0.1 parity and production hardening, breaking against 0.2.0 — read its Upgrade notes before upgrading from 0.2.x). `platform-eventcommon` admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0`.
|
|
2089
2169
|
|
|
2090
2170
|
**Go parity:** this port tracks Go `platform-pgcommon` **v2.0.1** — the same Tier 1 metrics, configuration, transaction, error and migration behaviour, and the same migrate CLI, except for the documented [divergences](ARCHITECTURE.md#divergences-from-go). The Python package keeps its own version line (`0.x`); the CHANGELOG names the Go version each change corresponds to.
|
|
2091
2171
|
|
|
@@ -2208,7 +2288,7 @@ MIGRATIONS_DIR=/migrations
|
|
|
2208
2288
|
|
|
2209
2289
|
| Consumer | pgcommon pin | Uses |
|
|
2210
2290
|
|---|---|---|
|
|
2211
|
-
| `platform-eventcommon` (Python port of `platform-events`) | `>=0.2.0,<0.
|
|
2291
|
+
| `platform-eventcommon` (Python port of `platform-events`) | `>=0.2.0,<0.5.0` on its `main` | `Pool` and `run_in_tx` for its outbox, inbox and DLQ stores; `with_guc_set` so message handlers run under RLS; `MigrationDirtyError` for its own schema bookkeeping |
|
|
2212
2292
|
| `platform-interop-tests` (Python probe) | — | Cross-language compatibility with the Go library (CI's `interop` job) |
|
|
2213
2293
|
| FastAPI platform services | their own | Pool, RLS injection, transactions, error helpers, metrics, `pgcommon.integrations.fastapi` |
|
|
2214
2294
|
|
|
@@ -2290,6 +2370,7 @@ BCBP Solutions FZC LLC — internal platform shared library.
|
|
|
2290
2370
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Design decisions, layer diagrams, and the asyncio/contextvars translation notes |
|
|
2291
2371
|
| [docs/MigrationGuide.md](./docs/MigrationGuide.md) | Concept-by-concept mapping from the Go `platform-pgcommon` to this port |
|
|
2292
2372
|
| [docs/lld/platform-lld-pgcommon-py.md](./docs/lld/platform-lld-pgcommon-py.md) | Low-level design: module layout, API contract, GUC contract, acquire / transaction / migration algorithms, failure handling, telemetry, invariants and open questions |
|
|
2373
|
+
| [docs/problem-details.md](./docs/problem-details.md) | RFC 9457 Problem Details: the FastAPI integration's 401 / 503 problems, the type base and factory hook, and the recommended mapping of pgcommon's database errors to problem types |
|
|
2293
2374
|
| [docs/architecture/README.md](./docs/architecture/README.md) | The standalone Mermaid sources of every ARCHITECTURE.md diagram, and how to keep them in sync |
|
|
2294
2375
|
| [docs/observability/README.md](./docs/observability/README.md) | The Enterprise Platform Observability Standard as implemented here: classification, wiring, divergences from Go, alerts and dashboard |
|
|
2295
2376
|
| [docs/observability/metrics-registry.md](./docs/observability/metrics-registry.md) | Generated metric inventory and ratification packets |
|