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:
DongHyeonka
2026-08-14 14:06:18 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit 0e61f86eb5
401 changed files with 34504 additions and 197 deletions
@@ -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 증거를 요구한다.
```