Implements the Stable and Experimental JPA persistence platform designs against real PostgreSQL, adapted to this repository's fail-closed 19-leaf registry. The design models the platform as 25 Gradle projects. `src/settings.gradle` throws unless the registry holds exactly 19 leaves, so the plan's modules become packages inside `:adapter:outbound:persistence-jpa` (starter in `:app-bootstrap`, testkit in its own source set). The full mapping, the renames this repository's naming gate required, and every deliberate substitution are recorded in `docs/jpa/repository-adaptation.md`. Seven Docker-backed lanes replace the plan's seven JVM test suites. Each fails closed: a lane that discovers nothing, or a container that cannot start, is an error rather than a skip. Three defects the contracts found against a real server: - `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and transport breaks as completion-unknown. A backend terminated mid-commit reports 57P01, and the commit record may already be in the WAL — so a possibly-committed transaction could be re-run. 57P01/57P02/57P03 now classify as completion-unknown. - `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target version, which is empty for a tenant already current, reporting migrated tenants as unmigrated during a partial rollout. It now reads the applied version back from the tenant's schema history. - `JpaStreamExecutor` checked only the declared return type for reactive publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout to `SET`, which is parsed before parameter binding. `JpaModuleBoundaryTest` enforces the plan's module map as package rules; `verifyCleanArchitectureDependencies` governs edges between leaves and cannot see these. Its first assertion is that the import is non-empty, because every rule under it is a `noClasses()` rule and would pass vacuously on an empty import. Verified: 128 container tests across all seven lanes, 1183 unit tests, `:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`, `verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3.0 KiB
Observability
Design §37. What is measured, and what must never appear in a measurement.
Bounded tags, always
Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure
category. All five are registered identifiers, validated by LowCardinality at construction rather
than at the registry — so an unbounded value fails where it was introduced instead of surviving
until a dashboard stops loading.
Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so each creates a time series per row or per failure; several are also the data the platform keeps out of logs, which a metrics backend would store just as durably and export just as widely.
Transaction metrics
| Meter | Why it exists |
|---|---|
jpa.transaction.duration |
the baseline |
jpa.transaction.rollback |
rollback rate by failure category |
jpa.transaction.timeout |
timeouts, distinct from other rollbacks |
jpa.transaction.completion.unknown |
its own counter, deliberately |
Completion-unknown gets a separate counter rather than being folded into failures. It is the one outcome that means a human has to look: every other failure is a transaction that definitely did not happen, while this one is a transaction that may have.
Query metrics
jpa.query.duration and jpa.query.rows. Rows are measured as well as duration because a query
that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic
per request — a duration metric alone reports it as merely slow.
Retry metrics
Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected cost of concurrency; logging each at WARN pages someone for a system working as designed, after which the retry log gets filtered out and takes the genuinely interesting entries with it.
jpa.retry.attempt, jpa.retry.attempts (distribution per operation), jpa.retry.exhausted.
Query names in SQL
NamedStatementInspector prefixes each statement with its registered query name as a SQL comment,
which travels into pg_stat_activity, auto_explain, and the slow-query log. Without it, "which
endpoint issues this query" is answered by grepping the codebase for fragments of SQL.
Diagnostics
SqlDiagnosticRedactor removes string literals, numbers, and anything email-shaped before SQL
reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing
which columns hold personal data.
The actuator endpoint
jpaplatform reports database major version, provider version, schema version, OSIV state, runtime
role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity
catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of
those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a
migration or a repair would be an admin capability exposed over HTTP.