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,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
Reference in New Issue
Block a user