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.7 KiB
Query and Fetch Guide
Design §23-§28. How queries are chosen, bounded, and proven.
Named queries
Every registered query carries a QueryName. It becomes the metric tag, the trace attribute, and
the SQL comment that appears in pg_stat_activity and the slow-query log — which is the only thing
that connects a statement on the server back to the use case that issued it. The format rejects raw
SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built
from a parameterised value leaks row data into telemetry.
Fetch plans, not eager mappings
N+1 is solved per use case with a registered entity graph, not by making an association EAGER in
the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every
other query against that entity, including the ones that only wanted the id.
fetchgraph and loadgraph are different: a fetch graph is exhaustive (attributes outside it are
lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either
missing data or the amplification the graph was meant to avoid.
Measuring, not guessing
QueryMeasurement records statements, hydrated entities, rows, fetches, and elapsed time. Statement
count alone cannot distinguish the two failures that matter:
- N+1 — many statements, few rows.
- Cartesian fetch — one statement, an enormous number of rows.
A suite asserting only on statement count passes the second one every time.
Pagination
Offset pagination makes the database walk and discard n rows before returning any. Keyset
pagination replaces it:
- The predicate is lexicographic. For an ordering of
(createdAt, id), "after(t, x)" iscreatedAt < t OR (createdAt = t AND id < x)— notcreatedAt <= t AND id < x, which reads plausibly and silently drops rows from the middle of the result set. - The ordering must end in a unique column. Without one, a page boundary inside a run of equal values duplicates and skips rows.
size + 1rows are fetched andsizereturned. That extra row answershasNextwithout a count query, which would be a second full scan whose answer is stale on arrival.
Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a caller seek to arbitrary keys.
Sorting
Client sort parameters are mapped through SafeSortRegistry, never passed through. A sort field
reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the
client's string means the client writes part of the statement. JpaSort.unsafe has no call site in
this platform.
The registry's tie-breaker is always appended, because a sort that does not end in a unique column has no total order and paging over a non-total order duplicates and skips rows.
Streaming
A JPA Stream is a live cursor holding a ResultSet, a statement, and a connection. JpaStreamExecutor
consumes it inside a try-with-resources and never returns it, because a stream returned past the
transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only
transaction is required: streaming inside a write transaction pins a write connection for the whole
traversal.
Batching
Configuring hibernate.jdbc.batch_size proves nothing. BatchExecutionResult.jdbcBatches comes from
counting real executeBatch() calls at the JDBC layer, because an IDENTITY generator, an interleaved
select, or a mid-loop flush disables batching while the configuration still says it is on.
Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity in the Persistence Context — the classic bulk-import out-of-memory.