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>
75 lines
3.7 KiB
Markdown
75 lines
3.7 KiB
Markdown
# 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)`" is
|
|
`createdAt < t OR (createdAt = t AND id < x)` — **not** `createdAt <= 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 + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without 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.
|