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,35 @@
|
||||
# ADR-JPA-001 — The domain owns the persistence model
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §10.1, §23.3
|
||||
|
||||
## Context
|
||||
|
||||
A persistence platform can either own the repository abstraction — a `GenericRepository<T, ID>`
|
||||
every aggregate inherits — or provide only the pieces domains assemble themselves.
|
||||
|
||||
## Decision
|
||||
|
||||
The domain owns entities, embeddables, repositories, queries, index requirements, and lock,
|
||||
soft-delete, and audit policy. The platform provides no generic CRUD repository and no base
|
||||
repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or
|
||||
`delete`, and is enforced not to acquire them.
|
||||
|
||||
## Consequences
|
||||
|
||||
A generic base repository has one property that looks like a benefit and is not: every aggregate
|
||||
gets the same operations. That means each aggregate is offered operations that may be wrong for it —
|
||||
a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse,
|
||||
one aggregate's later requirement changes the shared base and therefore changes behaviour for
|
||||
aggregates nobody reviewed.
|
||||
|
||||
Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be
|
||||
harder to opt out of.
|
||||
|
||||
The cost is a small amount of repetition: each domain declares the repository interface it needs.
|
||||
That repetition is the thing that makes each aggregate's persistence surface reviewable.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-002 — Retry re-runs the whole use case
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §19.2
|
||||
|
||||
## Context
|
||||
|
||||
Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what
|
||||
unit gets retried: the failed statement, the transaction, or the use case.
|
||||
|
||||
## Decision
|
||||
|
||||
The whole use case, in a new transaction with a new Persistence Context.
|
||||
`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the
|
||||
retry advice is ordered outside Spring's transaction advice so each attempt begins a new
|
||||
transaction.
|
||||
|
||||
## Consequences
|
||||
|
||||
Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means
|
||||
the state the attempt computed against is no longer the committed state; re-issuing the same
|
||||
statement computes the same wrong answer against a version that has moved on. The domain rules have
|
||||
to run again over reloaded data, which means the whole use case.
|
||||
|
||||
Reusing the Persistence Context would be equally wrong: the second attempt would read the first
|
||||
attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the
|
||||
retry loop would run inside one transaction that has already been marked rollback-only, so the
|
||||
second attempt fails immediately without executing anything.
|
||||
|
||||
The cost is that a retryable use case must be safe to run from scratch — no irreversible external
|
||||
effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not
|
||||
hold, and the policy then refuses to retry it whatever budget remains.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-JPA-003 — Completion unknown is never retried
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §17
|
||||
|
||||
## Context
|
||||
|
||||
A connection can break while a commit is in flight. The server may have committed; the
|
||||
acknowledgement may simply have been lost. The driver cannot tell the two apart.
|
||||
|
||||
## Decision
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is
|
||||
produced only by a failure observed while the transaction phase is `COMMITTING`, and only for
|
||||
SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is
|
||||
domain-specific reconciliation through `TransactionCompletionResolver`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate
|
||||
payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe,
|
||||
because the failure is epistemic rather than transient.
|
||||
|
||||
The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses
|
||||
to construct a retryable completion-unknown context, and the exception rebuilds its context through
|
||||
the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe
|
||||
retry — the value it would need does not exist.
|
||||
|
||||
The rule is deliberately narrow in the other direction too. Classifying every connection failure as
|
||||
completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation
|
||||
queue, which trains operators to clear that queue without reading it — and then the one entry that
|
||||
mattered gets cleared with the rest.
|
||||
|
||||
The cost is that the domain must supply the resolver. The platform cannot: only the domain knows
|
||||
which idempotency record, business row, or outbox entry proves the write happened.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate
|
||||
`completion-unknown-no-retry`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-004 — Flyway is the schema source of truth
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §31
|
||||
|
||||
## Context
|
||||
|
||||
Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts.
|
||||
Both cannot own the schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or
|
||||
`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is
|
||||
enforced by the server as well as by configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the
|
||||
result is a schema that is neither the previous one nor the one the mappings describe — produced
|
||||
silently, by whichever instance started first, with no record of what it did.
|
||||
|
||||
Two credentials rather than one is what makes this more than a convention. A configuration rule can
|
||||
be overridden by a property; a role without `CREATE` cannot be overridden by anything the
|
||||
application does.
|
||||
|
||||
Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts
|
||||
on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing.
|
||||
|
||||
The cost is that a schema change requires a migration script and a deployment step. That is the
|
||||
intended cost: it makes schema change reviewable and reversible.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`;
|
||||
`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-JPA-005 — Contracts run against real PostgreSQL
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §40
|
||||
|
||||
## Context
|
||||
|
||||
An in-memory database makes tests fast and hermetic. A container makes them slow and requires
|
||||
Docker.
|
||||
|
||||
## Decision
|
||||
|
||||
Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a
|
||||
local-development convenience and never satisfies a contract. The lanes fail closed when Docker is
|
||||
absent rather than skipping.
|
||||
|
||||
## Consequences
|
||||
|
||||
The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for
|
||||
the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent
|
||||
index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green
|
||||
H2 run is evidence that the code compiles and runs — not that any of the above holds.
|
||||
|
||||
Three versions rather than one because the platform claims three. A contract suite that ran only on
|
||||
16 would make "Stable on 17 and 18" an assumption.
|
||||
|
||||
Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract
|
||||
reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()`
|
||||
throws instead.
|
||||
|
||||
The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and
|
||||
fast, and is where most tests live; the container lanes verify the things only a real server can
|
||||
answer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,771 @@
|
||||
# JPA Experimental Expansion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stable JPA 플랫폼을 변경하지 않고 Multi-tenancy, PostgreSQL RLS, schema/database tenant 분리, consistency-aware Read Replica, Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19 호환성을 독립 Experimental 모듈과 승격 Gate로 검증한다.
|
||||
|
||||
**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다.
|
||||
|
||||
**Tech Stack:** Stable 계획의 Java 21·Spring Boot 4.1·PostgreSQL Testcontainers 기반, PostgreSQL RLS, AbstractRoutingDataSource, tenant-specific DataSource registry, Jakarta Persistence 4.0 preview/final compatibility lane, Hibernate ORM 8 compatibility lane, PostgreSQL 19 compatibility lane.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Stable 계획 Task 1~53이 완료되고 Release Gate가 통과한 뒤 시작한다.
|
||||
- 모듈 루트는 `modules/jpa-experimental`이다.
|
||||
- Experimental module은 `jpa-spring-boot-starter`의 기본 dependency가 아니다.
|
||||
- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다.
|
||||
- Tenant ID와 consistency token은 metric label에 기록하지 않는다.
|
||||
- Tenant context 누락은 fail-closed다.
|
||||
- `readOnly=true`만으로 replica routing하지 않는다.
|
||||
- Lock query, write transaction, read-after-write pin은 primary를 사용한다.
|
||||
- JPA4/Hibernate8/PG19 결과로 Stable 3.2/7.4/PG16~18 contract를 수정하지 않는다.
|
||||
- 승격 전 별도 security, failure, migration and compatibility evidence가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Experimental 파일 구조
|
||||
|
||||
```text
|
||||
modules/jpa-experimental/
|
||||
├── jpa-experimental-core/
|
||||
├── jpa-multitenancy-column/
|
||||
├── jpa-multitenancy-rls/
|
||||
├── jpa-multitenancy-schema/
|
||||
├── jpa-multitenancy-database/
|
||||
├── jpa-read-replica/
|
||||
└── jpa-next-compatibility/
|
||||
```
|
||||
|
||||
---
|
||||
### Task 1: Experimental Module·Feature Gate·Dependency Isolation 구성
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java`
|
||||
- Modify: `settings.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Stable `jpa-core-api` and explicit environment feature flags.
|
||||
- Produces: Isolated experimental projects that cannot enter the Stable starter transitively.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Every module depends only on Stable public contracts, never on Stable internal packages.
|
||||
- Feature gate fails startup when module is present but flag is absent.
|
||||
- Add a dependency graph test proving the Stable starter has no experimental dependency.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
class ExperimentalFeatureGateTest {
|
||||
@Test
|
||||
void featureIsDisabledUnlessExplicitlyEnabled() {
|
||||
assertThatThrownBy(() -> gate.requireEnabled(MULTITENANCY_COLUMN, Map.of()))
|
||||
.hasMessageContaining("backend.jpa.experimental.multitenancy-column=true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
public final class ExperimentalFeatureGate {
|
||||
public void requireEnabled(
|
||||
ExperimentalFeature feature,
|
||||
Map<String, Boolean> flags) {
|
||||
if (!Boolean.TRUE.equals(flags.get(feature.property()))) {
|
||||
throw new IllegalStateException(feature.property() + "=true is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-experimental-core/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts' 'modules/jpa-experimental/jpa-read-replica/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java' 'settings.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java'
|
||||
git commit -m "build: isolate jpa experimental modules"
|
||||
```
|
||||
|
||||
### Task 2: Shared-schema Tenant Context와 Column Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Explicit request/job tenant context and domain Entity tenant-column contracts.
|
||||
- Produces: Fail-closed tenant context propagation and query/write isolation evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Reject Repository access when tenant context is absent outside an audited admin scope.
|
||||
- Require tenant column in unique/index requirements where isolation depends on it.
|
||||
- Test async job context propagation and cleanup.
|
||||
- Do not rely on Hibernate filter alone as the final security boundary.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
class TenantColumnIsolationTest {
|
||||
@Test
|
||||
void tenantARepositoryCannotReadTenantBRows() {
|
||||
insertFor(TENANT_A, "a");
|
||||
insertFor(TENANT_B, "b");
|
||||
|
||||
assertThat(withTenant(TENANT_A, repository::findAll))
|
||||
.extracting(Item::value)
|
||||
.containsExactly("a");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
public final class TenantContext {
|
||||
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
|
||||
|
||||
public static TenantId require() {
|
||||
TenantId tenant = CURRENT.get();
|
||||
if (tenant == null) throw new IllegalStateException("tenant context is required");
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public static void clear() { CURRENT.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java'
|
||||
git commit -m "feat: add experimental tenant column isolation"
|
||||
```
|
||||
|
||||
### Task 3: PostgreSQL RLS Tenant Policy와 Connection Reuse Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: TenantContext, PostgreSQL transaction-local settings and restricted runtime role.
|
||||
- Produces: Database-enforced tenant isolation that resets safely across pooled connections.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Set tenant context with transaction-local `set_config` before tenant queries.
|
||||
- Prove a pooled connection cannot leak the prior tenant into the next transaction.
|
||||
- Runtime role must not own tables or bypass RLS.
|
||||
- Admin bypass requires a separate DataSource and audit token.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
class RlsIsolationFailureTest {
|
||||
@Test
|
||||
void pooledConnectionDoesNotLeakPriorTenantSetting() {
|
||||
withTenant(TENANT_A, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withTenant(TENANT_B, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withoutTenant(() -> assertThatThrownBy(repository::count).isInstanceOf(DataAccessException.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
public final class RlsTenantSessionBinder {
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
entityManager.createNativeQuery(
|
||||
"select set_config('app.tenant_id', :tenant, true)")
|
||||
.setParameter("tenant", tenant.value())
|
||||
.getSingleResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql' 'modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java'
|
||||
git commit -m "feat: add experimental postgresql rls isolation"
|
||||
```
|
||||
|
||||
### Task 4: Schema-per-tenant Connection Provider와 Migration Orchestrator 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Validated tenant→schema catalog and Flyway migration gate.
|
||||
- Produces: Bounded schema selection and per-tenant migration status without accepting raw schema names.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Map TenantId to a pre-registered schema identifier; no user-provided SQL identifier.
|
||||
- Reset schema/search_path when returning pooled connections.
|
||||
- Track migration version and failure per tenant.
|
||||
- Rate-limit tenant migrations and support resume without auto-repair.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
class SchemaTenantMigrationContractTest {
|
||||
@Test
|
||||
void migratesOnlyRegisteredSchemasAndResumesAfterFailure() {
|
||||
orchestrator.migrateAll(List.of(TENANT_A, TENANT_B));
|
||||
assertThat(status(TENANT_A).version()).isEqualTo(LATEST);
|
||||
assertThatThrownBy(() -> orchestrator.migrate(new TenantId("../public")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
public final class SchemaTenantRegistry {
|
||||
public String requireSchema(TenantId tenant) {
|
||||
return Optional.ofNullable(schemaByTenant.get(tenant))
|
||||
.orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java'
|
||||
git commit -m "feat: add experimental schema per tenant persistence"
|
||||
```
|
||||
|
||||
### Task 5: Database-per-tenant DataSource Registry와 Capacity Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Secret-backed tenant connection profiles and global DB connection budget.
|
||||
- Produces: Lazy bounded per-tenant pools with eviction, credential rotation and migration status.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Never create an unbounded Hikari pool per tenant.
|
||||
- Enforce global maximum pools and connections before creating a DataSource.
|
||||
- Drain and close pools on tenant removal or credential rotation.
|
||||
- Do not expose tenant JDBC URLs or credentials in diagnostics.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
class TenantPoolCapacityContractTest {
|
||||
@Test
|
||||
void refusesNewTenantPoolWhenGlobalConnectionBudgetIsExhausted() {
|
||||
registry.openTenants(globalBudget().maxTenants());
|
||||
assertThatThrownBy(() -> registry.require(ANOTHER_TENANT))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("tenant pool budget");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
public record TenantPoolBudget(
|
||||
int maxOpenPools,
|
||||
int maxConnectionsAcrossPools) {
|
||||
public void requireCapacity(int openPools, int allocatedConnections) {
|
||||
if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) {
|
||||
throw new IllegalStateException("tenant pool budget exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java'
|
||||
git commit -m "feat: add experimental database per tenant registry"
|
||||
```
|
||||
|
||||
### Task 6: Consistency-aware Read Replica Routing 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java`
|
||||
- Test: `modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Primary/replica DataSources, transaction state, lock intent and replica lag evidence.
|
||||
- Produces: Routing decisions for PRIMARY_REQUIRED, BOUNDED_STALENESS and EVENTUAL reads.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Writes, lock queries, REQUIRES_NEW writes and active write transactions always use primary.
|
||||
- Read-after-write uses a consistency token or primary pin, not `readOnly=true` alone.
|
||||
- Fallback to primary when replica lag exceeds policy or evidence is unavailable.
|
||||
- Keep routing fixed for the life of one transaction.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
class ReadAfterWriteRoutingContractTest {
|
||||
@Test
|
||||
void immediateReadAfterWriteUsesPrimaryUntilConsistencyTokenIsSatisfied() {
|
||||
var token = service.writeAndReturnConsistencyToken();
|
||||
var decision = router.route(readOnlyTransaction(), ReadConsistency.after(token));
|
||||
assertThat(decision.target()).isEqualTo(PRIMARY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
public final class ConsistencyAwareDataSourceRouter {
|
||||
public ReplicaRoutingDecision route(
|
||||
TransactionContext transaction,
|
||||
ReadConsistency consistency) {
|
||||
if (transaction.write() || transaction.locking() ||
|
||||
!lagMonitor.satisfies(consistency)) {
|
||||
return ReplicaRoutingDecision.primary();
|
||||
}
|
||||
return ReplicaRoutingDecision.replica();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java' 'modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java'
|
||||
git commit -m "feat: add experimental consistency aware replica routing"
|
||||
```
|
||||
|
||||
### Task 7: Jakarta Persistence 4.0 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-jpa4.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Published Jakarta Persistence 4.0 milestone/final artifact when available and the Stable contract suite.
|
||||
- Produces: A non-blocking compatibility report that does not alter Stable JPA 3.2 APIs.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run the Stable public API compilation and selected mapping contracts against JPA 4.
|
||||
- Record removed/changed APIs and provider support separately.
|
||||
- Do not publish JPA4 compiled artifacts under Stable coordinates.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class CompatibilityLaneDefinitionTest {
|
||||
@Test
|
||||
void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() {
|
||||
assertThat(lane("jpa4").publicationEnabled()).isFalse();
|
||||
assertThat(lane("jpa4").supportLevel()).isEqualTo(EXPERIMENTAL);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityJpa4") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-core-api"))
|
||||
implementation(libs.jakarta.persistence.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java' '.github/workflows/jpa-next-jpa4.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java'
|
||||
git commit -m "test: add jakarta persistence four compatibility lane"
|
||||
```
|
||||
|
||||
### Task 8: Hibernate ORM 8 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-hibernate8.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Hibernate ORM 8 milestone/final artifact and Stable Hibernate 7.4 regression suites.
|
||||
- Produces: Generated SQL, fetch pagination, statistics, batch and extension compatibility evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Re-run collection fetch pagination, StatementInspector, Statistics, JSONB, Batch and StatelessSession contracts.
|
||||
- Record SQL and performance differences without weakening the 7.4 Stable gate.
|
||||
- Do not allow Hibernate 8 dependencies in Stable published modules.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class HibernateCompatibilityPolicyTest {
|
||||
@Test
|
||||
void hibernateEightCannotReplaceStableProviderWithoutPromotion() {
|
||||
assertThat(policy.stableProvider()).isEqualTo("7.4");
|
||||
assertThat(policy.experimentalProviders()).contains("8");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityHibernate8") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-testkit-postgresql"))
|
||||
implementation(libs.hibernate.orm.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java' '.github/workflows/jpa-next-hibernate8.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java'
|
||||
git commit -m "test: add hibernate eight compatibility lane"
|
||||
```
|
||||
|
||||
### Task 9: PostgreSQL 19 Compatibility와 Stable 승격 Gate 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java`
|
||||
- Create: `docs/jpa/experimental-support-matrix.md`
|
||||
- Create: `docs/jpa/experimental-promotion-checklist.md`
|
||||
- Create: `.github/workflows/jpa-next-postgresql19.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PG19 image when GA, all Stable contracts, experimental security/failure/migration/performance reports.
|
||||
- Produces: A promotion decision that requires evidence rather than version availability alone.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run mapping, SQLSTATE, lock, batch, Flyway, plan and native extension contracts on PG19.
|
||||
- Promotion requires two supported patch runs and no unresolved semantic regression.
|
||||
- Multi-tenancy/replica promotion requires tenant leakage, failover, lag and pool-capacity evidence.
|
||||
- Update Stable support matrix only through a reviewed ADR.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class ExperimentalPromotionGateTest {
|
||||
@Test
|
||||
void promotionRequiresAllEvidenceAndReviewedAdr() {
|
||||
var evidence = evidence().withCompatibility(true).withSecurity(true).withFailure(true)
|
||||
.withMigration(true).withPerformance(true).withReviewedAdr(false);
|
||||
assertThat(gate.evaluate(evidence)).isEqualTo(BLOCKED_MISSING_ADR);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
public final class ExperimentalPromotionGate {
|
||||
public PromotionDecision evaluate(PromotionEvidence evidence) {
|
||||
if (!evidence.allTechnicalGatesPassed()) return BLOCKED_TECHNICAL;
|
||||
if (!evidence.reviewedAdr()) return BLOCKED_MISSING_ADR;
|
||||
return ELIGIBLE_FOR_STABLE_REVIEW;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java' 'docs/jpa/experimental-support-matrix.md' 'docs/jpa/experimental-promotion-checklist.md' '.github/workflows/jpa-next-postgresql19.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java'
|
||||
git commit -m "docs: add jpa experimental promotion gates"
|
||||
```
|
||||
## 2. Experimental 완료 조건
|
||||
|
||||
```text
|
||||
Stable starter가 Experimental module에 의존하지 않는다.
|
||||
Tenant context 누락과 connection reuse에서 fail-closed다.
|
||||
RLS runtime role이 policy를 bypass하지 못한다.
|
||||
Schema/database tenant migration과 pool capacity가 bounded다.
|
||||
Replica routing이 read-after-write와 lock query를 primary에 고정한다.
|
||||
JPA4/Hibernate8/PG19 lane이 Stable artifacts를 변경하지 않는다.
|
||||
승격은 ADR와 compatibility/security/failure/migration/performance 증거를 요구한다.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user