feat(jpa): implement the JPA relational persistence platform
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
0e61f86eb5
@@ -0,0 +1,64 @@
|
||||
# Entity Mapping Guide
|
||||
|
||||
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
|
||||
and expensive in production.
|
||||
|
||||
## The domain owns the model
|
||||
|
||||
The platform defines no business entity. Table names, column semantics, keys, unique and check
|
||||
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
|
||||
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
|
||||
single generic API forces every aggregate through the same operations — and one aggregate's later
|
||||
requirement then changes behaviour for all of them.
|
||||
|
||||
## Entities must be proxyable
|
||||
|
||||
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
|
||||
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
|
||||
- A non-private no-arg constructor. The provider instantiates entities reflectively before
|
||||
populating fields.
|
||||
|
||||
`EntityMappingCondition` in the testkit enforces both.
|
||||
|
||||
## Identifiers
|
||||
|
||||
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
|
||||
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
|
||||
surfaces later as a primary-key violation under load.
|
||||
|
||||
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
|
||||
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
|
||||
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
|
||||
letting the import silently run an order of magnitude slower.
|
||||
|
||||
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
|
||||
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
|
||||
never stays in cache and write amplification grows with the table.
|
||||
|
||||
## Values
|
||||
|
||||
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
|
||||
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
|
||||
existing row.
|
||||
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
|
||||
drift and reconciliation disagrees with the ledger.
|
||||
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
|
||||
compares wrongly in SQL.
|
||||
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
|
||||
other.
|
||||
|
||||
## Associations
|
||||
|
||||
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
|
||||
child also queries for its parent — the most common accidental N+1 in a JPA application.
|
||||
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
|
||||
unlinked, so aggregates expose an association helper that sets both sides.
|
||||
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
|
||||
Between independent aggregates it deletes rows another part of the system still owns.
|
||||
|
||||
## Entities never leave the transaction
|
||||
|
||||
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
|
||||
happens after the transaction closes, so a lazy association touched by the serialiser either throws
|
||||
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
|
||||
`EntityExposureCondition` checks generic type arguments, not just the erased return type.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Promotion Checklist
|
||||
|
||||
`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR.
|
||||
|
||||
## Technical evidence
|
||||
|
||||
- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two
|
||||
supported patch releases. One passing run is a coincidence.
|
||||
- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible,
|
||||
including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter
|
||||
that covers only entity queries covers none of those.
|
||||
- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route
|
||||
a read-after-write to a stale replica; the commit-ambiguity scenarios still behave.
|
||||
- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited.
|
||||
With one schema per tenant, a run is N independent migrations and "it failed" is not an answer.
|
||||
- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured,
|
||||
not estimated. Database-per-tenant fails as a sum, not as an individual pool.
|
||||
|
||||
## Decision
|
||||
|
||||
- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and
|
||||
what would cause it to be withdrawn.
|
||||
|
||||
The ADR is not a formality. The technical suites establish that something works; the ADR records
|
||||
that the platform should promise it, which is a different question with a different cost.
|
||||
|
||||
## What does not count as evidence
|
||||
|
||||
- The version being generally available.
|
||||
- The feature working in one environment.
|
||||
- A passing suite that skipped because Docker was unavailable.
|
||||
- A green lane whose assertions were relaxed to make it pass.
|
||||
|
||||
## Outcomes
|
||||
|
||||
| Decision | Meaning |
|
||||
|---|---|
|
||||
| `BLOCKED_TECHNICAL` | at least one suite has not passed |
|
||||
| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists |
|
||||
| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin |
|
||||
|
||||
The two blocked states are distinct because they need different work: one needs evidence, the other
|
||||
needs a decision.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Support Matrix
|
||||
|
||||
Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of
|
||||
it is part of the Stable composition.
|
||||
|
||||
| Feature | Flag | State |
|
||||
|---|---|---|
|
||||
| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental |
|
||||
| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental |
|
||||
| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental |
|
||||
| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental |
|
||||
| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental |
|
||||
| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental |
|
||||
| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental |
|
||||
| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental |
|
||||
|
||||
Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is
|
||||
present and its flag is not set, because an experimental module can arrive transitively and a
|
||||
tenant-isolation feature that switched itself on would be the worst possible default.
|
||||
|
||||
## Known constraints
|
||||
|
||||
- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with
|
||||
no tenant predicate, which returns every tenant's rows.
|
||||
- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML,
|
||||
`getReference`, or the second-level cache.
|
||||
- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is
|
||||
otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`.
|
||||
- Tenant bindings are transaction-local. A session-local setting survives the connection's return to
|
||||
the pool.
|
||||
- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or
|
||||
the primary.
|
||||
- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of
|
||||
freshness.
|
||||
- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred
|
||||
connections against a server that permits a hundred.
|
||||
- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition.
|
||||
|
||||
## Lanes never change Stable
|
||||
|
||||
A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates
|
||||
different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps
|
||||
asserting what 7.x must do, because that is what deployments run.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migration Guide
|
||||
|
||||
Design §31-§32. Flyway owns the schema; Hibernate only validates.
|
||||
|
||||
## Who may change the schema
|
||||
|
||||
| Environment | Mode |
|
||||
|---|---|
|
||||
| local, test, dev | migrate at startup with the migration credential |
|
||||
| staging, prod | deployment-owned migration; the application validates only |
|
||||
|
||||
Migrating from inside the application in production means every instance of a rolling deploy races
|
||||
to apply the same script, and the loser's failure is indistinguishable from a real one.
|
||||
|
||||
`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it
|
||||
produces a schema that is neither the old one nor the one the migrations describe — silently, on
|
||||
whichever instance started first.
|
||||
|
||||
## Validation fails closed and never repairs
|
||||
|
||||
`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration,
|
||||
or a schema Hibernate disagrees with. It never calls `repair`.
|
||||
|
||||
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the
|
||||
symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the
|
||||
applied one, and the interesting question is which change is missing from this database. Repair
|
||||
makes that question unaskable. It exists only as an explicit admin operation with an operator, a
|
||||
reason, and an approval (design §8.4).
|
||||
|
||||
Only Flyway's structured error codes reach the exception. Its messages embed the script path and
|
||||
part of the failing statement.
|
||||
|
||||
## Concurrent index builds
|
||||
|
||||
`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in
|
||||
one by default. The migration therefore needs a companion configuration:
|
||||
|
||||
```conf
|
||||
# V42__order_index.sql.conf
|
||||
executeInTransaction=false
|
||||
```
|
||||
|
||||
`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the
|
||||
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
|
||||
recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction
|
||||
of partial state when it did.
|
||||
|
||||
An invalid index is not merely useless — the planner ignores it while every write still maintains
|
||||
it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does
|
||||
not drop them: an invalid index can also mean a build is still running, and the two are
|
||||
indistinguishable from the catalog alone.
|
||||
|
||||
## Upgrade scenarios
|
||||
|
||||
Three, each catching something the others do not:
|
||||
|
||||
| Scenario | Catches |
|
||||
|---|---|
|
||||
| `empty` | an early migration edited to match a later one, no longer applying to a fresh database |
|
||||
| `previous-release` | the actual deployment path; the only one exercising this release's migrations |
|
||||
| `oldest-supported` | a migration that silently assumes state only recent databases have |
|
||||
|
||||
Each asserts a data invariant, not just the schema version. A migration that renames a column and
|
||||
loses its contents leaves the version correct and the data gone.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# PostgreSQL Extensions
|
||||
|
||||
Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.
|
||||
|
||||
Everything here is core PostgreSQL. No server extension is required.
|
||||
|
||||
## Locking
|
||||
|
||||
`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded
|
||||
lock request because it waits as long as the holder holds it, turning one slow transaction into a
|
||||
pile-up of blocked connections.
|
||||
|
||||
`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a
|
||||
single field with a magic zero is how "no wait" becomes "wait forever".
|
||||
|
||||
`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed:
|
||||
the first leaves the transaction alive and the caller in control; the second has already been rolled
|
||||
back by the server.
|
||||
|
||||
## Work claims
|
||||
|
||||
`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a
|
||||
repository flag. It deliberately returns an incomplete view of the table: correct for handing
|
||||
disjoint work to competing workers, silently wrong for anything that needs to see every matching
|
||||
row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`.
|
||||
|
||||
## Upserts
|
||||
|
||||
`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict
|
||||
target and update column set. The conflict target cannot be a bound parameter, so accepting one from
|
||||
a caller would mean building SQL from input.
|
||||
|
||||
An upsert is the correct answer to a create race precisely because the database decides.
|
||||
Read-then-write cannot be made correct: another transaction can commit between the read and the
|
||||
write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report
|
||||
insert-versus-update without a second query.
|
||||
|
||||
The executor flushes before and clears after: a native write is invisible to the Persistence
|
||||
Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded
|
||||
beforehand would keep serving pre-upsert values.
|
||||
|
||||
## JSONB
|
||||
|
||||
`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless
|
||||
at the database level, so without an envelope the only record of what a stored document means is the
|
||||
code that wrote it — and a document written two releases ago is indistinguishable from a current one.
|
||||
|
||||
The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization
|
||||
gadget: whoever can write a row chooses the class the reader instantiates.
|
||||
|
||||
Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a
|
||||
request field into one is concatenating untrusted input into a statement. Values are always bound.
|
||||
|
||||
## Arrays and ranges
|
||||
|
||||
Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is
|
||||
where quoting bugs live, and a tag containing a comma changes the array's shape rather than its
|
||||
content.
|
||||
|
||||
`PgRange` models both endpoints as independently optional and independently inclusive, because that
|
||||
is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the
|
||||
bracket, not the values, and a pair of `timestamptz` columns cannot express it.
|
||||
|
||||
## COPY (J4 admin)
|
||||
|
||||
`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That
|
||||
is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a
|
||||
row and byte cap, a finite server-side `statement_timeout`, and a named operator.
|
||||
|
||||
The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the
|
||||
*database server* as the server's OS user; it is superuser-only for exactly that reason and does not
|
||||
belong behind an application API.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,156 @@
|
||||
# JPA Relational Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
(copied to `docs/superpowers/specs/`)
|
||||
**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
|
||||
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
|
||||
structure are explicit implementation *assumptions* made because the real Backend Skeleton
|
||||
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
|
||||
conventions and root package while the public contracts and policy semantics are preserved.
|
||||
|
||||
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
|
||||
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
|
||||
semantics are implemented as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
|
||||
Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture
|
||||
template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by
|
||||
`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf
|
||||
identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19
|
||||
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the plan's library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
|
||||
This is the same adaptation already applied to the HTTP client platform
|
||||
(`docs/httpclient/repository-adaptation.md`).
|
||||
|
||||
| Plan module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
|
||||
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
|
||||
|
||||
The package boundary is enforced by `JpaModuleBoundaryTest`, which reproduces the plan's
|
||||
§3 module dependency map as package rules.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.jpa` → `dev.caskeleton.adapter.outbound.persistence`.
|
||||
|
||||
| Plan module | Plan package | Repository package |
|
||||
|---|---|---|
|
||||
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
|
||||
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
|
||||
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
|
||||
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
|
||||
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
|
||||
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
|
||||
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
|
||||
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
|
||||
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
|
||||
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
|
||||
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
|
||||
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
|
||||
| `jpa-security` | `…jpa.security` | `…persistence.security` |
|
||||
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
|
||||
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
|
||||
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
|
||||
|
||||
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
|
||||
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
|
||||
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
|
||||
existing name.
|
||||
|
||||
## 3. Test-suite mapping
|
||||
|
||||
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
|
||||
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
|
||||
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
|
||||
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|
||||
|
||||
| Plan suite | Repository lane |
|
||||
|---|---|
|
||||
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
|
||||
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` |
|
||||
|
||||
Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
|
||||
|
||||
## 4. Other deliberate substitutions
|
||||
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
|
||||
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. |
|
||||
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
|
||||
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
|
||||
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
|
||||
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
|
||||
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
|
||||
|
||||
### Types relocated to keep the dependency direction legal
|
||||
|
||||
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
|
||||
value-only types the design places in a downstream module are consumed by a core contract, so they
|
||||
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
|
||||
costs nothing and the alternative — a core contract importing an adapter package — would break the
|
||||
boundary the module map exists to hold.
|
||||
|
||||
| Type | Plan module | Repository package | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
|
||||
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
|
||||
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
|
||||
|
||||
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
|
||||
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
|
||||
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
|
||||
classpath to serve code that only ever runs in a test.
|
||||
|
||||
|
||||
### Findings the contracts produced against a real server
|
||||
|
||||
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
|
||||
showed it. Both are recorded here because the design text still reads the old way.
|
||||
|
||||
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
|
||||
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
|
||||
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
|
||||
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
|
||||
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
|
||||
again.
|
||||
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
|
||||
target version is empty for a tenant that was already current, so recording it reported migrated
|
||||
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
|
||||
applied version from the tenant's schema history.
|
||||
|
||||
## 5. What is unchanged from the design
|
||||
|
||||
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
|
||||
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
|
||||
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
|
||||
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
|
||||
`retryable=false`, and is never automatically retried — reconciliation handles it.
|
||||
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
|
||||
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
|
||||
`55P03`) and never parses localized message text.
|
||||
- Flyway is the source of truth for production schema change; Hibernate only validates;
|
||||
`ddl-auto` never mutates a deployed schema.
|
||||
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
|
||||
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
|
||||
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
|
||||
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
|
||||
Stable composition.
|
||||
@@ -0,0 +1,85 @@
|
||||
# JPA Platform Runbooks
|
||||
|
||||
Operator procedures for the failures this platform is designed to surface rather than hide.
|
||||
|
||||
## A transaction reported completion unknown
|
||||
|
||||
**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the
|
||||
reconciliation channel.
|
||||
|
||||
**What it means:** the commit may or may not have happened. It is not a rollback.
|
||||
|
||||
**Do not** re-run the use case. That is what the platform refused to do automatically, for the same
|
||||
reason.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. Take the `transactionKey` from the record.
|
||||
2. Check the idempotency record for that key.
|
||||
3. Check the business row the use case would have written.
|
||||
4. Check the outbox for a corresponding event.
|
||||
5. If all three agree the write happened, mark the record `COMMITTED` and stop.
|
||||
6. If all three agree it did not, the use case may be re-run.
|
||||
7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive
|
||||
answer is a legitimate outcome; guessing is not.
|
||||
|
||||
A record with no `transactionKey` cannot be resolved automatically — use the operation name and
|
||||
timestamp.
|
||||
|
||||
## Deadlock or serialization rate rising
|
||||
|
||||
**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero.
|
||||
|
||||
Retries are expected. Exhaustion is not.
|
||||
|
||||
1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an
|
||||
inconsistent lock order.
|
||||
2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a
|
||||
code fix, not a tuning one.
|
||||
3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required.
|
||||
4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure
|
||||
into a slow one.
|
||||
|
||||
## Pool exhaustion
|
||||
|
||||
**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero.
|
||||
|
||||
1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool
|
||||
must satisfy `(threads x (1 + depth)) + 1`.
|
||||
2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its
|
||||
connection until the pool notices.
|
||||
3. Check for external calls inside a DB transaction. The design forbids them precisely because an
|
||||
HTTP timeout then holds a connection for its whole duration.
|
||||
|
||||
## Flyway validation failed at startup
|
||||
|
||||
The deployment is running against a schema it was not built for. It failed closed, which is correct.
|
||||
|
||||
1. Read the reported error codes (the messages are deliberately not propagated).
|
||||
2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing
|
||||
from this database. **Do not run `repair`**: it rewrites history to match the scripts, which
|
||||
resolves the symptom by deleting the evidence.
|
||||
3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older
|
||||
artifact.
|
||||
|
||||
## An invalid index exists
|
||||
|
||||
**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty.
|
||||
|
||||
A concurrent build failed. The index is ignored by the planner and maintained by every write.
|
||||
|
||||
1. Confirm no build is currently running. An in-progress build looks identical in the catalog.
|
||||
2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration.
|
||||
3. Re-apply the index migration.
|
||||
|
||||
The platform does not drop these automatically: on a rolling deploy every instance would race to
|
||||
drop an index another instance was about to finish building.
|
||||
|
||||
## The runtime role failed verification
|
||||
|
||||
Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an
|
||||
unapproved schema.
|
||||
|
||||
This is not a false positive to be worked around. Re-provision from
|
||||
`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that
|
||||
makes every other schema guarantee unenforceable.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security
|
||||
|
||||
Design §36. Credential separation, privilege verification, and what never leaves the process.
|
||||
|
||||
## Three credentials
|
||||
|
||||
| Role | May |
|
||||
|---|---|
|
||||
| `app_migration` | own the schema, apply migrations (DDL) |
|
||||
| `app_runtime` | select, insert, update, delete (DML only) |
|
||||
| `app_admin` | J4 operations — COPY, backfill, maintenance |
|
||||
|
||||
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
|
||||
the application's own credential cannot execute DDL, then no code path, no library, and no injected
|
||||
statement can alter the schema at runtime, regardless of what the application intended.
|
||||
|
||||
`infra/jpa/roles/runtime-roles.sql` provisions them.
|
||||
|
||||
## Startup verification
|
||||
|
||||
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
|
||||
|
||||
```sql
|
||||
select current_user,
|
||||
current_setting('search_path'),
|
||||
has_schema_privilege(current_user, current_schema(), 'CREATE'),
|
||||
has_database_privilege(current_user, current_database(), 'CREATE')
|
||||
```
|
||||
|
||||
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
|
||||
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
|
||||
reconstructs that combination reliably.
|
||||
|
||||
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
|
||||
database.
|
||||
|
||||
## search_path
|
||||
|
||||
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
|
||||
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
|
||||
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
|
||||
application executes it without noticing. `$user` is exempt: only the connected role owns it.
|
||||
|
||||
Refusing the runtime role `CREATE` closes the same route from the other side.
|
||||
|
||||
## What never leaves the process
|
||||
|
||||
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
|
||||
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
|
||||
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
|
||||
name maps to a bounded unknown code rather than being passed through.
|
||||
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
|
||||
- The actuator report carries no JDBC URL, username, or SQL.
|
||||
|
||||
## Injection surfaces, and how each is closed
|
||||
|
||||
| Surface | Why it cannot be a parameter | Closed by |
|
||||
|---|---|---|
|
||||
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
|
||||
| JSON path | part of the statement | registered `JsonPathName` |
|
||||
| schema name | an identifier | registered `SchemaTenantRegistry` |
|
||||
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
|
||||
| COPY table | an identifier | registered `RegisteredCopyStatement` |
|
||||
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
|
||||
|
||||
Values are always bound. Identifiers are always registered.
|
||||
@@ -0,0 +1,79 @@
|
||||
# JPA Persistence Platform — Support Matrix
|
||||
|
||||
The machine-readable source for `JpaReleaseManifest`. A release gate parses this file, so a version
|
||||
or gate that stops being named here stops being claimed — and the build fails rather than the
|
||||
document quietly drifting from the code.
|
||||
|
||||
## Database
|
||||
|
||||
| Database | Support | Evidence |
|
||||
|---|---|---|
|
||||
| PostgreSQL 16 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 17 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 18 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR |
|
||||
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
|
||||
|
||||
H2 is not a second production target. It reports different SQLSTATEs for the same violation, has no
|
||||
`SKIP LOCKED` guarantee the platform relies on, no JSONB operators, no range types, and no
|
||||
concurrent index builds. A green H2 run is evidence that the code compiles and runs, and nothing
|
||||
more.
|
||||
|
||||
## Specification and provider
|
||||
|
||||
| Component | Stable | Experimental |
|
||||
|---|---|---|
|
||||
| Jakarta Persistence | 3.2 | 4.0 (lane) |
|
||||
| Hibernate ORM | 7.4 declared baseline | 8 (lane) |
|
||||
| Spring Boot | repository BOM | — |
|
||||
|
||||
The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's
|
||||
Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a
|
||||
constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes
|
||||
the difference visible instead of asserting a constant against itself. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §4.
|
||||
|
||||
## Capability support levels
|
||||
|
||||
| Capability | Level |
|
||||
|---|---|
|
||||
| Full-transaction retry | Stable |
|
||||
| Commit completion evidence | Stable |
|
||||
| Keyset pagination | Stable |
|
||||
| JDBC batch | Stable |
|
||||
| Flyway schema gate | Stable |
|
||||
| Runtime role verification | Stable |
|
||||
| Observability | Stable |
|
||||
| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced |
|
||||
| PostgreSQL work claim (`SKIP LOCKED`) | Advanced |
|
||||
| PostgreSQL JSONB | Advanced |
|
||||
| PostgreSQL array and range | Advanced |
|
||||
| Bulk DML | Advanced |
|
||||
| Hibernate `StatelessSession` | Advanced |
|
||||
| PostgreSQL `COPY` | Admin (J4) |
|
||||
| Hibernate second-level cache | Advanced |
|
||||
| Hibernate Envers | Advanced |
|
||||
| Multi-tenancy (column, RLS, schema, database) | Experimental |
|
||||
| Consistency-aware read replica | Experimental |
|
||||
|
||||
## Release gates
|
||||
|
||||
Each row is a way the platform could pass its tests and still be wrong in production.
|
||||
|
||||
| Gate | Kind | What it prevents |
|
||||
|---|---|---|
|
||||
| `postgresql-contract` | gate | a release whose only database evidence came from H2 |
|
||||
| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed |
|
||||
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
|
||||
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
|
||||
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
|
||||
| `hibernate-7.4-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
|
||||
|
||||
## Explicitly unsupported
|
||||
|
||||
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
|
||||
onto an event loop rather than removing it.
|
||||
- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema.
|
||||
- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1).
|
||||
- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain
|
||||
resolves.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Transaction Guide
|
||||
|
||||
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
|
||||
|
||||
## The application service owns the boundary
|
||||
|
||||
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
|
||||
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
|
||||
knows where it starts and ends.
|
||||
|
||||
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
|
||||
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
|
||||
|
||||
## Profiles
|
||||
|
||||
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
|
||||
write profile must carry a positive finite timeout — the type refuses to represent one without —
|
||||
because an unbounded write transaction holds a connection, its locks, and its row versions for as
|
||||
long as one stuck statement takes.
|
||||
|
||||
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
|
||||
profile using it must be paired with the pool-pressure evidence in design §38:
|
||||
|
||||
```text
|
||||
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
|
||||
```
|
||||
|
||||
## Retry is per use case, never per statement
|
||||
|
||||
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
|
||||
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
|
||||
means the state the attempt computed against is no longer the committed state, so re-issuing the
|
||||
same statement would compute the same wrong answer. The domain rules have to run again against
|
||||
reloaded data.
|
||||
|
||||
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
|
||||
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
|
||||
|
||||
Two additional refusals, independent of budget:
|
||||
|
||||
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
|
||||
Rollback reverses database work only; an email or a card charge has already changed the world.
|
||||
- Anything completion-unknown.
|
||||
|
||||
## Completion unknown
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
|
||||
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
|
||||
rebuilds its context through the safe factory whatever it is handed.
|
||||
|
||||
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
|
||||
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
|
||||
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
|
||||
mistaken for a rollback.
|
||||
|
||||
Recovery is reconciliation, not retry:
|
||||
|
||||
```text
|
||||
record the transaction key -> check the idempotency record
|
||||
-> check the business row
|
||||
-> check the outbox
|
||||
-> still undetermined? reconciliation queue
|
||||
```
|
||||
|
||||
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
|
||||
Writing it through the same connection would make the audit trail share the failure it documents.
|
||||
Reference in New Issue
Block a user