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.1 KiB
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:
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:
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.