From 0e61f86eb58512fb117093c359492d2fe2958382 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 14 Aug 2026 14:06:18 +0900 Subject: [PATCH] feat(jpa): implement the JPA relational persistence platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/scripts/verify-gradle-wrapper.sh | 6 + .github/workflows/jpa-next-hibernate8.yml | 43 + .github/workflows/jpa-next-jpa4.yml | 43 + .github/workflows/jpa-next-postgresql19.yml | 42 + .github/workflows/jpa-nightly.yml | 131 + .github/workflows/jpa-pr.yml | 114 + .github/workflows/jpa-release.yml | 73 + ...R-JPA-001-domain-owns-persistence-model.md | 35 + .../adr/ADR-JPA-002-full-transaction-retry.md | 37 + docs/adr/ADR-JPA-003-completion-unknown.md | 41 + ...R-JPA-004-flyway-schema-source-of-truth.md | 37 + .../ADR-JPA-005-postgresql-real-contract.md | 38 + docs/jpa/entity-mapping-guide.md | 64 + docs/jpa/experimental-promotion-checklist.md | 43 + docs/jpa/experimental-support-matrix.md | 43 + docs/jpa/migration-guide.md | 64 + docs/jpa/observability.md | 61 + docs/jpa/postgresql-extensions.md | 72 + docs/jpa/query-fetch-guide.md | 74 + docs/jpa/repository-adaptation.md | 156 + docs/jpa/runbooks.md | 85 + docs/jpa/security.md | 66 + docs/jpa/support-matrix.md | 79 + docs/jpa/transaction-guide.md | 66 + ...persistence-experimental-expansion-plan.md | 771 +++ ...ersistence-platform-implementation-plan.md | 4716 +++++++++++++++++ ...6-08-11-jpa-persistence-platform-design.md | 3276 ++++++++++++ infra/jpa/postgres/README.md | 39 + infra/jpa/roles/runtime-roles.sql | 54 + infra/jpa/toxiproxy/docker-compose.yml | 43 + .../outbound/persistence-jpa/CLAUDE.md | 48 + .../outbound/persistence-jpa/build.gradle | 141 + .../outbound/persistence-jpa/gradle.lockfile | 349 +- .../HikariPoolSaturationContractTest.java | 119 + .../pool/PoolPressureContractTest.java | 52 + .../RequiresNewPoolPressureContractTest.java | 100 + .../api/PersistenceOperationName.java | 28 + .../api/capability/CapabilitySupport.java | 54 + .../api/capability/JpaCapability.java | 70 + .../api/capability/SupportLevel.java | 22 + .../CheckConstraintViolationException.java | 38 + .../error/ConnectionUnavailableException.java | 22 + .../persistence/api/error/ConstraintCode.java | 31 + .../api/error/ConstraintViolationDetails.java | 51 + .../api/error/DataCorruptionException.java | 20 + .../api/error/DeadlockDetectedException.java | 20 + .../api/error/FailureCategory.java | 60 + .../error/ForeignKeyViolationException.java | 37 + .../api/error/JpaEntityNotFoundException.java | 20 + .../api/error/JpaFailureContext.java | 145 + .../api/error/JpaPersistenceException.java | 83 + .../NotNullConstraintViolationException.java | 38 + .../error/OptimisticConflictException.java | 20 + .../PessimisticLockTimeoutException.java | 21 + .../api/error/QueryTimeoutException.java | 20 + .../api/error/SchemaMismatchException.java | 21 + .../error/SerializationFailureException.java | 20 + .../error/SqlExceptionSqlStateResolver.java | 44 + .../api/error/SqlStateResolver.java | 17 + ...TransactionCompletionUnknownException.java | 83 + .../error/TransactionTimeoutException.java | 20 + .../UniqueConstraintViolationException.java | 39 + .../persistence/api/query/CursorCodec.java | 22 + .../api/query/CursorPayloadCodec.java | 24 + .../api/query/KeysetPageRequest.java | 53 + .../persistence/api/query/KeysetSlice.java | 46 + .../api/query/NoopQueryObservation.java | 42 + .../persistence/api/query/QueryName.java | 28 + .../api/query/QueryObservation.java | 14 + .../persistence/api/query/QueryScope.java | 25 + .../api/query/SignedJsonCursorCodec.java | 102 + .../persistence/api/query/SortDirection.java | 22 + .../api/transaction/IsolationLevel.java | 22 + .../api/transaction/JitterMode.java | 19 + .../api/transaction/JpaRetryPolicy.java | 22 + .../transaction/JpaTransactionExecutor.java | 24 + .../api/transaction/PropagationMode.java | 25 + .../api/transaction/RetryDecision.java | 54 + .../api/transaction/RetryDisposition.java | 19 + .../api/transaction/RetryProfile.java | 112 + .../api/transaction/TransactionAttempt.java | 43 + .../TransactionCompletionEvidence.java | 35 + .../api/transaction/TransactionProfile.java | 80 + .../persistence/auditing/AuditMetadata.java | 90 + .../auditing/JpaAuditingConfiguration.java | 48 + .../auditing/JpaAuditorProvider.java | 51 + .../cache/CacheConcurrencyStrategy.java | 24 + .../persistence/cache/CacheRegionCatalog.java | 67 + .../cache/HibernateCacheGuard.java | 69 + .../cache/HibernateCachePolicy.java | 51 + .../cache/HibernateCacheSettings.java | 27 + .../config/PersistenceVendorSettings.java | 5 +- .../persistence/envers/EntityRevision.java | 18 + .../envers/EnversConfigurationGuard.java | 66 + .../envers/EnversHistoryPolicy.java | 36 + .../envers/EnversHistoryReader.java | 22 + .../envers/EnversRevisionMetadata.java | 30 + .../envers/HibernateEnversHistoryReader.java | 75 + .../experimental/ExperimentalFeature.java | 47 + .../experimental/ExperimentalFeatureGate.java | 34 + .../database/TenantDataSourceLifecycle.java | 27 + .../database/TenantDataSourceRegistry.java | 103 + .../TenantEntityManagerFactoryRegistry.java | 62 + .../database/TenantPoolBudget.java | 45 + .../experimental/next/CompatibilityLane.java | 38 + .../next/ExperimentalPromotionGate.java | 26 + .../next/HibernateCompatibilityPolicy.java | 41 + .../experimental/next/PromotionDecision.java | 20 + .../experimental/next/PromotionEvidence.java | 61 + .../ConsistencyAwareDataSourceRouter.java | 49 + .../replica/ConsistencyToken.java | 31 + .../experimental/replica/ReadConsistency.java | 52 + .../replica/ReplicaLagMonitor.java | 41 + .../replica/ReplicaRoutingDecision.java | 35 + .../experimental/replica/ReplicaTarget.java | 11 + .../replica/TransactionContext.java | 26 + .../experimental/rls/RlsAdminBypassToken.java | 28 + .../experimental/rls/RlsPolicyVerifier.java | 112 + .../rls/RlsTenantSessionBinder.java | 39 + .../SchemaMultiTenantConnectionProvider.java | 73 + .../SchemaTenantMigrationOrchestrator.java | 103 + .../schema/SchemaTenantRegistry.java | 57 + .../schema/TenantMigrationStatus.java | 28 + .../tenant/TenantAwareRepositoryGuard.java | 59 + .../experimental/tenant/TenantContext.java | 72 + .../tenant/TenantEntityListenerGuard.java | 55 + .../experimental/tenant/TenantId.java | 29 + .../h2/H2IdempotencyClaimRepository.java | 4 +- .../h2/H2LocalTimeoutConfigurer.java | 12 +- .../persistence/h2/H2PersistenceConfig.java | 16 +- .../hibernate/HibernateProviderPolicy.java | 75 + .../HibernateStatisticsCollector.java | 74 + .../HibernateStatisticsSnapshot.java | 59 + .../hibernate/JdbcBatchCounter.java | 40 + .../hibernate/NamedStatementInspector.java | 44 + .../hibernate/QueryNameContext.java | 58 + .../hibernate/batch/BatchExecutionResult.java | 54 + .../HibernateBatchConfigurationGuard.java | 76 + .../batch/HibernateJpaBatchExecutor.java | 82 + .../hibernate/batch/JpaBatchExecutor.java | 22 + .../hibernate/batch/JpaBatchProfile.java | 49 + .../batch/JpaBatchProfileRegistry.java | 63 + .../bulk/AffectedRowsExpectation.java | 53 + .../hibernate/bulk/BulkDmlExecutor.java | 22 + .../hibernate/bulk/BulkDmlResult.java | 19 + .../hibernate/bulk/BulkOperationName.java | 26 + .../bulk/HibernateBulkDmlExecutor.java | 83 + .../HibernateStatelessSessionRunner.java | 64 + .../stateless/StatelessSessionRunner.java | 26 + .../stateless/StatelessWorkName.java | 20 + .../ConcurrentIndexMigrationInspector.java | 89 + .../FailedConcurrentIndexRecovery.java | 81 + .../migration/FlywaySchemaPolicy.java | 85 + .../migration/FlywayValidationGate.java | 66 + .../migration/MigrationResource.java | 20 + .../NonTransactionalMigrationPolicy.java | 38 + .../migration/SchemaManagementMode.java | 30 + .../migration/SchemaVersionSnapshot.java | 35 + .../observation/JpaMetricTags.java | 63 + .../observation/JpaRetryObservation.java | 85 + .../JpaTransactionObservation.java | 89 + .../observation/LowCardinality.java | 42 + .../MicrometerQueryObservation.java | 97 + .../observation/SqlDiagnosticRedactor.java | 55 + .../array/PostgreSqlArraySupport.java | 69 + .../PostgreSqlConstraintCatalog.java | 70 + ...stgreSqlConstraintViolationTranslator.java | 72 + .../copy/BoundedCopyInputStream.java | 62 + .../postgresql/copy/CopyAdminCapability.java | 29 + .../postgresql/copy/CopyFormat.java | 27 + .../postgresql/copy/CopyLimits.java | 32 + .../postgresql/copy/CopyOperationName.java | 26 + .../postgresql/copy/CopyResult.java | 22 + .../postgresql/copy/PostgreSqlCopyLoader.java | 21 + .../copy/RegisteredCopyStatement.java | 38 + .../copy/RegisteredPostgreSqlCopyLoader.java | 132 + .../postgresql/error/ConstraintCatalog.java | 19 + .../error/PostgreSqlExceptionTranslator.java | 123 + .../error/PostgreSqlFailureClassifier.java | 64 + .../error/PostgreSqlServerErrorFields.java | 83 + .../postgresql/error/PostgreSqlState.java | 83 + .../postgresql/json/JsonDocument.java | 66 + .../postgresql/json/JsonDocumentCodec.java | 93 + .../postgresql/json/JsonPathName.java | 33 + .../json/PostgreSqlJsonQuerySupport.java | 100 + .../postgresql/lock/LockWaitObservation.java | 36 + .../PostgreSqlLockExceptionTranslator.java | 99 + .../lock/PostgreSqlLockOptions.java | 52 + .../lock/PostgreSqlWorkClaimExecutor.java | 105 + .../postgresql/lock/WorkClaim.java | 31 + .../postgresql/lock/WorkClaimExecutor.java | 27 + .../postgresql/lock/WorkQueueDefinition.java | 38 + .../postgresql/lock/WorkQueueName.java | 26 + .../persistence/postgresql/range/PgRange.java | 92 + .../postgresql/range/PgRangeCodec.java | 112 + .../postgresql/range/PgRangeJdbcType.java | 78 + .../range/PostgreSqlRangeQuerySupport.java | 64 + .../postgresql/write/NativeWriteName.java | 25 + .../write/PostgreSqlUpsertExecutor.java | 23 + .../RegisteredPostgreSqlUpsertExecutor.java | 83 + .../write/RegisteredUpsertStatement.java | 49 + .../write/UpsertConflictTarget.java | 42 + .../postgresql/write/UpsertResult.java | 41 + .../postgresql/write/WriteDisposition.java | 20 + .../persistence/querydsl/PredicatePolicy.java | 52 + .../persistence/querydsl/QueryPage.java | 32 + .../querydsl/QuerydslJpaSupport.java | 55 + .../security/DatabasePrivilegeReport.java | 25 + .../security/DatabaseRolePolicy.java | 67 + .../PostgreSqlRuntimeRoleVerifier.java | 59 + .../security/SearchPathPolicy.java | 52 + .../springdata/EntityGraphCatalog.java | 69 + .../springdata/EntityManagerAccess.java | 19 + .../springdata/FetchPlanApplier.java | 64 + .../persistence/springdata/FetchPlanName.java | 26 + .../springdata/JpaKeysetQuerySupport.java | 56 + .../JpaRepositoryFragmentSupport.java | 74 + .../springdata/JpaStreamExecutor.java | 132 + .../springdata/JpaStreamScope.java | 45 + .../springdata/KeysetPredicateBuilder.java | 72 + .../springdata/KeysetSliceAssembler.java | 44 + .../persistence/springdata/KeysetTerm.java | 23 + .../springdata/RegisteredQuery.java | 36 + .../persistence/springdata/SafeSortField.java | 45 + .../springdata/SafeSortMapper.java | 75 + .../springdata/SafeSortRegistry.java | 76 + .../persistence/springdata/ScrollPolicy.java | 42 + .../springdata/SpecificationPolicy.java | 52 + .../transaction/BackoffCalculator.java | 77 + .../transaction/CommitFailureClassifier.java | 147 + .../transaction/CompletionResolution.java | 21 + .../transaction/CompletionUnknownRecord.java | 68 + .../CompletionUnknownRecorder.java | 22 + .../transaction/DefaultJpaRetryPolicy.java | 68 + .../EvidenceAwareJpaTransactionManager.java | 77 + .../FullTransactionRetryCoordinator.java | 107 + .../IrreversibleSideEffectContext.java | 38 + .../OptimisticConflictTranslator.java | 114 + .../persistence/transaction/RetryBudget.java | 53 + .../transaction/RetryEventListener.java | 41 + .../persistence/transaction/RetrySleeper.java | 23 + .../transaction/RetryableJpaTransaction.java | 44 + .../RetryableJpaTransactionInterceptor.java | 109 + .../SpringJpaTransactionExecutor.java | 72 + .../transaction/ThreadRetrySleeper.java | 26 + .../TransactionCompletionResolver.java | 23 + .../TransactionDefinitionMapper.java | 87 + .../TransactionEvidenceContext.java | 85 + .../transaction/TransactionEvidenceFrame.java | 55 + .../TransactionProfileRegistry.java | 70 + .../transaction/UnknownOperation.java | 19 + .../db/experimental-rls/V1__tenant_rls.sql | 45 + .../platform/CommitAmbiguityContractTest.java | 165 + .../platform/ConstraintRaceContractTest.java | 132 + .../platform/EnversHistoryContractTest.java | 171 + .../platform/FlywayUpgradeContractTest.java | 150 + ...bernateBulkDmlExecutorIntegrationTest.java | 154 + ...CollectionFetchPaginationContractTest.java | 143 + ...ernateJpaBatchExecutorIntegrationTest.java | 111 + ...StatelessSessionRunnerIntegrationTest.java | 108 + .../platform/IdStrategyContractTest.java | 119 + .../platform/JpaAuditingContractTest.java | 62 + .../JpaLifecycleAssociationContractTest.java | 219 + .../platform/JpaPlatformContractSupport.java | 141 + .../JpaPlatformEntityManagerSupport.java | 138 + .../platform/JpaValueMappingContractTest.java | 165 + .../OptimisticRetryIntegrationTest.java | 174 + .../PostgreSqlArrayRangeContractTest.java | 170 + ...tgreSqlConcurrencyFailureContractTest.java | 119 + .../PostgreSqlCopyLoaderIntegrationTest.java | 146 + .../platform/PostgreSqlJsonbContractTest.java | 132 + ...ostgreSqlMigrationUpgradeContractTest.java | 110 + ...PostgreSqlPessimisticLockContractTest.java | 142 + .../PostgreSqlQueryPlanContractTest.java | 90 + .../PostgreSqlSecurityContractTest.java | 94 + .../PostgreSqlSqlStateContractTest.java | 151 + .../PostgreSqlUpsertContractTest.java | 130 + .../PostgreSqlWorkClaimContractTest.java | 100 + .../StablePostgreSqlMatrixContractTest.java | 91 + .../ReadAfterWriteRoutingContractTest.java | 156 + .../experimental/RlsIsolationFailureTest.java | 247 + .../SchemaTenantMigrationContractTest.java | 202 + .../TenantColumnIsolationTest.java | 176 + .../TenantPoolCapacityContractTest.java | 158 + .../persistence/JpaModuleBoundaryTest.java | 171 + .../api/PersistenceOperationNameTest.java | 38 + .../api/error/JpaFailureContextTest.java | 69 + .../error/JpaPersistenceExceptionTest.java | 59 + .../persistence/api/query/QueryNameTest.java | 35 + .../api/query/SignedJsonCursorCodecTest.java | 107 + .../transaction/TransactionProfileTest.java | 90 + .../cache/HibernateCacheGuardTest.java | 87 + .../ExperimentalFeatureGateTest.java | 133 + .../next/CompatibilityLaneDefinitionTest.java | 39 + .../next/ExperimentalPromotionGateTest.java | 71 + .../next/Hibernate8CompatibilityTest.java | 65 + .../HibernateCompatibilityPolicyTest.java | 45 + .../next/Jpa4CompatibilityTest.java | 78 + .../next/PostgreSql19CompatibilityTest.java | 81 + .../persistence/h2/H2ClaimSqlTest.java | 9 +- .../HibernateStatisticsCollectorTest.java | 95 + .../HibernateBatchConfigurationGuardTest.java | 54 + ...ConcurrentIndexMigrationInspectorTest.java | 85 + .../migration/FlywayValidationGateTest.java | 89 + .../observation/JpaMetricTagsTest.java | 60 + .../JpaObservabilityContractTest.java | 147 + .../PostgreSqlConstraintCatalogTest.java | 48 + .../PostgreSqlFailureClassifierTest.java | 54 + .../lock/PostgreSqlLockOptionsTest.java | 64 + .../postgresql/range/PgRangeTest.java | 60 + .../querydsl/QuerydslJpaSupportTest.java | 53 + .../security/DatabaseRolePolicyTest.java | 61 + .../springdata/FetchPlanApplierTest.java | 67 + .../springdata/JpaKeysetQuerySupportTest.java | 65 + .../JpaRepositoryFragmentSupportTest.java | 76 + .../springdata/JpaStreamExecutorTest.java | 125 + .../springdata/KeysetSliceAssemblerTest.java | 42 + .../springdata/SafeSortMapperTest.java | 69 + .../springdata/ScrollPolicyTest.java | 35 + .../testkit/JpaReleaseManifestTest.java | 64 + .../arch/JpaArchitectureRulesTest.java | 92 + .../testkit/arch/entity/FinalEntity.java | 18 + .../entity/NoDefaultConstructorEntity.java | 20 + .../testkit/arch/entity/OrderEntity.java | 18 + .../testkit/arch/web/BadOrderController.java | 12 + .../testkit/arch/web/GoodOrderController.java | 12 + .../arch/web/ListReturningController.java | 13 + .../PostgreSqlFailureScenarioTest.java | 29 + .../fetch/FetchPaginationExpectationTest.java | 36 + .../testkit/id/UuidV7GeneratorTest.java | 64 + .../lifecycle/EntityStateProbeTest.java | 64 + .../mapping/DurationMillisConverterTest.java | 49 + .../migration/MigrationScenarioTest.java | 45 + .../testkit/pool/PoolMeasurementTest.java | 43 + .../postgresql/PostgreSqlVersionTest.java | 57 + .../testkit/query/JpaQueryAssertionsTest.java | 120 + .../queryplan/QueryPlanAssertionsTest.java | 88 + .../transaction/BackoffCalculatorTest.java | 77 + .../CommitFailureClassifierTest.java | 80 + .../CompletionUnknownRecorderTest.java | 92 + .../DefaultJpaRetryPolicyTest.java | 98 + ...videnceAwareJpaTransactionManagerTest.java | 144 + .../FullTransactionRetryCoordinatorTest.java | 148 + .../transaction/RetryBudgetTest.java | 41 + ...etryableJpaTransactionInterceptorTest.java | 211 + .../SpringJpaTransactionExecutorTest.java | 163 + .../TransactionDefinitionMapperTest.java | 69 + .../TransactionProfileRegistryTest.java | 44 + .../testkit/arch/EntityExposureCondition.java | 71 + .../testkit/arch/EntityMappingCondition.java | 56 + .../testkit/arch/JpaArchitectureRules.java | 95 + .../testkit/envers/AuditedDocument.java | 52 + .../testkit/failure/CommitAmbiguityProxy.java | 76 + .../failure/PostgreSqlFailureScenario.java | 43 + .../fetch/FetchPaginationExpectation.java | 36 + .../persistence/testkit/fetch/PagedChild.java | 67 + .../testkit/fetch/PagedParent.java | 81 + .../testkit/id/IdentityEntity.java | 46 + .../testkit/id/SequenceEntity.java | 56 + .../testkit/id/UuidV7Generator.java | 67 + .../testkit/jdbc/CountingDataSource.java | 97 + .../jdbc/CountingJdbcBatchCounter.java | 34 + .../testkit/lifecycle/EntityState.java | 23 + .../testkit/lifecycle/EntityStateProbe.java | 44 + .../testkit/lifecycle/LifecycleChild.java | 67 + .../testkit/lifecycle/LifecycleParent.java | 97 + .../mapping/DurationMillisConverter.java | 29 + .../testkit/mapping/MappingEntity.java | 117 + .../persistence/testkit/mapping/Money.java | 41 + .../migration/MigrationContractRunner.java | 82 + .../testkit/migration/MigrationScenario.java | 64 + .../testkit/migration/MigrationSnapshot.java | 32 + .../testkit/pool/PoolMeasurement.java | 34 + .../PostgreSqlContainerFactory.java | 64 + .../PostgreSqlContractExtension.java | 70 + .../testkit/postgresql/PostgreSqlVersion.java | 80 + .../testkit/query/FetchExpectation.java | 37 + .../testkit/query/JpaQueryAssertions.java | 61 + .../testkit/query/QueryExpectation.java | 159 + .../testkit/query/QueryMeasurement.java | 47 + .../testkit/queryplan/NormalizedPlan.java | 51 + .../queryplan/PostgreSqlExplainRunner.java | 117 + .../queryplan/QueryPlanAssertions.java | 24 + .../queryplan/QueryPlanExpectation.java | 84 + .../testkit/release/JpaReleaseGate.java | 45 + .../testkit/release/JpaReleaseManifest.java | 78 + .../jpa/JpaDangerousConfigurationGuard.java | 74 + .../jpa/JpaDataSourceProfileValidator.java | 68 + .../jpa/JpaDataSourceSettings.java | 21 + .../JpaObservabilityAutoConfiguration.java | 81 + .../jpa/JpaPlatformAutoConfiguration.java | 117 + .../jpa/JpaPlatformEndpoint.java | 34 + .../autoconfigure/jpa/JpaPlatformReport.java | 64 + .../autoconfigure/jpa/JpaSafetySettings.java | 39 + .../jpa/JpaTransactionAutoConfiguration.java | 95 + .../jpa/PostgreSqlVersionPolicy.java | 49 + .../JpaDangerousConfigurationGuardTest.java | 96 + .../JpaDataSourceProfileValidatorTest.java | 95 + .../jpa/JpaPlatformAutoConfigurationTest.java | 121 + .../ProfileSeparationContractTest.java | 17 +- src/build.gradle | 16 + 401 files changed, 34504 insertions(+), 197 deletions(-) create mode 100644 .github/workflows/jpa-next-hibernate8.yml create mode 100644 .github/workflows/jpa-next-jpa4.yml create mode 100644 .github/workflows/jpa-next-postgresql19.yml create mode 100644 .github/workflows/jpa-nightly.yml create mode 100644 .github/workflows/jpa-pr.yml create mode 100644 .github/workflows/jpa-release.yml create mode 100644 docs/adr/ADR-JPA-001-domain-owns-persistence-model.md create mode 100644 docs/adr/ADR-JPA-002-full-transaction-retry.md create mode 100644 docs/adr/ADR-JPA-003-completion-unknown.md create mode 100644 docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md create mode 100644 docs/adr/ADR-JPA-005-postgresql-real-contract.md create mode 100644 docs/jpa/entity-mapping-guide.md create mode 100644 docs/jpa/experimental-promotion-checklist.md create mode 100644 docs/jpa/experimental-support-matrix.md create mode 100644 docs/jpa/migration-guide.md create mode 100644 docs/jpa/observability.md create mode 100644 docs/jpa/postgresql-extensions.md create mode 100644 docs/jpa/query-fetch-guide.md create mode 100644 docs/jpa/repository-adaptation.md create mode 100644 docs/jpa/runbooks.md create mode 100644 docs/jpa/security.md create mode 100644 docs/jpa/support-matrix.md create mode 100644 docs/jpa/transaction-guide.md create mode 100644 docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md create mode 100644 docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md create mode 100644 infra/jpa/postgres/README.md create mode 100644 infra/jpa/roles/runtime-roles.sql create mode 100644 infra/jpa/toxiproxy/docker-compose.yml create mode 100644 src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/SupportLevel.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/CheckConstraintViolationException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DataCorruptionException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DeadlockDetectedException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ForeignKeyViolationException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/NotNullConstraintViolationException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/OptimisticConflictException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/PessimisticLockTimeoutException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/QueryTimeoutException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SchemaMismatchException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SerializationFailureException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlExceptionSqlStateResolver.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlStateResolver.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionTimeoutException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/UniqueConstraintViolationException.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorPayloadCodec.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/IsolationLevel.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JitterMode.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaTransactionExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/PropagationMode.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDisposition.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionAttempt.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditorProvider.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheConcurrencyStrategy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheRegionCatalog.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCachePolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheSettings.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EntityRevision.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversConfigurationGuard.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryReader.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversRevisionMetadata.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceLifecycle.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLane.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGate.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionDecision.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionEvidence.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyToken.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReadConsistency.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaRoutingDecision.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaTarget.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/TransactionContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsAdminBypassToken.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/TenantMigrationStatus.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfile.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/AffectedRowsExpectation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlResult.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkOperationName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessSessionRunner.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspector.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FailedConcurrentIndexRecovery.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywaySchemaPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGate.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/MigrationResource.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/NonTransactionalMigrationPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaManagementMode.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaVersionSnapshot.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/LowCardinality.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/MicrometerQueryObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/SqlDiagnosticRedactor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/array/PostgreSqlArraySupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalog.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/BoundedCopyInputStream.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyAdminCapability.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyFormat.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyLimits.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyOperationName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyResult.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/PostgreSqlCopyLoader.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredCopyStatement.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredPostgreSqlCopyLoader.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/ConstraintCatalog.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlServerErrorFields.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlState.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocument.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocumentCodec.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonPathName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/PostgreSqlJsonQuerySupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/LockWaitObservation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockExceptionTranslator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptions.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlWorkClaimExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaim.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaimExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueDefinition.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeJdbcType.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PostgreSqlRangeQuerySupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/NativeWriteName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/PostgreSqlUpsertExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredUpsertStatement.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertConflictTarget.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertResult.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/WriteDisposition.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/PredicatePolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QueryPage.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabasePrivilegeReport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityManagerAccess.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanName.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamScope.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssembler.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetTerm.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/RegisteredQuery.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionResolution.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/IrreversibleSideEffectContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryEventListener.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetrySleeper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransaction.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/ThreadRetrySleeper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionCompletionResolver.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceFrame.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/UnknownOperation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/experimental-rls/V1__tenant_rls.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/EnversHistoryContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/FlywayUpgradeContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateBulkDmlExecutorIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateStatelessSessionRunnerIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaLifecycleAssociationContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformEntityManagerSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlArrayRangeContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlCopyLoaderIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlJsonbContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlMigrationUpgradeContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlPessimisticLockContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSqlStateContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/ReadAfterWriteRoutingContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/SchemaTenantMigrationContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantColumnIsolationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuardTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGateTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLaneDefinitionTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGateTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Hibernate8CompatibilityTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicyTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Jpa4CompatibilityTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PostgreSql19CompatibilityTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollectorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuardTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspectorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGateTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTagsTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaObservabilityContractTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalogTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifierTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptionsTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupportTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplierTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupportTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupportTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssemblerTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapperTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicyTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRulesTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/FinalEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/NoDefaultConstructorEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/OrderEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/BadOrderController.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/GoodOrderController.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/ListReturningController.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenarioTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7GeneratorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbeTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverterTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenarioTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurementTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersionTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertionsTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertionsTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculatorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifierTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorderTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicyTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinatorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudgetTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapperTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistryTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityMappingCondition.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/envers/AuditedDocument.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenario.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedChild.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedParent.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/IdentityEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/SequenceEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingJdbcBatchCounter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleChild.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleParent.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/Money.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenario.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationSnapshot.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersion.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/FetchExpectation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertions.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryExpectation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryMeasurement.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java create mode 100644 src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuard.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaObservabilityAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformEndpoint.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaSafetySettings.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/PostgreSqlVersionPolicy.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuardTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidatorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfigurationTest.java diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index 8dd8fe7e..3af4a570 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -24,7 +24,13 @@ readonly EXPECTED_WORKFLOW_LOCK=( '58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml' '823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml' 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' + 'e0cb998969b8f4b8531f50d38413ee4640931839be7ad50509e1d0e1a84f919e .github/workflows/jpa-next-hibernate8.yml' + '6f577e71cd10d74facdf76353f211132d2e2ba04363b8025affb45873f349d9f .github/workflows/jpa-next-jpa4.yml' + 'f69e174cd0e5a2451078ea23d52efefc13fb27d2c120f5fe30dc93ffc4d532aa .github/workflows/jpa-next-postgresql19.yml' + '053593c3f1b5acdc98f01f1986ffbe74163d61c58384d16e27949b879757bef1 .github/workflows/jpa-nightly.yml' + '04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' + 'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' diff --git a/.github/workflows/jpa-next-hibernate8.yml b/.github/workflows/jpa-next-hibernate8.yml new file mode 100644 index 00000000..eb81e6c7 --- /dev/null +++ b/.github/workflows/jpa-next-hibernate8.yml @@ -0,0 +1,43 @@ +name: jpa-next-hibernate8 + +# Hibernate ORM 8 compatibility lane (experimental plan Task 8). +# +# Re-runs the contracts most likely to move between provider majors: collection fetch pagination, +# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not +# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x +# behaves as documented. + +on: + workflow_dispatch: + schedule: + - cron: '0 5 * * 1' + +permissions: + contents: read + +jobs: + hibernate8-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Report Hibernate ORM 8 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-next-jpa4.yml b/.github/workflows/jpa-next-jpa4.yml new file mode 100644 index 00000000..79cd2eef --- /dev/null +++ b/.github/workflows/jpa-next-jpa4.yml @@ -0,0 +1,43 @@ +name: jpa-next-jpa4 + +# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7). +# +# Non-blocking by design: it reports whether the Stable public API still compiles and whether the +# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never +# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what +# deployments run. + +on: + workflow_dispatch: + schedule: + - cron: '0 4 * * 1' + +permissions: + contents: read + +jobs: + jpa4-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Report Jakarta Persistence 4.0 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-next-postgresql19.yml b/.github/workflows/jpa-next-postgresql19.yml new file mode 100644 index 00000000..34fc6364 --- /dev/null +++ b/.github/workflows/jpa-next-postgresql19.yml @@ -0,0 +1,42 @@ +name: jpa-next-postgresql19 + +# PostgreSQL 19 compatibility lane (experimental plan Task 9). +# +# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic +# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what +# ExperimentalPromotionGate encodes. + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + postgresql19-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 60 + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Report PostgreSQL 19 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-nightly.yml b/.github/workflows/jpa-nightly.yml new file mode 100644 index 00000000..a609bd6a --- /dev/null +++ b/.github/workflows/jpa-nightly.yml @@ -0,0 +1,131 @@ +name: jpa-nightly + +# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle +# of the PostgreSQL matrix. +# +# The failure-injection lane is the one that matters most and is easiest to lose: it is the only +# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit +# acknowledgement produces completion-unknown rather than a retry. + +on: + workflow_dispatch: + schedule: + # 02:30 UTC daily. + - cron: '30 2 * * *' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + jpa-full-matrix: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + postgresql: ["16", "17", "18"] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformContractTest + -Pjpa.matrix.versions=${{ matrix.postgresql }} + --no-daemon + --stacktrace + + jpa-failure-injection: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Reproduce deadlock, serialization, and commit-ambiguity scenarios + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformFailureTest + --no-daemon + --stacktrace + + jpa-query-plan-and-security: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the query plan and database security suites + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest + :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest + --no-daemon + --stacktrace + + jpa-pool-pressure: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Measure pool saturation and REQUIRES_NEW pressure + working-directory: src + # Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a + # noisy shared runner does not produce a red build that means nothing. + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest + -Pperformance.assertions.enabled=false + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-pr.yml b/.github/workflows/jpa-pr.yml new file mode 100644 index 00000000..8cf45400 --- /dev/null +++ b/.github/workflows/jpa-pr.yml @@ -0,0 +1,114 @@ +name: jpa-pr + +# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly / +# jpa-release. A support level with no job behind it is a marketing claim. +# +# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour +# that differs across the matrix almost always differs at its ends, and the middle version is +# covered nightly. What it does not do is skip the container lane on a runner without Docker — +# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database +# nobody tested. + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/adapter/outbound/persistence-jpa/**' + - 'src/app-bootstrap/src/**/jpa/**' + - 'src/config/architecture/modules.json' + - 'docs/jpa/**' + - 'docs/adr/ADR-JPA-*' + - 'infra/jpa/**' + - '.github/workflows/jpa-pr.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + jpa-unit-and-architecture: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the JPA unit and architecture suites + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test + :app-bootstrap:test --tests '*CleanArchitectureTest' + verifyCleanArchitectureDependencies + verifyOneTypePerFile + --no-daemon + --stacktrace + + jpa-postgresql-contract: + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # 16 and 18 — the ends of the Stable matrix. 17 runs nightly. + postgresql: ["16", "18"] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformContractTest + -Pjpa.matrix.versions=${{ matrix.postgresql }} + --no-daemon + --stacktrace + + jpa-migration-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the migration upgrade smoke scenarios + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml new file mode 100644 index 00000000..9e78dedd --- /dev/null +++ b/.github/workflows/jpa-release.yml @@ -0,0 +1,73 @@ +name: jpa-release + +# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion +# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build +# rather than quietly ceasing to be checked. + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + jpa-release-gate: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the full JPA release gate + working-directory: src + run: >- + ./gradlew + jpaReleaseGate + -Pjpa.matrix.versions=16,17,18 + --no-daemon + --stacktrace + + jpa-architecture-and-docs: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Verify architecture boundaries and the support matrix + working-directory: src + run: >- + ./gradlew + verifyCleanArchitectureDependencies + verifyOneTypePerFile + :app-bootstrap:test --tests '*CleanArchitectureTest' + :adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest' + --no-daemon + --stacktrace diff --git a/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md b/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md new file mode 100644 index 00000000..835275e5 --- /dev/null +++ b/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md @@ -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` +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`. diff --git a/docs/adr/ADR-JPA-002-full-transaction-retry.md b/docs/adr/ADR-JPA-002-full-transaction-retry.md new file mode 100644 index 00000000..76eb2cb6 --- /dev/null +++ b/docs/adr/ADR-JPA-002-full-transaction-retry.md @@ -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`. diff --git a/docs/adr/ADR-JPA-003-completion-unknown.md b/docs/adr/ADR-JPA-003-completion-unknown.md new file mode 100644 index 00000000..82e4caf9 --- /dev/null +++ b/docs/adr/ADR-JPA-003-completion-unknown.md @@ -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`. diff --git a/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md b/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md new file mode 100644 index 00000000..584424be --- /dev/null +++ b/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md @@ -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`. diff --git a/docs/adr/ADR-JPA-005-postgresql-real-contract.md b/docs/adr/ADR-JPA-005-postgresql-real-contract.md new file mode 100644 index 00000000..20d1806c --- /dev/null +++ b/docs/adr/ADR-JPA-005-postgresql-real-contract.md @@ -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`. diff --git a/docs/jpa/entity-mapping-guide.md b/docs/jpa/entity-mapping-guide.md new file mode 100644 index 00000000..61a00665 --- /dev/null +++ b/docs/jpa/entity-mapping-guide.md @@ -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` 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. diff --git a/docs/jpa/experimental-promotion-checklist.md b/docs/jpa/experimental-promotion-checklist.md new file mode 100644 index 00000000..80d3814f --- /dev/null +++ b/docs/jpa/experimental-promotion-checklist.md @@ -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. diff --git a/docs/jpa/experimental-support-matrix.md b/docs/jpa/experimental-support-matrix.md new file mode 100644 index 00000000..f7293fa6 --- /dev/null +++ b/docs/jpa/experimental-support-matrix.md @@ -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. diff --git a/docs/jpa/migration-guide.md b/docs/jpa/migration-guide.md new file mode 100644 index 00000000..f5cef523 --- /dev/null +++ b/docs/jpa/migration-guide.md @@ -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. diff --git a/docs/jpa/observability.md b/docs/jpa/observability.md new file mode 100644 index 00000000..3e242447 --- /dev/null +++ b/docs/jpa/observability.md @@ -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. diff --git a/docs/jpa/postgresql-extensions.md b/docs/jpa/postgresql-extensions.md new file mode 100644 index 00000000..7b2a2640 --- /dev/null +++ b/docs/jpa/postgresql-extensions.md @@ -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. diff --git a/docs/jpa/query-fetch-guide.md b/docs/jpa/query-fetch-guide.md new file mode 100644 index 00000000..ac73f945 --- /dev/null +++ b/docs/jpa/query-fetch-guide.md @@ -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. diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md new file mode 100644 index 00000000..3aa7d544 --- /dev/null +++ b/docs/jpa/repository-adaptation.md @@ -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` 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. diff --git a/docs/jpa/runbooks.md b/docs/jpa/runbooks.md new file mode 100644 index 00000000..1e585db6 --- /dev/null +++ b/docs/jpa/runbooks.md @@ -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. diff --git a/docs/jpa/security.md b/docs/jpa/security.md new file mode 100644 index 00000000..a466e37e --- /dev/null +++ b/docs/jpa/security.md @@ -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. diff --git a/docs/jpa/support-matrix.md b/docs/jpa/support-matrix.md new file mode 100644 index 00000000..8daf6383 --- /dev/null +++ b/docs/jpa/support-matrix.md @@ -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. diff --git a/docs/jpa/transaction-guide.md b/docs/jpa/transaction-guide.md new file mode 100644 index 00000000..ac5e8df8 --- /dev/null +++ b/docs/jpa/transaction-guide.md @@ -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. diff --git a/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md b/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md new file mode 100644 index 00000000..bc9c1ae1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md @@ -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 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 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("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("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 증거를 요구한다. +``` diff --git a/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md b/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md new file mode 100644 index 00000000..43d000df --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md @@ -0,0 +1,4716 @@ +# JPA 관계형 영속성 플랫폼 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:** Java/Spring Backend Skeleton에 도메인 Repository 소유권, Application Use Case Transaction, SQLSTATE 기반 오류, 전체 Transaction Retry, Fetch·Pagination·Batch 검증, PostgreSQL Native Extension, Flyway Schema Gate, 관측성·보안·실제 PostgreSQL Release Matrix를 갖춘 JPA 관계형 영속성 플랫폼을 구현한다. + +**Architecture:** `jpa-core-api`는 Spring·JPA 비종속 안정 계약을 소유하고, `jpa-transaction`, `jpa-spring-data`, `jpa-hibernate`, `jpa-postgresql`, `jpa-migration-flyway`가 이를 구현한다. 도메인 모듈은 Entity와 Repository를 직접 소유하며 플랫폼은 Generic CRUD Repository를 만들지 않는다. Retry는 새 Persistence Context의 전체 Use Case 단위이고 Commit 결과 불명은 자동 Retry하지 않는다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 dependency management, Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4, PostgreSQL 16·17·18, HikariCP, Flyway, Micrometer, Spring Observation, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. + +## Global Constraints + +- Root package는 `io.backend.skeleton.jpa`이다. +- 모듈 루트는 `modules/jpa`이다. +- Java 21과 Spring Boot 4.1 BOM 조합을 사용하며 개별 Hibernate·Flyway·Hikari 버전을 임의로 override하지 않는다. +- Stable JPA 규격은 Jakarta Persistence 3.2, Stable Provider는 Hibernate ORM 7.4다. +- Stable DB Matrix는 PostgreSQL 16·17·18이다. +- H2는 Local Convenience이며 PostgreSQL 계약 증거로 사용하지 않는다. +- 도메인 모듈이 Entity, Embeddable, Repository, Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유한다. +- `GenericRepository` 또는 Spring Data CRUD를 재구현하는 Base Repository를 만들지 않는다. +- 일반 애플리케이션의 Transaction 경계는 Application Service다. +- OSIV는 모든 운영 profile에서 명시적으로 false다. +- 운영 Schema 변경의 Source of Truth는 Flyway이고 Hibernate는 validate만 수행한다. +- 운영에서 `ddl-auto=update`, `create`, `create-drop`을 허용하지 않는다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 새 Persistence Context와 새 DB Transaction에서 전체 Use Case를 재실행한다. +- `TransactionCompletionUnknownException`은 자동 Retry하지 않는다. +- 외부 HTTP, Object Storage, Messaging 호출을 DB Transaction 안에서 대기하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 제한한다. +- Entity를 Controller 응답, Message payload, Redis Java serialization 값으로 직접 노출하지 않는다. +- Fetch 전략은 Use Case별 EntityGraph·Fetch Join·Projection·Batch Fetch로 결정한다. +- Hibernate 7.4 collection fetch pagination은 PG16·17·18 generated SQL과 row amplification을 계약 테스트한다. +- Dynamic Sort는 allowlist를 사용하고 Native SQL 값은 parameter binding한다. +- JDBC Batch 완료는 실제 batch 통계로 증명한다. +- Bulk DML은 flush → bulk → clear 규칙을 따른다. +- Runtime·Migration·Admin DB credential을 분리한다. +- SQL parameter, Entity ID, Tenant ID 원문, PII를 metric label과 일반 로그에 기록하지 않는다. +- Multi-tenancy, Read Replica, JPA 4, Hibernate 8, PostgreSQL 19는 별도 Experimental 계획으로 구현한다. +- 각 Task는 실패 테스트 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── settings.gradle.kts +├── build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── roles/ +│ └── toxiproxy/ +├── docs/jpa/ +│ ├── support-matrix.md +│ ├── entity-mapping-guide.md +│ ├── transaction-guide.md +│ ├── query-fetch-guide.md +│ ├── migration-guide.md +│ ├── postgresql-extensions.md +│ ├── observability.md +│ ├── security.md +│ └── runbooks.md +└── docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md +``` + +## 2. 핵심 package + +```text +io.backend.skeleton.jpa.api +io.backend.skeleton.jpa.api.capability +io.backend.skeleton.jpa.api.error +io.backend.skeleton.jpa.api.query +io.backend.skeleton.jpa.api.transaction +io.backend.skeleton.jpa.transaction +io.backend.skeleton.jpa.springdata +io.backend.skeleton.jpa.querydsl +io.backend.skeleton.jpa.hibernate +io.backend.skeleton.jpa.postgresql +io.backend.skeleton.jpa.migration +io.backend.skeleton.jpa.auditing +io.backend.skeleton.jpa.envers +io.backend.skeleton.jpa.cache +io.backend.skeleton.jpa.observation +io.backend.skeleton.jpa.security +io.backend.skeleton.jpa.autoconfigure +io.backend.skeleton.jpa.testkit +``` + +## 3. Module dependency map + +```text +jpa-core-api + → no project dependency + +jpa-transaction + → jpa-core-api + +jpa-spring-data + → jpa-core-api + +jpa-querydsl + → jpa-core-api + → jpa-spring-data + +jpa-hibernate + → jpa-core-api + +jpa-postgresql + → jpa-core-api + → jpa-hibernate + +jpa-postgresql-copy + → jpa-core-api + → jpa-postgresql + +jpa-migration-flyway + → jpa-core-api + +jpa-auditing + → jpa-core-api + +jpa-envers + → jpa-core-api + → jpa-hibernate + +jpa-cache-hibernate + → jpa-core-api + → jpa-hibernate + +jpa-observability + → jpa-core-api + → jpa-hibernate + +jpa-security + → jpa-core-api + +jpa-spring-boot-starter + → jpa-core-api + → jpa-transaction + → jpa-spring-data + → jpa-hibernate + → jpa-postgresql + → jpa-migration-flyway + → jpa-auditing + → jpa-observability + → jpa-security + +jpa-testkit + → jpa-core-api + +jpa-testkit-postgresql + → jpa-testkit + → jpa-postgresql + +jpa-testkit-migration + → jpa-testkit-postgresql + → jpa-migration-flyway + +jpa-testkit-queryplan + → jpa-testkit-postgresql + → jpa-observability +``` + +Provider SDK, Spring Data, Hibernate, Flyway, Querydsl, PostgreSQL JDBC dependencies are added only in the owning module. `jpa-core-api` remains framework-free. + +--- +### Task 1: Gradle 멀티모듈과 JPA 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts` +- Create: `modules/jpa/jpa-core-api/build.gradle.kts` +- Create: `modules/jpa/jpa-transaction/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-data/build.gradle.kts` +- Create: `modules/jpa/jpa-querydsl/build.gradle.kts` +- Create: `modules/jpa/jpa-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql-copy/build.gradle.kts` +- Create: `modules/jpa/jpa-migration-flyway/build.gradle.kts` +- Create: `modules/jpa/jpa-auditing/build.gradle.kts` +- Create: `modules/jpa/jpa-envers/build.gradle.kts` +- Create: `modules/jpa/jpa-cache-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-observability/build.gradle.kts` +- Create: `modules/jpa/jpa-security/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-boot-starter/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-migration/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-queryplan/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Test: `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 18 isolated JPA modules and `test`, `integrationTest`, `contractTest`, `migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest` suites. + +**Implementation requirements:** +- Register every module under `:modules:jpa:*` and apply Java 21 toolchains. +- Do not pin Hibernate, Flyway, Hikari, Spring Data versions outside the Boot BOM. +- Expose integration suites only in modules that own external resources. +- Make `check` depend on unit and architecture tests; release aggregates are added in Task 53. +- Ensure experimental modules are not included in this Stable dependency graph. + +- [ ] **Step 1: Write the failing test** + +```kotlin +class JpaModuleBoundaryTest { + @Test + fun `core api has no framework dependency`() { + val core = project(":modules:jpa:jpa-core-api") + assertThat(core.directDependencies()) + .noneMatch { it.startsWith("org.springframework") || + it.startsWith("org.hibernate") || + it.startsWith("jakarta.persistence") } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + `java-library` + `jvm-test-suite` +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +testing { + suites { + named("test") { useJUnitJupiter() } + register("contractTest") { + useJUnitJupiter() + dependencies { implementation(project()) } + } + } +} +``` + +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:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts' 'modules/jpa/jpa-core-api/build.gradle.kts' 'modules/jpa/jpa-transaction/build.gradle.kts' 'modules/jpa/jpa-spring-data/build.gradle.kts' 'modules/jpa/jpa-querydsl/build.gradle.kts' 'modules/jpa/jpa-hibernate/build.gradle.kts' 'modules/jpa/jpa-postgresql/build.gradle.kts' 'modules/jpa/jpa-postgresql-copy/build.gradle.kts' 'modules/jpa/jpa-migration-flyway/build.gradle.kts' 'modules/jpa/jpa-auditing/build.gradle.kts' 'modules/jpa/jpa-envers/build.gradle.kts' 'modules/jpa/jpa-cache-hibernate/build.gradle.kts' 'modules/jpa/jpa-observability/build.gradle.kts' 'modules/jpa/jpa-security/build.gradle.kts' 'modules/jpa/jpa-spring-boot-starter/build.gradle.kts' 'modules/jpa/jpa-testkit/build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/build.gradle.kts' 'modules/jpa/jpa-testkit-migration/build.gradle.kts' 'modules/jpa/jpa-testkit-queryplan/build.gradle.kts' 'settings.gradle.kts' 'build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt' +git commit -m "build: add jpa platform modules and test suites" +``` + +### Task 2: Core Operation Name과 Capability 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java` + +**Interfaces:** +- Consumes: Only Java 21 standard library. +- Produces: Bounded operation names and explicit Stable/Advanced/Experimental capability metadata. + +**Implementation requirements:** +- Operation names must match `[a-z][a-z0-9.-]{2,95}`. +- Capability constraints must be immutable and must not store provider objects. +- Include capabilities for transaction retry, completion evidence, keyset pagination, batch, PostgreSQL native write, schema gate, L2 cache, Envers. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api; + +class PersistenceOperationNameTest { + @Test + void rejectsDynamicIdentifiers() { + assertThatThrownBy(() -> new PersistenceOperationName("order/" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsRegisteredLowCardinalityName() { + assertThat(new PersistenceOperationName("order.place").value()) + .isEqualTo("order.place"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +``` + +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.api; + +public record PersistenceOperationName(String value) { + private static final Pattern FORMAT = + Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public PersistenceOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +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:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java' +git commit -m "feat: add jpa operation and capability contracts" +``` + +### Task 3: 안정 JPA 오류 계층과 Failure Context 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName` from Task 2. +- Produces: Provider-independent, structured, sanitized persistence exceptions. + +**Implementation requirements:** +- Every exception preserves operation, SQLSTATE, attempt, retryable, completionUnknown, elapsed and trace ID. +- Constraint exceptions preserve a registered constraint code and optional bounded database constraint name. +- Exception messages must never contain SQL parameter values, Entity IDs or PII. +- `TransactionCompletionUnknownException` must always report `completionUnknown=true` and `retryable=false`. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.error; + +class JpaFailureContextTest { + @Test + void completionUnknownCanNeverBeMarkedRetryable() { + var context = JpaFailureContext.completionUnknown( + new PersistenceOperationName("payment.commit"), "40003", 1, Duration.ofMillis(50), "trace"); + + assertThat(context.retryable()).isFalse(); + assertThat(context.completionUnknown()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +``` + +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.api.error; + +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { + + public static JpaFailureContext completionUnknown( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext( + operation, sqlState, attempt, false, true, elapsed, traceId); + } +} +``` + +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:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java' +git commit -m "feat: add stable jpa persistence error model" +``` + +### Task 4: PostgreSQL SQLSTATE 분류와 예외 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java` +- Test: `modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java` + +**Interfaces:** +- Consumes: Stable exceptions from Task 3 and PostgreSQL `PSQLException` structured fields. +- Produces: Message-text-independent SQLSTATE classification for `40001`, `40003`, `40P01`, `23505`, `23503`, `23514`, `55P03`. + +**Implementation requirements:** +- Unwrap Spring, Hibernate, JDBC and PostgreSQL exception chains without parsing localized message text. +- Map constraint names through a bounded `ConstraintCatalog` before exposing them. +- Unknown SQLSTATE must remain an explicit UNKNOWN category, not an optimistic guess. +- Do not classify every connection exception as completion unknown; commit phase evidence is required by Task 6. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.error; + +class PostgreSqlFailureClassifierTest { + @ParameterizedTest + @CsvSource({ + "40001,SERIALIZATION_FAILURE", + "40003,COMPLETION_UNKNOWN", + "40P01,DEADLOCK", + "23505,UNIQUE_CONSTRAINT", + "55P03,LOCK_NOT_AVAILABLE" + }) + void classifiesBySqlState(String state, FailureCategory expected) { + assertThat(new PostgreSqlFailureClassifier().classify(state)) + .isEqualTo(expected); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +``` + +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.postgresql.error; + +public final class PostgreSqlFailureClassifier { + public FailureCategory classify(String sqlState) { + return switch (sqlState) { + case "40001" -> FailureCategory.SERIALIZATION_FAILURE; + case "40003" -> FailureCategory.COMPLETION_UNKNOWN; + case "40P01" -> FailureCategory.DEADLOCK; + case "23505" -> FailureCategory.UNIQUE_CONSTRAINT; + case "23503" -> FailureCategory.FOREIGN_KEY_CONSTRAINT; + case "23514" -> FailureCategory.CHECK_CONSTRAINT; + case "55P03" -> FailureCategory.LOCK_NOT_AVAILABLE; + default -> FailureCategory.UNKNOWN; + }; + } +} +``` + +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:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java' +git commit -m "feat: translate postgresql sqlstate failures" +``` + +### Task 5: Transaction Profile과 Retry Profile Core 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName`, `JpaPersistenceException` and `FailureCategory`. +- Produces: Framework-free transaction, attempt and retry contracts. + +**Implementation requirements:** +- Stable propagation values are REQUIRED, MANDATORY and explicitly opt-in REQUIRES_NEW. +- Expose DEFAULT, READ_COMMITTED, REPEATABLE_READ and SERIALIZABLE isolation. +- Require positive finite timeout for write profiles. +- Require `maxAttempts >= 1`; completion unknown is never a retryable failure category. +- RetryDecision must distinguish full transaction retry, reconciliation and fail. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.transaction; + +class TransactionProfileTest { + @Test + void writeProfileRequiresFiniteTimeout() { + assertThatThrownBy(() -> new TransactionProfile( + "write", PropagationMode.REQUIRED, IsolationLevel.READ_COMMITTED, + Duration.ZERO, false, RetryProfile.none())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +``` + +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.api.transaction; + +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { + + public TransactionProfile { + if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) { + throw new IllegalArgumentException("write transaction requires positive timeout"); + } + } +} +``` + +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:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java' +git commit -m "feat: define jpa transaction and retry profiles" +``` + +### Task 6: Commit Evidence를 추적하는 JpaTransactionManager 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java` + +**Interfaces:** +- Consumes: Spring ORM `JpaTransactionManager`, Task 3 error model and Task 4 classifier SPI. +- Produces: Transaction phase evidence and commit-phase-only completion unknown translation. + +**Implementation requirements:** +- Track NOT_STARTED, ACTIVE, COMMITTING, COMMITTED, ROLLED_BACK and UNKNOWN per transaction. +- Set COMMITTING immediately before delegating to the provider commit. +- Only convert transport/SQLSTATE failures during COMMITTING to completion unknown. +- Clear ThreadLocal evidence in every success and failure path. +- Preserve the original provider exception as cause without leaking parameters. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class EvidenceAwareJpaTransactionManagerTest { + @Test + void connectionLossDuringCommitBecomesCompletionUnknown() { + var manager = fixtureThatCommitsThenDropsResponse(); + + assertThatThrownBy(() -> inTransaction(manager, () -> repository.insert("key-1"))) + .isInstanceOf(TransactionCompletionUnknownException.class) + .satisfies(error -> assertThat(((JpaPersistenceException) error) + .context().completionUnknown()).isTrue()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +``` + +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.transaction; + +public final class EvidenceAwareJpaTransactionManager extends JpaTransactionManager { + private final CommitFailureClassifier classifier; + + @Override + protected void doCommit(DefaultTransactionStatus status) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + try { + super.doCommit(status); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + } catch (RuntimeException failure) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.UNKNOWN); + throw classifier.translateCommitFailure(failure); + } finally { + TransactionEvidenceContext.clear(); + } + } +} +``` + +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:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java' +git commit -m "feat: track jpa transaction completion evidence" +``` + +### Task 7: Programmatic JpaTransactionExecutor 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java` + +**Interfaces:** +- Consumes: Task 5 transaction contracts and Spring `PlatformTransactionManager`. +- Produces: A programmatic transaction boundary that maps profile propagation, isolation, timeout and read-only exactly. + +**Implementation requirements:** +- Use a fresh `TransactionTemplate` definition per call without mutable global state. +- Map timeout to whole seconds only after rejecting sub-second truncation or documenting rounding. +- Propagate `PersistenceOperationName` into observation context. +- Do not implement retry in this class; Task 8 owns retry coordination. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class SpringJpaTransactionExecutorTest { + @Test + void mapsSerializableReadOnlyProfile() { + var profile = profile(SERIALIZABLE, Duration.ofSeconds(3), true); + executor.execute(OPERATION, profile, () -> null); + + assertThat(transactionProbe.isolation()).isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + assertThat(transactionProbe.readOnly()).isTrue(); + assertThat(transactionProbe.timeoutSeconds()).isEqualTo(3); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +``` + +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.transaction; + +public final class SpringJpaTransactionExecutor implements JpaTransactionExecutor { + private final PlatformTransactionManager transactionManager; + + @Override + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + var template = new TransactionTemplate(transactionManager); + TransactionDefinitionMapper.apply(template, profile); + return template.execute(status -> work.get()); + } +} +``` + +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:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java' +git commit -m "feat: execute jpa transaction profiles" +``` + +### Task 8: 전체 Transaction Retry Coordinator 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java` + +**Interfaces:** +- Consumes: `JpaTransactionExecutor`, `JpaRetryPolicy`, `RetryProfile` and stable exceptions. +- Produces: Bounded retry that calls the transaction executor anew for every attempt. + +**Implementation requirements:** +- Every attempt must create a new transaction and new Persistence Context. +- Never retry completion unknown, constraint, schema or data corruption failures. +- Apply exponential backoff, configured jitter, max elapsed deadline and attempt budget. +- Emit one logical operation result and attempt-level events without logging every retry as WARN. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class FullTransactionRetryCoordinatorTest { + @Test + void retriesWholeUseCaseWithFreshPersistenceContext() { + var contexts = new ArrayList(); + var result = coordinator.execute(OPERATION, RETRY_PROFILE, () -> { + contexts.add(entityManagerIdentity()); + if (contexts.size() == 1) throw optimisticConflict(); + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(contexts).hasSize(2).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +``` + +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.transaction; + +public final class FullTransactionRetryCoordinator { + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + for (int attempt = 1; ; attempt++) { + try { + return transactionExecutor.execute(operation, profile, work); + } catch (JpaPersistenceException failure) { + RetryDecision decision = retryPolicy.classify( + failure, new TransactionAttempt(attempt, clock.instant())); + if (decision.disposition() != RETRY_FULL_TRANSACTION) throw failure; + sleeper.sleep(decision.delay()); + } + } + } +} +``` + +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:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java' +git commit -m "feat: retry complete jpa transactions safely" +``` + +### Task 9: RetryableJpaTransaction Annotation과 AOP ordering 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java` + +**Interfaces:** +- Consumes: Task 8 coordinator and named transaction profiles. +- Produces: An opt-in public-method annotation whose retry interceptor wraps the Spring transaction interceptor. + +**Implementation requirements:** +- Require a registered operation name and profile name in the annotation. +- Order retry advice outside transaction advice so each attempt creates a transaction. +- Reject self-invocation in documentation and architecture tests. +- Reject methods that return reactive types because JPA is blocking. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class RetryableJpaTransactionInterceptorTest { + @Test + void retryAdviceRunsOutsideTransactionAdvice() { + service.failOnceWithSerializationFailure(); + service.execute(); + + assertThat(probe.transactionIds()).containsExactly("tx-1", "tx-2"); + assertThat(probe.retryAdviceOrder()).isLessThan(probe.transactionAdviceOrder()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +``` + +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.transaction; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RetryableJpaTransaction { + String operation(); + String profile(); +} + +@Order(Ordered.HIGHEST_PRECEDENCE + 100) +public final class RetryableJpaTransactionInterceptor implements MethodInterceptor { + public Object invoke(MethodInvocation invocation) { + var policy = annotation(invocation.getMethod()); + return coordinator.execute( + new PersistenceOperationName(policy.operation()), + profiles.require(policy.profile()), + () -> proceed(invocation)); + } +} +``` + +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:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java' +git commit -m "feat: add retryable jpa transaction advice" +``` + +### Task 10: Completion Unknown Reconciliation SPI와 Audit 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java` + +**Interfaces:** +- Consumes: `TransactionCompletionUnknownException` and domain-provided transaction keys. +- Produces: A durable/auditable handoff for domain-specific committed/not-committed/unknown reconciliation. + +**Implementation requirements:** +- Core resolver returns COMMITTED, NOT_COMMITTED or STILL_UNKNOWN without guessing. +- Recording must happen outside the unknown transaction using a separate durable channel chosen by the application. +- Preserve operation, transaction key, SQLSTATE, trace ID and occurrence time; never persist SQL parameters. +- Do not automatically call the original use case from the resolver. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class CompletionUnknownRecorderTest { + @Test + void recordsUnknownWithoutRetryingOriginalWork() { + recorder.record(exception("payment-42")); + + assertThat(audit.last().transactionKey()).isEqualTo("payment-42"); + assertThat(originalUseCase.invocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +``` + +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.transaction; + +public interface TransactionCompletionResolver { + CompletionResolution resolve(K transactionKey); +} + +public enum CompletionResolution { + COMMITTED, + NOT_COMMITTED, + STILL_UNKNOWN +} +``` + +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:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java' +git commit -m "feat: add transaction completion reconciliation contracts" +``` + +### Task 11: OSIV와 DDL Auto 위험 설정 Startup Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Spring Boot Environment and the design global constraints. +- Produces: Fail-fast startup validation for OSIV and production schema mutation settings. + +**Implementation requirements:** +- Fail when `spring.jpa.open-in-view=true` outside an explicit local convenience profile. +- Fail in dev/staging/prod when ddl-auto is update/create/create-drop. +- Allow validate or none according to schema-management policy. +- Error messages must name the unsafe property and approved alternatives. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDangerousConfigurationGuardTest { + @Test + void productionRejectsOpenSessionInViewAndDdlUpdate() { + context.withPropertyValues( + "spring.profiles.active=prod", + "spring.jpa.open-in-view=true", + "spring.jpa.hibernate.ddl-auto=update") + .run(result -> assertThat(result).hasFailed()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +``` + +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.autoconfigure; + +public final class JpaDangerousConfigurationGuard { + public void validate(Environment environment) { + boolean osiv = environment.getProperty( + "spring.jpa.open-in-view", Boolean.class, false); + String ddl = environment.getProperty( + "spring.jpa.hibernate.ddl-auto", "none"); + if (osiv) throw new IllegalStateException("spring.jpa.open-in-view must be false"); + if (Set.of("update", "create", "create-drop").contains(ddl)) { + throw new IllegalStateException("Flyway owns schema changes; use validate or none"); + } + } +} +``` + +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:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java' +git commit -m "feat: reject unsafe jpa startup configuration" +``` + +### Task 12: Hikari·PostgreSQL Runtime Profile 검증 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Configured DataSource metadata, Hikari configuration and Stable PG16·17·18 policy. +- Produces: Runtime validation for database product/version, explicit pool limits and finite acquisition timeout. + +**Implementation requirements:** +- Reject non-PostgreSQL production datasource unless a future profile is explicitly installed. +- Accept PostgreSQL 16, 17 and 18; report but do not Stable-enable 19. +- Require explicit maximumPoolSize and connectionTimeout in production properties. +- Do not impose a universal pool size; validate consistency with positive bounds only. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDataSourceProfileValidatorTest { + @Test + void rejectsPostgreSqlNineteenFromStableProfile() { + var metadata = metadata("PostgreSQL", 19); + assertThatThrownBy(() -> validator.validateStable(metadata, properties())) + .hasMessageContaining("PostgreSQL 16, 17 or 18"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +``` + +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.autoconfigure; + +public final class PostgreSqlVersionPolicy { + private static final Set STABLE = Set.of(16, 17, 18); + + public void requireStable(DatabaseMetaData metadata) throws SQLException { + if (!"PostgreSQL".equals(metadata.getDatabaseProductName()) || + !STABLE.contains(metadata.getDatabaseMajorVersion())) { + throw new IllegalStateException("Stable JPA profile requires PostgreSQL 16, 17 or 18"); + } + } +} +``` + +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:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java' +git commit -m "feat: validate jpa datasource and postgresql profile" +``` + +### Task 13: Entity Mapping ArchUnit Rule Pack 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java` +- Test: `modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java` + +**Interfaces:** +- Consumes: ArchUnit and Jakarta Persistence annotations in the inspected application. +- Produces: Reusable rules for field access, non-final Entity, protected no-arg constructor, no web exposure and no Hibernate dependency in domain packages. + +**Implementation requirements:** +- Detect Controller methods returning an `@Entity` type or collection of Entity. +- Detect Entity classes in web/controller packages. +- Detect `org.hibernate` dependencies from domain packages. +- Detect final Entity classes and missing protected/public no-arg constructors. +- Provide separate warning-level rules for Cascade.ALL and EAGER associations rather than silently rewriting them. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class JpaArchitectureRulesTest { + @Test + void controllerMayNotReturnEntity() { + var classes = new ClassFileImporter().importClasses(BadOrderController.class, OrderEntity.class); + assertThatThrownBy(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .hasMessageContaining("OrderEntity"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +``` + +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.security; + +public final class JpaArchitectureRules { + public static ArchRule noEntityFromWeb() { + return methods().that().areDeclaredInClassesThat() + .resideInAPackage("..web..") + .should(new EntityExposureCondition()); + } + + public static ArchRule entitiesFollowPortableMappingRules() { + return classes().that().areAnnotatedWith(Entity.class) + .should(new EntityMappingCondition()); + } +} +``` + +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:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java' 'modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java' +git commit -m "feat: enforce jpa entity architecture rules" +``` + +### Task 14: Sequence·UUID ID Strategy Contract Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java` + +**Interfaces:** +- Consumes: PostgreSQL Testcontainer foundation and Hibernate statistics. +- Produces: Application UUIDv7 generator and evidence that Sequence batches while IDENTITY is classified as limited. + +**Implementation requirements:** +- UUIDv7 output must be monotonic enough for the test clock and set RFC variant/version bits. +- Sequence fixture must align allocationSize with the migration sequence increment. +- Contract test records actual prepared statements and JDBC batches. +- Do not expose PostgreSQL 18 `uuidv7()` as PG16·17 common behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.id; + +class UuidV7GeneratorTest { + @Test + void producesVersionSevenUuidInTimeOrder() { + var first = generator.next(Instant.parse("2026-08-11T00:00:00Z")); + var second = generator.next(Instant.parse("2026-08-11T00:00:01Z")); + + assertThat(first.version()).isEqualTo(7); + assertThat(first.compareTo(second)).isLessThan(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +``` + +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.testkit.id; + +public final class UuidV7Generator { + public UUID next(Instant instant) { + long unixMillis = instant.toEpochMilli() & 0x0000_FFFF_FFFF_FFFFL; + long most = (unixMillis << 16) | 0x7000L | random.nextLong(0x1000L); + long least = (random.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | + 0x8000_0000_0000_0000L; + return new UUID(most, least); + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java' +git commit -m "test: add jpa id strategy contracts" +``` + +### Task 15: JPA 3.2 Value Mapping Contract Fixture 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java` + +**Interfaces:** +- Consumes: JPA 3.2, Hibernate 7.4 and PostgreSQL round-trip test infrastructure. +- Produces: Round-trip contracts for Instant, OffsetDateTime, LocalDate, UUID, String Enum, record Embeddable and Duration converter. + +**Implementation requirements:** +- Use STRING or explicit converter for Enum; never ORDINAL. +- Verify record Embeddable construction and dirty checking under Hibernate 7.4. +- Specify timezone and precision assertions explicitly. +- Malformed database values must produce stable data corruption errors. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.mapping; + +class DurationMillisConverterTest { + @Test + void roundTripsDurationAsMilliseconds() { + var duration = Duration.ofSeconds(42).plusMillis(7); + assertThat(converter.convertToEntityAttribute( + converter.convertToDatabaseColumn(duration))).isEqualTo(duration); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +``` + +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.testkit.mapping; + +@Converter(autoApply = false) +public final class DurationMillisConverter + implements AttributeConverter { + public Long convertToDatabaseColumn(Duration value) { + return value == null ? null : value.toMillis(); + } + public Duration convertToEntityAttribute(Long value) { + return value == null ? null : Duration.ofMillis(value); + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java' +git commit -m "test: define jpa value mapping contracts" +``` + +### Task 16: Entity Lifecycle·Association Persistence Context Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java` + +**Interfaces:** +- Consumes: Jakarta Persistence 3.2 EntityManager lifecycle and domain-style parent/child fixtures. +- Produces: Explicit persist, merge, find, getReference, dirty-check, flush, clear, detach, refresh, owning-side, cascade and orphan-removal contracts. + +**Implementation requirements:** +- Prove `merge` returns the managed copy and does not attach the passed detached instance. +- Prove flush writes SQL but does not imply transaction commit. +- Prove clear/detach stop dirty checking and refresh reloads database state. +- Prove only the owning side updates the foreign key and helper methods synchronize both sides. +- Test cascade/orphan removal only on an aggregate-owned child fixture; do not define a platform-wide default. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.lifecycle; + +class EntityStateProbeTest { + @Test + void distinguishesManagedDetachedAndMergedInstances() { + var original = new LifecycleParent("p-1"); + entityManager.persist(original); + entityManager.flush(); + entityManager.detach(original); + + var merged = entityManager.merge(original); + assertThat(entityManager.contains(original)).isFalse(); + assertThat(entityManager.contains(merged)).isTrue(); + assertThat(merged).isNotSameAs(original); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +``` + +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.testkit.lifecycle; + +public final class EntityStateProbe { + private final EntityManager entityManager; + + public EntityState stateOf(Object entity) { + if (entityManager.contains(entity)) return EntityState.MANAGED; + Object id = entityManager.getEntityManagerFactory() + .getPersistenceUnitUtil().getIdentifier(entity); + return id == null ? EntityState.TRANSIENT : EntityState.DETACHED; + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java' +git commit -m "test: add jpa lifecycle and association contracts" +``` + +### Task 17: Spring Data Auditing Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java` +- Test: `modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java` + +**Interfaces:** +- Consumes: Spring Data auditing and application-provided current actor resolver. +- Produces: Embeddable technical auditing without a mandatory BaseEntity. + +**Implementation requirements:** +- Provide createdAt, createdBy, modifiedAt and modifiedBy as an opt-in Embeddable. +- Use `Instant` and a bounded opaque actor identifier. +- Do not confuse technical auditing with business audit or Entity history. +- Allow system/background jobs to use an explicit system actor. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.auditing; + +class JpaAuditingContractTest { + @Test + void persistsTechnicalAuditWhenEntityOptsIn() { + var saved = repository.save(new AuditedFixture("value")); + entityManager.flush(); + + assertThat(saved.audit().createdAt()).isEqualTo(clock.instant()); + assertThat(saved.audit().createdBy()).isEqualTo("user-42"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +``` + +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.auditing; + +@Embeddable +public class AuditMetadata { + @CreatedDate private Instant createdAt; + @CreatedBy private String createdBy; + @LastModifiedDate private Instant modifiedAt; + @LastModifiedBy private String modifiedBy; + + protected AuditMetadata() {} +} +``` + +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:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +./gradlew :modules:jpa:jpa-auditing:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java' 'modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java' +git commit -m "feat: add opt in spring data jpa auditing" +``` + +### Task 18: QueryName과 QueryObservation Core 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java` + +**Interfaces:** +- Consumes: Java 21 only and the operation-name validation pattern. +- Produces: Low-cardinality query identity and framework-neutral observation scopes. + +**Implementation requirements:** +- Query names use a bounded registry format and never contain IDs or raw SQL. +- QueryScope records row count, failure and close exactly once. +- Provide a no-op implementation for modules that do not install observability. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class QueryNameTest { + @Test + void rejectsRawSqlAsMetricIdentity() { + assertThatThrownBy(() -> new QueryName("select * from orders where id=42")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +``` + +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.api.query; + +public record QueryName(String value) { + public QueryName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid query name"); + } + } +} +``` + +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:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java' +git commit -m "feat: add bounded jpa query observation contract" +``` + +### Task 19: Custom Repository Fragment 지원과 Generic Repository 금지 규칙 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java` +- Modify: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java` + +**Interfaces:** +- Consumes: Spring Data JPA custom fragment model and Task 18 query names. +- Produces: A helper base for domain-owned custom implementations, not a CRUD repository. + +**Implementation requirements:** +- Do not declare save, findById, findAll or delete methods in platform interfaces. +- Expose EntityManager only to custom repository implementation packages. +- Require a registered QueryName for helper-created typed/native queries. +- Add an architecture test that fails if a platform type named GenericRepository or BaseRepository extends CrudRepository. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaRepositoryFragmentSupportTest { + @Test + void platformDoesNotReimplementCrudRepository() { + assertThat(JpaRepositoryFragmentSupport.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("save", "findById", "findAll", "delete"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +``` + +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.springdata; + +public abstract class JpaRepositoryFragmentSupport { + private final EntityManager entityManager; + + protected JpaRepositoryFragmentSupport(EntityManager entityManager) { + this.entityManager = entityManager; + } + + protected final TypedQuery typedQuery( + QueryName name, String jpql, Class resultType) { + return entityManager.createQuery(jpql, resultType) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +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:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java' +git commit -m "feat: support domain owned jpa repository fragments" +``` + +### Task 20: Specification과 Querydsl 선택 Integration 구현 + +**Files:** +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java` +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java` +- Test: `modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java` + +**Interfaces:** +- Consumes: Optional Querydsl JPA dependency, Spring Data Specification and registered QueryName. +- Produces: Explicit Q2 dynamic query helpers without changing J1 repository contracts. + +**Implementation requirements:** +- Keep Querydsl as an optional module; starter must not pull it transitively unless enabled. +- Reject an unbounded query when no predicate and no explicit allow-all token is present. +- Require page size and sort allowlist for collection queries. +- Do not accept user-provided path expressions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.querydsl; + +class QuerydslJpaSupportTest { + @Test + void rejectsUnboundedPredicateForCollectionQuery() { + assertThatThrownBy(() -> support.select(ORDER_QUERY, order, null, page(100))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bounded predicate"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +``` + +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.querydsl; + +public final class QuerydslJpaSupport { + public JPAQuery select( + QueryName name, + EntityPath root, + Predicate predicate, + QueryPage page) { + PredicatePolicy.requireBounded(predicate, page); + return queryFactory.selectFrom(root) + .where(predicate) + .limit(page.size()) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +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:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +./gradlew :modules:jpa:jpa-querydsl:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java' 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java' 'modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java' +git commit -m "feat: add optional jpa specification and querydsl support" +``` + +### Task 21: Dynamic Sort Allowlist와 Safe Sort Mapper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java` + +**Interfaces:** +- Consumes: Spring Data `Sort` and a domain-registered field catalog. +- Produces: Injection-safe sort mapping with deterministic tie-breakers. + +**Implementation requirements:** +- Reject unknown field, function expression, whitespace and punctuation from user input. +- Map public sort names to fixed entity paths. +- Append the configured stable tie-breaker when absent. +- Do not use `JpaSort.unsafe` for user-controlled values. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class SafeSortMapperTest { + @Test + void rejectsSqlExpressionAndAddsTieBreaker() { + assertThatThrownBy(() -> mapper.map(List.of("name desc nulls last; drop table"))) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(mapper.map(List.of("createdAt,desc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("createdAt", "id"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +``` + +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.springdata; + +public final class SafeSortMapper { + public Sort map(List requested) { + var orders = requested.stream() + .map(value -> registry.require(value.field()).toOrder(value.direction())) + .collect(Collectors.toCollection(ArrayList::new)); + if (orders.stream().noneMatch(order -> order.getProperty().equals(registry.tieBreaker()))) { + orders.add(Sort.Order.desc(registry.tieBreaker())); + } + return Sort.by(orders); + } +} +``` + +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:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java' +git commit -m "feat: enforce allowlisted deterministic jpa sorting" +``` + +### Task 22: Hibernate Statement Inspector와 Statistics Snapshot 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4 StatementInspector/Statistics and `QueryName`. +- Produces: Per-scope statement, entity, collection, flush and batch statistics without SQL parameter capture. + +**Implementation requirements:** +- Use query-name comments or context metadata without including dynamic values. +- Snapshot entity load/fetch and collection load/fetch separately. +- Record prepared statement count, flush count and JDBC batch execution count. +- Clear query context in finally blocks. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate; + +class HibernateStatisticsCollectorTest { + @Test + void separatesEntityLoadFromEntityFetch() { + var before = collector.snapshot(); + fixture.loadOrdersWithSharedUser(); + var delta = collector.snapshot().minus(before); + + assertThat(delta.entityLoadCount()).isPositive(); + assertThat(delta.entityFetchCount()).isGreaterThanOrEqualTo(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +``` + +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.hibernate; + +public record HibernateStatisticsSnapshot( + long preparedStatements, + long entityLoads, + long entityFetches, + long collectionLoads, + long collectionFetches, + long flushes, + long jdbcBatches) { + + public HibernateStatisticsSnapshot minus(HibernateStatisticsSnapshot before) { + return new HibernateStatisticsSnapshot( + preparedStatements - before.preparedStatements, + entityLoads - before.entityLoads, + entityFetches - before.entityFetches, + collectionLoads - before.collectionLoads, + collectionFetches - before.collectionFetches, + flushes - before.flushes, + jdbcBatches - before.jdbcBatches); + } +} +``` + +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:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java' +git commit -m "feat: collect hibernate query and fetch statistics" +``` + +### Task 23: Query Count·N+1 Assertion Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java` +- Test: `modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java` + +**Interfaces:** +- Consumes: Task 22 statistics snapshots and a statement/row measurement adapter. +- Produces: Assertions for statement count, fetch count, hydrated entities, rows and bounded execution time. + +**Implementation requirements:** +- Do not reduce N+1 verification to statement count only. +- Allow upper bounds and exact expectations separately. +- Error output must show queryName and each measured dimension. +- Support skewed and shared-association fixtures in PG contract suites. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.query; + +class JpaQueryAssertionsTest { + @Test + void reportsCartesianAmplificationEvenForOneStatement() { + var measurement = new QueryMeasurement(1, 100, 2000, 2000, Duration.ofMillis(40)); + assertThatThrownBy(() -> assertions.assertMatches( + measurement, QueryExpectation.maxRows(500))) + .hasMessageContaining("rows=2000"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +``` + +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.testkit.query; + +public final class JpaQueryAssertions { + public void assertMatches( + QueryMeasurement actual, + QueryExpectation expected) { + if (!expected.matches(actual)) { + throw new AssertionError("JPA query expectation failed: " + actual.summary()); + } + } +} +``` + +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:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +./gradlew :modules:jpa:jpa-testkit:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java' 'modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java' +git commit -m "test: add quantitative jpa query assertions" +``` + +### Task 24: Use Case Fetch Plan과 EntityGraph Helper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java` + +**Interfaces:** +- Consumes: EntityManager graphs, registered QueryName and domain-defined graph names. +- Produces: Use-case-specific EntityGraph selection without changing mapping fetch defaults. + +**Implementation requirements:** +- Require a registered fetch-plan name; no arbitrary attribute strings from API input. +- Support fetchgraph and loadgraph semantics explicitly. +- Do not mutate global Entity mapping or turn associations EAGER. +- Expose applied fetch plan to observation context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class FetchPlanApplierTest { + @Test + void appliesRegisteredGraphAndRejectsUnknownGraph() { + var query = fixtureQuery(); + applier.apply(query, new FetchPlanName("order.detail")); + assertThat(query.getHints()).containsKey("jakarta.persistence.fetchgraph"); + + assertThatThrownBy(() -> applier.apply(query, new FetchPlanName("order.secret"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +``` + +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.springdata; + +public final class FetchPlanApplier { + public TypedQuery apply(TypedQuery query, FetchPlanName name) { + EntityGraph graph = catalog.require(name); + return query.setHint("jakarta.persistence.fetchgraph", graph); + } +} +``` + +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:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java' +git commit -m "feat: add use case specific entity graph support" +``` + +### Task 25: Hibernate 7.4 Collection Fetch Pagination 회귀 Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4, PG16·17·18, Task 23 measurement and a parent/child skew fixture. +- Produces: A version-specific gate for SQL limit/subquery behavior, parent count, row amplification and count correctness. + +**Implementation requirements:** +- Test one fetched collection with Page and exact parent limit. +- Capture generated SQL and prove DB-level bounded selection under Hibernate 7.4. +- Keep a negative multiple-collection Cartesian test. +- Run on all Stable PostgreSQL versions and every Boot/Hibernate patch upgrade. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.fetch; + +class FetchPaginationExpectationTest { + @Test + void oneCollectionPageRequiresBoundedParentSelection() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(20); + assertThat(expected.maxReturnedParents()).isEqualTo(20); + assertThat(expected.requiresDatabaseLimit()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +``` + +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.testkit.fetch; + +public record FetchPaginationExpectation( + int maxReturnedParents, + boolean requiresDatabaseLimit, + int maxRowAmplification) { + + public static FetchPaginationExpectation hibernate74PostgreSql(int pageSize) { + return new FetchPaginationExpectation(pageSize, true, pageSize * 100); + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java' 'modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java' +git commit -m "test: certify hibernate collection fetch pagination" +``` + +### Task 26: Keyset Pagination Core Cursor 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java` + +**Interfaces:** +- Consumes: Java JSON codec adapter and an application-provided HMAC key. +- Produces: Versioned, bounded, tamper-evident cursor API independent of Spring Data. + +**Implementation requirements:** +- Require page size between 1 and a configured maximum. +- Cursor payload includes version and all ordering tie-breakers. +- Do not place JPQL, SQL fragments or raw entity paths in cursor data. +- Reject signature mismatch and unknown cursor version. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class SignedJsonCursorCodecTest { + @Test + void detectsTamperingAndRoundTripsTieBreaker() { + var cursor = new OrderCursor(Instant.parse("2026-08-11T00:00:00Z"), UUID.randomUUID()); + var encoded = codec.encode(cursor); + assertThat(codec.decode(encoded)).isEqualTo(cursor); + assertThatThrownBy(() -> codec.decode(encoded + "x")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +``` + +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.api.query; + +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { + public KeysetPageRequest { + if (size < 1 || size > 500) throw new IllegalArgumentException("invalid page size"); + } +} +``` + +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:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java' +git commit -m "feat: add signed keyset cursor contracts" +``` + +### Task 27: Spring Data Keyset Query Support 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java` + +**Interfaces:** +- Consumes: Task 26 cursor types, Criteria API and domain-provided keyset adapters. +- Produces: Deterministic size+1 keyset query execution and next-cursor assembly. + +**Implementation requirements:** +- Use lexicographic predicates matching the exact sort direction and null policy. +- Require a unique tie-breaker. +- Fetch at most `size + 1` rows and return only `size`. +- Do not execute a count query. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaKeysetQuerySupportTest { + @Test + void duplicateCreatedAtUsesIdTieBreakerWithoutGap() { + var first = repository.findRecent(request(Optional.empty(), 2)); + var second = repository.findRecent(request(first.nextCursor(), 2)); + + assertThat(Stream.concat(first.items().stream(), second.items().stream())) + .extracting(OrderSummary::id) + .doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +``` + +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.springdata; + +public final class KeysetSliceAssembler { + public KeysetSlice assemble( + List fetched, + int requestedSize, + Function cursorExtractor) { + boolean hasNext = fetched.size() > requestedSize; + List items = List.copyOf(fetched.subList(0, Math.min(fetched.size(), requestedSize))); + Optional next = hasNext ? Optional.of(cursorExtractor.apply(items.getLast())) : Optional.empty(); + return new KeysetSlice<>(items, next, hasNext); + } +} +``` + +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:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java' +git commit -m "feat: implement deterministic jpa keyset pagination" +``` + +### Task 28: Scroll·Stream Resource Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java` + +**Interfaces:** +- Consumes: Spring Data Scroll/Stream APIs, Transaction synchronization and QueryName. +- Produces: A bounded resource scope that closes Stream/ResultSet and forbids returning it beyond the transaction. + +**Implementation requirements:** +- Require an active read-only transaction for stream execution. +- Close the stream in normal, exception and cancellation paths. +- Require fetch size, maximum rows or explicit admin token. +- Reject WebFlux/Reactor return types in this blocking module. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaStreamExecutorTest { + @Test + void closesStreamWhenConsumerFails() { + assertThatThrownBy(() -> executor.consume(QUERY, policy(100), stream -> { + stream.findFirst(); + throw new IllegalStateException("boom"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(resourceProbe.closed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +``` + +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.springdata; + +public final class JpaStreamExecutor { + public R consume( + QueryName query, + ScrollPolicy policy, + Supplier> supplier, + Function, R> consumer) { + TransactionGuard.requireActiveReadOnly(); + try (Stream stream = supplier.get()) { + return consumer.apply(stream.limit(policy.maxRows())); + } + } +} +``` + +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:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java' +git commit -m "feat: guard jpa scroll and stream resources" +``` + +### Task 29: Optimistic Lock 오류 변환과 전체 Use Case Retry 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java` +- Modify: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Test: `modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java` + +**Interfaces:** +- Consumes: JPA `OptimisticLockException`, Spring optimistic locking exceptions and Task 8 coordinator. +- Produces: Stable `OptimisticConflictException` and bounded full-transaction recomputation. + +**Implementation requirements:** +- Translate conflicts thrown at flush or commit. +- Ensure retry reloads the entity and reruns domain rules. +- Do not retry when the use case declared external irreversible side effects. +- Record conflict entity type only from a bounded catalog, never Entity ID. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class OptimisticRetryIntegrationTest { + @Test + void secondAttemptReloadsAndRecomputesAggregate() { + concurrentWriterUpdatesVersion(); + var result = retryingService.increaseQuantity(orderId, 2); + + assertThat(result.attempts()).isEqualTo(2); + assertThat(repository.findById(orderId).orElseThrow().quantity()).isEqualTo(5); + assertThat(probe.persistenceContextIds()).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +``` + +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.transaction; + +public final class DefaultJpaRetryPolicy implements JpaRetryPolicy { + public RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt) { + if (failure instanceof TransactionCompletionUnknownException) { + return RetryDecision.reconcile("transaction completion is unknown"); + } + if (failure instanceof OptimisticConflictException || + failure instanceof SerializationFailureException || + failure instanceof DeadlockDetectedException) { + return RetryDecision.retry(backoff.forAttempt(attempt.number())); + } + return RetryDecision.fail("non-retryable persistence failure"); + } +} +``` + +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:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java' +git commit -m "feat: retry optimistic conflicts as complete transactions" +``` + +### Task 30: Pessimistic Lock Timeout과 Deadlock 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java` + +**Interfaces:** +- Consumes: JPA Pessimistic lock hints, SQLSTATE classifier and PostgreSQL Testcontainers. +- Produces: Distinct lock-timeout, NOWAIT and deadlock errors with lock-wait metrics. + +**Implementation requirements:** +- Distinguish statement-level lock timeout from transaction-aborting deadlock. +- Map `55P03` to lock-not-available/timeout and `40P01` to deadlock. +- Require finite lock timeout for pessimistic lock profiles. +- Hold locks only inside the Application Transaction. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlPessimisticLockContractTest { + @Test + void nowaitFailsImmediatelyWhileBlockingLockTimesOutSeparately() { + lockRowInOtherTransaction(); + + assertThatThrownBy(() -> repository.findForUpdateNowait(id)) + .isInstanceOf(PessimisticLockTimeoutException.class); + assertThat(lockProbe.lastWait()).isLessThan(Duration.ofSeconds(1)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +``` + +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.postgresql.lock; + +public record PostgreSqlLockOptions( + LockModeType mode, + Duration timeout, + boolean nowait) { + public PostgreSqlLockOptions { + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("lock timeout must be finite"); + } + } +} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java' +git commit -m "feat: classify postgresql pessimistic lock failures" +``` + +### Task 31: PostgreSQL NOWAIT·SKIP LOCKED Work Claim Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java` + +**Interfaces:** +- Consumes: EntityManager native query, registered queue SQL and PostgreSQL `FOR UPDATE SKIP LOCKED`. +- Produces: Queue-specific batch claim semantics instead of a generic inconsistent-read API. + +**Implementation requirements:** +- Require a registered queue name and fixed SQL template. +- Claim rows in deterministic priority/id order. +- Return lease owner and lease-until evidence in the same transaction. +- Do not expose `skipLocked=true` on arbitrary repository methods. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlWorkClaimContractTest { + @Test + void competingWorkersClaimDisjointRows() { + var first = workerA.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + var second = workerB.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + + assertThat(first).extracting(WorkClaim::id) + .doesNotContainAnyElementsOf(second.stream().map(WorkClaim::id).toList()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +``` + +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.postgresql.lock; + +public interface WorkClaimExecutor { + List> claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java' +git commit -m "feat: add postgresql skip locked work claims" +``` + +### Task 32: Constraint Violation Catalog와 Race-safe 오류 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java` +- Modify: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java` + +**Interfaces:** +- Consumes: Structured PostgreSQL server error fields and design-time constraint registry. +- Produces: Stable application constraint codes for unique, foreign-key, not-null and check violations. + +**Implementation requirements:** +- Two concurrent inserts of the same logical key must result in one commit and one unique exception. +- Do not rely on a prior `exists` query for correctness. +- Unknown constraint names map to a generic bounded code and secure diagnostic metadata. +- Support partial unique index and `NULLS NOT DISTINCT` migration names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.constraint; + +class ConstraintRaceContractTest { + @Test + void concurrentCreateIsResolvedByDatabaseConstraint() { + var results = runConcurrently( + () -> service.create("same@example.test"), + () -> service.create("same@example.test")); + + assertThat(results.successCount()).isEqualTo(1); + assertThat(results.failure()).isInstanceOf(UniqueConstraintViolationException.class); + assertThat(((UniqueConstraintViolationException) results.failure()) + .details().code()).isEqualTo(new ConstraintCode("user.active-email.unique")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +``` + +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.postgresql.constraint; + +public final class PostgreSqlConstraintCatalog { + private final Map byDatabaseName; + + public ConstraintCode resolve(String databaseName) { + return byDatabaseName.getOrDefault( + databaseName, new ConstraintCode("database.constraint.unknown")); + } +} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java' +git commit -m "feat: map database constraints to stable error codes" +``` + +### Task 33: Hibernate JDBC Batch Profile과 Configuration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate batch settings and Entity identifier metadata. +- Produces: Named batch profiles and startup diagnostics for IDENTITY and sequence mismatch. + +**Implementation requirements:** +- Require positive batch, flush and clear sizes for enabled profiles. +- Warn/fail when a write-heavy batch profile targets IDENTITY entities. +- Validate sequence allocation size against migration metadata in the contract suite. +- Treat `order_inserts` and `order_updates` as profile options, not universal defaults. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateBatchConfigurationGuardTest { + @Test + void rejectsIdentityEntityInRequiredBatchProfile() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, true); + assertThatThrownBy(() -> guard.validate(profile, IdentityEntity.class)) + .hasMessageContaining("IDENTITY disables insert batching"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +``` + +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.hibernate.batch; + +public record JpaBatchProfile( + String name, + int jdbcBatchSize, + int flushSize, + int clearSize, + boolean orderInserts, + boolean orderUpdates, + boolean batchingRequired) { + public JpaBatchProfile { + if (jdbcBatchSize < 1 || flushSize < 1 || clearSize < 1) { + throw new IllegalArgumentException("batch sizes must be positive"); + } + } +} +``` + +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:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java' +git commit -m "feat: define verified hibernate batch profiles" +``` + +### Task 34: Chunked Batch Persist Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: Task 33 profile, EntityManager and Hibernate statistics. +- Produces: Flush/clear bounded batch persistence with measured JDBC batch execution. + +**Implementation requirements:** +- Persist each item exactly once inside a caller-owned transaction. +- Flush and clear at configured boundaries and once at the end. +- Reject a Stream that cannot report or enforce a maximum input count unless admin capability is present. +- Return processed rows, flush count, statement count and actual batch count. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateJpaBatchExecutorIntegrationTest { + @Test + void executesActualJdbcBatchesAndBoundsPersistenceContext() { + var result = executor.persist(BATCH_PROFILE, fixtures(1_000), entityManager::persist); + + assertThat(result.processed()).isEqualTo(1_000); + assertThat(result.jdbcBatches()).isGreaterThan(1); + assertThat(result.maxManagedEntities()).isLessThanOrEqualTo(50); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +``` + +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.hibernate.batch; + +public final class HibernateJpaBatchExecutor implements JpaBatchExecutor { + public BatchExecutionResult persist( + JpaBatchProfile profile, + Iterable items, + Consumer persister) { + int processed = 0; + for (T item : items) { + persister.accept(item); + processed++; + if (processed % profile.flushSize() == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + entityManager.flush(); + entityManager.clear(); + return measurements.result(processed); + } +} +``` + +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:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java' +git commit -m "feat: execute bounded hibernate jdbc batches" +``` + +### Task 35: Bulk DML flush-clear Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: EntityManager, registered bulk operation and Task 18 QueryObservation. +- Produces: Explicit flush → bulk SQL → clear execution with affected-row guard. + +**Implementation requirements:** +- Require an active transaction and registered operation name. +- Flush before query execution and clear immediately after it. +- Require minimum/maximum expected affected rows; fail on unexpected blast radius. +- Document that callbacks and optimistic version checks are bypassed. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.bulk; + +class HibernateBulkDmlExecutorIntegrationTest { + @Test + void clearsStaleManagedEntitiesAfterBulkUpdate() { + var managed = repository.findById(id).orElseThrow(); + executor.execute(OPERATION, () -> query.executeUpdate(), expectedRows(1)); + + assertThat(entityManager.contains(managed)).isFalse(); + assertThat(repository.findById(id).orElseThrow().status()).isEqualTo("ARCHIVED"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +``` + +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.hibernate.bulk; + +public final class HibernateBulkDmlExecutor implements BulkDmlExecutor { + public BulkDmlResult execute( + BulkOperationName name, + IntSupplier statement, + AffectedRowsExpectation expectation) { + entityManager.flush(); + int affected = statement.getAsInt(); + entityManager.clear(); + expectation.verify(affected); + return new BulkDmlResult(name, affected); + } +} +``` + +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:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java' +git commit -m "feat: execute safe jpa bulk dml with context clearing" +``` + +### Task 36: Hibernate StatelessSession Advanced Runner 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java` + +**Interfaces:** +- Consumes: Hibernate SessionFactory and J4/Advanced authorization token. +- Produces: An opt-in bulk session with explicit no-dirty-checking/no-cascade semantics. + +**Implementation requirements:** +- Do not register this runner as the default Repository implementation. +- Require a named operation, row cap and explicit transaction mode. +- Document that returned objects are not managed and aliases may occur. +- Measure rows, statements and memory independent of persistence-context size. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.stateless; + +class HibernateStatelessSessionRunnerIntegrationTest { + @Test + void insertsWithoutGrowingPersistenceContext() { + var result = runner.execute(WORK, 10_000, session -> { + fixtures(10_000).forEach(session::insert); + return 10_000; + }); + + assertThat(result).isEqualTo(10_000); + assertThat(hibernateSessionStatistics.managedEntityCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +``` + +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.hibernate.stateless; + +public final class HibernateStatelessSessionRunner implements StatelessSessionRunner { + public T execute( + StatelessWorkName name, + long maxRows, + Function work) { + try (StatelessSession session = sessionFactory.openStatelessSession()) { + Transaction tx = session.beginTransaction(); + try { + T result = work.apply(session); + tx.commit(); + return result; + } catch (RuntimeException failure) { + tx.rollback(); + throw failure; + } + } + } +} +``` + +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:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java' +git commit -m "feat: add opt in hibernate stateless session runner" +``` + +### Task 37: PostgreSQL JSONB Mapping과 Query Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JSON JDBC type, Jackson adapter and PostgreSQL JSONB operators. +- Produces: Versioned JSONB value mapping and parameter-bound JSON path/containment queries. + +**Implementation requirements:** +- Do not store Java class names in JSON payload. +- Require schema name/version in `JsonDocument`. +- Use parameters for values and a registered catalog for JSON paths. +- Test GIN index plan separately in Task 44. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.json; + +class PostgreSqlJsonbContractTest { + @Test + void roundTripsVersionedDocumentAndQueriesByRegisteredPath() { + repository.save(entity(json("profile", 2, Map.of("tier", "pro")))); + entityManager.flush(); + + assertThat(querySupport.contains(PATH_TIER, "pro")) + .extracting(Result::schemaVersion) + .containsExactly(2); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +``` + +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.postgresql.json; + +public record JsonDocument( + String schema, + int version, + JsonNode payload) { + public JsonDocument { + if (schema == null || schema.isBlank() || version < 1) { + throw new IllegalArgumentException("invalid json document envelope"); + } + } +} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java' +git commit -m "feat: add postgresql jsonb persistence support" +``` + +### Task 38: PostgreSQL Array·Range Mapping Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JDBC type SPI and PostgreSQL array/range types. +- Produces: Typed array and bounded/unbounded range round-trip and overlap/containment query support. + +**Implementation requirements:** +- Represent open/closed and unbounded endpoints explicitly. +- Reject invalid ranges in Java before sending them. +- Do not flatten ranges into two unrelated columns in this extension. +- Run identical contracts on PG16·17·18. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.range; + +class PostgreSqlArrayRangeContractTest { + @Test + void roundTripsClosedOpenRangeAndArray() { + var saved = repository.save(fixture( + List.of("a", "b"), PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)))); + entityManager.flush(); + entityManager.clear(); + + var loaded = repository.findById(saved.id()).orElseThrow(); + assertThat(loaded.tags()).containsExactly("a", "b"); + assertThat(loaded.window().upperInclusive()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +``` + +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.postgresql.range; + +public record PgRange>( + Optional lower, + boolean lowerInclusive, + Optional upper, + boolean upperInclusive) { + public PgRange { + if (lower.isPresent() && upper.isPresent() && + lower.get().compareTo(upper.get()) > 0) { + throw new IllegalArgumentException("range lower bound exceeds upper bound"); + } + } +} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java' +git commit -m "feat: add postgresql array and range mappings" +``` + +### Task 39: PostgreSQL ON CONFLICT·RETURNING Native Write 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java` + +**Interfaces:** +- Consumes: Registered native SQL, parameter binder, QueryObservation and Persistence Context clear policy. +- Produces: Explicit upsert result with inserted/updated disposition and returned projection. + +**Implementation requirements:** +- Require a registered conflict target and fixed update column set. +- Parameter-bind all values; dynamic table/column names are forbidden. +- Return whether insert or conflict-update occurred when SQL can expose it. +- Clear or refresh affected managed Entity state before returning to JPA code. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.write; + +class PostgreSqlUpsertContractTest { + @Test + void concurrentUpsertReturnsOneLogicalRow() { + runConcurrently( + () -> executor.execute(UPSERT, command("key", 1)), + () -> executor.execute(UPSERT, command("key", 2))); + + assertThat(jdbc.queryForObject("select count(*) from counters where key='key'", Long.class)) + .isEqualTo(1L); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +``` + +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.postgresql.write; + +public interface PostgreSqlUpsertExecutor { + UpsertResult execute(NativeWriteName operation, C command); +} + +public record UpsertResult(WriteDisposition disposition, R value) {} +``` + +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:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java' +git commit -m "feat: add registered postgresql upsert writes" +``` + +### Task 40: PostgreSQL COPY Bulk Loader J4 Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java` +- Test: `modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java` + +**Interfaces:** +- Consumes: PostgreSQL JDBC `CopyManager`, admin capability token and bounded input stream. +- Produces: Explicit J4 bulk load with row/byte limits, transaction policy and audit identity. + +**Implementation requirements:** +- Require a registered COPY statement; no caller-provided table or column strings. +- Enforce max rows, max bytes and finite timeout. +- Run only under a configured bulk/admin role. +- Return rows and bytes; never use Entity callbacks or Persistence Context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.copy; + +class PostgreSqlCopyLoaderIntegrationTest { + @Test + void loadsBoundedCsvWithoutEntityHydration() { + var result = loader.load(IMPORT, csvOf(10_000), limits(10_000, 5_000_000)); + + assertThat(result.rows()).isEqualTo(10_000); + assertThat(hibernateStatistics.entityLoadCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +``` + +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.postgresql.copy; + +public interface PostgreSqlCopyLoader { + CopyResult load( + CopyOperationName operation, + InputStream source, + CopyLimits limits); +} +``` + +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:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +./gradlew :modules:jpa:jpa-postgresql-copy:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java' 'modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java' +git commit -m "feat: add guarded postgresql copy bulk loader" +``` + +### Task 41: Flyway Schema Policy와 Hibernate Validate Gate 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java` + +**Interfaces:** +- Consumes: Flyway validate/migrate information and environment profile. +- Produces: Environment-specific migration policy that never auto-repairs or allows runtime DDL mutation. + +**Implementation requirements:** +- Local/test/dev may migrate with migration credential; staging/prod support deployment-owned migration. +- Hibernate validate must run after migration in tests and runtime startup. +- Checksum mismatch, missing migration and schema mismatch fail closed. +- Repair is represented only as an admin operation descriptor, not startup behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class FlywayValidationGateTest { + @Test + void checksumMismatchFailsAndNeverRepairsAutomatically() { + var result = validationResultWithChecksumMismatch(); + assertThatThrownBy(() -> gate.requireValid(result)) + .isInstanceOf(SchemaMismatchException.class); + assertThat(flywayProbe.repairInvocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +``` + +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.migration; + +public final class FlywayValidationGate { + public void requireValid(ValidateResult result) { + if (!result.validationSuccessful) { + throw new SchemaMismatchException( + "Flyway validation failed: " + sanitizedErrorCodes(result)); + } + } +} +``` + +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:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java' +git commit -m "feat: enforce flyway schema validation policy" +``` + +### Task 42: Migration Snapshot Upgrade Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java` +- Create: `modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java` +- Test: `modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java` + +**Interfaces:** +- Consumes: PostgreSQL containers, schema snapshots and Task 41 validation gate. +- Produces: Repeatable empty, N-1 and oldest-supported upgrade scenarios plus checksum/missing migration failures. + +**Implementation requirements:** +- Restore snapshots into a clean database before each scenario. +- Run migrations and Hibernate validate after upgrade. +- Assert data invariants as well as schema version. +- Persist recovery instructions for non-transactional migration failures. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.migration; + +class MigrationScenarioTest { + @Test + void requiresEmptyPreviousAndOldestSupportedScenarios() { + assertThat(MigrationScenario.required()) + .extracting(MigrationScenario::name) + .containsExactlyInAnyOrder("empty", "previous-release", "oldest-supported"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +``` + +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.testkit.migration; + +public record MigrationScenario( + String name, + MigrationSnapshot snapshot, + Consumer invariant) { + public static List required() { + return List.of(empty(), previousRelease(), oldestSupported()); + } +} +``` + +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:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +./gradlew :modules:jpa:jpa-testkit-migration:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java' 'modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java' 'modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java' +git commit -m "test: add flyway upgrade snapshot contracts" +``` + +### Task 43: Non-transactional Concurrent Index Migration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java` + +**Interfaces:** +- Consumes: Flyway migration resource metadata and PostgreSQL index catalog. +- Produces: A gate ensuring `CREATE INDEX CONCURRENTLY` is explicitly non-transactional and recoverable. + +**Implementation requirements:** +- Detect concurrent index SQL in transactional migrations and fail validation. +- Require a companion `.conf` or registered policy marking execute-in-transaction false. +- Detect invalid indexes after failed migration and generate a bounded recovery report. +- Do not auto-drop invalid indexes in application startup. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class ConcurrentIndexMigrationInspectorTest { + @Test + void concurrentIndexMustBeMarkedNonTransactional() { + var migration = sql("V42__order_index.sql", "create index concurrently ix_order on orders(created_at)"); + assertThatThrownBy(() -> inspector.validate(migration, transactionEnabled())) + .hasMessageContaining("executeInTransaction=false"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +``` + +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.migration; + +public final class ConcurrentIndexMigrationInspector { + public void validate(MigrationResource migration, boolean executeInTransaction) { + if (migration.sql().toLowerCase(Locale.ROOT).contains("create index concurrently") && + executeInTransaction) { + throw new IllegalStateException( + migration.name() + " must set executeInTransaction=false"); + } + } +} +``` + +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:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java' +git commit -m "feat: guard concurrent index migrations" +``` + +### Task 44: PostgreSQL Query Plan Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java` +- Test: `modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java` + +**Interfaces:** +- Consumes: Registered SQL/parameters under a test/admin role and `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`. +- Produces: Structural plan assertions for node types, row-estimate ratio, sort spill and buffer use. + +**Implementation requirements:** +- Do not globally fail every sequential scan. +- Normalize volatile cost/time fields before snapshot comparison. +- Require representative parameters and fixture statistics. +- Never run ANALYZE write queries outside isolated test databases. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.queryplan; + +class QueryPlanAssertionsTest { + @Test + void detectsUnexpectedSortSpillAndEstimateError() { + var plan = planWithDiskSortAndEstimateRatio(100.0); + assertThatThrownBy(() -> assertions.assertMatches(plan, + expectation().maxEstimateRatio(10).forbidDiskSort())) + .hasMessageContaining("Disk Sort"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +``` + +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.testkit.queryplan; + +public record QueryPlanExpectation( + Set requiredNodeTypes, + Set forbiddenNodeTypes, + double maxEstimateRatio, + boolean forbidDiskSort) { +} +``` + +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:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +./gradlew :modules:jpa:jpa-testkit-queryplan:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java' 'modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java' +git commit -m "test: add postgresql query plan regression toolkit" +``` + +### Task 45: Database Role·search_path Security Verifier 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java` +- Test: `modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java` + +**Interfaces:** +- Consumes: Runtime DataSource, `current_user`, `current_setting(search_path)` and privilege functions. +- Produces: Fail-fast proof that runtime role has DML but lacks DDL and untrusted schema CREATE privilege. + +**Implementation requirements:** +- Verify current user and schema against configured allowlists. +- Reject runtime role with CREATE on application schema or database. +- Reject untrusted writable schemas in search_path. +- Do not expose usernames or JDBC URLs in Actuator output beyond bounded profile names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class PostgreSqlRuntimeRoleVerifierIntegrationTest { + @Test + void runtimeRoleCanWriteRowsButCannotCreateTable() { + verifier.requireSafe(runtimeDataSource, policy()); + assertThatThrownBy(() -> jdbc.execute("create table forbidden(id bigint)")) + .isInstanceOf(DataAccessException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +``` + +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.security; + +public final class PostgreSqlRuntimeRoleVerifier { + public DatabasePrivilegeReport verify(DataSource dataSource, DatabaseRolePolicy policy) { + return jdbc(dataSource).queryForObject(""" + select current_user, + current_setting('search_path'), + has_schema_privilege(current_user, current_schema(), 'CREATE') + """, reportMapper); + } + + public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) { + DatabasePrivilegeReport report = verify(dataSource, policy); + policy.requireSafe(report); + } +} +``` + +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:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java' 'modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java' +git commit -m "feat: verify postgresql runtime role safety" +``` + +### Task 46: Hibernate Second-level Cache Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java` +- Test: `modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate L2 cache settings and Entity metadata. +- Produces: ENABLE_SELECTIVE, Entity-by-Entity cache enrollment while keeping Query Cache disabled by default. + +**Implementation requirements:** +- Fail if Query Cache is enabled without an explicit experimental approval. +- Require registered cache region and concurrency strategy for each cached Entity. +- Require a Bulk DML eviction strategy. +- Document external DB writer and cluster invalidation assumptions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.cache; + +class HibernateCacheGuardTest { + @Test + void queryCacheIsOffAndOnlyRegisteredEntitiesAreCacheable() { + assertThatThrownBy(() -> guard.validate(settings(queryCacheEnabled()), catalog())) + .hasMessageContaining("Query Cache is disabled by default"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +``` + +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.cache; + +public final class HibernateCacheGuard { + public void validate(HibernateCacheSettings settings, CacheRegionCatalog catalog) { + if (settings.queryCacheEnabled()) { + throw new IllegalStateException("Query Cache is disabled by default"); + } + if (settings.sharedCacheMode() != SharedCacheMode.ENABLE_SELECTIVE) { + throw new IllegalStateException("Use ENABLE_SELECTIVE for L2 cache"); + } + catalog.validate(settings.cacheableEntities()); + } +} +``` + +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:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +./gradlew :modules:jpa:jpa-cache-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java' 'modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java' +git commit -m "feat: add opt in hibernate second level cache guard" +``` + +### Task 47: Hibernate Envers Entity History Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java` +- Test: `modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java` + +**Interfaces:** +- Consumes: Hibernate Envers and application-provided revision actor/context. +- Produces: Entity-specific history without conflating it with technical or business audit. + +**Implementation requirements:** +- Require explicit `@Audited` or catalog enrollment. +- Record bounded actor/correlation metadata, not entire security principals. +- Require retention and PII deletion policy before production enablement. +- Do not enable Envers for every Entity through a global base class. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.envers; + +class EnversHistoryContractTest { + @Test + void storesHistoryOnlyForOptedInEntity() { + updateAuditedEntity(); + updateNonAuditedEntity(); + + assertThat(reader.revisions(AuditedFixture.class, auditedId)).hasSize(2); + assertThat(reader.revisions(PlainFixture.class, plainId)).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +``` + +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.envers; + +public interface EnversHistoryReader { + List> revisions(Class entityType, Object id); +} +``` + +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:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +./gradlew :modules:jpa:jpa-envers:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java' 'modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java' +git commit -m "feat: add opt in hibernate envers history" +``` + +### Task 48: JPA Metrics·Tracing·Log Redaction 구현 + +**Files:** +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java` +- Test: `modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java` + +**Interfaces:** +- Consumes: Micrometer, Spring Observation, QueryName, PersistenceOperationName and Hibernate statistics. +- Produces: Logical transaction/query/retry metrics with bounded tags and PII-safe diagnostics. + +**Implementation requirements:** +- Measure transaction count/duration/rollback/timeout/retry/completion-unknown. +- Measure query count/duration/rows/fetch metrics and JDBC batch count. +- Allow only registered operation/query/entity type tags. +- Reject SQL parameters, IDs, tenant values and dynamic exception messages from metric tags. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.observation; + +class JpaObservabilityContractTest { + @Test + void metricsNeverUseEntityIdOrSqlParameterAsTag() { + observation.recordFailure(OPERATION, QUERY, uniqueViolation("secret@example.test")); + + assertThat(registry.getMeters()) + .flatExtracting(meter -> meter.getId().getTags()) + .extracting(Tag::getValue) + .noneMatch(value -> value.contains("secret@example.test") || value.contains("entity-42")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +``` + +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.observation; + +public record JpaMetricTags( + String persistenceUnit, + String operationName, + String queryName, + String outcome, + String failureCategory) { + public JpaMetricTags { + LowCardinality.requireRegistered(operationName, queryName, failureCategory); + } +} +``` + +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:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +./gradlew :modules:jpa:jpa-observability:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java' 'modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java' +git commit -m "feat: add safe jpa observability contracts" +``` + +### Task 49: PostgreSQL 16·17·18 공통 Contract Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java` + +**Interfaces:** +- Consumes: All Stable mapping, transaction, query, fetch, batch, extension and security contracts. +- Produces: A parameterized release matrix over real PostgreSQL 16, 17 and 18 containers. + +**Implementation requirements:** +- PR profile runs 16 and 18; release profile runs 16, 17 and 18. +- Pin image digests or approved tags and record exact server version. +- Run Flyway before Hibernate validate. +- H2 results must not satisfy this suite. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.postgresql; + +class PostgreSqlVersionTest { + @Test + void stableVersionsAreExactlySixteenSeventeenAndEighteen() { + assertThat(PostgreSqlVersion.stable()) + .containsExactly(PG_16, PG_17, PG_18); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +``` + +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.testkit.postgresql; + +public enum PostgreSqlVersion { + PG_16("postgres:16"), + PG_17("postgres:17"), + PG_18("postgres:18"); + + public static List stable() { + return List.of(PG_16, PG_17, PG_18); + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java' +git commit -m "test: add postgresql stable compatibility matrix" +``` + +### Task 50: Deadlock·Serialization·Commit Ambiguity Failure Injection Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java` +- Modify: `infra/jpa/toxiproxy/docker-compose.yml` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java` + +**Interfaces:** +- Consumes: Toxiproxy, deterministic transaction barriers, Task 6 evidence manager and Task 8 retry coordinator. +- Produces: Reproducible `40P01`, `40001` and commit-response-loss scenarios. + +**Implementation requirements:** +- Deadlock uses opposite lock order and confirms bounded full-TX retry. +- Serialization uses SERIALIZABLE invariant contention. +- Commit ambiguity distinguishes before-COMMIT, during-COMMIT and after-server-commit response loss. +- After-server-commit loss must emit completion unknown and must not rerun the original mutation. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.failure; + +class PostgreSqlFailureScenarioTest { + @Test + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly(BEFORE_COMMIT, DURING_COMMIT, AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +``` + +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.testkit.failure; + +public enum PostgreSqlFailureScenario { + BEFORE_COMMIT, + DURING_COMMIT, + AFTER_SERVER_COMMIT_BEFORE_RESPONSE; + + public static List commitPoints() { + return List.of(values()); + } +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java' 'infra/jpa/toxiproxy/docker-compose.yml' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java' +git commit -m "test: add jpa concurrency and commit ambiguity failures" +``` + +### Task 51: Hikari Pool·REQUIRES_NEW Saturation Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java` + +**Interfaces:** +- Consumes: Hikari metrics, bounded executor and nested transaction fixtures. +- Produces: Evidence for pending/acquire latency, connection timeout and outer+inner connection pressure. + +**Implementation requirements:** +- Test finite pool saturation without changing production defaults. +- Show that concurrent REQUIRED uses one connection per transaction while REQUIRES_NEW can require two. +- Ensure rejected/acquire-timeout work releases all connections. +- Record transaction duration and pending acquire latency together. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.pool; + +class PoolMeasurementTest { + @Test + void reportsPendingAndAcquireLatencyTogether() { + var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)); + assertThat(measurement.pending()).isEqualTo(3); + assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +``` + +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.testkit.pool; + +public record PoolMeasurement( + int active, + int idle, + int pending, + Duration acquireLatency) { +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java' +git commit -m "test: certify hikari and requires new pool behavior" +``` + +### Task 52: Spring Boot Starter·Actuator·Capability Report 완성 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: Tasks 6~12, 18, 22~25, 41, 45 and 48. +- Produces: Conditional Stable auto-configuration and a sanitized actuator endpoint. + +**Implementation requirements:** +- Back off when the application supplies its own transaction manager or observation implementation. +- Auto-configure only Stable modules; Querydsl, Envers, L2 and COPY require explicit dependencies/properties. +- Endpoint reports DB major version, provider version, schema version, OSIV, role verification and capabilities. +- Do not expose JDBC URL, username, SQL, credentials or Entity catalog. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaPlatformAutoConfigurationTest { + @Test + void configuresStablePlatformAndSanitizesEndpoint() { + context.withUserConfiguration(TestJpaApplication.class) + .run(result -> { + assertThat(result).hasSingleBean(JpaTransactionExecutor.class); + assertThat(result.getBean(JpaPlatformEndpoint.class).platform()) + .doesNotHaveToString(".*jdbc:.*|.*password.*"); + }); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +``` + +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.autoconfigure; + +@AutoConfiguration +@EnableConfigurationProperties({JpaSafetyProperties.class, JpaDataSourceProperties.class}) +public class JpaPlatformAutoConfiguration { + @Bean + JpaPlatformReport jpaPlatformReport( + DatabaseMetadata metadata, + FlywaySchemaPolicy schema, + DatabasePrivilegeReport privileges) { + return JpaPlatformReport.sanitized(metadata, schema, privileges); + } +} +``` + +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:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java' +git commit -m "feat: complete jpa spring boot starter and actuator" +``` + +### Task 53: CI Matrix·문서·ADR·Release Gate 완성 + +**Files:** +- Create: `.github/workflows/jpa-pr.yml` +- Create: `.github/workflows/jpa-nightly.yml` +- Create: `.github/workflows/jpa-release.yml` +- Create: `docs/jpa/support-matrix.md` +- Create: `docs/jpa/entity-mapping-guide.md` +- Create: `docs/jpa/transaction-guide.md` +- Create: `docs/jpa/query-fetch-guide.md` +- Create: `docs/jpa/migration-guide.md` +- Create: `docs/jpa/postgresql-extensions.md` +- Create: `docs/jpa/observability.md` +- Create: `docs/jpa/security.md` +- Create: `docs/jpa/runbooks.md` +- Create: `docs/adr/ADR-JPA-001-domain-owns-persistence-model.md` +- Create: `docs/adr/ADR-JPA-002-full-transaction-retry.md` +- Create: `docs/adr/ADR-JPA-003-completion-unknown.md` +- Create: `docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md` +- Create: `docs/adr/ADR-JPA-005-postgresql-real-contract.md` +- Modify: `build.gradle.kts` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java` + +**Interfaces:** +- Consumes: All Stable modules, test suites, design decisions and support matrix. +- Produces: PR/nightly/release aggregation, operator documentation and a machine-readable release manifest. + +**Implementation requirements:** +- PR runs unit, architecture, PG16·18 contract and migration smoke. +- Nightly runs PG16·17·18, failure, plan, pool and security suites. +- Release runs all Stable contracts, upgrade snapshots, performance and artifact compatibility checks. +- Document Stable/Advanced/Experimental/Unsupported features exactly as the design. +- Release fails if H2 is the only database test, OSIV is on, ddl-auto mutates schema, completion unknown retry exists or runtime DDL succeeds. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.testkit.release; + +class JpaReleaseManifestTest { + @Test + void manifestContainsAllStableVersionsAndMandatoryGates() { + var manifest = JpaReleaseManifest.load("docs/jpa/support-matrix.md"); + assertThat(manifest.postgreSqlVersions()).containsExactly(16, 17, 18); + assertThat(manifest.gates()).contains( + "completion-unknown-no-retry", + "osiv-disabled", + "flyway-validate", + "runtime-role-no-ddl", + "hibernate-7.4-fetch-pagination"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + base +} + +tasks.register("jpaReleaseGate") { + dependsOn( + ":modules:jpa:jpa-testkit-postgresql:contractTest", + ":modules:jpa:jpa-testkit-postgresql:failureTest", + ":modules:jpa:jpa-testkit-postgresql:performanceTest", + ":modules:jpa:jpa-testkit-migration:migrationTest", + ":modules:jpa:jpa-testkit-queryplan:test" + ) +} +``` + +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:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add '.github/workflows/jpa-pr.yml' '.github/workflows/jpa-nightly.yml' '.github/workflows/jpa-release.yml' 'docs/jpa/support-matrix.md' 'docs/jpa/entity-mapping-guide.md' 'docs/jpa/transaction-guide.md' 'docs/jpa/query-fetch-guide.md' 'docs/jpa/migration-guide.md' 'docs/jpa/postgresql-extensions.md' 'docs/jpa/observability.md' 'docs/jpa/security.md' 'docs/jpa/runbooks.md' 'docs/adr/ADR-JPA-001-domain-owns-persistence-model.md' 'docs/adr/ADR-JPA-002-full-transaction-retry.md' 'docs/adr/ADR-JPA-003-completion-unknown.md' 'docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md' 'docs/adr/ADR-JPA-005-postgresql-real-contract.md' 'build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java' +git commit -m "docs: add jpa release matrix and runbooks" +``` +## 4. 최종 실행 순서와 Review Gate + +```text +Task 1~12 +→ 모듈·Core·오류·Transaction·Starter Guard + +Task 13~28 +→ Mapping·Persistence Context·Repository·Query·Fetch·Pagination + +Task 29~32 +→ Optimistic/Pessimistic·Constraint + +Task 33~40 +→ Batch·Bulk·Hibernate·PostgreSQL Native + +Task 41~45 +→ Flyway·Migration·Plan·Security + +Task 46~48 +→ L2 Cache·Envers·Observability + +Task 49~53 +→ PostgreSQL Matrix·Failure·Pool·Starter·Release +``` + +각 Task 뒤에는 두 단계 review를 수행한다. + +1. **Specification review:** 설계서의 계약과 exact type/signature가 일치하는가. +2. **Quality review:** 테스트가 failure mode를 실제로 재현하고 위험한 우회 경로를 남기지 않는가. + +Stable 계획이 끝나기 전 Experimental module을 구현하지 않는다. + +## 5. 계획 완료 기준 + +```text +53개 Task가 순서대로 존재한다. +각 Task에 정확한 파일 경로와 public interface가 있다. +각 Task가 failing test와 예상 실패를 포함한다. +각 Task가 최소 구현 코드와 pass command를 포함한다. +각 Task가 독립 commit으로 종료한다. +Generic Repository 재구현 Task가 없다. +Commit Unknown 자동 Retry가 없다. +PG16·17·18 Release Matrix가 있다. +Flyway, Security, Fetch, Batch, Pool, Failure Gate가 구현 순서에 포함된다. +``` diff --git a/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md b/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md new file mode 100644 index 00000000..a907c6b0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md @@ -0,0 +1,3276 @@ +# JPA 관계형 영속성 플랫폼 설계서 + +- 문서 상태: 구현 기준 설계 +- 기준일: 2026-08-11 +- 대상 저장소: `backend-skeleton` +- 설계 경로: `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` +- 요구사항 원본: `붙여넣은 마크다운(1)(20260811-071252).md` + +--- + +## 1. 문서 목적 + +이 문서는 Java/Spring Backend Skeleton에서 사용할 JPA 관계형 영속성 플랫폼의 공개 계약, 모듈 경계, 트랜잭션 의미론, Hibernate·PostgreSQL 확장, Flyway 스키마 관리, 오류·Retry·관측성·보안·검증 기준을 구현 가능한 수준으로 확정한다. + +이 플랫폼은 `JpaRepository`를 다시 감싸는 CRUD 라이브러리가 아니다. 도메인 모듈이 Entity, Embeddable, Repository, 업무 Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유하고, 플랫폼은 다음 기술적 기반을 제공한다. + +```text +도메인 소유 +├─ Entity / Embeddable +├─ Repository Interface +├─ 도메인 Query +├─ 도메인 Constraint·Index 요구 +└─ 도메인 Lock·Soft-delete·Audit 정책 + +플랫폼 소유 +├─ Persistence Context·Transaction 정책 +├─ SQLSTATE 기반 오류 모델 +├─ 전체 Use Case Retry +├─ Fetch·Query·Pagination 검증 도구 +├─ Hibernate Batch·Statistics 확장 +├─ PostgreSQL Native Capability +├─ Flyway Migration·Schema Gate +├─ 관측성·보안 규칙 +└─ PostgreSQL 실제 계약 Testkit +``` + +구현자가 이 문서를 읽은 뒤 다시 결정하지 않아야 하는 핵심 질문은 다음과 같다. + +```text +어디에 Transaction을 시작하는가? +어떤 실패에서 전체 업무를 다시 실행할 수 있는가? +Commit 결과를 모르면 무엇을 하는가? +어떤 Fetch Plan을 선택하고 어떻게 N+1을 검증하는가? +어떤 Query는 JPQL이고 어떤 Query는 Native SQL인가? +Batch가 실제 JDBC Batch인지 어떻게 증명하는가? +Entity Mapping과 Schema 중 무엇이 Source of Truth인가? +PostgreSQL 고유 기능을 어디까지 공개하는가? +어떤 DB 계정이 어떤 권한을 갖는가? +어떤 PostgreSQL 버전에서 Stable을 선언하는가? +``` + +--- + +## 2. 목표와 성공 기준 + +### 2.1 목표 + +1. 도메인 Repository를 보존하면서 JPA·Hibernate·PostgreSQL 사용 규칙을 일관되게 제공한다. +2. Application Use Case 단위 Transaction과 전체 Transaction Retry를 구현한다. +3. Optimistic Conflict, Deadlock, Serialization Failure, Lock Timeout, Constraint Violation, Commit 결과 불명을 안정 오류로 변환한다. +4. OSIV, 전역 EAGER, 전역 Cascade, 전역 Soft Delete, 운영 `ddl-auto=update` 같은 위험한 기본값을 구조적으로 차단한다. +5. EntityGraph, Fetch Join, Projection, Batch Fetch, Keyset Pagination을 Use Case별 Fetch·Query 전략으로 제공한다. +6. JDBC Batch, Bulk DML, StatelessSession, PostgreSQL Native Write를 서로 다른 Capability로 제공한다. +7. Flyway를 운영 Schema 변경의 Source of Truth로 고정하고 빈 DB·이전 Release Snapshot·최장 지원 Snapshot 업그레이드를 검증한다. +8. H2가 아닌 PostgreSQL 16·17·18 실제 의미론으로 Stable을 인증한다. +9. Query Count, Entity/Collection Fetch, Row Load, Query Plan, Pool·Transaction·Retry를 관측한다. +10. 일반 애플리케이션이 Hibernate Session·Native SQL·운영 DDL을 무제한으로 사용하지 못하게 한다. + +### 2.2 성공 기준 + +| 영역 | 완료 기준 | +|---|---| +| Repository | 플랫폼에 `GenericRepository` 재구현이 없고 도메인 Repository가 Spring Data를 직접 확장할 수 있다. | +| Mapping | Field Access, protected no-arg constructor, Entity 직렬화 금지, association 규칙이 정적·통합 테스트로 검증된다. | +| Transaction | Application Service 경계, propagation, isolation, timeout, rollback rule이 계약 테스트로 고정된다. | +| Retry | 새 Persistence Context와 새 DB Transaction에서 전체 Use Case만 재실행된다. | +| Completion Unknown | Commit 단계 연결 손실이 일반 transient 오류와 분리되고 자동 Retry되지 않는다. | +| Fetch | N+1, Multiple Collection Cartesian Product, Collection Fetch Pagination을 정량 검증한다. | +| Pagination | Page·Slice·Keyset·Scroll의 사용 기준과 stable ordering이 코드로 제공된다. | +| Batch | SQL log가 아니라 Hibernate/JDBC 통계로 실제 batch 실행을 증명한다. | +| Migration | `Flyway migrate + Hibernate validate`, checksum·missing migration 실패, N-1/oldest snapshot 업그레이드가 CI에 연결된다. | +| PostgreSQL | JSONB·Array·Range·`ON CONFLICT`·`NOWAIT`·`SKIP LOCKED`가 PG16·17·18에서 검증된다. | +| Security | Runtime·Migration·Admin 역할이 분리되고 Runtime 역할의 DDL이 실패한다. | +| Observability | queryName 기반 저카디널리티 지표를 제공하고 SQL parameter·PII를 기록하지 않는다. | +| Release | Stable·Advanced·Experimental 경계가 문서, 의존성, CI lane에서 일치한다. | + +--- + +## 3. 입력 자료와 명시적 구현 가정 + +### 3.1 요구사항 원본이 확정한 사항 + +- Java 21을 Stable baseline으로 사용한다. +- Spring Boot BOM이 관리하는 Spring Data JPA·Hibernate·Flyway·Hikari 조합을 사용한다. +- Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4를 Stable 기준으로 삼는다. +- PostgreSQL 16·17·18을 Stable DB Matrix로 삼는다. +- H2는 Local Convenience이며 PostgreSQL 호환성 증거가 아니다. +- Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19는 별도 compatibility lane이다. +- J1 Standard, J2 Advanced, J3 Provider/DB Extension, J4 Admin/Operations 계층을 사용한다. +- 도메인이 Entity와 Repository를 소유하고 플랫폼은 Generic CRUD Repository를 만들지 않는다. +- Persistence Context는 transaction-scoped이며 OSIV를 명시적으로 비활성화한다. +- Transaction 경계는 Application Service에 둔다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 전체 Transaction 재실행이다. +- Commit 결과 불명은 `TransactionCompletionUnknown`으로 분류하고 자동 Retry하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 JDBC Batch 제약 때문에 제한한다. +- Fetch 전략은 Use Case별 Fetch Plan으로 관리한다. +- Hibernate 7.4의 Collection Fetch Join + Pagination은 과거 금지 규칙을 복사하지 않고 실제 SQL·row amplification을 검증한다. +- Flyway가 실제 Schema 변경의 Source of Truth이며 운영 `ddl-auto=update`를 금지한다. +- Application·Migration·Admin DB credential을 분리한다. +- Multi-tenancy와 Read Replica는 초기 Experimental이다. + +### 3.2 실제 저장소가 제공되지 않아 고정한 가정 + +| 항목 | 설계 가정 | +|---|---| +| 저장소 | Gradle Kotlin DSL 멀티모듈 `backend-skeleton` | +| 모듈 루트 | `modules/jpa` | +| Root package | `io.backend.skeleton.jpa` | +| Spring Boot | 4.1 계열 BOM. 정확한 patch는 host 저장소 version catalog가 소유한다. | +| Runtime DB | PostgreSQL 16 이상 | +| 기본 Provider | Hibernate ORM 7.4 | +| Migration | Flyway | +| Connection Pool | HikariCP | +| 테스트 | JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy | +| 관측성 | Micrometer, Spring Observation, OpenTelemetry exporter adapter | +| CI | PR: PG16·18, Release: PG16·17·18 | + +연구 자료가 범용 numeric timeout, pool size, batch size를 확정하지 않았으므로 플랫폼은 이를 보편 상수로 하드코딩하지 않는다. Production profile은 명시적 값을 요구하고, Testkit만 결정적인 fixture 값을 제공한다. + +### 3.3 우선순위 + +```text +사용자 지시 +→ 이 설계서의 명시적 계약 +→ 심층 리서치 원본 +→ host 저장소의 기존 convention +→ Spring Boot BOM 기본값 +``` + +기존 저장소 구조가 다르면 경로와 convention plugin 이름은 매핑할 수 있지만, 공개 계약과 불변 조건은 유지한다. + +--- + +## 4. 범위 + +### 4.1 Stable 범위 + +```text +Spring Data domain repository +Jakarta Persistence 3.2 +Hibernate ORM 7.4 +PostgreSQL 16·17·18 +REQUIRED transaction +READ COMMITTED 기본 isolation +read-only·timeout +Optimistic Lock +표준 Pessimistic Lock +Derived Query·JPQL·Projection +EntityGraph·Fetch Join +Page·Slice·Keyset +JDBC Batch +Flyway migrate·validate +SQLSTATE 기반 오류 +bounded full-transaction retry +OSIV off +L1 Persistence Context +Spring Data auditing opt-in +PG Testcontainers contract +``` + +### 4.2 Advanced opt-in 범위 + +```text +MANDATORY·REQUIRES_NEW +Specification·Querydsl +Query Hint·Scroll·Stream +NOWAIT·SKIP LOCKED +Batch Fetch·Subselect Fetch +Bulk DML +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +COPY 기반 대량 import +Envers +Hibernate L2 Cache +Concurrent Index migration +Query Plan regression +``` + +### 4.3 Experimental 범위 + +```text +Shared schema tenant column +PostgreSQL RLS +Schema-per-tenant +Database-per-tenant +Read Replica routing +Jakarta Persistence 4.0 +Hibernate ORM 8 +PostgreSQL 19 +``` + +Experimental 기능은 별도 모듈과 CI lane에서만 활성화하며 Stable Core의 공개 API를 변경하지 않는다. + +### 4.4 명시적 비지원 + +```text +GenericRepository CRUD 재구현 +Entity를 Web/API DTO로 직접 반환 +Extended Persistence Context 일반 사용 +OSIV +전역 EAGER +전역 Cascade.ALL +전역 implicit Soft Delete +운영 ddl-auto update/create/create-drop +Repository method 단위 부분 Retry +Commit 결과 불명 자동 Retry +Remote distributed transaction 기본화 +임의 XA 기본 지원 +무제한 findAll +자유로운 raw SQL +H2 결과로 PostgreSQL Stable 선언 +annotation 하나만으로 Read Replica 자동 routing +Hibernate Query Cache 기본 활성화 +``` + +### 4.5 Reactive 경계 + +JPA와 JDBC는 Blocking 기술이다. 이 플랫폼은 Reactor 타입을 공개 API에 넣지 않는다. WebFlux 애플리케이션이 JPA를 사용할 경우 애플리케이션 또는 별도 execution adapter가 bounded blocking executor로 격리해야 하며, Reactor event-loop에서 Repository를 호출하는 것은 금지한다. Reactive relational persistence가 필요하면 별도 R2DBC 모듈을 설계한다. + +--- + +## 5. 핵심 설계 원칙 + +1. **도메인 소유권 유지:** Entity·Embeddable·Repository·업무 Query·Index Requirement는 도메인이 소유한다. +2. **추상화 중복 금지:** Spring Data의 CRUD 추상화를 다시 감싸지 않는다. +3. **Use Case Transaction:** Transaction은 Application Use Case 단위다. +4. **전체 Transaction Retry:** Retry는 새 Persistence Context와 새 Transaction에서 전체 작업을 다시 실행한다. +5. **불명확성 보존:** Commit 결과를 모르면 성공 또는 실패로 추정하지 않는다. +6. **Fetch Plan 명시:** Mapping annotation 하나로 모든 Use Case의 Fetch를 결정하지 않는다. +7. **Schema Source of Truth 분리:** Entity Mapping은 객체-관계 매핑 계약이고 실제 Schema 변경은 Flyway가 소유한다. +8. **PostgreSQL 실제 검증:** H2나 mock으로 Lock·Constraint·SQLSTATE·Plan 의미론을 증명하지 않는다. +9. **Provider 차이 노출:** Hibernate·PostgreSQL 고유 기능은 J3 Extension으로 명시한다. +10. **위험 기능 opt-in:** REQUIRES_NEW, Native SQL, Bulk DML, StatelessSession, L2 Cache, Envers는 선택 모듈이다. +11. **정량 성능 검증:** Query 수뿐 아니라 rows, hydrated entity, collection fetch, batch, pool wait를 측정한다. +12. **권한 최소화:** Runtime 계정은 DML만, Migration·Admin 계정은 별도다. + +--- + +## 6. 전체 아키텍처 + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Custom Repository Contract +├─ Projection / Read Model +└─ Application Service @Transactional + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ JPA Persistence Platform │ +│ │ +│ J1 Standard │ +│ ├─ Spring Data integration │ +│ ├─ Transaction defaults │ +│ ├─ Stable error model │ +│ └─ Auditing opt-in │ +│ │ +│ J2 Advanced │ +│ ├─ Fetch / Query support │ +│ ├─ Keyset / Scroll │ +│ ├─ Full-TX retry │ +│ ├─ Batch / Bulk │ +│ └─ Pessimistic lock │ +│ │ +│ J3 Provider / DB Extension │ +│ ├─ Hibernate Session / Statistics │ +│ ├─ StatelessSession │ +│ ├─ PostgreSQL types │ +│ ├─ ON CONFLICT / RETURNING │ +│ └─ NOWAIT / SKIP LOCKED / COPY │ +│ │ +│ J4 Admin / Operations │ +│ ├─ Flyway │ +│ ├─ Index / Backfill │ +│ ├─ Plan regression │ +│ └─ Role / Schema validation │ +└───────────────────────┬──────────────────────────┘ + │ + ▼ + PostgreSQL 16~18 +``` + +### 6.1 일반 Write 흐름 + +```text +Controller +→ Application Service +→ @Transactional 시작 +→ Domain Repository +→ Entity persist/update +→ flush +→ DB constraint/lock 검증 +→ commit +→ 결과 반환 +``` + +외부 HTTP, Object Storage, Messaging 호출은 DB Transaction 밖으로 이동한다. DB 변경과 메시지 발행은 기존 Messaging Platform의 Transactional Outbox를 사용한다. + +### 6.2 Retry 흐름 + +```text +Application Use Case +→ Attempt 1: 새 EntityManager + 새 Transaction +→ OptimisticConflict / Deadlock / SerializationFailure +→ Retry Policy 분류 +→ bounded backoff + jitter +→ Attempt 2: 새 EntityManager + 새 Transaction +→ commit +``` + +부분 SQL만 다시 실행하거나 동일 Persistence Context를 재사용하지 않는다. + +### 6.3 Completion Unknown 흐름 + +```text +Application +→ COMMIT 전송 +→ PostgreSQL commit 가능 +→ 응답 전에 connection loss +→ EvidenceAwareJpaTransactionManager +→ TransactionCompletionUnknown +→ 자동 Retry 금지 +→ transactionKey / unique key / outbox / 상태 조회 +→ domain-specific reconciliation +``` + +### 6.4 Read 흐름 + +```text +Application Query +→ QueryName +→ Projection / EntityGraph / Fetch Join / Native Query +→ QueryObservation +→ Statement + Hibernate statistics +→ DTO / Projection 반환 +``` + +Entity를 Controller에 반환하지 않는다. + +--- + +## 7. 모듈 구조 + +```text +backend-skeleton/ +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── modules/jpa-experimental/ +│ ├── jpa-multitenancy-column/ +│ ├── jpa-multitenancy-rls/ +│ ├── jpa-multitenancy-schema/ +│ ├── jpa-multitenancy-database/ +│ ├── jpa-read-replica/ +│ └── jpa-next-compatibility/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── toxiproxy/ +│ └── roles/ +└── docs/jpa/ + ├── entity-mapping-guide.md + ├── transaction-guide.md + ├── query-fetch-guide.md + ├── migration-guide.md + ├── postgresql-extensions.md + ├── observability.md + ├── security.md + ├── support-matrix.md + └── runbooks.md +``` + +### 7.1 모듈 책임 + +| 모듈 | 책임 | +|---|---| +| `jpa-core-api` | Spring/JPA 비종속 안정 오류·Transaction Profile·Query Name·Capability 계약 | +| `jpa-transaction` | Spring Transaction Adapter, full-TX retry, completion evidence | +| `jpa-spring-data` | Custom Fragment 기반 지원, Safe Sort, Projection·EntityGraph helper | +| `jpa-querydsl` | 선택 Querydsl integration | +| `jpa-hibernate` | Statistics, Fetch·Batch·Bulk·StatelessSession extension | +| `jpa-postgresql` | SQLSTATE, JSONB·Array·Range, native write, lock extension | +| `jpa-postgresql-copy` | J4 대량 import/backfill COPY | +| `jpa-migration-flyway` | Migration policy, validate, snapshot upgrade gate | +| `jpa-auditing` | Spring Data auditing opt-in | +| `jpa-envers` | Entity history opt-in | +| `jpa-cache-hibernate` | Hibernate L2 Cache opt-in; Query Cache 기본 비활성 | +| `jpa-observability` | queryName·transaction·retry·Hibernate statistics 관측 | +| `jpa-security` | ArchUnit rule, DB role/search_path validation, log redaction | +| `jpa-spring-boot-starter` | AutoConfiguration·Properties·Actuator·startup guard | +| `jpa-testkit*` | PostgreSQL·Migration·Query Plan·Concurrency 계약 테스트 | + +### 7.2 의존 방향 + +```text +jpa-core-api +↑ +├─ jpa-transaction +├─ jpa-spring-data +├─ jpa-hibernate +├─ jpa-postgresql +├─ jpa-migration-flyway +├─ jpa-auditing +├─ jpa-observability +└─ jpa-security + +jpa-spring-boot-starter +→ 위 Stable 모듈 조합 + +jpa-testkit* +→ 테스트 대상 모듈 +``` + +`jpa-core-api`는 `jakarta.persistence`, Spring, Hibernate, PostgreSQL JDBC, Flyway에 의존하지 않는다. + +### 7.3 ArchUnit 경계 + +```text +jpa-core-api → provider/framework dependency 금지 +platform → domain Entity 정의 금지 +domain → org.hibernate 직접 의존 금지 +web/controller → @Entity 반환 금지 +@Entity → web DTO annotation 금지 +application → J4 admin package 접근 금지 +``` + +--- + +## 8. 공개 계층 J1~J4 + +### 8.1 J1 Standard Persistence + +일반 애플리케이션이 기본으로 사용한다. + +```text +Spring Data Repository +Derived Query +JPQL +DTO / Interface Projection +Application Service @Transactional +@Version Optimistic Lock +Spring Data Auditing opt-in +Page / Slice +``` + +도메인 Repository 예시: + +```java +public interface OrderRepository + extends JpaRepository, OrderRepositoryCustom { + + Optional findByOrderNumber(OrderNumber orderNumber); +} + +public interface OrderRepositoryCustom { + KeysetSlice findRecent( + OrderSearchCondition condition, + KeysetPageRequest page); +} +``` + +### 8.2 J2 Advanced Persistence + +```text +Specification +Querydsl +EntityGraph +Query Hint +Pessimistic Lock +Keyset / Scroll / Stream +JDBC Batch +Bulk DML +Full Transaction Retry +``` + +J2 사용은 명시적 모듈 의존성과 Query Name 등록을 요구한다. + +### 8.3 J3 Provider / Database Extension + +```text +Hibernate Session +Hibernate Fetch Profile +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +NOWAIT·SKIP LOCKED +Native SQL +``` + +J3 API는 `io.backend.skeleton.jpa.postgresql` 또는 `io.backend.skeleton.jpa.hibernate` package에 격리하고 application service가 provider type을 직접 받지 않게 한다. + +### 8.4 J4 Admin / Operations + +```text +Flyway migrate·validate·repair 승인 +Concurrent Index +Backfill +COPY +Partition +Maintenance SQL +Schema Drift +Plan Regression +Role Verification +``` + +J4는 일반 Runtime credential로 실행하지 않는다. `repair`, purge, destructive migration은 operation ID, operator, reason, dry-run 또는 승인 절차를 요구한다. + +--- + +## 9. Core 공개 계약 + +### 9.1 Operation Name + +```java +public record PersistenceOperationName(String value) { + public PersistenceOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +Operation Name은 metric·trace·retry policy의 bounded key이다. 동적 SQL이나 Entity ID를 넣지 않는다. + +### 9.2 Transaction Profile + +```java +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { +} + +public enum PropagationMode { + REQUIRED, + MANDATORY, + REQUIRES_NEW +} + +public enum IsolationLevel { + DEFAULT, + READ_COMMITTED, + REPEATABLE_READ, + SERIALIZABLE +} +``` + +Stable 기본은 `REQUIRED + READ_COMMITTED`. `REQUIRES_NEW`는 별도 opt-in profile과 pool pressure test를 요구한다. + +### 9.3 Transaction Executor + +```java +public interface JpaTransactionExecutor { + T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work); +} +``` + +일반 Use Case는 `@Transactional`을 사용할 수 있다. Programmatic retry·동적 profile이 필요한 Use Case는 executor를 사용한다. + +### 9.4 Retry Policy + +```java +public interface JpaRetryPolicy { + RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt); +} + +public record RetryDecision( + RetryDisposition disposition, + Duration delay, + String reason) { +} + +public enum RetryDisposition { + RETRY_FULL_TRANSACTION, + RECONCILE, + FAIL +} +``` + +### 9.5 Query Observation + +```java +public interface QueryObservation { + QueryScope start(QueryName queryName); +} + +public interface QueryScope extends AutoCloseable { + void rows(long count); + void failure(Throwable failure); + @Override void close(); +} +``` + +--- + +## 10. Entity 소유권과 Mapping 규칙 + +### 10.1 소유권 + +플랫폼은 업무 Entity를 정의하지 않는다. 도메인 모듈이 다음을 소유한다. + +```text +@Table 이름 +@Column 의미 +PK·FK·Unique·Check 요구 +Association +Cascade +Soft Delete +Audit +Index Requirement +Lock 정책 +``` + +플랫폼은 규칙, annotation helper, test fixture, static check만 제공한다. + +### 10.2 기본 규칙 + +| 항목 | 기본 계약 | +|---|---| +| Access | Field Access | +| Constructor | `protected` no-arg | +| Entity class | non-final | +| Persistent field | proxy 호환성을 해치지 않게 설계 | +| API 반환 | Entity 금지, DTO·Projection 사용 | +| `toString` | LAZY association 제외 | +| equals/hashCode | mutable association·mutable business field 제외 | +| Callback | 외부 HTTP·Messaging·File I/O 금지 | +| BaseEntity | 전역 강제 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | opt-in | + +### 10.3 equals/hashCode + +ID가 DB 생성이면 transient 상태에서 ID가 없음을 고려한다. mutable generated ID를 hash-based collection에 넣은 뒤 hashCode가 바뀌는 설계를 피한다. 권장 패턴은 domain-assigned immutable ID 또는 class + stable immutable key를 사용하되 각 Aggregate가 계약을 명시하는 것이다. + +### 10.4 Entity 외부 노출 금지 + +다음은 금지한다. + +```text +Controller method 반환형이 @Entity +Entity에 Jackson API contract annotation 사용 +Lazy collection을 JSON serializer가 탐색 +Entity를 Message payload로 직접 사용 +Entity를 Redis value로 직접 Java serialize +``` + +--- + +## 11. ID 생성 전략 + +### 11.1 기본 선택 + +| 전략 | 등급 | 계약 | +|---|---|---| +| PostgreSQL Sequence | Stable 기본 | write-heavy Entity, JDBC Batch와 호환 | +| JPA UUID | Stable | 분산 ID, insert 전 identity 확보 | +| Application-assigned UUID/UUIDv7 | Stable | PG16~18 공통 방식 | +| PostgreSQL 18 `uuidv7()` | J3 PG18 전용 | Stable Matrix 공통 기본으로 사용하지 않음 | +| IDENTITY | 제한 | insert batching 제약; 소규모 write만 | +| Composite ID | Domain-specific | 실제 composite identity일 때만 | +| Natural ID | 별도 unique index | PK와 혼동하지 않음 | + +### 11.2 Sequence 규칙 + +```java +@SequenceGenerator( + name = "order_seq", + sequenceName = "order_seq", + allocationSize = 50 +) +@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq") +``` + +`allocationSize=50`은 universal constant가 아니라 reference profile이다. 실제 workload benchmark와 sequence increment가 일치해야 하며 플랫폼은 mismatch를 테스트한다. + +### 11.3 UUIDv7 + +PG16·17·18 공통 지원을 위해 application-generated UUIDv7을 기본 extension으로 제공할 수 있다. DB-generated PG18 UUIDv7은 별도 Capability로 노출한다. + +--- + +## 12. Value Mapping + +| 타입 | 기본 계약 | +|---|---| +| `Instant` | 서버 간 절대 시점 | +| `OffsetDateTime` | offset 자체가 업무 의미일 때 | +| `LocalDate` | 날짜 | +| `LocalDateTime` | timezone 없는 업무 시간에만 | +| `Duration` | converter/provider mapping contract test | +| `UUID` | Stable | +| Enum | STRING 또는 명시적 converter; ordinal 금지 | +| Money | Embeddable value object | +| Record Embeddable | JPA 3.2 Stable, provider round-trip test 필수 | +| JSONB·Array·Range·INET | `jpa-postgresql` | +| LOB | 일반 목록 fetch에서 제한 | +| 암호화 값 | key rotation·queryability 포함 별도 capability | + +### 12.1 Converter 규칙 + +- Converter는 null, unknown version, malformed value를 명확히 처리한다. +- Java class name을 wire/schema 값으로 저장하지 않는다. +- Enum rename은 DB migration 없이 수행하지 않는다. +- `AttributeConverter` 내부에서 외부 I/O를 수행하지 않는다. + +--- + +## 13. Association·Cascade·Collection + +### 13.1 ToOne + +- 기본적으로 명시적 LAZY를 검토한다. +- 실제 lazy proxy 동작을 Hibernate contract test로 보증한다. +- FK nullable과 `optional`을 일치시킨다. +- 목록 조회에서 필요한 ToOne은 EntityGraph·Fetch Join·Projection으로 가져온다. + +### 13.2 ToMany + +- LAZY가 기본이다. +- `List`, `Set`, `Map` 선택은 중복·순서 의미를 반영한다. +- `List` 두 개를 동시에 join fetch하는 설계를 피한다. +- collection 전체를 항상 필요한 aggregate가 아니면 Projection 또는 별도 Query를 사용한다. + +### 13.3 Cascade + +```text +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ Parent가 Child lifecycle을 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 Join Entity 우선 +``` + +### 13.4 양방향 관계 + +Owning side가 DB 변경을 결정한다. `addChild/removeChild` helper가 양쪽 in-memory graph를 항상 동기화해야 한다. + +--- + +## 14. Persistence Context 계약 + +```text +Transient +Managed +Detached +Removed +``` + +### 14.1 기본 계약 + +```text +persist != merge +find != getReference +save != immediate INSERT +flush != commit +Entity mutation != immediate UPDATE +``` + +### 14.2 Scope + +- transaction-scoped Persistence Context만 Stable이다. +- Extended Persistence Context는 지원하지 않는다. +- EntityManager는 thread-safe로 취급하지 않는다. +- OSIV는 false다. +- Lazy association 접근은 Application Transaction 내부에서만 허용한다. + +### 14.3 Flush + +- Query 전에 AUTO flush가 발생할 수 있다. +- 명시적 flush는 SQL 동기화 지점이지 commit 증거가 아니다. +- Batch는 chunk마다 flush·clear한다. +- Bulk DML 전 flush, 후 clear 또는 refresh한다. + +### 14.4 Merge + +`merge()` 반환값이 managed instance다. 전달한 detached instance가 managed로 변한다고 가정하지 않는다. 신규 Entity 판정과 ID strategy를 이해하지 못한 무분별한 `save()` 사용을 코드리뷰 규칙으로 제한한다. + +--- + +## 15. Transaction 경계 + +### 15.1 기본 경계 + +```text +Controller +→ Application Service @Transactional +→ Domain Repository +``` + +Repository가 독립 업무 Transaction을 임의로 시작하지 않는다. + +### 15.2 금지 경계 + +```text +Controller 전체 요청 Transaction +Entity Listener가 새 Transaction 시작 +동일 Bean self-invocation으로 Propagation 기대 +DB Transaction 안에서 장시간 HTTP/Object Storage/Messaging 대기 +``` + +### 15.3 Rollback Rule + +RuntimeException·Error 기본 rollback을 사용한다. Checked exception rollback이 필요하면 안정 application exception hierarchy 또는 `rollbackFor`를 명시한다. + +### 15.4 Timeout + +모든 write Transaction profile은 유한 timeout을 요구한다. read-only query도 long-running admin query가 아니라면 timeout을 지정한다. 숫자는 환경 SLO가 소유한다. + +--- + +## 16. Propagation·Isolation + +### 16.1 Propagation + +| Mode | 등급 | 규칙 | +|---|---|---| +| REQUIRED | Stable 기본 | Use Case Transaction | +| MANDATORY | Advanced | 상위 Transaction 필수 내부 write service | +| SUPPORTS | 제한 | read helper | +| REQUIRES_NEW | Advanced 위험 | 별도 physical connection, pool capacity test 필수 | +| NESTED | J3/JDBC savepoint | portable JPA로 광고하지 않음 | +| NOT_SUPPORTED | Advanced | 긴 외부 I/O 분리 등에 제한 | + +### 16.2 Isolation + +| Isolation | 기본 사용 | +|---|---| +| READ COMMITTED | 일반 업무 기본 | +| REPEATABLE READ | transaction snapshot 일관성 필요 시 | +| SERIALIZABLE | 좁은 핵심 invariant, abort/retry 전제 | +| READ UNCOMMITTED | PostgreSQL profile에서 공개하지 않음 | + +### 16.3 Self-invocation + +`this.method()` 호출은 Spring transaction proxy를 통과하지 않는다. Retry·REQUIRES_NEW method는 별도 Bean의 public method 또는 programmatic executor로 구성한다. + +--- + +## 17. Commit 결과 불명확성 + +### 17.1 상태 + +```java +public enum TransactionCompletionEvidence { + NOT_STARTED, + ACTIVE, + COMMITTING, + COMMITTED, + ROLLED_BACK, + UNKNOWN +} +``` + +### 17.2 감지 + +`EvidenceAwareJpaTransactionManager`가 `doCommit` 진입 전 evidence를 `COMMITTING`으로 기록한다. 다음 조건에서 `TransactionCompletionUnknownException`으로 변환한다. + +```text +SQLSTATE 40003 +OR +commit phase의 connection loss / transport exception +AND +rollback 또는 commit 여부를 driver가 확정하지 못함 +``` + +일반 query 단계 connection failure를 completion unknown으로 과대 분류하지 않는다. + +### 17.3 오류 계약 + +```java +public final class TransactionCompletionUnknownException + extends JpaPersistenceException { + + private final String transactionKey; + private final TransactionCompletionEvidence evidence; +} +``` + +### 17.4 복구 + +```text +자동 Retry 금지 +→ transactionKey로 상태 조회 +→ Unique Constraint / Idempotency Record 확인 +→ 업무 Row 확인 +→ Outbox 확인 +→ 결과 확정 불가 시 Reconciliation Queue +``` + +`TransactionCompletionResolver`는 domain-specific SPI이며 Core가 업무 성공을 추측하지 않는다. + +--- + +## 18. 안정 오류 모델과 SQLSTATE + +```text +JpaPersistenceException +├─ JpaEntityNotFoundException +├─ OptimisticConflictException +├─ PessimisticLockTimeoutException +├─ DeadlockDetectedException +├─ SerializationFailureException +├─ UniqueConstraintViolationException +├─ ForeignKeyViolationException +├─ CheckConstraintViolationException +├─ QueryTimeoutException +├─ TransactionTimeoutException +├─ ConnectionUnavailableException +├─ SchemaMismatchException +├─ DataCorruptionException +└─ TransactionCompletionUnknownException +``` + +### 18.1 공통 Metadata + +```java +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + String constraintName, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { +} +``` + +SQL parameter, Entity ID, Tenant ID, 전체 SQL 원문, PII는 exception message에 넣지 않는다. + +### 18.2 SQLSTATE 분류 + +| 분류 | 대표 코드 | +|---|---| +| Serialization Failure | `40001` | +| Statement Completion Unknown | `40003` | +| Deadlock | `40P01` | +| Unique Violation | `23505` | +| Foreign Key Violation | `23503` | +| Check Violation | `23514` | +| Not Null Violation | `23502` | +| Lock Not Available | `55P03` | + +문자열 오류 메시지를 parsing하지 않고 SQLSTATE와 structured server error field를 사용한다. + +--- + +## 19. Retry 정책 + +### 19.1 Retry 대상 + +| 오류 | 기본 | +|---|---| +| Optimistic Conflict | 조건부 전체 Transaction Retry | +| Serialization Failure | bounded 전체 Transaction Retry | +| Deadlock | bounded 전체 Transaction Retry | +| Lock Timeout | deadline·업무 정책에 따라 | +| Transaction 시작 전 Connection 실패 | 제한적 Retry | +| Unique Violation | 기본 Retry 금지; idempotent create면 기존 결과 조회 | +| FK·Check Violation | Retry 금지 | +| Query Timeout | 기본 Retry 금지 | +| Schema Mismatch | Retry 금지 | +| Completion Unknown | 자동 Retry 금지, reconcile | + +### 19.2 안전 조건 + +```text +전체 Use Case가 재계산 가능 +AND +외부 irreversible side effect 없음 +AND +새 Persistence Context 생성 +AND +새 Transaction 생성 +AND +deadline 남음 +AND +retry budget 남음 +``` + +### 19.3 Retry Profile + +```java +public record RetryProfile( + String name, + int maxAttempts, + Duration initialBackoff, + Duration maxBackoff, + double multiplier, + JitterMode jitter, + Set retryableFailures) { +} +``` + +### 19.4 Annotation Adapter + +```java +@RetryableJpaTransaction(profile = "order-write") +@Transactional +public OrderId place(PlaceOrder command) { ... } +``` + +Retry interceptor는 Transaction interceptor보다 바깥에서 실행되어 각 attempt가 새 transaction을 생성해야 한다. 같은 클래스 self-invocation은 지원하지 않는다. + +--- + +## 20. Optimistic Lock + +- mutable aggregate에는 `@Version` 사용을 기본 검토한다. +- version은 API update command에 전달하거나 서버가 re-read 후 검증한다. +- Conflict는 flush 또는 commit 시점에 나타날 수 있다. +- Bulk DML은 version을 자동 검증하지 않는다. +- 일부 Repository method만 Retry하지 않는다. + +```java +@Entity +public class Order { + @Version + private long version; +} +``` + +Retry 후에는 최신 Entity를 다시 조회하고 업무 규칙을 다시 계산한다. + +--- + +## 21. Pessimistic Lock·PostgreSQL Lock Extension + +### 21.1 표준 Lock + +```text +PESSIMISTIC_READ +PESSIMISTIC_WRITE +PESSIMISTIC_FORCE_INCREMENT +``` + +Transaction timeout, lock timeout, deadlock을 구분한다. + +### 21.2 NOWAIT + +대기 없이 즉시 실패해야 하는 use case에서 J3 Native Query로 제공한다. 일반 Repository API에 전역 옵션으로 넣지 않는다. + +### 21.3 `FOR UPDATE SKIP LOCKED` + +일반 일관된 조회가 아니라 work queue claim에만 제공한다. + +```java +public interface WorkClaimExecutor { + List claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +### 21.4 Lock Ordering + +여러 Row를 잠글 때 stable key order를 사용한다. deadlock fixture로 규칙을 검증한다. + +--- + +## 22. Constraint와 경쟁 조건 + +### 22.1 최종 불변식 + +```text +Bean Validation +→ 조기 사용자 오류 + +Database Constraint +→ concurrency에서도 지켜지는 최종 invariant +``` + +### 22.2 지원 + +```text +PRIMARY KEY +FOREIGN KEY +NOT NULL +UNIQUE +CHECK +EXCLUSION +Partial Unique Index +NULLS NOT DISTINCT +``` + +### 22.3 Exists-before-insert + +`exists()`는 UX 검증일 뿐 경쟁을 차단하지 않는다. Unique Constraint 위반을 안정 오류로 변환한다. + +### 22.4 Constraint Catalog + +Constraint name을 bounded registry에 등록해 `user-email-active-unique` 같은 안정 code로 변환한다. raw table·column·value는 외부 오류에 노출하지 않는다. + +--- + +## 23. Repository와 Query 선택 + +### 23.1 Query 등급 + +| 등급 | 방식 | +|---|---| +| Q1 | Derived Query, JPQL, DTO/Interface Projection | +| Q2 | Specification, Criteria, Querydsl, EntityGraph | +| Q3 | Native SQL, Hibernate Query API, PostgreSQL CTE·Window·JSONB | +| Q4 | Backfill, Maintenance, Bulk/Admin SQL | + +### 23.2 선택 규칙 + +- Derived method가 업무 의미보다 SQL 구조를 설명하기 시작하면 Custom Query로 승격한다. +- 고정 query는 JPQL과 DTO Projection을 우선한다. +- optional filter 조합은 Specification 또는 Querydsl을 사용한다. +- PostgreSQL plan·syntax 제어가 필요하면 J3 Native Query를 사용한다. +- 모든 nontrivial query에는 `QueryName`을 등록한다. + +### 23.3 Custom Fragment + +플랫폼은 `BaseRepository`를 강제하지 않는다. 도메인이 `OrderRepositoryCustom`을 정의하고 구현에서 플랫폼 helper를 사용한다. + +### 23.4 Dynamic Sort + +사용자 문자열을 `JpaSort.unsafe()`에 연결하지 않는다. `SafeSortRegistry`가 허용된 field enum을 실제 JPA path로 변환한다. + +--- + +## 24. Projection + +### 24.1 DTO Projection + +목록·read model의 기본 후보다. Entity 전체 hydration과 Lazy association을 줄인다. + +### 24.2 Interface Projection + +간단한 projection에 사용하되 nested association이 추가 query를 유발하는지 검증한다. + +### 24.3 Dynamic Projection + +public API에서 임의 class를 입력받지 않는다. 등록된 projection catalog만 사용한다. + +### 24.4 Entity 직접 반환 + +Application 내부 aggregate mutation use case에만 Entity를 사용하고 Web/API boundary에서는 DTO로 변환한다. + +--- + +## 25. Fetch Plan과 N+1 + +### 25.1 전략 + +```text +Mapping +→ 최소 graph + +Use Case Query +→ EntityGraph / Fetch Join / Projection / Batch Fetch +``` + +### 25.2 선택표 + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join / EntityGraph | +| 하나의 bounded ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 분할 Query / Batch Fetch | +| 목록 화면 | DTO Projection | +| 대규모 read model | Native Projection | +| 반복 LAZY N+1 | explicit fetch plan 또는 batch fetch | + +### 25.3 정량 지표 + +```text +statementCount +entityLoadCount +entityFetchCount +collectionLoadCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +executionTime +``` + +### 25.4 Fixture + +```text +0 child +1 child +10~100 children +shared ToOne +multiple collections +Zipf skew +``` + +SQL 1개라는 이유만으로 좋은 Query로 판정하지 않는다. + +--- + +## 26. Hibernate 7.4 Collection Fetch Pagination + +과거 Hibernate의 collection fetch join + pagination 전체 로드 문제를 영구 금지 규칙으로 복사하지 않는다. Stable baseline인 Hibernate 7.4 + PostgreSQL 16~18에서 다음을 검증한다. + +```text +generated SQL에 DB limit/subquery가 적용되는가 +반환 parent 수가 정확한가 +hydrated row 수가 허용 범위인가 +count query가 정확한가 +여러 collection Cartesian amplification이 없는가 +``` + +`hibernate.query.fail_on_pagination_over_collection_fetch`는 호환성 lane에서 회귀 감지를 위해 사용하되, 7.4 지원 경로를 무조건 차단하지 않는다. + +--- + +## 27. Pagination·Cursor·Scroll + +### 27.1 사용 기준 + +| 방식 | 용도 | +|---|---| +| Page | 작은 관리자 목록, total count 필요 | +| Slice | count 불필요 일반 목록 | +| Offset | 작은 데이터·얕은 page | +| Keyset/Cursor | 대규모·시간순 목록 | +| Scroll/Stream | batch/read processing | + +### 27.2 Keyset 계약 + +```java +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { +} + +public record KeysetSlice( + List items, + Optional nextCursor, + boolean hasNext) { +} +``` + +정렬이 `created_at DESC, id DESC`이면 Cursor도 두 값을 모두 포함한다. + +### 27.3 Cursor 보안 + +Cursor는 versioned JSON을 Base64URL로 encoding하고 HMAC signature를 선택적으로 제공한다. raw SQL fragment를 포함하지 않는다. + +### 27.4 Stream + +Stream은 transaction과 ResultSet 수명을 가진다. try-with-resources와 fetch size를 강제하고 Web/API에 그대로 반환하지 않는다. + +--- + +## 28. JDBC Batch + +### 28.1 의미 + +```text +saveAll != one SQL +JDBC Batch != one SQL +IDENTITY != batch-friendly +``` + +### 28.2 Profile + +```yaml +backend: + jpa: + batch-profiles: + order-import: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +숫자는 profile이 소유한다. Platform은 batch size와 flush/clear invariant를 검증한다. + +### 28.3 Verification + +Hibernate statistics와 datasource proxy를 통해 실제 `executeBatch` 횟수와 statement 수를 확인한다. + +--- + +## 29. Bulk DML + +### 29.1 계약 + +```text +flush +→ JPQL / Native Bulk DML +→ clear +→ 필요 시 재조회 +``` + +### 29.2 제한 + +- Bulk DML은 Entity callback과 optimistic version check를 자동 실행하지 않는다. +- 도메인 invariant를 우회할 수 있으므로 Q4 또는 명시적 J2 API에서만 사용한다. +- 영향 Row 수를 반환하고 예상 범위를 검증한다. + +```java +public interface BulkDmlExecutor { + int execute(BulkOperationName operation, Runnable bulkStatement); +} +``` + +--- + +## 30. StatelessSession·COPY + +### 30.1 StatelessSession + +Persistence Context·dirty checking이 없는 Hibernate extension이다. 일반 Repository를 대체하지 않고 대량 import/backfill에만 사용한다. + +### 30.2 PostgreSQL COPY + +`jpa-postgresql-copy`는 JDBC connection을 명시적으로 unwrap해 COPY를 실행한다. J4 credential·operation name·row/byte cap·transaction policy를 요구한다. + +### 30.3 선택표 + +```text +일반 업무 write → JPA Entity +수천~수만 rows → JPA JDBC Batch +대규모 import/backfill → StatelessSession / COPY +``` + +--- + +## 31. PostgreSQL Extension + +### 31.1 Stable J3 + +```text +JSONB +Array +Range +UUID +ON CONFLICT +RETURNING +NOWAIT +SKIP LOCKED Work Claim +Window Function +``` + +### 31.2 Advanced + +```text +INET +Native Enum +CTE / Recursive CTE +Advisory Lock +Generated Column +Full-text Search +``` + +### 31.3 Admin + +```text +Partial / Expression / INCLUDE Index +Partition +RLS Policy +Extension 설치 +``` + +### 31.4 Native SQL 제한 + +- 등록된 Query Name 필수 +- 값은 parameter binding +- 동적 table/column 문자열 금지 +- row mapping 명시 +- PG16·17·18 Contract Test 필수 + +--- + +## 32. ON CONFLICT·RETURNING + +Upsert 의미를 단순 `save()`로 숨기지 않는다. + +```java +public interface PostgreSqlUpsertExecutor { + R execute( + NativeWriteName operation, + C command, + UpsertConflictTarget target); +} +``` + +Conflict target, update columns, version semantics, returned columns을 호출 계약으로 고정한다. 동일 업무에 JPA Entity update와 Native Upsert를 섞을 때 Persistence Context를 clear하거나 해당 Entity를 다시 조회한다. + +--- + +## 33. Flyway와 Schema Source of Truth + +### 33.1 환경 정책 + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | validate | +| H2 convenience | 선택 create/drop | 호환성 증거 아님 | +| test | migrate | validate | +| dev | migrate | validate | +| staging | deployment migration | validate | +| prod | 별도 migration role/process | validate | + +### 33.2 금지 + +```text +prod ddl-auto update/create/create-drop +runtime credential DDL +적용 완료 Versioned Migration 수정 +startup auto repair +``` + +### 33.3 Validation + +```text +checksum mismatch → fail +missing migration → fail +schema mismatch → fail +unsupported DB version → fail +``` + +### 33.4 Repair + +Flyway repair는 J4 승인 operation이다. 자동 실행하지 않고 operator, reason, before/after report를 남긴다. + +--- + +## 34. 무중단 Migration + +```text +Expand +→ 새 nullable column/table/index + +Migrate +→ chunked backfill / dual read·write + +Contract +→ old column/index 제거, constraint 강화 +``` + +### 34.1 Concurrent Index + +PostgreSQL `CREATE INDEX CONCURRENTLY`는 transaction block 밖에서 실행해야 하므로 non-transactional Flyway migration으로 명시한다. 실패한 invalid index 정리 runbook을 제공한다. + +### 34.2 Snapshot Gate + +```text +empty → latest +N-1 release → latest +oldest supported snapshot → latest +checksum modified → validation failure +missing migration → validation failure +failed non-transactional migration → documented recovery +``` + +--- + +## 35. Constraint·Index·Query Plan + +### 35.1 Index Requirement + +각 도메인 Query는 다음 문서를 소유한다. + +```text +queryName +predicate +sort +expected cardinality +data distribution +required index +representative parameters +expected plan shape +``` + +### 35.2 Query Plan Testkit + +`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`을 Test/Admin 계정으로 실행한다. 모든 Seq Scan을 실패시키지 않고 기대 node, row estimate ratio, sort spill, execution time budget을 Query별로 검증한다. + +### 35.3 Plan Snapshot + +PostgreSQL minor version과 statistics에 따라 plan이 달라질 수 있으므로 raw JSON 전체 byte snapshot보다 normalized structural expectation을 사용한다. + +--- + +## 36. Auditing·History·Soft Delete + +### 36.1 Auditing + +`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`를 opt-in Embeddable 또는 annotation set으로 제공한다. 전역 BaseEntity 상속을 강제하지 않는다. + +### 36.2 구분 + +```text +Technical Auditing != Business Audit != Entity History != Security Audit +``` + +### 36.3 Envers + +별도 모듈이며 Entity별 opt-in이다. 대용량 audit table, relation revision, 개인정보 보존 정책을 검토한 뒤 활성화한다. + +### 36.4 Soft Delete + +전역 filter를 제공하지 않는다. 도메인 상태 또는 `deletedAt`을 명시하고 필요하면 Flyway partial unique index를 사용한다. 물리 삭제·개인정보 파기와 복구 가능한 삭제를 구분한다. + +--- + +## 37. Cache + +### 37.1 기본 + +```text +L1 Persistence Context → 항상 +L2 Cache → Entity별 opt-in +Query Cache → OFF +Application Cache → Redis 플랫폼 +``` + +### 37.2 L2 Gate + +- `ENABLE_SELECTIVE` +- Cache Region 명시 +- 외부 DB writer가 있을 때 invalidation 정책 +- Bulk DML 후 eviction +- cluster node 일관성 +- hit/miss/stale metric + +Redis application cache와 Hibernate L2 Cache는 같은 기능으로 취급하지 않는다. + +--- + +## 38. Multi-tenancy·Replica Experimental + +### 38.1 Multi-tenancy + +```text +Shared schema + tenant column +PostgreSQL RLS +Schema per tenant +Database per tenant +``` + +Stable Core는 tenant context를 강제하지 않는다. Experimental module이 query, connection, cache, async propagation, admin cross-tenant access를 별도 검증한다. + +### 38.2 Read Replica + +`readOnly=true`만으로 routing하지 않는다. Read-after-write, replica lag, transaction pinning, lock query primary 강제, consistency token을 설계한 뒤 별도 module에서 제공한다. + +--- + +## 39. Connection Pool과 Hikari + +### 39.1 관측 + +```text +active +idle +pending +max +acquire duration +timeout +connection lifetime +transaction duration +``` + +### 39.2 규칙 + +- pool size를 무작정 크게 하지 않는다. +- `REQUIRES_NEW`는 outer + inner connection을 동시에 요구할 수 있다. +- long transaction과 external I/O를 제거한다. +- pending/acquire latency가 alert의 핵심이다. +- DB max connections와 인스턴스 수를 함께 계산한다. + +### 39.3 Startup Validation + +Production profile은 maximumPoolSize, connectionTimeout, maxLifetime 등의 명시 여부를 검사할 수 있다. Universal numeric default를 플랫폼 상수로 고정하지 않는다. + +--- + +## 40. Observability + +### 40.1 Metric + +```text +jdbc.connections.* +hikaricp.* +jpa.transaction.count +duration +rollback +timeout +retry +completion-unknown +jpa.query.count +duration +rows +lock-wait +jpa.fetch.entity +collection +jpa.batch.execute +jpa.constraint.failure +jpa.migration.duration +``` + +### 40.2 Low-cardinality Tag + +허용: + +```text +persistenceUnit +operationName +bounded entityType +queryName +outcome +failureCategory +isolation +attemptBucket +``` + +금지: + +```text +entityId +userId +tenantId 원문 +SQL parameter +전체 동적 SQL +PII +constraint value +``` + +### 40.3 Query Name + +등록된 `QueryName`을 metric·trace의 primary key로 사용한다. SQL fingerprint는 secure diagnostic에서만 사용하고 metric label로 raw SQL을 사용하지 않는다. + +### 40.4 Logging + +SQL parameter logging은 production 기본 OFF다. exception message에 parameter와 Entity state를 넣지 않는다. + +--- + +## 41. Security + +### 41.1 DB 역할 + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ required sequence usage + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ bounded SELECT + +Admin Role +└─ approved operations +``` + +### 41.2 search_path + +Application role의 `search_path`를 고정하고 untrusted schema의 object resolution을 차단한다. startup verifier가 current_user, current_schema, search_path, schema CREATE privilege를 검사한다. + +### 41.3 Injection 방어 + +```text +JPQL/Native values → parameter binding +Dynamic sort → allowlist +Dynamic table/column → enum/catalog mapping만 +Entity → API mass binding 금지 +``` + +### 41.4 Secret + +DB password는 secret manager/workload identity에서 주입하고 config·log·metric에 기록하지 않는다. + +--- + +## 42. Spring Boot AutoConfiguration + +### 42.1 Properties + +```yaml +backend: + jpa: + enabled: true + require-postgresql: true + open-in-view: false + schema-management: VALIDATE + transaction-profiles: {} + retry-profiles: {} + observability: + hibernate-statistics: true + sql-parameters: false + security: + verify-runtime-role: true + verify-search-path: true +``` + +### 42.2 Startup Failures + +```text +spring.jpa.open-in-view=true +prod ddl-auto != validate/none +unsupported PostgreSQL version +runtime role has DDL privilege +migration checksum mismatch +required transaction profile timeout missing +Experimental module enabled without feature flag +``` + +### 42.3 Actuator + +```text +jpaPlatform +├─ database version +├─ provider version +├─ schema version +├─ OSIV state +├─ DDL mode +├─ role verification +├─ retry profile count +└─ capability list +``` + +민감 URL·username·schema secrets는 노출하지 않는다. + +--- + +## 43. Test Architecture + +### 43.1 층위 + +```text +Pure Unit +→ domain logic / classifier + +@DataJpaTest +→ quick mapping / repository wiring + +PostgreSQL Testcontainers +→ real semantics + +PG16·17·18 Matrix +→ release compatibility + +Toxiproxy / DB restart +→ failure evidence + +Migration Snapshot +→ real upgrade path +``` + +### 43.2 공통 Fixture + +```text +JpaTestEntity +VersionedEntity +Parent / Child +TwoCollectionsAggregate +SkewedFeedFixture +UniqueConstraintFixture +WorkQueueFixture +BatchEntity +JSONB / Array / Range Entity +``` + +공용 fixture만 testkit에 두고 업무 Entity를 플랫폼 production module에 넣지 않는다. + +### 43.3 계약 목록 + +```text +Mapping +Lifecycle +Transaction +Propagation +Isolation +Optimistic Lock +Pessimistic Lock +Deadlock +Serialization Failure +Constraint Race +Query / Projection +Fetch / N+1 +Pagination +Batch +Bulk +PostgreSQL Extension +Flyway +Security +Pool +Completion Unknown +Observability +``` + +--- + +## 44. Failure Injection + +### 44.1 Deterministic Deadlock + +두 transaction이 서로 반대 순서로 row를 잠그게 해 `40P01`을 재현한다. + +### 44.2 Serialization Failure + +SERIALIZABLE에서 동일 invariant를 변경하는 transaction을 경쟁시켜 `40001`을 재현한다. + +### 44.3 Completion Unknown + +DB proxy가 COMMIT 전, COMMIT 전송 중, server commit 후 response 전에 connection을 끊는 세 지점을 구분한다. 마지막 경우 자동 Retry가 발생하지 않고 `TransactionCompletionUnknownException`이 기록돼야 한다. + +### 44.4 DB Restart + +Transaction 시작 전, query 중, commit 중 PostgreSQL restart를 구분한다. + +--- + +## 45. 성능 인증 + +### 45.1 Query + +```text +p50 / p95 / p99 +statement count +rows +entity hydration +collection fetch +plan node +buffer hit/read +sort spill +``` + +### 45.2 Write + +```text +records/sec +JDBC batch count +statement count +flush count +Persistence Context size +heap allocation +transaction duration +``` + +### 45.3 Pool + +```text +active +pending +acquire p95/p99 +REQUIRES_NEW saturation +connection timeout +``` + +### 45.4 Gate + +성능 숫자는 workload별 문서가 소유한다. Platform release는 bounded memory, actual batching, no unbounded query, pool recovery, no retry storm을 증명한다. + +--- + +## 46. 지원 Matrix와 Release Lane + +| Lane | 실행 | +|---|---| +| PR | PostgreSQL 16·18, mapping/query/transaction/migration smoke | +| Nightly | PG16·17·18, failure injection, query plan, batch, security | +| Release | 전체 Stable Contract, upgrade snapshots, performance, role separation | +| Experimental | JPA4/Hibernate8, PG19, multitenancy, replica | + +### 46.1 H2 + +H2는 빠른 local smoke에만 사용한다. H2-only test가 release gate를 대체하지 않는다. + +### 46.2 Upgrade + +Spring Boot BOM patch 변경 시 Hibernate generated SQL, collection pagination, SQLSTATE mapping, Flyway validate, metrics 이름을 회귀 검증한다. + +--- + +## 47. 완료 정의 + +다음 질문에 모두 구현·테스트 증거로 답할 수 있어야 한다. + +```text +도메인이 Entity와 Repository를 소유하는가? +플랫폼이 GenericRepository를 만들지 않았는가? +OSIV가 모든 운영 profile에서 꺼져 있는가? +Transaction 경계가 Application Service인가? +Retry가 새 Persistence Context에서 전체 Use Case를 실행하는가? +Commit 결과 불명에서 자동 Retry가 금지되는가? +SQLSTATE로 오류를 안정 분류하는가? +Unique 경쟁을 DB Constraint가 최종 보장하는가? +N+1과 Cartesian amplification을 정량 검증하는가? +Hibernate 7.4 collection fetch pagination SQL을 실제 PG에서 검증하는가? +Keyset cursor가 tie-breaker를 포함하는가? +saveAll과 JDBC Batch를 구분하는가? +Bulk DML 후 Persistence Context가 정리되는가? +Flyway가 Schema Source of Truth인가? +운영 Runtime 계정으로 DDL이 실패하는가? +PG16·17·18에서 Stable Contract를 통과하는가? +Metric과 로그에 SQL parameter·PII가 없는가? +Experimental 기능이 Stable dependency에 유입되지 않는가? +``` + +--- + +## 48. ADR 목록 + +```text +ADR-JPA-001 Domain owns entities and repositories +ADR-JPA-002 No generic repository wrapper +ADR-JPA-003 Application service transaction boundary +ADR-JPA-004 Full transaction retry only +ADR-JPA-005 Transaction completion unknown is first-class +ADR-JPA-006 OSIV disabled +ADR-JPA-007 Use-case fetch plans +ADR-JPA-008 Flyway owns schema changes +ADR-JPA-009 PostgreSQL real-service contract tests +ADR-JPA-010 PostgreSQL extensions are J3 +ADR-JPA-011 L2 cache and Envers are opt-in +ADR-JPA-012 Multitenancy and replicas are experimental +``` + +--- + +## 49. 단계별 구현 순서 + +```text +Foundation +→ Error / Transaction Semantics +→ Mapping / Repository Rules +→ Query / Fetch / Pagination +→ Concurrency / Constraint +→ Batch / Bulk +→ PostgreSQL Extension +→ Flyway / Migration +→ Observability / Security +→ Advanced Opt-in +→ PostgreSQL Matrix / Failure / Performance +→ Experimental Expansion +``` + +Stable 계획의 Task가 모두 끝난 뒤 Experimental 계획으로 이동한다. + +--- + +## 50. 요구사항 추적표 + +| 조사 결론 | 설계 위치 | 구현 계획 | +|---|---|---| +| GenericRepository 금지 | 1, 5, 7, 8 | Task 1, 19, 53 | +| J1~J4 계층 | 8 | Task 1, 53 | +| Entity Mapping | 10~13 | Task 13~16 | +| Persistence Context | 14 | Task 11, 16, 19 | +| Application TX | 15~16 | Task 5~9 | +| Completion Unknown | 17 | Task 6, 10, 50 | +| SQLSTATE Error | 18 | Task 3~4, 29~32 | +| Full-TX Retry | 19 | Task 7~9 | +| Optimistic/Pessimistic | 20~21 | Task 29~31 | +| Query·Projection | 23~24 | Task 18~21 | +| Fetch·N+1 | 25~26 | Task 22~25 | +| Pagination | 27 | Task 26~28 | +| Batch·Bulk | 28~30 | Task 33~36 | +| PostgreSQL Extension | 31~32 | Task 30~31, 37~40 | +| Flyway | 33~34 | Task 41~43 | +| Query Plan | 35 | Task 44 | +| Audit·Cache | 36~37 | Task 17, 46~47 | +| Multitenancy·Replica | 38 | Experimental Plan | +| Pool | 39 | Task 12, 51 | +| Observability | 40 | Task 48 | +| Security | 41 | Task 45 | +| Test·Release | 43~46 | Task 49~53 | + +--- + +## 51. 구현 시 금지되는 즉흥 결정 + +```text +새 BaseRepository를 만들어 모든 Repository가 상속하게 한다. +Entity를 Controller 응답에 바로 사용한다. +OSIV를 편의를 위해 켠다. +Deadlock에서 Repository method 하나만 retry한다. +Commit 응답 유실을 connection transient로 보고 자동 retry한다. +모든 ToOne을 EAGER로 바꾼다. +Collection Fetch Join + Pagination을 버전 검증 없이 무조건 금지하거나 허용한다. +saveAll 호출만 보고 batching을 완료로 판정한다. +Flyway migration 대신 ddl-auto update를 켠다. +H2 테스트 통과로 PostgreSQL 지원을 선언한다. +Native SQL 문자열에 사용자 입력 sort/column을 연결한다. +Runtime DB 사용자에게 DDL 권한을 준다. +ReadOnly annotation만 보고 replica로 routing한다. +모든 Entity에 Soft Delete나 Envers를 강제한다. +``` + +--- + +## 52. 설계 승인 상태 + +이 설계는 첨부 심층 리서치와 사용자가 반복적으로 확정한 Backend Skeleton 방향을 기준으로 작성됐다. 구현자는 Stable 계획을 순서대로 수행하고, 각 Task의 계약 테스트가 통과하기 전 다음 Task의 의미론을 임의로 완화하지 않는다. + + +--- + +# 부록 A. 심층 리서치 원문 보존본 + +> 아래 내용은 설계 판단의 원본 근거를 보존하기 위해 첨부 파일을 변경 없이 수록한 것이다. 상단 설계 본문이 구현 계약이며, 충돌 시 상단 설계 본문을 따른다. + +# JPA 관계형 영속성 플랫폼 심층 리서치 + +이번 조사의 결론부터 정리하면, `jpa`는 **`JpaRepository`를 한 번 더 감싸는 공통 Repository 라이브러리로 설계해서는 안 됩니다.** Spring Data JPA 자체가 이미 Repository, Query Method, Pagination, Auditing, Custom Repository, Querydsl 통합 등을 제공하고 있으므로, 공통 플랫폼이 다시 CRUD 추상화를 만드는 것은 기능 중복과 추상화 누수를 동시에 만듭니다. 현재 Spring Data JPA 공식 프로젝트 페이지의 안정 버전은 `4.1.0`입니다. citeturn20view0 + +따라서 권장 구조는 다음과 같습니다. + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Domain Query +├─ Index Requirement +└─ Domain-specific Lock / Soft-delete / Audit policy + │ + ▼ +JPA Persistence Platform +├─ jpa-core +│ ├─ transaction policy +│ ├─ persistence-context policy +│ ├─ error model +│ └─ observability contract +├─ jpa-spring-data +│ ├─ repository fragments +│ ├─ specification +│ ├─ projection +│ └─ auditing support +├─ jpa-hibernate +│ ├─ batching +│ ├─ fetch extensions +│ ├─ statistics +│ └─ StatelessSession +├─ jpa-postgresql +│ ├─ PostgreSQL types +│ ├─ native write/query +│ ├─ lock extensions +│ └─ keyset pagination +├─ jpa-migration-flyway +│ ├─ migration +│ ├─ validation +│ └─ schema release gate +└─ jpa-testkit + ├─ PostgreSQL Testcontainers + ├─ query-count assertions + ├─ concurrency fixtures + ├─ migration fixtures + └─ failure injection +``` + +핵심 설계 질문도 사용자께서 제시한 방향이 맞습니다. + +> **현재 EntityManager 안에서 성공했는가가 아니라, 데이터베이스에 어떤 상태가 확정되었는지, 충돌·Deadlock·Serialization Failure 뒤 전체 업무 트랜잭션을 다시 실행해도 되는지, Commit 결과조차 알 수 없을 때 어떤 증거로 복구할지를 플랫폼 계약으로 만들어야 합니다.** + +## 지원 기준과 공개 계층 + +**기술 기준선.** 2026년 8월 기준 Spring Data JPA 공식 페이지는 `4.1.0`을 표시하고 있으며, Spring Boot `4.1.0`의 dependency management를 사용하는 것이 개별 Hibernate/Flyway/Hikari 버전을 임의로 조립하는 것보다 안전한 기준선입니다. Boot 4.1 BOM은 HikariCP `7.0.2`를 포함하고 있으며, 같은 BOM이 Spring Data JPA, Hibernate ORM, Flyway 등 Spring 생태계의 검증된 조합을 관리합니다. citeturn20view0turn20view1 + +Hibernate ORM의 현재 안정 계열은 **7.4**이며, Hibernate의 7.4 문서는 현재 `7.4.6.Final`을 기준으로 제공되고 있습니다. Jakarta Persistence의 완성된 현재 규격은 **3.2**이고, Persistence 4.0은 아직 개발 중이며 2026년 후반을 목표로 하고 있으므로 Stable 계약으로 고정하면 안 됩니다. citeturn13search0turn7search2turn7search1 + +따라서 지원 매트릭스는 다음이 적절합니다. + +| 구성요소 | 권장 등급 | 기준 | +|---|---|---| +| Java 21 | **Stable baseline** | 플랫폼 언어 기준선 | +| Spring Boot BOM | **Stable baseline** | 개별 dependency 임의 조합 금지 | +| Spring Data JPA 4.1 | **Stable** | Repository·Projection·Specification·Auditing의 기본 진입점 citeturn20view0 | +| Jakarta Persistence 3.2 | **Stable** | 표준 JPA 계약 citeturn7search2turn17search0 | +| Hibernate ORM 7.4 | **Stable provider** | 기본 JPA Provider citeturn13search0 | +| Hibernate Validator | **Stable** | Bean-level early validation | +| Flyway | **Stable migration** | 실제 Schema 변경 Source of Truth | +| HikariCP | **Stable pool** | Boot-managed pool | +| PostgreSQL 16·17·18 | **Stable DB matrix** | 세 버전 모두 공식 지원 기간 내이며 PostgreSQL은 일반적으로 major 버전을 약 5년 지원 citeturn0search3turn13search5 | +| H2 | **Local Convenience** | PostgreSQL 호환성 증명에 사용하지 않음 | +| Testcontainers PostgreSQL | **Required** | 실제 PostgreSQL 의미론을 검증하는 Contract 환경 | +| Jakarta Persistence 4.0 | **Experimental** | 아직 개발 중 citeturn7search1 | +| Hibernate ORM 8 | **Experimental** | 7.4 Stable 이후 차세대 호환성 lane | +| MySQL·MariaDB·Oracle | **Future Profile** | 초기 공통 계약 밖 | + +PostgreSQL 18이 현재 정식 문서의 current 버전이고 PostgreSQL 19는 2026년 8월 현재 beta 단계이므로, **PG19를 Stable에 포함해서는 안 됩니다.** PostgreSQL 공식 문서는 현재 18을 Current로 표시하고 19 Beta 문서를 별도로 제공합니다. citeturn13search5 + +**H2의 위치도 명확해야 합니다.** H2는 빠른 로컬 개발이나 순수 Mapping smoke test에는 쓸 수 있지만, PostgreSQL의 locking, SQLSTATE, partial index, `NULLS NOT DISTINCT`, JSONB, Array, Range, `SKIP LOCKED`, isolation, query planner 동작을 증명하지 못합니다. Stable 선언은 실제 PostgreSQL 테스트를 통해서만 이루어져야 합니다. + +공개 계층은 다음처럼 나누는 것이 가장 자연스럽습니다. + +| 계층 | 공개 범위 | 대표 기능 | 정책 | +|---|---|---|---| +| **J1 Standard Persistence** | 일반 애플리케이션 | Spring Data Repository, JPQL, Projection, 기본 Transaction, `@Version` | 기본 경로 | +| **J2 Advanced Persistence** | 명시적 고급 사용 | Specification, EntityGraph, Query Hint, Pessimistic Lock, Batch, Scrolling | 공통 정책 적용 | +| **J3 Provider / DB Extension** | 제한형 | Hibernate Session, StatelessSession, JSONB, `ON CONFLICT`, `SKIP LOCKED`, Native SQL | 별도 모듈·명시적 의존성 | +| **J4 Admin / Operations** | 운영 계층 | Flyway, Index 생성, Backfill, Partition, maintenance SQL | 일반 서비스 코드에서 금지 | + +Spring Data의 `CrudRepository.save()` 자체도 Entity가 신규인지 판단해 `EntityManager.persist()` 또는 `merge()`를 호출합니다. 즉 `GenericRepository.save()`를 한 계층 더 추가해도 JPA의 `persist`/`merge` 차이를 없애지 못하며 오히려 숨길 뿐입니다. citeturn9search0 + +**권장 공개 구조는 따라서 다음입니다.** + +```java +// Domain owns this +public interface OrderRepository extends JpaRepository, + OrderRepositoryCustom { + Optional findByOrderNumber(OrderNumber orderNumber); +} + +// Domain-specific custom query contract +public interface OrderRepositoryCustom { + Slice findRecentOrders(OrderCursor cursor, int size); +} + +// J3 implementation may internally use: +// EntityManager +// Hibernate Session +// PostgreSQL native SQL +// +// but those types do not leak into application services. +``` + +`EntityManager`를 금지할 필요는 없습니다. 다만 **애플리케이션 전체에 자유롭게 노출하는 것이 아니라 Custom Repository 구현 또는 J3 Extension 내부에서 사용**하는 것이 좋습니다. Spring Data 역시 단순 Repository를 넘는 데이터 접근 코드를 custom fragment로 결합할 수 있도록 설계되어 있습니다. citeturn20view0 + +## Entity Mapping과 Persistence Context 계약 + +Jakarta Persistence 3.2는 Entity가 top-level 또는 static nested class여야 하고, public/protected no-arg constructor가 필요하며, portable Entity는 non-final class와 non-final persistent members를 사용하도록 규정합니다. Field access와 property access는 annotation 위치에 의해 결정되고, 계층 안에서 이를 암묵적으로 뒤섞으면 동작이 정의되지 않으므로 접근 전략을 일관되게 유지해야 합니다. citeturn17search0 + +따라서 Entity Mapping 기본 규칙은 다음이 적절합니다. + +| 항목 | 플랫폼 기본 정책 | +|---|---| +| Access | **Field Access 기본**, 특별한 이유가 있을 때만 `@Access(PROPERTY)` | +| Entity final | 금지 | +| no-arg constructor | `protected` 권장 | +| Entity API 직렬화 | 기본 금지 | +| Controller 반환 | DTO / Projection 사용 | +| `toString()` | LAZY association 포함 금지 | +| `equals/hashCode` | mutable association·mutable business field 포함 금지 | +| Entity callback | 데이터 정규화·감사 필드 같은 로컬 작업만; HTTP/Messaging 등 외부 I/O 금지 | +| BaseEntity | 전역 강제 상속 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | Opt-in capability | +| Association | 기본적으로 use-case fetch plan과 분리 | + +Jakarta Persistence 3.2에서는 `Instant`, `Year`, `UUID` 등이 표준 basic type에 포함되고, **Java record를 Embeddable로 사용할 수 있습니다.** 반면 record는 Entity가 될 수 없습니다. 따라서 record Embeddable은 Stable JPA 3.2 기능으로 볼 수 있지만, 실제 Boot-managed Hibernate 조합의 round-trip·dirty checking·nested embeddable 계약 테스트를 통과하는 것을 release gate로 두는 것이 안전합니다. citeturn17search0turn7search2 + +**Value Mapping 권고안은 다음과 같습니다.** + +| Java/domain type | 권장 | +|---|---| +| `Instant` | Stable, 서버 간 절대 시점 | +| `OffsetDateTime` | Stable, offset 자체가 업무적으로 필요한 경우 | +| `LocalDate` | Stable | +| `LocalDateTime` | timezone 없는 업무 시간에만 사용 | +| `Duration` | Converter 또는 provider mapping 검증 | +| `UUID` | Stable | +| Enum | 기본은 STRING 또는 명시적 converter; ordinal 금지 권고 | +| Money | Embeddable/value object | +| JSONB | `jpa-postgresql` | +| Array | `jpa-postgresql` | +| Range | `jpa-postgresql` | +| INET | Advanced PostgreSQL extension | +| LOB | 일반 Entity 조회에서 신중하게 사용 | +| Encrypted value | AttributeConverter만으로 끝내지 말고 key rotation·queryability 포함 별도 capability | + +**ID 생성 전략에서 PostgreSQL용 기본값은 `SEQUENCE`가 가장 안전합니다.** Hibernate 7.4는 `IDENTITY` 사용 시 INSERT JDBC batching을 수행할 수 없다고 명시하며, `IDENTITY`는 `persist()` 시 식별자를 얻기 위해 INSERT가 즉시 필요할 수 있습니다. 반대로 sequence 계열은 insert 전에 ID를 확보해 batching과 write-behind를 유지하기 쉽습니다. citeturn14view0turn14view1 + +| ID 전략 | Batch | 분산 생성 | Insert 전 ID | 권장 범위 | +|---|---:|---:|---:|---| +| `SEQUENCE` | 좋음 | DB 의존 | 가능 | **PostgreSQL 기본 추천** | +| `IDENTITY` | 나쁨 | DB 의존 | 불가 | 소규모 write에 한정 | +| JPA `UUID` | 좋음 | 가능 | 가능 | Stable | +| Application-assigned UUID | 좋음 | 가능 | 가능 | Stable | +| UUIDv7 | 좋음 | 가능 | 가능 | PG16~18 공통 생성 방식을 별도 정의 | +| Composite ID | 상황별 | 상황별 | 가능 | 도메인이 실제 composite identity인 경우만 | +| Natural ID | 별도 index 필요 | 상황별 | 보통 가능 | PK와 혼동하지 않음 | + +PostgreSQL 18은 native `uuidv7()`을 제공하지만 PostgreSQL 16·17 Stable 범위 전체에서 공통으로 사용할 수 있는 기능은 아닙니다. 따라서 DB-generated UUIDv7을 J1 표준으로 만들지 말고, **application-generated UUIDv7 또는 PostgreSQL 18 전용 extension**으로 분류해야 합니다. 또한 JPA의 `GenerationType.UUID`가 곧 UUIDv7을 뜻하지도 않습니다. citeturn8search0turn8search12turn17search0 + +Sequence를 쓸 때는 `allocationSize`를 명시적으로 관리해야 합니다. 값은 글로벌 상수 하나보다 write profile에 맞춰 benchmark해야 하며, 여러 프로세스가 같은 sequence를 이용하는 경우 allocation 동작도 실제 PostgreSQL에서 검증해야 합니다. + +**Association 정책은 FetchType보다 Fetch Plan이 더 중요합니다.** JPA에서 `EAGER`는 반드시 eager fetch 해야 하는 요구이고 `LAZY`는 provider에 대한 hint입니다. EntityGraph는 query/find 단위 fetch plan을 표현하기 위한 표준 기능입니다. 따라서 mapping에서 연관관계를 무조건 EAGER로 만들어 use case마다 필요 없는 graph를 끌고 오는 것보다, 최소 graph + explicit fetch plan을 기본으로 삼는 것이 적절합니다. citeturn17search0 + +권장 Association 계약은 다음과 같습니다. + +```text +ToOne +→ 기본적으로 명시적 LAZY를 검토 +→ 실제 proxy/lazy 동작을 Hibernate Contract Test로 보증 + +ToMany +→ LAZY +→ List 화면에서는 DTO Projection / EntityGraph / Fetch Join 선택 + +Cascade +→ lifecycle이 실제로 동일한 aggregate 내부에서만 + +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ child lifecycle을 parent가 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 join entity 우선 검토 +``` + +JPA 규격상 양방향 관계에서 persistence 동작에 중요한 것은 owning side이며, 양쪽 in-memory 객체 graph를 서로 맞추는 책임은 애플리케이션에게 있습니다. 따라서 양방향 association에는 `addChild/removeChild` 같은 편의 메서드 계약을 두는 것이 좋습니다. citeturn12view0 + +**Persistence Context 계약도 API 문서보다 중요합니다.** `persist`, `merge`, `flush`, `commit`은 서로 다른 의미를 가집니다. `merge()`는 detached instance 자체를 managed로 바꾸는 것이 아니라 그 state를 managed instance에 복사하는 방식이고, `flush()`는 Persistence Context를 DB와 동기화하지만 transaction commit과 동일하지 않습니다. citeturn12view0 + +플랫폼 계약은 아래처럼 고정하는 것이 좋습니다. + +```text +Persistence Context +→ transaction-scoped + +Extended Persistence Context +→ Stable 비지원 + +EntityManager +→ thread-safe로 간주하지 않음 + +OSIV +→ 명시적으로 false + +Lazy loading +→ application transaction 내부 + +Web/API +→ Entity 직접 반환 금지 + +flush() +→ SQL 반영 시점 제어 +→ commit 보장 아님 + +clear() +→ managed state 제거 + +refresh() +→ DB state 재조회 + +Bulk DML +→ flush +→ bulk DML +→ clear 또는 필요한 entity refresh +``` + +JPA Bulk UPDATE/DELETE는 persistence context를 자동으로 동기화하지 않고 optimistic locking check도 자동 적용하지 않습니다. 따라서 Bulk DML 후 이미 managed 상태인 Entity를 계속 사용하는 것은 stale-state 오류의 직접 원인이 됩니다. citeturn12view1 + +`Open Session in View`는 **플랫폼 차원에서 명시적으로 비활성화**하는 것이 좋습니다. 중요한 것은 Spring Boot의 특정 버전 기본값에 의존하지 않고 다음 invariant를 만드는 것입니다. + +```properties +spring.jpa.open-in-view=false +``` + +그 결과 `LazyInitializationException`은 Web serialization에서 우연히 발생하는 production 장애가 아니라, use case에 필요한 Fetch Plan을 Repository 계층에서 빠뜨렸다는 **개발 시점 계약 위반**으로 취급할 수 있습니다. + +## Transaction·Lock·Retry와 Commit 불명확성 + +Spring Data JPA도 여러 Repository를 묶는 unit of work에서는 service/facade 수준에 transaction boundary를 두는 방식을 권장합니다. 외부 transaction이 있으면 내부 Repository 설정보다 외부 unit-of-work transaction이 실제 경계를 결정합니다. citeturn15search1 + +따라서 기본 계약은 다음입니다. + +```text +Controller + │ + ▼ +Application Service ← @Transactional boundary + │ + ├─ Repository A + ├─ Repository B + └─ Domain operation +``` + +그리고 다음 구조는 피해야 합니다. + +```text +@Transactional +DB UPDATE +→ 3초 HTTP 호출 +→ Object Storage 전송 +→ Kafka publish +→ DB COMMIT +``` + +Spring Framework는 transaction context가 일반적인 remote call까지 전파되는 모델이 아니며, 긴 외부 작업을 로컬 DB transaction 내부에 넣으면 connection과 row lock의 보유 시간이 외부 시스템 latency에 종속됩니다. DB 변경과 메시지 발행을 연계해야 한다면 XA처럼 보이게 숨기기보다 Transactional Outbox를 사용하는 것이 더 안전한 경계입니다. citeturn15search7 + +**Propagation 정책은 다음 정도로 강하게 제한하는 것이 좋습니다.** + +| Propagation | 등급 | 플랫폼 규칙 | +|---|---|---| +| `REQUIRED` | 기본 | Application use case 기본 | +| `MANDATORY` | 선택 Stable | 반드시 상위 transaction이 필요한 내부 write service | +| `SUPPORTS` | 제한 | read helper 정도 | +| `REQUIRES_NEW` | 주의 | 명시적 독립 commit이 업무적으로 필요한 경우만 | +| `NESTED` | Advanced | JPA portable 기능처럼 취급하지 않고 savepoint 호환성 검증 | +| `NOT_SUPPORTED` | Advanced | 긴 외부 I/O 분리 등에 제한적으로 사용 | + +Spring의 `REQUIRES_NEW`는 별도의 physical transaction과 resource를 사용합니다. 외부 transaction이 connection을 붙잡은 채 내부 transaction이 또 다른 connection을 요구하므로, 동시 호출이 많으면 pool exhaustion 또는 deadlock으로 이어질 수 있다고 Spring 문서가 명시적으로 경고합니다. `NESTED`는 JDBC savepoint를 기반으로 하는 의미론입니다. citeturn15search0 + +또한 Spring의 기본 proxy transaction model에서는 **self-invocation이 transactional interception을 거치지 않습니다.** 따라서 같은 클래스 안에서 `this.someRequiresNewMethod()`를 호출하고 별도 transaction이 생성된다고 가정하는 코드는 금지 대상이 되어야 합니다. citeturn15search6turn15search9 + +Spring `@Transactional`의 기본값은 `REQUIRED`, isolation `DEFAULT`, read-write이며, 기본 rollback 규칙은 `RuntimeException`과 `Error`입니다. Checked exception까지 rollback해야 하는 업무에서는 `rollbackFor` 또는 안정적인 application exception hierarchy를 명시해야 합니다. citeturn15search6 + +**Isolation은 PostgreSQL 실제 의미론을 기준으로 계약해야 합니다.** + +| Isolation | PostgreSQL 관점 | 권장 | +|---|---|---| +| `READ COMMITTED` | 기본 isolation | 일반 업무 기본 | +| `REPEATABLE READ` | snapshot 내 일관성 강화; concurrent update 시 serialization failure 가능 | 명시적 use case | +| `SERIALIZABLE` | serial execution과 동등한 결과를 목표로 하며 abort/retry 가능 | 좁은 핵심 invariant | +| `READ UNCOMMITTED` | PostgreSQL에서는 실질적으로 READ COMMITTED 의미 | 공개 profile로 권장하지 않음 | + +PostgreSQL은 Repeatable Read/Serializable에서 concurrency anomaly를 해결하기 위해 transaction을 abort시킬 수 있으며, Serializable 문서는 실패한 경우 **transaction 전체를 처음부터 다시 실행**해야 한다고 명시합니다. citeturn13search9turn8search6 + +이 때문에 Retry 단위는 다음과 같아야 합니다. + +```text +잘못된 방식 + +@Transactional +service() + repository.update() // 실패 + retry(repository.update) // 일부 SQL만 재실행 + + +권장 방식 + +retryTransaction( + () -> applicationUseCase() +) +``` + +즉 **새 Persistence Context와 새 DB transaction에서 전체 use case를 재실행**해야 합니다. + +**Optimistic Lock은 기본 동시성 제어의 첫 번째 선택지**로 두는 것이 적절합니다. + +```java +@Version +private long version; +``` + +JPA는 optimistic version check가 flush 또는 commit 시점까지 지연될 수 있음을 허용하며, 충돌 시 `OptimisticLockException`을 발생시킵니다. 즉 update method 호출 직후 충돌이 반드시 드러난다고 가정하면 안 됩니다. citeturn12view2 + +Optimistic retry는 다음 조건을 모두 만족해야 합니다. + +```text +전체 application transaction을 다시 계산할 수 있음 +AND +외부 irreversible side effect가 없음 +AND +업무 deadline이 남아 있음 +AND +retry 횟수가 제한됨 +``` + +**Pessimistic Lock은 다음 계약으로 제한**하는 것이 좋습니다. + +| 기능 | 용도 | 위험 | +|---|---|---| +| `PESSIMISTIC_READ` | shared-style lock 요구 | 장시간 transaction | +| `PESSIMISTIC_WRITE` | 쓰기 경쟁 직렬화 | lock wait·deadlock | +| `PESSIMISTIC_FORCE_INCREMENT` | version까지 증가 | contention | +| `NOWAIT` | 기다리지 않고 즉시 실패 | 실패율 증가 | +| `SKIP LOCKED` | work queue형 competing worker | 일반 조회에는 inconsistent view | + +JPA의 pessimistic lock은 transaction 종료까지 유지되어야 하며, database transaction rollback 수준의 lock 실패와 statement 수준 timeout을 `PessimisticLockException`/`LockTimeoutException`으로 구분합니다. PostgreSQL의 `SKIP LOCKED`는 일관된 일반 조회 view를 제공하지 않기 때문에 queue-like consumer에 적합하다고 공식 문서가 명시합니다. citeturn12view3turn8search4turn8search5 + +따라서 `SKIP LOCKED`를 `findAllUnlocked()` 같은 공통 Repository API로 제공해서는 안 되고, + +```text +jpa-postgresql +└─ WorkClaimExtension + └─ claimNextBatch(...) +``` + +처럼 semantics가 드러나는 API로 한정하는 것이 좋습니다. + +**DB Constraint는 최종 불변식입니다.** 다음 코드는 경쟁을 막지 못합니다. + +```java +if (!repository.existsByEmail(email)) { + repository.save(new User(email)); +} +``` + +동시에 두 transaction이 `false`를 읽을 수 있기 때문입니다. 최종 uniqueness는 `UNIQUE` constraint/index가 담당하고 애플리케이션의 `exists` 검사는 빠른 UX validation 정도로만 사용해야 합니다. PostgreSQL은 unique constraint/primary key에 unique index를 자동 생성하며, `NULLS NOT DISTINCT`를 사용해 NULL도 동일 값처럼 취급하는 unique semantics를 제공할 수 있습니다. citeturn17search1 + +PostgreSQL 전용 partial unique index가 필요하다면 Entity annotation에 억지로 추상화하지 말고 Flyway migration으로 관리합니다. + +```sql +CREATE UNIQUE INDEX uq_user_active_email +ON users (email) +WHERE deleted_at IS NULL; +``` + +이는 Soft Delete와 Unique Constraint 충돌을 해결하는 대표적인 PostgreSQL extension 패턴입니다. + +**Commit 결과 불명확성은 별도 오류로 모델링해야 합니다.** + +예를 들어: + +```text +Application + │ + │ COMMIT + ▼ +PostgreSQL + │ + │ 실제 commit 완료 + X TCP connection loss + │ +Application + └─ commit 결과를 받지 못함 +``` + +이때 같은 업무를 자동 재실행하면 이미 commit된 INSERT나 상태 변경을 두 번 실행할 수 있습니다. PostgreSQL의 SQLSTATE 체계 자체에도 `40003 statement_completion_unknown`이라는 별도 completion-unknown condition이 정의되어 있고, SQLSTATE는 문자열 오류 메시지보다 안정적인 기계 판독 기준으로 사용하도록 PostgreSQL이 권고합니다. citeturn13search1 + +따라서 플랫폼에는 JPA 표준 exception이 아닌 **플랫폼 고유 분류**로 다음을 두는 것을 권장합니다. + +```java +final class TransactionCompletionUnknown + extends JpaPersistenceException { + + String operationName; + String transactionKey; + String sqlState; + boolean commitAttempted; + String traceId; +} +``` + +이 오류에 대한 정책은 명확해야 합니다. + +```text +TransactionCompletionUnknown +→ 자동 Retry 금지 +→ 동일 업무 key로 상태 재조회 +→ Unique Constraint / Idempotency Record 확인 +→ Outbox / transaction record 확인 +→ 결과 확정 불가 시 reconciliation +``` + +즉 error taxonomy는 단순히 “transient/non-transient” 두 종류로 나누면 부족합니다. + +## Query·Fetch·Pagination과 Write 성능 + +Spring Data JPA는 derived query, custom query, pagination, custom repository, Querydsl integration 등을 공식 지원하므로, 플랫폼의 역할은 이를 하나의 API로 대체하는 것이 아니라 **어떤 레벨에서 무엇을 쓸지 결정하는 것**입니다. citeturn20view0 + +권장 Query 등급은 다음과 같습니다. + +| 등급 | 방식 | 사용 기준 | +|---|---|---| +| Q1 | Derived Query | 짧고 명확한 equality/range 조회 | +| Q1 | JPQL `@Query` | 고정 query, domain repository 안에서 읽기 쉬운 경우 | +| Q1 | DTO Projection | 목록·read model 기본 후보 | +| Q2 | Specification | optional filter 조합 | +| Q2 | Criteria | framework-level dynamic query | +| Q2 | Querydsl | 복잡한 type-safe dynamic query의 선택 capability | +| Q2 | EntityGraph | use-case fetch plan | +| Q3 | Native SQL | PostgreSQL 기능·계획 통제가 필요한 경우 | +| Q3 | Hibernate Query API | provider 기능 필요 시 | +| Q4 | Bulk/Admin SQL | backfill, maintenance | + +Derived query에 “최대 단어 수” 같은 임의 숫자를 플랫폼에 박는 것은 좋지 않습니다. 대신 **method name이 업무 의미보다 SQL 구조를 설명하기 시작하면 custom query로 승격한다**는 코드리뷰 규칙이 더 안정적입니다. + +Dynamic sort는 field allowlist가 필요합니다. Spring Data는 일반적인 domain property 기반 `Sort`와 명시적으로 unsafe한 expression sort를 구분하기 때문에, 사용자 입력 문자열을 `JpaSort.unsafe()` 등에 직접 연결하지 않는 정책이 필요합니다. citeturn18search12 + +**Fetch 전략은 Mapping이 아니라 Use Case 계약으로 관리**해야 합니다. + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join 또는 EntityGraph | +| 하나의 필요한 ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 다단계 query / batch fetch | +| 목록 화면 | DTO Projection | +| 페이지형 parent + collection | Hibernate 버전과 SQL plan 검증 | +| 대규모 read model | Projection / Native Query | +| 반복 LAZY N+1 | Batch Fetch 또는 explicit fetch plan | + +Hibernate는 여러 to-one fetch를 한 query에서 사용하는 것은 비교적 안전하지만, 여러 collection을 병렬 join fetch하면 DB 레벨 Cartesian product가 발생해 row 수와 hydration cost가 크게 증가할 수 있음을 문서화하고 있습니다. citeturn13search14 + +여기에는 **2026년 기준 중요한 변경점**이 있습니다. + +기존 Hibernate 6 또는 초기 Hibernate 7에서는 collection fetch join과 pagination을 조합하면 limit이 JVM에서 적용되어 전체 결과를 읽어버리는 심각한 문제가 있었습니다. 그러나 **Hibernate ORM 7.4에서는 PostgreSQL처럼 subquery 안의 limit/offset을 지원하는 DB에서 이 문제가 해결되었습니다.** Hibernate 7.4의 “What’s New”가 이를 명시적으로 새 기능으로 소개합니다. citeturn13search0turn13search11 + +따라서 기존 규칙인 + +```text +Collection Fetch Join + Pagination +→ 무조건 금지 +``` + +는 현재 baseline에서는 너무 강합니다. + +정확한 규칙은 다음이어야 합니다. + +```text +Hibernate 7.4 + PostgreSQL 16~18 +→ 지원 가능 +→ generated SQL / rows / count query / cartesian amplification을 Contract Test + +Hibernate 이전 버전 또는 다른 provider +→ capability 재검증 + +여러 collection fetch +→ pagination 해결 여부와 별개로 Cartesian 위험 때문에 기본 제한 +``` + +이 부분은 반드시 회귀 테스트에 넣어야 합니다. “과거 성능 장애 사례”와 “현재 지원 기능”을 구분하지 않으면 JPA 플랫폼이 이미 수정된 Hibernate 제한을 영구 정책으로 굳히게 됩니다. citeturn13search0turn13search2 + +**N+1 테스트는 SQL 개수 하나만 보면 부족합니다.** + +```text +statementCount +entityLoadCount +entityFetchCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +duration +``` + +를 함께 보아야 합니다. 예컨대 SQL 1개라도 100 parent × 100 child × 20 second-child Cartesian product가 만들어지면 좋은 Fetch Plan이 아닙니다. + +테스트 fixture 역시: + +```text +0 child +1 child +10~100 children +shared ToOne +두 개 이상의 collection +skewed distribution +``` + +을 포함해야 합니다. + +**Pagination 계약은 세 종류로 나누는 것이 좋습니다.** + +| 방식 | 장점 | 단점 | 기본 용도 | +|---|---|---|---| +| `Page` | total count 제공 | count query 비용 | 작은 관리자 화면 | +| `Slice` | count 불필요 | 전체 개수 없음 | 일반 목록 | +| Offset | 구현 간단 | 깊은 페이지 비용·삽입 시 이동 | 작은 데이터 | +| Keyset/Cursor | 큰 데이터에 유리 | stable ordering·cursor 설계 필요 | 일반 대규모 목록 | +| Stream/Scroll | 전체 적재 회피 | transaction/resource lifetime | batch/read processing | + +Spring Data의 Scroll API는 offset/keyset scrolling을 지원하지만 query 방식에 따라 지원 범위가 다르며, 공식 문서는 string-based `@Query`나 stored procedure에서 scrolling을 지원하지 않는 제한을 명시합니다. citeturn18search12turn9search8 + +Keyset cursor에는 반드시 전체 순서를 결정하는 tie-breaker가 필요합니다. + +```sql +ORDER BY created_at DESC, id DESC +``` + +라면 cursor도: + +```text +(createdAt, id) +``` + +두 값을 모두 저장해야 합니다. `created_at` 하나만 cursor로 쓰면 같은 timestamp를 가진 row가 누락되거나 반복될 수 있습니다. + +**Batch Write는 `saveAll()`과 동일하지 않습니다.** Hibernate의 JDBC batching은 `hibernate.jdbc.batch_size`가 0 이하이면 꺼져 있고, batching 활성화 뒤에도 ID generator와 SQL shape에 따라 실제 batch 여부가 달라집니다. Hibernate 7.4는 `order_inserts`, `order_updates`를 제공하지만 이 옵션 역시 overhead가 있으므로 benchmark를 권고합니다. citeturn14view1 + +권장 write profile은 다음입니다. + +```yaml +jpa: + write-profiles: + default: + batch-size: 0 + + batch: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +정확한 50이라는 값 자체가 universal optimum이라는 뜻은 아니며 프로파일 기본 예시입니다. 실제 완료 조건은 “configured batch size가 SQL/JDBC batch로 관찰됨”입니다. + +Hibernate는 대량 Entity를 하나의 stateful Session에 계속 넣으면 Persistence Context에 Entity가 누적되고 장기 transaction이 connection pool을 오래 점유한다고 설명하며, batch loop에서 주기적인 `flush()`와 `clear()`를 권장합니다. citeturn14view1 + +```java +for (int i = 0; i < records.size(); i++) { + entityManager.persist(records.get(i)); + + if (i > 0 && i % batchSize == 0) { + entityManager.flush(); + entityManager.clear(); + } +} +``` + +**대규모 Backfill은 JPA Entity lifecycle 자체가 필요하지 않을 수도 있습니다.** Hibernate `StatelessSession`은 Persistence Context와 연결되지 않은 detached-like object를 반환하고 insert/update/delete가 DB row에 직접 작용하는 다른 semantics를 갖습니다. 따라서 일반 Repository 대체가 아니라 J3/J4 대량 작업 extension으로 분류해야 합니다. citeturn14view4 + +권장 계층은 다음과 같습니다. + +```text +일반 업무 write +→ JPA Entity + +수천~수만 row +→ JPA + JDBC batch + chunk flush/clear + +대규모 migration/backfill +→ StatelessSession / JdbcTemplate / PostgreSQL COPY + +운영 대량 수정 +→ J4 Job +``` + +**Bulk DML**은 더 엄격합니다. + +```text +flush +→ JPQL/Native Bulk UPDATE +→ clear +→ 필요 시 재조회 +``` + +가 기본 계약입니다. Bulk JPQL/Criteria DML은 managed entity state를 자동 동기화하지 않으며 optimistic locking도 자동 적용하지 않습니다. citeturn12view1 + +**Cache 정책도 단순하게 가져가는 편이 안전합니다.** + +```text +First-level Cache +→ JPA 기본, 항상 존재 + +Second-level Cache +→ 기본 Opt-out / Entity별 명시 Opt-in + +Query Cache +→ 기본 OFF + +Application Cache +→ 별도 Redis/cache 플랫폼 +``` + +Hibernate 7.4는 query cache 기본값이 false이고, shared cache mode에서는 `ENABLE_SELECTIVE`를 기본·권장하여 명시적으로 cacheable인 Entity만 second-level cache에 넣도록 설명합니다. 또한 외부 애플리케이션이 DB를 변경하면 Hibernate cache가 이를 자동 인지하지 못한다는 제한도 있습니다. citeturn14view3 + +즉 Redis application cache와 Hibernate L2 cache를 “같은 Cache 기능”으로 묶으면 안 됩니다. + +## PostgreSQL·Schema Migration·확장 정책 + +JPA Mapping은 **애플리케이션의 object-relational mapping 계약**이고, 실제 schema 변경 Source of Truth는 **Flyway migration**으로 두는 것이 적절합니다. + +권장 환경 정책은 다음입니다. + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | `validate` | +| H2 convenience | 선택적 create/drop | 실제 호환성 증명 아님 | +| test | migrate | `validate` | +| dev | migrate | `validate` | +| staging | deployment migration | `validate` | +| prod | 별도 권한/배포 주체로 migration | `validate` | + +운영에서 다음은 기본 금지로 두는 것이 좋습니다. + +```text +hibernate.ddl-auto=update +hibernate.ddl-auto=create +hibernate.ddl-auto=create-drop +application runtime credential의 DDL 권한 +적용 완료된 Versioned Migration 수정 +startup 시 자동 Flyway repair +``` + +Flyway `validate`는 적용된 migration과 로컬 migration의 name/type/checksum 등을 비교하고 불일치나 누락을 실패로 보고합니다. SQL migration checksum은 현재 문서 기준 CRC32로 저장됩니다. citeturn19search0 + +Versioned migration은 순서대로 한 번 적용하고 이미 영구 환경에 적용한 파일은 수정하지 않고 새 migration으로 roll-forward하는 것이 Flyway가 권장하는 방식입니다. Repeatable migration은 checksum이 변경될 때 다시 실행됩니다. citeturn19search3turn19search6 + +`repair`는 단순한 “검증 복구” 기능이 아닙니다. 실패 migration 기록 제거, checksum/description/type 재정렬, missing migration을 deleted로 표시하는 등의 변경을 수행하며, DB에 남은 user object는 수동으로 정리해야 할 수 있습니다. 따라서 J4 승인 작업으로 두어야 합니다. citeturn19search1 + +**무중단 Migration의 기본 패턴은 Expand → Migrate → Contract입니다.** + +```text +Release A +ADD nullable column +ADD new table/index +Application can handle old + new schema + + ↓ + +Backfill +chunked data migration + + ↓ + +Release B +new column becomes authoritative + + ↓ + +Release C +old column/index/API removed +constraint tightened +``` + +큰 테이블에서 index를 만드는 경우 PostgreSQL의 `CREATE INDEX CONCURRENTLY`를 별도 migration 유형으로 취급해야 합니다. PostgreSQL은 concurrent index build를 transaction block 안에서 실행할 수 없다고 명시하므로, Flyway의 일반 transaction wrapping과 충돌하지 않도록 해당 migration을 non-transactional로 명시적으로 분리해야 합니다. citeturn17search3turn19search16 + +Flyway의 `group=true`는 여러 pending migrations를 한 transaction에 묶는 옵션이지만, DDL transaction을 적절히 지원하는 DB에서만 권장되며 기본은 false입니다. 무조건 활성화할 설정이 아닙니다. citeturn19search13 + +**Constraint 정책은 아래처럼 나누는 것이 좋습니다.** + +```text +Bean Validation +→ 빠른 입력/객체 검증 +→ 사용자 친화적 오류 + +Database Constraint +→ concurrency 하에서도 지켜져야 하는 최종 invariant +``` + +| Constraint | DB 필수성 | +|---|---| +| Primary Key | 필수 | +| Foreign Key | 관계 불변식에 기본 | +| `NOT NULL` | 실제 non-null invariant이면 DB에도 적용 | +| Unique | 경쟁 가능 uniqueness는 DB가 최종 보장 | +| Check | DB 자체로 표현 가능한 invariant에 적극 검토 | +| Exclusion | PostgreSQL 고유 overlap 등 고급 invariant | + +**Index 역시 Entity field에 자동 생성하는 문제가 아닙니다.** PostgreSQL은 B-tree, GiST, GIN, BRIN, multicolumn, expression, partial, covering `INCLUDE` 등 다양한 index 기능을 제공합니다. 특히 multicolumn index는 실제 predicate, sort와 data distribution을 기준으로 설계해야 합니다. citeturn17search2turn8search9 + +플랫폼은 “자동 Index 생성기”보다 다음을 제공하는 것이 더 유용합니다. + +```text +Query Name +→ representative parameters +→ EXPLAIN / EXPLAIN ANALYZE +→ estimated rows / actual rows +→ scan type +→ sort +→ temporary spill +→ buffers +→ execution time +→ expected index document +``` + +**PostgreSQL extension 지원표**는 다음이 적절합니다. + +| 기능 | 등급 | 비고 | +|---|---|---| +| JSONB | **P1 Stable Extension** | PostgreSQL-native value/query | +| Array | **P1 Stable Extension** | 타입별 contract test | +| Range | **P1 Stable Extension** | 기간·구간 도메인 | +| UUID | **P1 Stable** | standard/native | +| INET | P2 Advanced | networking domain | +| Native Enum | P2 Advanced | migration coupling 큼 | +| `ON CONFLICT` | **P1 Native Write Extension** | 명시적 upsert semantics | +| `RETURNING` | **P1 Native Write Extension** | native write 최적화 | +| Window Function | P1/P2 Query Extension | read model | +| CTE | P2 | 복잡한 read/write | +| Recursive CTE | P2 | 제한된 use case | +| `NOWAIT` | **P1 Lock Extension** | fast-fail lock | +| `SKIP LOCKED` | **P1 Worker Extension** | queue-like use case만 citeturn8search4 | +| Advisory Lock | P2 Advanced | transaction/session scope를 명시 | +| Partial Index | **J4 Migration** | query-specific | +| Expression Index | J4 Migration | query-specific | +| `NULLS NOT DISTINCT` | **J4 Stable Migration** | PG unique semantics citeturn17search1 | +| Generated Column | P2/J4 | mapping·migration 검증 | +| Full-text Search | P2 | 전문 검색 규모에서는 별도 검색 플랫폼과 비교 | +| Partitioning | **J4 Admin** | application Repository가 생성·삭제하지 않음 | +| Row-Level Security | Experimental/Admin | tenant context·connection reuse까지 검증 필요 | + +**Auditing은 강제 BaseEntity보다 선택형이 낫습니다.** Spring Data JPA는 created/modified user/time을 기록하는 auditing 기능을 이미 제공하므로 공통 플랫폼은 이를 활성화할 수 있는 primitive만 제공하고, 도메인이 필요한 Entity에 선택적으로 적용하도록 해야 합니다. citeturn20view0turn18search5 + +```text +Technical Auditing +createdAt / createdBy / modifiedAt / modifiedBy + +≠ + +Business Audit +“누가 주문 상태를 왜 취소했는가” + +≠ + +Entity History +과거 row revision + +≠ + +Security Audit +관리자 권한·DDL·replay +``` + +Hibernate Envers는 Entity History 선택 기능으로 둘 수 있지만 J1 기본 기능으로 만들 필요는 없습니다. + +**Soft Delete 역시 global 기능으로 제공하지 않는 것이 좋습니다.** + +```text +Global @Where deleted=false +→ 비추천 + +Domain-specific status/deletedAt +→ 필요 도메인에만 + +복구 가능한 삭제 +→ 도메인 계약 + +법적/개인정보 물리 삭제 +→ 별도 lifecycle +``` + +Soft Delete를 공통 필터로 숨기면 unique constraint, FK, admin query, archive, restore, 개인정보 삭제가 모두 암묵적 semantics에 묶입니다. PostgreSQL partial unique index 같은 기능이 필요한 이유도 이 경계 때문입니다. + +**Multi-tenancy는 초기 Stable Core에서 제외하는 것이 안전합니다.** + +| 모델 | 권장 초기 등급 | +|---|---| +| 단일 DB·schema | Stable | +| Shared schema + tenant column | Experimental capability | +| Schema per tenant | Experimental | +| DB per tenant | Experimental | +| RLS 기반 | Experimental | +| Multi DataSource | Advanced/Experimental | +| Read Replica routing | Experimental | + +Read replica는 `@Transactional(readOnly=true)`만 보고 자동 routing해서는 안 됩니다. replica lag 때문에 같은 사용자 흐름의 직전 write가 보이지 않을 수 있고 lock query는 primary가 필요하기 때문입니다. Stable Core에는 transaction read-only hint까지만 포함하고 routing은 별도 profile로 두는 것이 적절합니다. + +## 오류·보안·관측성·테스트 계약 + +Spring의 exception translation과 PostgreSQL SQLSTATE를 활용하되 애플리케이션에 provider/vendor exception을 그대로 노출하지 않는 것이 좋습니다. PostgreSQL 공식 문서는 오류 판단 시 locale에 따라 달라지는 message text가 아니라 SQLSTATE를 검사하라고 권장하며, integrity violation에서는 constraint name 같은 structured field도 전달합니다. citeturn13search1 + +권장 오류 모델은 다음과 같습니다. + +```text +JpaPersistenceException +├─ EntityNotFound +├─ OptimisticConflict +├─ PessimisticLockTimeout +├─ DeadlockDetected +├─ SerializationFailure +├─ UniqueConstraintViolation +├─ ForeignKeyViolation +├─ CheckConstraintViolation +├─ QueryTimeout +├─ TransactionTimeout +├─ ConnectionUnavailable +├─ SchemaMismatch +├─ DataCorruption +└─ TransactionCompletionUnknown +``` + +PostgreSQL SQLSTATE를 활용하면 대표적으로 serialization failure `40001`, deadlock `40P01`, 그리고 completion unknown 계열을 문자열 parsing 없이 분류할 수 있습니다. Constraint violation도 class 23을 기준으로 구조화할 수 있습니다. citeturn13search1 + +**Retry 판정표는 다음처럼 두는 것이 좋습니다.** + +| 오류 | 자동 Retry | 단위 | 조건 | +|---|---|---|---| +| `OptimisticConflict` | 조건부 | 전체 use case transaction | 재계산 가능, side effect 없음 | +| `SerializationFailure` | 조건부 | 전체 transaction | bounded attempts + jitter | +| `DeadlockDetected` | 조건부 | 전체 transaction | bounded attempts | +| Lock timeout | 조건부 | 전체 use case | deadline과 업무 정책 확인 | +| Connection acquire 전 실패 | 제한적 | 전체 use case | DB에 작업이 시작되지 않았음이 확실 | +| Unique violation | 기본 금지 | — | idempotent create라면 기존 record 재조회 가능 | +| FK violation | 금지 | — | 업무 순서/데이터 오류 | +| Check violation | 금지 | — | 업무 invariant 오류 | +| Query timeout | 기본 금지 | — | 동일 부하에서 반복하면 부하만 증폭 | +| Schema mismatch | 금지 | — | 배포 오류 | +| Commit 결과 불명 | **금지** | reconciliation | 중복 실행 위험 | + +모든 retry에는: + +```text +maxAttempts +maxElapsedTime +exponentialBackoff +jitter +transaction deadline +retry metrics +``` + +가 있어야 합니다. + +**보안 정책은 Repository API보다 DB credential과 dynamic query 제한이 중요합니다.** + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ 필요한 sequence 사용 + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ 필요한 SELECT + +Admin Role +└─ 승인된 운영 작업 +``` + +운영 application credential에는 `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, extension 설치 권한을 주지 않는 것이 적절합니다. + +PostgreSQL은 `search_path`에 CREATE 권한을 가진 신뢰하지 않는 schema가 들어가면 object resolution이 보안 문제가 될 수 있음을 문서화하고 있으며, 안전한 schema privilege 패턴을 별도로 설명합니다. 따라서 migration schema를 명확히 하고 application role의 `search_path`를 고정·검증해야 합니다. citeturn21search6 + +추가 보안 규칙은 다음처럼 고정하는 것이 좋습니다. + +```text +JPQL +→ parameter binding + +Native SQL +→ J3 내부 +→ 값 문자열 연결 금지 + +Dynamic sort +→ allowlist + +Dynamic table/column +→ 원칙적 금지 +→ 불가피하면 enum/catalog mapping + +Entity +→ API request mass binding 금지 + +SQL parameter logging +→ production 기본 OFF + +Tenant ID +→ metric tag / raw log 금지 + +DB password +→ secret manager / workload identity 경로 +``` + +**관측성은 이미 Boot에서 상당 부분 제공됩니다.** Spring Boot는 DataSource에 `jdbc.connections` active/idle/max/min gauge를 만들고 Hikari-specific `hikaricp` metrics도 제공합니다. `hibernate-micrometer`가 있고 Hibernate statistics를 활성화하면 Hibernate metrics를, Spring Data Repository 호출에는 `spring.data.repository.invocations`를 제공합니다. citeturn21search0 + +공통 관측 계약은 이를 다음처럼 확장하는 것이 적절합니다. + +| 계층 | 필수 관측 | +|---|---| +| Pool | active, idle, pending, max, acquire latency, timeout | +| Transaction | count, latency, rollback, timeout, isolation, retry, completion-unknown | +| Query | queryName, count, latency, rows, timeout, lock wait | +| Fetch | statement count, entity load/fetch, collection fetch | +| Batch | batch count, batch size, flushed entities | +| Lock | optimistic conflict, pessimistic timeout, deadlock | +| Migration | version, validate result, migration duration | +| Retry | reason, attempt, elapsed | +| Cache | L2/query hit/miss when enabled | + +Metric cardinality는 낮게 유지합니다. + +**허용:** + +```text +persistenceUnit +operationName +bounded entityType +bounded queryName +outcome +failureCategory +isolation +``` + +**금지:** + +```text +entityId +userId +tenantId 원문 +SQL parameter +Email / Phone / PII +임의 SQL text +dynamic WHERE clause +``` + +SQL 전체 문자열을 metric dimension으로 쓰는 대신 정규화된 query fingerprint 또는 등록된 `queryName`을 사용합니다. SQL parameter logging은 production에서 기본 비활성화해야 합니다. + +HikariCP의 pool size 자체도 무작정 키우면 안 됩니다. Hikari는 maximum pool size 도달 시 connection 반환을 기다리다가 `connectionTimeout` 이후 실패하는 모델을 사용하므로, 관측해야 할 핵심은 단순 active count가 아니라 **pending/acquire latency와 transaction duration**입니다. citeturn11search2turn21search0 + +**테스트는 H2 중심이 아니라 PostgreSQL Contract 중심으로 설계해야 합니다.** + +테스트 피라미드는 다음이 적절합니다. + +```text +Pure Unit +→ domain logic + +@DataJpaTest +→ repository wiring / quick mapping + +PostgreSQL Testcontainers +→ real persistence semantics + +PostgreSQL 16 / 17 / 18 Matrix +→ release compatibility + +Fault Injection +→ lock / network / commit ambiguity + +Migration Snapshot +→ real upgrade path +``` + +Testcontainers는 실제 PostgreSQL image를 실행할 수 있으므로 DB 고유 기능에 의존하는 integration test를 H2 대체 구현이 아니라 실제 DB에서 수행하는 기반으로 적합합니다. citeturn11search0turn11search10 + +필수 Contract Test 목록은 다음과 같습니다. + +| 범주 | Release Gate | +|---|---| +| Mapping | ID, Embeddable, record Embeddable, Enum, time, converter, association | +| Lifecycle | persist, merge, dirty check, flush, clear, detach, refresh | +| Transaction | commit, rollback, checked/unchecked rollback rule, `REQUIRES_NEW`, self-invocation | +| Concurrency | optimistic conflict, pessimistic timeout, deadlock, serialization failure | +| Constraint | unique race, FK, check, partial unique | +| Query | derived, JPQL, projection, specification, native | +| Fetch | N+1, graph, fetch join, multiple collections, statement count | +| Pagination | Page, Slice, keyset, duplicate sort values, concurrent insertion | +| Hibernate 7.4 | **collection fetch join + pagination regression** | +| Batch | actual JDBC batching, IDENTITY no-batch, sequence batch, flush/clear | +| Bulk | bulk update 뒤 stale Entity | +| PostgreSQL | JSONB, Array, Range, `ON CONFLICT`, `SKIP LOCKED` | +| Flyway | empty DB, previous release snapshot, repeatable, checksum mismatch | +| Security | restricted application role, dynamic sort injection, SQL log masking | +| Pool | saturation, acquire timeout, `REQUIRES_NEW` pressure | +| Failure | process kill, network loss, DB restart, transaction retry | +| Commit ambiguity | COMMIT 전/중/후 connection loss simulation | +| Observability | cardinality, PII masking, queryName/failureCategory | + +PostgreSQL version matrix는 PR마다 최소 oldest/current인 `16 + 18`, release branch에서 `16 + 17 + 18` 전체를 실행하는 방식이 비용과 호환성 검증의 균형점입니다. 다만 “16·17·18 Stable”이라고 선언하려면 release gate에서는 세 버전을 모두 통과해야 합니다. + +Migration은 단순히 **빈 DB → latest**만 테스트하면 부족합니다. + +```text +empty +→ latest + +previous release N-1 +→ latest + +oldest supported upgrade snapshot +→ latest + +checksum modified +→ validation must fail + +missing migration +→ validation must fail + +failed non-transactional migration +→ known recovery procedure +``` + +를 함께 검증해야 합니다. Flyway가 checksum/name/type/missing migration을 validation 대상으로 삼기 때문입니다. citeturn19search0turn19search4 + +**실무 실패 사례를 플랫폼 규칙으로 변환하면 다음과 같습니다.** + +| 상황 | 직접 원인 | 설계 규칙 | 회귀 테스트 | +|---|---|---|---| +| OSIV 뒤에서 N+1 발생 | Web serialization 중 LAZY load | OSIV off, DTO/fetch plan | Controller 밖 Entity access 실패 | +| EAGER 폭증 | mapping이 use case fetch plan을 결정 | 최소 mapping + query fetch plan | SQL/row count | +| 여러 collection fetch | Cartesian product | DTO/분할 조회 | skewed collection fixture | +| Fetch Join + Page 전체 load | **구 Hibernate 동작** | 7.4+ PG에서는 새 SQL behavior 검증 | Hibernate 7.4 pagination regression citeturn13search0 | +| `saveAll()`인데 batch 없음 | JDBC batch 미설정/IDENTITY | actual batch 관측 | statement/batch count | +| IDENTITY batch 실패 | ID 얻기 위해 즉시 insert | write-heavy Entity는 sequence | ID strategy benchmark citeturn14view0 | +| Bulk update 후 stale | PC 미동기화 | flush → DML → clear | stale entity assertion citeturn12view1 | +| TX 안에서 API 대기 | DB resource 장기 보유 | 외부 I/O TX 밖 | pool pressure test | +| `REQUIRES_NEW` 고갈 | outer+inner connection 동시 점유 | 제한 + pool capacity test | concurrent nested tx citeturn15search0 | +| Optimistic 부분 Retry | stale PC에서 일부 코드 재실행 | 전체 unit-of-work retry | conflict fixture | +| Deadlock 무한 Retry | retry budget 없음 | bounded full-TX retry | deterministic deadlock | +| DDL auto update | runtime schema 변경 | Flyway only | app role DDL deny | +| H2만 통과 | DB semantics 차이 | PG contract mandatory | PG16~18 | +| Entity JSON 반환 | lazy graph serialization | DTO/projection | detached serialization | +| Soft Delete unique 충돌 | deleted row도 unique에 존재 | domain policy + partial index | recreate-after-delete | +| Replica stale read | replication lag | replica experimental | read-after-write lag | +| Commit 응답 유실 | 결과 모호 | no auto retry, reconciliation | protocol failure injection | + +## Stable 범위와 구현 순서 + +최종적인 **Stable / Experimental / 비지원 범위**는 다음이 현실적입니다. + +| 영역 | Stable | Experimental / Advanced | 초기 비지원 | +|---|---|---|---| +| Repository | Spring Data domain repository | custom fragments | GenericRepository 재구현 | +| JPA | Persistence 3.2 | Persistence 4.0 compatibility | Extended PC 일반 사용 | +| Provider | Hibernate 7.4 | Hibernate 8 lane | 임의 provider 동일 보장 선언 | +| DB | PostgreSQL 16·17·18 | PG19 compatibility | MySQL/Oracle 호환 선언 | +| Local DB | H2 convenience | — | H2를 PG 증명으로 사용 | +| Transaction | REQUIRED, read-only, timeout | MANDATORY, REQUIRES_NEW | remote distributed transaction 기본화 | +| Lock | Optimistic, standard pessimistic | NOWAIT/SKIP LOCKED extension | generic distributed lock | +| Query | Derived, JPQL, projection | Specification, Querydsl, native | 자유로운 raw SQL | +| Fetch | EntityGraph, fetch join, projection | batch/subselect fetch | global EAGER | +| Pagination | Page, Slice, keyset | Scroll/Stream | 무제한 findAll | +| Batch | JDBC batch | StatelessSession/COPY | `saveAll`을 batch guarantee로 정의 | +| Migration | Flyway migrate/validate | non-transactional/admin migration | prod ddl-auto update | +| Audit | Spring Data auditing opt-in | Envers | 모든 Entity 강제 history | +| Soft Delete | domain-specific | helper capability | global implicit soft delete | +| Cache | L1 | L2 opt-in | Query cache 기본 활성화 | +| Multi-tenancy | single tenant baseline | tenant column/RLS/schema/db | 투명 자동 multi-tenant | +| Replica | primary | read replica experimental | annotation만으로 자동 routing | +| Retry | bounded full-TX retry | domain-specific policy | repository-method retry | +| Completion unknown | error + reconciliation | domain-specific resolver | 자동 retry | + +이 조사에서 가장 중요한 결정은 **JPA 플랫폼이 많은 API를 제공하는 것보다 잘못된 사용을 어렵게 만드는 것**입니다. + +권장 핵심 API는 거대한 Repository가 아니라 다음과 같은 작은 기술 primitive입니다. + +```java +public interface JpaTransactionExecutor { + T execute(TransactionProfile profile, Supplier work); +} + +public record TransactionProfile( + String name, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile +) {} + +public interface JpaRetryPolicy { + RetryDecision classify(JpaPersistenceException error); +} + +public interface QueryObservation { + QueryScope start(String queryName); +} + +public interface PostgreSqlExtension { + // marker / capability boundary +} +``` + +다만 평범한 application service는 이런 저수준 API조차 직접 다루지 않고 보통 Spring `@Transactional` + domain repository를 사용하게 하는 편이 좋습니다. + +```java +@Service +@RequiredArgsConstructor +public class PlaceOrderService { + + private final OrderRepository orders; + private final OutboxRepository outbox; + + @Transactional + public OrderId place(PlaceOrder command) { + Order order = Order.place(command); + orders.save(order); + + outbox.save(OutboxMessage.from(order)); + + return order.getId(); + } +} +``` + +**단계별 구현 순서와 완료 조건**은 다음과 같이 잡는 것이 좋습니다. + +| 단계 | 구현 | 완료 조건 | +|---|---|---| +| Foundation | `jpa-core`, Boot BOM, PostgreSQL profile, Hikari, OSIV off | PG16·17·18 bootstrap 및 기본 CRUD contract 통과 | +| Mapping | Entity/ID/association/value 규칙, test fixtures | Mapping rule 문서 + ArchUnit/static check + PG round trip | +| Transaction | profile, boundaries, propagation, timeout | rollback/self-invocation/REQUIRES_NEW tests | +| Concurrency | version, lock, SQLSTATE error mapper | optimistic/deadlock/serialization/lock timeout 재현 | +| Error/Retry | common exception + full-TX retry | retryable/non-retryable matrix 자동 테스트 | +| Query | projection/specification/custom fragments | query startup validation + queryName 체계 | +| Fetch | EntityGraph/fetch join/query-count toolkit | N+1 및 Cartesian regression gate | +| Pagination | Slice/keyset/cursor | duplicate sort·concurrent insert contract | +| Batch | sequence profile, JDBC batch, flush/clear | 실제 JDBC batching 관측 | +| PostgreSQL Extension | JSONB/Array/Range, ON CONFLICT, lock extension | PG16·17·18 native capability tests | +| Migration | Flyway, validation, snapshots | empty + N-1 + oldest-supported migration 통과 | +| Observability | pool/tx/query/retry metrics | cardinality·PII tests | +| Security | DB role separation, log masking | app credential로 DDL 실패 보장 | +| Advanced | Envers, L2 cache, StatelessSession | 기능별 opt-in contract | +| Experimental | multi-tenancy, replica, JPA4/Hibernate8 | 별도 compatibility suite 통과 전 Stable 승격 금지 | + +최종적으로 이번 조사에서 요구된 산출물은 다음과 같이 귀결됩니다. + +| 요구 산출물 | 조사 결론 | +|---|---| +| Java·Spring Data·Hibernate·PG 지원 매트릭스 | Java 21 + Boot BOM + JPA 3.2 + Hibernate 7.4 + PG16~18 | +| J1~J4 계층 | Standard / Advanced / Provider Extension / Admin | +| Entity Mapping | Field access 중심, Entity 외부 직렬화 금지, domain ownership | +| ID 전략 | PG 기본 Sequence, UUID stable, IDENTITY write-heavy 제한 | +| Association | global cascade/eager 금지, lifecycle 명시 | +| Persistence Context | transaction-scoped, OSIV off | +| Transaction | Application Service boundary | +| Commit Unknown | 별도 `TransactionCompletionUnknown`, 자동 retry 금지 | +| Optimistic/Pessimistic | optimistic 우선, lock extension 제한 | +| Query | Derived → JPQL/Projection → Dynamic → Native 단계화 | +| Fetch | use-case fetch plan, quantitative regression | +| Pagination | Page/Slice/Keyset 역할 분리 | +| Batch | saveAll과 JDBC batch 구분 | +| Migration | Flyway가 schema change source of truth | +| Constraint/Index | DB invariant + query-driven index | +| PostgreSQL Extension | 별도 `jpa-postgresql` | +| Auditing/Soft Delete/History | 각각 별개 capability | +| Cache | L1 기본, L2 opt-in, query cache off | +| Multi-tenancy/Replica | 초기 Experimental | +| 오류/Retry | SQLSTATE 기반 안정 오류 + full-TX retry | +| Metric/Trace/Logging | queryName 기반, parameter·PII 배제 | +| Security | runtime/migration/admin credential 분리 | +| Tests | PG Testcontainers + 실제 version matrix | +| Stable/Experimental | JPA4/Hibernate8/multitenancy/replica 분리 | +| 구현 순서 | Foundation → semantics → performance → operations | + +가장 중요한 최종 설계 규칙은 여섯 가지로 압축됩니다. + +**첫째**, 도메인이 Entity와 Repository를 소유하며 JPA 플랫폼은 `GenericRepository`를 만들지 않습니다. Spring Data JPA가 이미 그 추상화를 제공하기 때문입니다. citeturn20view0 + +**둘째**, transaction은 Repository method가 아니라 **Application Use Case** 단위이며, Optimistic conflict·Deadlock·Serialization Failure의 retry도 새 Persistence Context에서 전체 transaction을 다시 실행합니다. PostgreSQL Serializable 역시 transaction 재실행을 전제로 합니다. citeturn13search9 + +**셋째**, DB에 요청을 보냈다는 사실과 commit이 확정됐다는 사실을 구분합니다. Commit 결과가 모호하면 `TransactionCompletionUnknown`으로 올리고 자동 retry하지 않습니다. PostgreSQL도 completion-unknown을 SQLSTATE에서 별도 condition으로 정의합니다. citeturn13search1 + +**넷째**, Fetch 전략은 Entity annotation의 EAGER/LAZY만으로 결정하지 않고 **use-case-specific Fetch Plan**으로 관리합니다. 특히 Hibernate 7.4에서 PostgreSQL의 collection fetch join + pagination 동작이 과거 버전과 달라졌으므로, 오래된 금지 규칙을 그대로 복사하지 말고 현재 버전 SQL을 contract test해야 합니다. citeturn13search0turn13search11 + +**다섯째**, Entity Mapping이 schema의 Source of Truth가 아닙니다. 운영 schema는 Flyway가 소유하고 Hibernate는 `validate` 역할을 맡으며, `repair`, concurrent index, backfill, partition 같은 작업은 J4 Admin 영역으로 분리합니다. citeturn19search0turn19search1turn17search3 + +**여섯째**, `H2에서 된다`를 호환성 증거로 쓰지 않습니다. **PostgreSQL 16·17·18의 실제 locking, constraint, batch, migration, query plan, SQLSTATE를 통과하는 것**을 이 플랫폼의 Stable 완료 조건으로 삼는 것이 적절합니다. citeturn0search3turn13search5 diff --git a/infra/jpa/postgres/README.md b/infra/jpa/postgres/README.md new file mode 100644 index 00000000..728b1d4f --- /dev/null +++ b/infra/jpa/postgres/README.md @@ -0,0 +1,39 @@ +# infra/jpa/postgres + +Server-side settings the JPA platform's contracts assume, and why each one matters. + +The contract suites start their own containers through +`dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory`, so +nothing here is needed to run them. This directory records what a *deployed* PostgreSQL has to look +like for the platform's guarantees to hold, because several of them are server settings rather than +application code. + +## Settings the platform depends on + +| Setting | Why the platform cares | +|---|---| +| `statement_timeout` | The last bound on a runaway statement. The platform sets transaction timeouts, but a single statement inside a transaction can still outlive the request that asked for it. | +| `idle_in_transaction_session_timeout` | An idle open transaction holds its locks and its snapshot indefinitely, which blocks writers and prevents vacuum. This is what turns "someone left a transaction open" into a bounded incident. | +| `lock_timeout` | A cluster-wide floor under the per-request lock bounds in `PostgreSqlLockOptions`. | +| `max_connections` | The number `app.jpa-platform.datasource.maximum-pool-size` must be sized against — across every instance, and allowing for `REQUIRES_NEW` taking a second connection while pinning the first. | +| `default_transaction_isolation` | Left at `read committed`. The platform selects `repeatable read` or `serializable` per transaction profile; changing the default would silently change every transaction that did not ask. | + +## Suggested baseline + +```conf +statement_timeout = '30s' +idle_in_transaction_session_timeout = '60s' +lock_timeout = '10s' +default_transaction_isolation = 'read committed' +``` + +These are starting points, not recommendations: the right `statement_timeout` depends on the +slowest legitimate query in the application, and setting it below that turns a working report into +an error. Measure before pinning. + +## What is deliberately not configured here + +- **Roles.** Credential separation lives in [`../roles/runtime-roles.sql`](../roles/runtime-roles.sql). +- **Schema.** Flyway owns it (design §31). Nothing in this directory creates a table. +- **Extensions.** The platform's PostgreSQL support — JSONB, arrays, ranges, `SKIP LOCKED`, + `ON CONFLICT` — is all core PostgreSQL. No extension is required, and none should be assumed. diff --git a/infra/jpa/roles/runtime-roles.sql b/infra/jpa/roles/runtime-roles.sql new file mode 100644 index 00000000..06305b0f --- /dev/null +++ b/infra/jpa/roles/runtime-roles.sql @@ -0,0 +1,54 @@ +-- Runtime / migration / admin credential separation for the JPA persistence platform. +-- Design §36; enforced at startup by PostgreSqlRuntimeRoleVerifier + DatabaseRolePolicy. +-- +-- 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. +-- +-- Run as a superuser once per database. Replace the placeholder passwords with values from the +-- deployment's secret store; they are intentionally not committed. + +-- 1. The schema the application owns. Owned by the migration role, not the runtime role. +create schema if not exists app authorization app_migration; + +-- 2. Roles. +-- app_migration : owns the schema, applies Flyway migrations. DDL. +-- app_runtime : the application's credential. DML only, no DDL, no CREATE. +-- app_admin : J4 operations — COPY, backfill, maintenance. Never used by request paths. +create role app_migration login password 'REPLACE_FROM_SECRET_STORE'; +create role app_runtime login password 'REPLACE_FROM_SECRET_STORE'; +create role app_admin login password 'REPLACE_FROM_SECRET_STORE'; + +-- 3. Revoke the PUBLIC grants that make the checks in DatabaseRolePolicy necessary. +-- Before PostgreSQL 15, PUBLIC held CREATE on the public schema — which is how an unprivileged +-- role ends up able to plant an object that shadows a real one through search_path. +revoke all on database current_database() from public; +revoke create on schema public from public; + +-- 4. Runtime: read and write rows in the application schema. Nothing else. +grant connect on database current_database() to app_runtime; +grant usage on schema app to app_runtime; +grant select, insert, update, delete on all tables in schema app to app_runtime; +grant usage, select on all sequences in schema app to app_runtime; + +-- Tables created by future migrations must inherit the same grants, or the first deployment after +-- a new table silently fails at runtime with a permission error. +alter default privileges for role app_migration in schema app + grant select, insert, update, delete on tables to app_runtime; +alter default privileges for role app_migration in schema app + grant usage, select on sequences to app_runtime; + +-- 5. Explicitly deny the two privileges the startup verifier checks for. +revoke create on schema app from app_runtime; +revoke create on database current_database() from app_runtime; + +-- 6. Admin: bulk operations under an audited identity, still without schema ownership. +grant connect on database current_database() to app_admin; +grant usage on schema app to app_admin; +grant select, insert, update, delete on all tables in schema app to app_admin; +alter default privileges for role app_migration in schema app + grant select, insert, update, delete on tables to app_admin; + +-- 7. Pin the runtime search_path so an unqualified name cannot resolve anywhere unexpected. +alter role app_runtime set search_path = app, pg_catalog; +alter role app_admin set search_path = app, pg_catalog; diff --git a/infra/jpa/toxiproxy/docker-compose.yml b/infra/jpa/toxiproxy/docker-compose.yml new file mode 100644 index 00000000..d0faf6b7 --- /dev/null +++ b/infra/jpa/toxiproxy/docker-compose.yml @@ -0,0 +1,43 @@ +# Commit-ambiguity failure injection for the JPA platform (design §39). +# +# The suite needs a proxy rather than a kill switch because the scenario that matters cannot be +# produced any other way. Stopping the container, killing the process, or closing the client socket +# all break *before* the server commits — the easy case, where the transaction rolled back and the +# use case may simply be re-run. The hard case is a commit the server completed whose +# acknowledgement never came back, and it only exists if you can cut the return path while leaving +# the forward path intact. +# +# That is what CommitAmbiguityProxy does with a downstream-only toxic, and it is the one scenario +# that distinguishes a platform that reports completion-unknown from one that retries a write which +# already succeeded. +# +# Ordinary contract runs use Testcontainers and do not need this file; it exists for reproducing a +# failure scenario by hand. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: jpa_failure + POSTGRES_USER: jpa_failure + POSTGRES_PASSWORD: jpa_failure + # No published port: the suite must reach PostgreSQL only through the proxy, or the injected + # fault can be bypassed by connecting directly and the test passes without testing anything. + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jpa_failure -d jpa_failure"] + interval: 2s + timeout: 3s + retries: 30 + + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.11.0 + depends_on: + postgres: + condition: service_healthy + ports: + # 8474 is the control API the suite drives; 8666 is the proxied PostgreSQL port. + - "8474:8474" + - "8666:8666" + command: ["-host", "0.0.0.0"] diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 6da5ad62..750e930a 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -18,6 +18,54 @@ This module is the RDBMS/JPA implementation base. It is not a datastore-neutral core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence adapters implement application/domain ports directly and must not depend on this module. +## JPA relational persistence platform + +The platform in `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` is +implemented here. The design models it as 18 Stable library modules; this repository's fail-closed +19-leaf registry outranks that layout, so those modules are **packages** in this leaf and +`docs/jpa/repository-adaptation.md` records the mapping. Read it before moving a type between +packages. + +| Platform package | Owns | +|---|---| +| `api` (+ `.capability`, `.error`, `.query`, `.transaction`) | framework-free contracts: operation and query names, the stable error hierarchy, transaction and retry profiles, keyset cursors | +| `transaction` | commit evidence, the Spring executor, full-transaction retry, completion-unknown reconciliation | +| `springdata` | repository fragments, safe sort, fetch plans, keyset execution, stream guard | +| `hibernate` (+ `.batch`, `.bulk`, `.stateless`) | statement inspector, statistics, batch, bulk DML, stateless session | +| `postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`, `.copy`) | SQLSTATE classification, locks and work claims, JSONB, arrays and ranges, upserts, COPY | +| `migration` | Flyway policy, validate gate, concurrent-index guard | +| `auditing`, `cache`, `envers`, `querydsl`, `security`, `observation` | opt-in capabilities and the runtime-role verifier | +| `experimental` | multi-tenancy, RLS, read replica, forward-compatibility lanes — all flag-gated | +| `testkit` (`src/testkit/java`) | ArchUnit rules, fixtures, query/plan assertions, PostgreSQL matrix, failure injection | + +### Non-negotiables + +- No `GenericRepository` and no platform base repository. Domains own their repositories. +- `TransactionCompletionUnknownException` is never retried. `JpaFailureContext` refuses to + represent a retryable completion-unknown failure, so a policy bug cannot produce one. +- Retry re-runs the whole use case in a new transaction and a new Persistence Context. +- OSIV is false in every runtime profile; Flyway owns schema change and Hibernate only validates. +- Metric tags, exception messages, and logs carry no SQL parameters, entity ids, tenant ids, or PII. +- Contracts run against real PostgreSQL. H2 never satisfies one. + +### Platform lanes + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test # hermetic unit lane +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest # real PostgreSQL +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest # Flyway upgrade scenarios +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest # deadlock, commit ambiguity +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest # EXPLAIN structure +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest # runtime role privileges +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest # pool pressure +./gradlew jpaReleaseGate # every gate, from the root +``` + +Every Docker-backed lane fails closed. A selected lane that discovers nothing, or a container that +cannot start, is an error rather than a skip — a skipped contract reports success for a database +nobody tested. + ## Responsibility - JPA entities. diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 7bbb2308..3cd45558 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -3,6 +3,15 @@ // implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository / // SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor // Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral). +// The JPA relational persistence platform (docs/superpowers/specs/2026-08-11-jpa-persistence- +// platform-design.md) models itself as 18 Stable library modules. This repository's fail-closed +// 19-leaf registry outranks that layout, so those modules are packages here and +// JpaModuleBoundaryTest enforces the design's module dependency table. The full mapping is in +// docs/jpa/repository-adaptation.md. +// +// The testkit is its own source set rather than part of `test` because more than one lane consumes +// it and because a source set whose dependencies are declared only on the test configurations gives +// the design's "no production module depends on the testkit" guarantee without a new Gradle project. sourceSets { postgresqlIntegrationTest { java.setSrcDirs(['src/postgresqlIntegrationTest/java']) @@ -10,6 +19,16 @@ sourceSets { compileClasspath += sourceSets.main.output runtimeClasspath += output + compileClasspath } + testkit { + java.srcDir 'src/testkit/java' + compileClasspath += sourceSets.main.output + runtimeClasspath += output + compileClasspath + } + jpaPlatformPerformanceTest { + java.srcDir 'src/jpaPlatformPerformanceTest/java' + compileClasspath += sourceSets.main.output + sourceSets.testkit.output + runtimeClasspath += output + compileClasspath + } } configurations { @@ -17,6 +36,20 @@ configurations { postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor + testkitImplementation.extendsFrom testImplementation + testkitRuntimeOnly.extendsFrom testRuntimeOnly + jpaPlatformPerformanceTestImplementation.extendsFrom testImplementation + jpaPlatformPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly +} + +// Every test lane compiles and runs against the testkit. +sourceSets.test { + compileClasspath += sourceSets.testkit.output + runtimeClasspath += sourceSets.testkit.output +} +sourceSets.postgresqlIntegrationTest { + compileClasspath += sourceSets.testkit.output + runtimeClasspath += sourceSets.testkit.output } ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine' @@ -43,9 +76,41 @@ dependencies { runtimeOnly 'com.h2database:h2' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + // JPA platform observability (design §37). Micrometer's observation API already arrives with + // Spring; the meter registry does not, and the platform's transaction/query/retry metrics need + // it. Version managed by the Spring Boot BOM. + implementation 'io.micrometer:micrometer-core' + + // Querydsl and Envers are Advanced opt-ins (design §4.2): the platform implements their + // contracts, but the Stable runtime classpath must not carry either. compileOnly keeps them off + // every deployment while still compiling the support classes; a deployment that opts in adds the + // artifact itself, and the guards refuse the capability when the classes are absent. + compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + compileOnly 'org.hibernate.orm:hibernate-envers' + + testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + testImplementation 'org.hibernate.orm:hibernate-envers' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql' postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-toxiproxy' postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql' + + testkitImplementation 'org.testcontainers:testcontainers' + testkitImplementation 'org.testcontainers:testcontainers-postgresql' + testkitImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testkitImplementation 'org.testcontainers:testcontainers-toxiproxy' + testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testkitRuntimeOnly 'org.postgresql:postgresql' + + // The performance lane starts its own servers: pool saturation is only observable against a + // real database, because the thing being measured is what happens when every connection to it + // is already held. + jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers' + jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers-postgresql' + jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + jpaPlatformPerformanceTestRuntimeOnly 'org.postgresql:postgresql' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } @@ -169,4 +234,80 @@ postgresqlSecurityBaselineIntegrationTest.configure { dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest') } +// JPA platform lanes (design §40-§41). Each maps one of the plan's JVM test suites onto this +// leaf's existing Docker-backed source set; the mapping is recorded in +// docs/jpa/repository-adaptation.md §3. +// +// Every lane fails closed. `failOnNoDiscoveredTests` matters more here than usual: a selected lane +// that discovers nothing reports success, and a contract suite that silently stopped running is +// indistinguishable from one that passes. +Closure registerJpaPlatformLane = { String taskName, String tag, String description -> + tasks.register(taskName, Test) { + group = 'verification' + it.description = description + testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs + classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath + useJUnitPlatform { + includeTags tag + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs('-Duser.timezone=UTC') + // The Stable matrix selection. An unknown or empty value is an error in + // PostgreSqlVersion.parseSelection rather than an empty run. + systemProperty 'jpa.matrix.versions', + (project.findProperty('jpa.matrix.versions') ?: '16').toString() + } +} + +def jpaPlatformContractTest = registerJpaPlatformLane( + 'jpaPlatformContractTest', + 'jpa-contract', + 'Runs the JPA platform contract suite against real PostgreSQL (design §40).') +def jpaPlatformMigrationTest = registerJpaPlatformLane( + 'jpaPlatformMigrationTest', + 'jpa-migration', + 'Runs the Flyway upgrade snapshot scenarios (design §31).') +def jpaPlatformFailureTest = registerJpaPlatformLane( + 'jpaPlatformFailureTest', + 'jpa-failure', + 'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).') +def jpaPlatformQueryPlanTest = registerJpaPlatformLane( + 'jpaPlatformQueryPlanTest', + 'jpa-queryplan', + 'Asserts query plan structure and planner estimate error (design §33).') +def jpaPlatformSecurityTest = registerJpaPlatformLane( + 'jpaPlatformSecurityTest', + 'jpa-security', + 'Verifies runtime role privileges and search_path safety (design §36).') + +// Machine-dependent bounds live in their own source set and never gate an ordinary build: attaching +// them to `check` would make a laptop's `check` fail for reasons that are not about the code. +def jpaPlatformPerformanceTest = tasks.register('jpaPlatformPerformanceTest', Test) { + group = 'verification' + description = 'Certifies Hikari pool and REQUIRES_NEW connection pressure (design §38).' + testClassesDirs = sourceSets.jpaPlatformPerformanceTest.output.classesDirs + classpath = sourceSets.jpaPlatformPerformanceTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs('-Duser.timezone=UTC') + systemProperty 'performance.assertions.enabled', + (project.findProperty('performance.assertions.enabled') ?: 'false').toString() +} + +// The JPA release gate (design §41). Aggregates every lane whose absence would let one of the +// documented gates in docs/jpa/support-matrix.md pass unverified. +tasks.register('jpaPlatformReleaseGate') { + group = 'verification' + description = 'Runs every JPA platform lane required for a release (design §41).' + dependsOn tasks.named('test') + dependsOn jpaPlatformContractTest + dependsOn jpaPlatformMigrationTest + dependsOn jpaPlatformFailureTest + dependsOn jpaPlatformQueryPlanTest + dependsOn jpaPlatformSecurityTest + dependsOn jpaPlatformPerformanceTest +} + apply from: rootProject.file('gradle/jpa-evidence.gradle') diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 0e04cce5..1d80bd48 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -1,211 +1,226 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml:classmate:1.7.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath -com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath -com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath +com.google.code.gson:gson:2.13.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -com.h2database:h2:2.4.240=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.h2database:h2:2.4.240=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.mysema.commons:mysema-commons-lang:0.2.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.sun.istack:istack-commons-runtime:4.1.2=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:7.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.querydsl:querydsl-core:5.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.querydsl:querydsl-jpa:5.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath +commons-codec:commons-codec:1.19.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath +commons-io:commons-io:2.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.smallrye:jandex:3.3.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -net.minidev:accessors-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.java.dev.jna:jna:5.18.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.minidev:accessors-smart:2.6.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.minidev:json-smart:2.6.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs +org.apache.commons:commons-compress:1.28.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=postgresqlIntegrationTestCompileClasspath,testCompileClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.aspectj:aspectjweaver:1.9.25=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.assertj:assertj-core:3.27.6=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.awaitility:awaitility:4.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.angus:angus-activation:2.0.3=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.14.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.models:hibernate-models:1.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.hamcrest:hamcrest:3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hibernate.orm:hibernate-envers:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.jetbrains:annotations:17.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.jetbrains:annotations:17.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jpaPlatformPerformanceTestAnnotationProcessor,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.mockito:mockito-core:5.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.objenesis:objenesis:3.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +org.postgresql:postgresql:42.7.8=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aspects:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-messaging:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-orm:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java new file mode 100644 index 00000000..96769a31 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.pool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.testkit.pool.PoolMeasurement; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * A finite pool refuses rather than waiting forever (design §38). + * + *

The bound being asserted is behavioural, not machine-dependent: with every connection held, a + * further acquisition must fail within the configured timeout. That is the difference between pool + * exhaustion presenting as failed requests and presenting as requests that never return. + */ +class HikariPoolSaturationContractTest { + + private static final int POOL_SIZE = 2; + private static final Duration ACQUIRE_TIMEOUT = Duration.ofMillis(500); + + private static PostgreSQLContainer container; + + @BeforeAll + static void startServer() { + container = PostgreSqlContainerFactory.create(PostgreSqlVersion.PG_16); + container.start(); + } + + @AfterAll + static void stopServer() { + if (container != null) { + container.stop(); + } + } + + @Test + @DisplayName("a saturated pool fails within its acquisition timeout") + void saturatedPoolFailsWithinItsTimeout() throws SQLException { + try (HikariDataSource pool = pool()) { + List held = new ArrayList<>(); + try { + for (int index = 0; index < POOL_SIZE; index++) { + held.add(pool.getConnection()); + } + + Instant startedAt = Instant.now(); + assertThatThrownBy(pool::getConnection).isInstanceOf(SQLException.class); + Duration waited = Duration.between(startedAt, Instant.now()); + + assertThat(waited) + .as("an unbounded wait turns exhaustion into requests that never return") + .isLessThan(ACQUIRE_TIMEOUT.plusSeconds(2)); + } finally { + for (Connection connection : held) { + connection.close(); + } + } + } + } + + @Test + @DisplayName("releasing a connection lets the next caller through") + void releasingAConnectionLetsTheNextCallerThrough() throws SQLException { + try (HikariDataSource pool = pool()) { + Connection first = pool.getConnection(); + Connection second = pool.getConnection(); + first.close(); + + try (Connection third = pool.getConnection()) { + assertThat(third.isValid(1)).isTrue(); + } + second.close(); + } + } + + @Test + @DisplayName("the measurement reports what the pool is actually doing") + void measurementReportsPoolState() throws SQLException { + try (HikariDataSource pool = pool()) { + try (Connection held = pool.getConnection()) { + var bean = pool.getHikariPoolMXBean(); + var measurement = + new PoolMeasurement( + bean.getActiveConnections(), + bean.getIdleConnections(), + bean.getThreadsAwaitingConnection(), + Duration.ZERO); + + assertThat(measurement.active()).isEqualTo(1); + assertThat(measurement.total()).isGreaterThanOrEqualTo(1); + assertThat(held.isValid(1)).isTrue(); + } + } + } + + private static HikariDataSource pool() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(POOL_SIZE); + config.setConnectionTimeout(ACQUIRE_TIMEOUT.toMillis()); + return new HikariDataSource(config); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java new file mode 100644 index 00000000..62081d5c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.pool; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.testkit.pool.PoolMeasurement; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Pool pressure certification (design §38). + * + *

The lane reports rather than asserts unless {@code performance.assertions.enabled} is set, + * because the numbers depend on the machine. A shared CI runner producing a red build for a + * threshold it never had the resources to meet teaches people to ignore the lane. + * + *

What is asserted unconditionally is the arithmetic the pool has to satisfy. With {@code + * REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a second one, so + * a pool sized for the thread count alone deadlocks with every connection held by a thread waiting + * for another connection. + */ +class PoolPressureContractTest { + + private static final boolean ASSERTIONS_ENABLED = + Boolean.parseBoolean(System.getProperty("performance.assertions.enabled", "false")); + + @Test + @DisplayName("pending count and acquire latency are reported together") + void reportsPendingAndAcquireLatencyTogether() { + var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)); + + assertThat(measurement.pending()).isEqualTo(3); + assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80)); + assertThat(measurement.total()).isEqualTo(6); + assertThat(measurement.saturated()).isTrue(); + } + + @Test + @DisplayName("a REQUIRES_NEW depth of one needs two connections per concurrent thread") + void requiresNewNeedsTwoConnectionsPerThread() { + int concurrentThreads = 8; + int maxRequiresNewDepth = 1; + + int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; + + assertThat(required).isEqualTo(17); + if (!ASSERTIONS_ENABLED) { + // Machine-dependent bounds are not asserted in this run; the arithmetic above is. + assertThat(ASSERTIONS_ENABLED).isFalse(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java new file mode 100644 index 00000000..2d0f0614 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.pool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion; +import java.sql.Connection; +import java.sql.SQLException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * {@code REQUIRES_NEW} needs a second connection while pinning the first (design §38). + * + *

This is the arithmetic behind the pool-sizing rule, demonstrated rather than asserted from a + * formula: one thread holding an outer connection and asking for an inner one needs two, and a pool + * sized for the thread count alone deadlocks with every connection held by a thread waiting for + * another connection. + */ +class RequiresNewPoolPressureContractTest { + + private static PostgreSQLContainer container; + + @BeforeAll + static void startServer() { + container = PostgreSqlContainerFactory.create(PostgreSqlVersion.PG_16); + container.start(); + } + + @AfterAll + static void stopServer() { + if (container != null) { + container.stop(); + } + } + + @Test + @DisplayName("a pool of one deadlocks the moment an inner transaction is needed") + void poolOfOneCannotServeAnInnerTransaction() throws SQLException { + try (HikariDataSource pool = pool(1)) { + try (Connection outer = pool.getConnection()) { + outer.setAutoCommit(false); + + assertThatThrownBy(pool::getConnection) + .as("the outer transaction still holds its connection while the inner one is opened") + .isInstanceOf(SQLException.class); + + outer.rollback(); + } + } + } + + @Test + @DisplayName("a pool of two serves the same nesting") + void poolOfTwoServesTheSameNesting() throws SQLException { + try (HikariDataSource pool = pool(2)) { + try (Connection outer = pool.getConnection()) { + outer.setAutoCommit(false); + + try (Connection inner = pool.getConnection()) { + inner.setAutoCommit(false); + assertThat(inner.isValid(1)).isTrue(); + assertThat(inner).isNotSameAs(outer); + inner.commit(); + } + + outer.rollback(); + } + } + } + + @Test + @DisplayName("the sizing rule matches the observed requirement") + void sizingRuleMatchesTheObservedRequirement() { + int concurrentThreads = 1; + int maxRequiresNewDepth = 1; + + int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; + + assertThat(required) + .as("one thread at depth one needs two connections; the rule adds headroom") + .isEqualTo(3); + } + + private static HikariDataSource pool(int size) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(size); + config.setConnectionTimeout(500L); + return new HikariDataSource(config); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java new file mode 100644 index 00000000..dad1a125 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.persistence.api; + +import java.util.regex.Pattern; + +/** + * Bounded, low-cardinality identity for one logical persistence operation (design §9.1). + * + *

The value is the key used by metrics, traces, and retry policy, so it must never carry a + * dynamic identifier: no entity id, no tenant id, no SQL fragment, no request-scoped value. The + * format is fixed by the design and validated in the canonical constructor, which is what keeps + * metric cardinality bounded at the type level rather than by convention. + */ +public record PersistenceOperationName(String value) { + + /** Design §9.1 — the exact accepted shape of an operation name. */ + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public PersistenceOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java new file mode 100644 index 00000000..290b8948 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.persistence.api.capability; + +import java.util.List; +import java.util.Objects; + +/** + * An immutable declaration that one {@link JpaCapability} is supported at one {@link SupportLevel} + * under a fixed list of constraints. + * + *

Constraints are plain bounded strings on purpose. A capability record must stay serialisable + * into a report and safe to publish through an actuator endpoint, so it never stores a provider + * object — no {@code DataSource}, no {@code EntityManagerFactory}, no Hibernate {@code + * SessionFactory}. Holding one would drag a live resource into a value type and let a report leak a + * JDBC URL or credentials. + */ +public record CapabilitySupport( + JpaCapability capability, SupportLevel level, List constraints) { + + public CapabilitySupport { + Objects.requireNonNull(capability, "capability"); + Objects.requireNonNull(level, "level"); + constraints = List.copyOf(Objects.requireNonNull(constraints, "constraints")); + for (String constraint : constraints) { + if (constraint == null || constraint.isBlank()) { + throw new IllegalArgumentException("capability constraint must not be blank"); + } + } + } + + /** Declares a capability with no additional constraint. */ + public static CapabilitySupport of(JpaCapability capability, SupportLevel level) { + return new CapabilitySupport(capability, level, List.of()); + } + + /** Declares a capability that callers may rely on without an extra opt-in. */ + public static CapabilitySupport stable(JpaCapability capability, String... constraints) { + return new CapabilitySupport(capability, SupportLevel.STABLE, List.of(constraints)); + } + + /** Declares a capability that requires an explicit dependency, registration, or token. */ + public static CapabilitySupport advanced(JpaCapability capability, String... constraints) { + return new CapabilitySupport(capability, SupportLevel.ADVANCED, List.of(constraints)); + } + + /** Declares a capability that is only reachable behind an experimental feature flag. */ + public static CapabilitySupport experimental(JpaCapability capability, String... constraints) { + return new CapabilitySupport(capability, SupportLevel.EXPERIMENTAL, List.of(constraints)); + } + + /** Whether an ordinary application may use this capability without a further opt-in. */ + public boolean usableByDefault() { + return level == SupportLevel.STABLE; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java new file mode 100644 index 00000000..77a4ffd3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.persistence.api.capability; + +/** + * The named capabilities the JPA persistence platform can report on (design §4, §8). + * + *

A capability is a contract the platform either supports at a declared {@link SupportLevel} or + * does not. The id is a bounded metric/report key, so it follows the same low-cardinality rule as + * {@link dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName}. + */ +public enum JpaCapability { + + /** Full use-case retry in a new transaction and a new Persistence Context (design §19). */ + TRANSACTION_RETRY("transaction.retry"), + + /** Commit-phase evidence tracking that yields completion-unknown instead of a guess (§17). */ + COMPLETION_EVIDENCE("transaction.completion-evidence"), + + /** Signed, tie-broken keyset cursors instead of deep offset pagination (§27). */ + KEYSET_PAGINATION("query.keyset-pagination"), + + /** Verified JDBC batch execution with bounded Persistence Context growth (§28). */ + BATCH("write.jdbc-batch"), + + /** Registered PostgreSQL {@code ON CONFLICT} / {@code RETURNING} writes (§8.3). */ + POSTGRESQL_NATIVE_WRITE("postgresql.native-write"), + + /** Flyway-owned schema with a fail-closed Hibernate validate gate (§31). */ + SCHEMA_GATE("migration.schema-gate"), + + /** Selective Hibernate second-level cache with Query Cache off by default (§34). */ + L2_CACHE("cache.hibernate-l2"), + + /** Opt-in Hibernate Envers entity history (§35). */ + ENVERS("history.envers"), + + /** PostgreSQL {@code FOR UPDATE SKIP LOCKED} queue claims (§21.3). */ + POSTGRESQL_WORK_CLAIM("postgresql.work-claim"), + + /** Versioned JSONB document mapping and registered path queries (§8.3). */ + POSTGRESQL_JSONB("postgresql.jsonb"), + + /** Typed array and bounded/unbounded range mapping (§8.3). */ + POSTGRESQL_ARRAY_RANGE("postgresql.array-range"), + + /** Admin-only bounded {@code COPY} bulk load (§30). */ + POSTGRESQL_COPY("postgresql.copy"), + + /** Hibernate {@code StatelessSession} bulk runner (§30.1). */ + STATELESS_SESSION("hibernate.stateless-session"), + + /** Bulk DML with mandatory flush → statement → clear ordering (§29). */ + BULK_DML("write.bulk-dml"), + + /** Runtime database role and {@code search_path} verification (§36). */ + RUNTIME_ROLE_VERIFICATION("security.runtime-role"), + + /** Bounded-tag metrics, tracing, and redacted SQL diagnostics (§37). */ + OBSERVABILITY("observability.jpa"); + + private final String id; + + JpaCapability(String id) { + this.id = id; + } + + /** The bounded report/metric key for this capability. */ + public String id() { + return id; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/SupportLevel.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/SupportLevel.java new file mode 100644 index 00000000..0f7ec46c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/SupportLevel.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.capability; + +/** + * How far a platform capability is supported (design §4, §7.1). + * + *

The level is declared, not inferred: a capability whose evidence suite has not run is not + * {@link #STABLE} merely because the code compiles. + */ +public enum SupportLevel { + + /** Verified by the Stable contract suite on the whole PostgreSQL Stable matrix. */ + STABLE, + + /** Available, but requires an explicit module dependency, registration, or capability token. */ + ADVANCED, + + /** Behind a {@code backend.jpa.experimental.*} flag; never part of the Stable composition. */ + EXPERIMENTAL, + + /** Explicitly out of scope (design §4.4); selecting it is a configuration error. */ + UNSUPPORTED +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/CheckConstraintViolationException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/CheckConstraintViolationException.java new file mode 100644 index 00000000..00de31ea --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/CheckConstraintViolationException.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; + +/** + * A check constraint rejected the write ({@code 23514}). + * + *

The database enforced an invariant the application should have enforced first; re-running the + * same write cannot succeed. + */ +public final class CheckConstraintViolationException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + private final transient ConstraintViolationDetails details; + + public CheckConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) { + super(FailureCategory.CHECK_CONSTRAINT, contextWithConstraint(context, details), cause); + this.details = details; + } + + public CheckConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details) { + this(context, details, null); + } + + /** The registered constraint code, and the bounded physical name when the server reported one. */ + public ConstraintViolationDetails details() { + return details; + } + + private static JpaFailureContext contextWithConstraint( + JpaFailureContext context, ConstraintViolationDetails details) { + Objects.requireNonNull(details, "details"); + return details.databaseName().map(context::withConstraintName).orElse(context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java new file mode 100644 index 00000000..8d67af0c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A connection could not be obtained, or was lost outside the commit phase. + * + *

A connection failure is only escalated to {@link TransactionCompletionUnknownException} when + * it happens while the transaction is committing; classifying every connection loss as + * completion-unknown would make ordinary pool exhaustion look like possible data loss (design + * §17.2). + */ +public final class ConnectionUnavailableException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public ConnectionUnavailableException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.CONNECTION_UNAVAILABLE, context, cause); + } + + public ConnectionUnavailableException(JpaFailureContext context) { + super(FailureCategory.CONNECTION_UNAVAILABLE, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java new file mode 100644 index 00000000..e9192122 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.regex.Pattern; + +/** + * The application-owned identity of a database constraint (design §22.4). + * + *

This is the value a use case may branch on — {@code "user.active-email.unique"} — as opposed + * to the physical index name the server reported. The indirection is what lets an index be renamed, + * split into a partial index, or rebuilt concurrently without changing a single line of application + * logic. + * + *

The type lives in the framework-free core rather than in the PostgreSQL package because {@link + * ConstraintViolationDetails} is a core contract and the core may not depend on a vendor module. + * See {@code docs/jpa/repository-adaptation.md} §4. + */ +public record ConstraintCode(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public ConstraintCode { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid constraint code"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java new file mode 100644 index 00000000..24862ec4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * What a constraint violation is allowed to tell the application (design §22.4). + * + *

The {@link ConstraintCode} is the registered, application-owned identity of the rule that was + * violated — it is what a use case may branch on. The {@code databaseConstraintName} is the + * physical name the server reported; it is optional, bounded, and intended for operators rather + * than for business logic, so that renaming an index never silently changes application behaviour. + */ +public record ConstraintViolationDetails(ConstraintCode code, String databaseConstraintName) { + + /** Physical constraint names come from the server and are bounded before they are exposed. */ + private static final Pattern DATABASE_NAME = Pattern.compile("[A-Za-z0-9._-]{1,128}"); + + /** The code used when the reported constraint is not in the registry. */ + public static final ConstraintCode UNKNOWN_CODE = + new ConstraintCode("database.constraint.unknown"); + + public ConstraintViolationDetails { + Objects.requireNonNull(code, "code"); + if (databaseConstraintName != null + && !DATABASE_NAME.matcher(databaseConstraintName).matches()) { + databaseConstraintName = JpaFailureContext.REDACTED; + } + } + + /** A registered violation with no physical constraint name available. */ + public static ConstraintViolationDetails of(ConstraintCode code) { + return new ConstraintViolationDetails(code, null); + } + + /** The fallback used when the server reported a constraint the catalog does not know. */ + public static ConstraintViolationDetails unknown(String databaseConstraintName) { + return new ConstraintViolationDetails(UNKNOWN_CODE, databaseConstraintName); + } + + /** The physical constraint name, when the server reported a bounded one. */ + public Optional databaseName() { + return Optional.ofNullable(databaseConstraintName); + } + + /** Whether this violation was resolved against the registered catalog. */ + public boolean registered() { + return !UNKNOWN_CODE.equals(code); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DataCorruptionException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DataCorruptionException.java new file mode 100644 index 00000000..91c20dac --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DataCorruptionException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A stored value cannot be read back into its declared Java type (design §12). + * + *

The offending value is never included: the whole point of this type is to report the failure + * without copying the malformed data into a log. + */ +public final class DataCorruptionException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public DataCorruptionException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.DATA_CORRUPTION, context, cause); + } + + public DataCorruptionException(JpaFailureContext context) { + super(FailureCategory.DATA_CORRUPTION, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DeadlockDetectedException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DeadlockDetectedException.java new file mode 100644 index 00000000..a7997810 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/DeadlockDetectedException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * The server selected this transaction as a deadlock victim and aborted it ({@code 40P01}). + * + *

Unlike a lock timeout, the transaction is already rolled back, so the only safe continuation + * is a complete re-run in a new transaction (design §19.1). + */ +public final class DeadlockDetectedException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public DeadlockDetectedException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.DEADLOCK, context, cause); + } + + public DeadlockDetectedException(JpaFailureContext context) { + super(FailureCategory.DEADLOCK, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java new file mode 100644 index 00000000..23a24ab2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * Provider-independent classification of a persistence failure (design §18.2). + * + *

The category is derived from SQLSTATE and structured server error fields, never from parsing a + * localized message. A state the platform does not recognise stays {@link #UNKNOWN}; it is not + * optimistically folded into a retryable category, because guessing here is what turns a + * non-idempotent write into a duplicate. + */ +public enum FailureCategory { + + /** {@code 40001} — the transaction lost a serialization race and may be re-run. */ + SERIALIZATION_FAILURE, + + /** {@code 40003} — the server could not report whether the statement completed. */ + COMPLETION_UNKNOWN, + + /** {@code 40P01} — the server chose this transaction as the deadlock victim. */ + DEADLOCK, + + /** {@code 23505} — a unique or exclusion constraint rejected the write. */ + UNIQUE_CONSTRAINT, + + /** {@code 23503} — a foreign key constraint rejected the write. */ + FOREIGN_KEY_CONSTRAINT, + + /** {@code 23514} — a check constraint rejected the write. */ + CHECK_CONSTRAINT, + + /** {@code 23502} — a not-null constraint rejected the write. */ + NOT_NULL_CONSTRAINT, + + /** {@code 55P03} — a lock could not be acquired within the configured bound. */ + LOCK_NOT_AVAILABLE, + + /** An optimistic {@code @Version} check failed at flush or commit. */ + OPTIMISTIC_CONFLICT, + + /** A statement exceeded its configured statement timeout. */ + QUERY_TIMEOUT, + + /** A transaction exceeded its configured transaction timeout. */ + TRANSACTION_TIMEOUT, + + /** A connection could not be obtained or was lost outside the commit phase. */ + CONNECTION_UNAVAILABLE, + + /** The physical schema does not match what the provider or migration gate requires. */ + SCHEMA_MISMATCH, + + /** A stored value cannot be read back into its declared Java type. */ + DATA_CORRUPTION, + + /** A required row was absent where the use case requires it to exist. */ + ENTITY_NOT_FOUND, + + /** The SQLSTATE is not registered; the platform refuses to guess a disposition. */ + UNKNOWN +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ForeignKeyViolationException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ForeignKeyViolationException.java new file mode 100644 index 00000000..541dc7c2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ForeignKeyViolationException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; + +/** + * A foreign key constraint rejected the write ({@code 23503}). + * + *

Never retryable: the referenced row's absence is a state fact, not a transient race. + */ +public final class ForeignKeyViolationException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + private final transient ConstraintViolationDetails details; + + public ForeignKeyViolationException( + JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) { + super(FailureCategory.FOREIGN_KEY_CONSTRAINT, contextWithConstraint(context, details), cause); + this.details = details; + } + + public ForeignKeyViolationException( + JpaFailureContext context, ConstraintViolationDetails details) { + this(context, details, null); + } + + /** The registered constraint code, and the bounded physical name when the server reported one. */ + public ConstraintViolationDetails details() { + return details; + } + + private static JpaFailureContext contextWithConstraint( + JpaFailureContext context, ConstraintViolationDetails details) { + Objects.requireNonNull(details, "details"); + return details.databaseName().map(context::withConstraintName).orElse(context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java new file mode 100644 index 00000000..e3683e8c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A row the use case requires does not exist (design §18). + * + *

The missing identifier is not part of the message; the operation name is what identifies the + * lookup that failed. + */ +public final class JpaEntityNotFoundException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public JpaEntityNotFoundException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.ENTITY_NOT_FOUND, context, cause); + } + + public JpaEntityNotFoundException(JpaFailureContext context) { + super(FailureCategory.ENTITY_NOT_FOUND, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java new file mode 100644 index 00000000..3f79511d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import java.time.Duration; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The bounded metadata every stable persistence failure carries (design §18.1). + * + *

Every component is either a registered identity, a SQLSTATE, a counter, a boolean, or a + * duration. No component may carry a SQL parameter value, an entity id, a tenant id, the SQL text, + * or PII — this record is what reaches logs, metrics, and error responses. + * + *

The record enforces one invariant that the rest of the platform depends on: a failure whose + * completion is unknown is never {@code retryable}. Automatically re-running work whose commit may + * already have succeeded is the single most damaging thing this platform could do, so the type + * system refuses to represent it (design §17.4, §19.1). + */ +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + String constraintName, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { + + /** SQLSTATE is five alphanumeric characters; anything else is not a SQLSTATE. */ + private static final Pattern SQL_STATE = Pattern.compile("[0-9A-Za-z]{5}"); + + /** Constraint and trace identifiers are bounded to keep diagnostics free of free-form text. */ + private static final Pattern BOUNDED_IDENTIFIER = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Substituted for any identifier that is not provably bounded. */ + public static final String REDACTED = "redacted"; + + /** Used when the driver reported no SQLSTATE at all. */ + public static final String NO_SQL_STATE = "00000"; + + public JpaFailureContext { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(elapsed, "elapsed"); + if (elapsed.isNegative()) { + throw new IllegalArgumentException("elapsed must not be negative"); + } + if (transactionAttempt < 1) { + throw new IllegalArgumentException("transactionAttempt must be at least 1"); + } + if (completionUnknown && retryable) { + throw new IllegalArgumentException("completion unknown failures are never retryable"); + } + sqlState = normalizeSqlState(sqlState); + constraintName = normalizeOptionalIdentifier(constraintName); + traceId = normalizeOptionalIdentifier(traceId); + } + + /** + * A failure the platform may re-run as a whole new transaction. + * + * @param attempt the 1-based attempt that produced this failure + */ + public static JpaFailureContext retryable( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext(operation, sqlState, null, attempt, true, false, elapsed, traceId); + } + + /** A failure the platform must surface rather than re-run. */ + public static JpaFailureContext terminal( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext( + operation, sqlState, null, attempt, false, false, elapsed, traceId); + } + + /** + * A failure whose commit outcome the driver could not determine (design §17.2). + * + *

The result is always {@code retryable=false} and {@code completionUnknown=true}; there is no + * argument that lets a caller weaken either. + */ + public static JpaFailureContext completionUnknown( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext(operation, sqlState, null, attempt, false, true, elapsed, traceId); + } + + /** Returns a copy that also carries the bounded database constraint name. */ + public JpaFailureContext withConstraintName(String databaseConstraintName) { + return new JpaFailureContext( + operation, + sqlState, + databaseConstraintName, + transactionAttempt, + retryable, + completionUnknown, + elapsed, + traceId); + } + + /** Returns a copy recorded against a later attempt of the same logical operation. */ + public JpaFailureContext withAttempt(int attempt) { + return new JpaFailureContext( + operation, + sqlState, + constraintName, + attempt, + retryable, + completionUnknown, + elapsed, + traceId); + } + + /** Whether a bounded database constraint name is present. */ + public boolean hasConstraintName() { + return !constraintName.isEmpty(); + } + + private static String normalizeSqlState(String candidate) { + if (candidate == null || candidate.isBlank()) { + return NO_SQL_STATE; + } + String trimmed = candidate.trim(); + return SQL_STATE.matcher(trimmed).matches() ? trimmed : REDACTED; + } + + private static String normalizeOptionalIdentifier(String candidate) { + if (candidate == null || candidate.isBlank()) { + return ""; + } + String trimmed = candidate.trim(); + return BOUNDED_IDENTIFIER.matcher(trimmed).matches() ? trimmed : REDACTED; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java new file mode 100644 index 00000000..4d5af3f7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; + +/** + * Root of the provider-independent persistence error hierarchy (design §18). + * + *

The message is composed by this class from the bounded parts of {@link JpaFailureContext} and + * a fixed category label. Subclasses do not pass free-form text, which is what keeps SQL parameter + * values, entity ids, and PII out of every log line, error response, and metric derived from these + * exceptions. The provider exception is preserved as the {@linkplain #getCause() cause} for + * in-process classification and server-side diagnosis only. + */ +public class JpaPersistenceException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient JpaFailureContext context; + private final transient FailureCategory category; + + public JpaPersistenceException( + FailureCategory category, JpaFailureContext context, Throwable cause) { + super(describe(category, context), cause); + this.category = Objects.requireNonNull(category, "category"); + this.context = Objects.requireNonNull(context, "context"); + } + + public JpaPersistenceException(FailureCategory category, JpaFailureContext context) { + this(category, context, null); + } + + /** The bounded metadata carried by this failure. */ + public JpaFailureContext context() { + return context; + } + + /** The provider-independent classification of this failure. */ + public FailureCategory category() { + return category; + } + + /** Whether the platform may re-run the whole use case for this failure. */ + public boolean retryable() { + return context.retryable(); + } + + /** Whether the commit outcome of the failing transaction is undetermined. */ + public boolean completionUnknown() { + return context.completionUnknown(); + } + + /** + * Builds the exception message from bounded values only. + * + *

Every fragment below is a registered operation name, a validated SQLSTATE, a bounded + * identifier, an enum constant, an int, or a boolean. Nothing here can originate from row data. + */ + private static String describe(FailureCategory category, JpaFailureContext context) { + StringBuilder message = new StringBuilder(160); + message + .append("jpa persistence failure [") + .append(category) + .append("] operation=") + .append(context.operation().value()) + .append(" sqlState=") + .append(context.sqlState()) + .append(" attempt=") + .append(context.transactionAttempt()) + .append(" retryable=") + .append(context.retryable()) + .append(" completionUnknown=") + .append(context.completionUnknown()) + .append(" elapsedMillis=") + .append(context.elapsed().toMillis()); + if (context.hasConstraintName()) { + message.append(" constraint=").append(context.constraintName()); + } + if (!context.traceId().isEmpty()) { + message.append(" traceId=").append(context.traceId()); + } + return message.toString(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/NotNullConstraintViolationException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/NotNullConstraintViolationException.java new file mode 100644 index 00000000..8ce632cf --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/NotNullConstraintViolationException.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; + +/** + * A not-null constraint rejected the write ({@code 23502}, design §18.2). + * + *

The column name reaches the application only through the registered constraint catalog, so a + * schema rename cannot change what the application branches on. + */ +public final class NotNullConstraintViolationException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + private final transient ConstraintViolationDetails details; + + public NotNullConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) { + super(FailureCategory.NOT_NULL_CONSTRAINT, contextWithConstraint(context, details), cause); + this.details = details; + } + + public NotNullConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details) { + this(context, details, null); + } + + /** The registered constraint code, and the bounded physical name when the server reported one. */ + public ConstraintViolationDetails details() { + return details; + } + + private static JpaFailureContext contextWithConstraint( + JpaFailureContext context, ConstraintViolationDetails details) { + Objects.requireNonNull(details, "details"); + return details.databaseName().map(context::withConstraintName).orElse(context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/OptimisticConflictException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/OptimisticConflictException.java new file mode 100644 index 00000000..f1d5199e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/OptimisticConflictException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * An optimistic {@code @Version} check failed at flush or commit (design §20). + * + *

The conflicting entity type is carried by the observation layer through a bounded catalog; the + * entity id is deliberately absent, because it is row data. + */ +public final class OptimisticConflictException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public OptimisticConflictException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.OPTIMISTIC_CONFLICT, context, cause); + } + + public OptimisticConflictException(JpaFailureContext context) { + super(FailureCategory.OPTIMISTIC_CONFLICT, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/PessimisticLockTimeoutException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/PessimisticLockTimeoutException.java new file mode 100644 index 00000000..7e77b2f5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/PessimisticLockTimeoutException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A pessimistic lock could not be acquired within its bound, or {@code NOWAIT} refused it + * immediately (design §21.1, §21.2). + * + *

This is a statement-level outcome: the transaction is still the caller's to end. It is + * distinct from {@link DeadlockDetectedException}, which the server has already aborted. + */ +public final class PessimisticLockTimeoutException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public PessimisticLockTimeoutException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.LOCK_NOT_AVAILABLE, context, cause); + } + + public PessimisticLockTimeoutException(JpaFailureContext context) { + super(FailureCategory.LOCK_NOT_AVAILABLE, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/QueryTimeoutException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/QueryTimeoutException.java new file mode 100644 index 00000000..c537e8df --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/QueryTimeoutException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A statement exceeded its configured statement timeout. + * + *

Not retryable by default: a query that ran out of time usually costs the same the second time, + * and re-running it doubles the load that caused the timeout (design §19.1). + */ +public final class QueryTimeoutException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public QueryTimeoutException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.QUERY_TIMEOUT, context, cause); + } + + public QueryTimeoutException(JpaFailureContext context) { + super(FailureCategory.QUERY_TIMEOUT, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SchemaMismatchException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SchemaMismatchException.java new file mode 100644 index 00000000..f0d4b4a4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SchemaMismatchException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * The physical schema does not match what Hibernate validate or the Flyway gate requires (design + * §31). + * + *

Fail-closed and never retryable: the deployment is running against a schema it was not built + * for, and a second attempt cannot change that. + */ +public final class SchemaMismatchException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public SchemaMismatchException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.SCHEMA_MISMATCH, context, cause); + } + + public SchemaMismatchException(JpaFailureContext context) { + super(FailureCategory.SCHEMA_MISMATCH, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SerializationFailureException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SerializationFailureException.java new file mode 100644 index 00000000..6a6e6f19 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SerializationFailureException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * The transaction lost a serialization race ({@code 40001}) and was rolled back. + * + *

Bounded full-transaction retry is the designed response, because the whole use case must be + * recomputed against the committed state that won (design §19.1). + */ +public final class SerializationFailureException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public SerializationFailureException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.SERIALIZATION_FAILURE, context, cause); + } + + public SerializationFailureException(JpaFailureContext context) { + super(FailureCategory.SERIALIZATION_FAILURE, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlExceptionSqlStateResolver.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlExceptionSqlStateResolver.java new file mode 100644 index 00000000..6eac9cfa --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlExceptionSqlStateResolver.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.sql.SQLException; +import java.util.IdentityHashMap; +import java.util.Optional; + +/** + * The vendor-neutral {@link SqlStateResolver}: walks the cause chain for a {@link SQLException} and + * reads its {@linkplain SQLException#getSQLState() SQLSTATE}. + * + *

{@link SQLException#getNextException()} is followed as well as {@link Throwable#getCause()}. + * Drivers chain batch failures through {@code getNextException}, and the state that explains the + * failure is frequently on the second link rather than the first. + * + *

Traversal is cycle-safe. Provider exception chains are assembled by several layers — Spring, + * Hibernate, JDBC, the driver — and a chain that loops back on itself would otherwise hang the + * thread that is trying to report an error. + */ +public final class SqlExceptionSqlStateResolver implements SqlStateResolver { + + private static final int MAX_DEPTH = 64; + + @Override + public Optional resolve(Throwable failure) { + IdentityHashMap seen = new IdentityHashMap<>(); + Throwable current = failure; + int depth = 0; + while (current != null && depth++ < MAX_DEPTH && seen.put(current, Boolean.TRUE) == null) { + if (current instanceof SQLException sqlFailure) { + String state = sqlFailure.getSQLState(); + if (state != null && !state.isBlank()) { + return Optional.of(state.trim()); + } + SQLException next = sqlFailure.getNextException(); + if (next != null && !seen.containsKey(next)) { + current = next; + continue; + } + } + current = current.getCause(); + } + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlStateResolver.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlStateResolver.java new file mode 100644 index 00000000..7fa59c30 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/SqlStateResolver.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Optional; + +/** + * Extracts the SQLSTATE from a provider exception chain (design §18.2). + * + *

This is an SPI so the transaction module never depends on a vendor package: the commit-phase + * classifier needs a SQLSTATE, not a PostgreSQL driver. Implementations must read structured driver + * fields and must never parse a localized message, which changes with server locale and version. + */ +@FunctionalInterface +public interface SqlStateResolver { + + /** The SQLSTATE this failure carries, when the chain exposes one. */ + Optional resolve(Throwable failure); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java new file mode 100644 index 00000000..d7261b84 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * The commit outcome of a transaction could not be determined (design §17.3). + * + *

This exception is the one the whole retry design exists to protect: it is never + * retryable and never automatically re-run. The write may already be committed on the server, so a + * second attempt would be a duplicate rather than a repair. The invariant is enforced twice — the + * constructor rebuilds the context through {@link JpaFailureContext#completionUnknown}, and {@link + * JpaFailureContext} itself refuses to represent a retryable completion-unknown failure. + * + *

Recovery is reconciliation, not retry: look the {@link #transactionKey()} up against the + * idempotency record, the business row, and the outbox, and enqueue it when the answer is still + * undetermined (design §17.4). + */ +public final class TransactionCompletionUnknownException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + /** Transaction keys are application-chosen correlation ids, bounded before they are exposed. */ + private static final Pattern TRANSACTION_KEY = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + private final transient String transactionKey; + private final transient TransactionCompletionEvidence evidence; + + public TransactionCompletionUnknownException( + JpaFailureContext context, + String transactionKey, + TransactionCompletionEvidence evidence, + Throwable cause) { + super(FailureCategory.COMPLETION_UNKNOWN, forceCompletionUnknown(context), cause); + this.transactionKey = normalizeKey(transactionKey); + this.evidence = Objects.requireNonNull(evidence, "evidence"); + } + + public TransactionCompletionUnknownException( + JpaFailureContext context, TransactionCompletionEvidence evidence, Throwable cause) { + this(context, null, evidence, cause); + } + + /** + * The application-supplied correlation key used to reconcile this transaction, when one was bound + * to the unit of work. + */ + public Optional transactionKey() { + return transactionKey.isEmpty() ? Optional.empty() : Optional.of(transactionKey); + } + + /** The furthest phase the platform could prove the transaction reached. */ + public TransactionCompletionEvidence evidence() { + return evidence; + } + + private static JpaFailureContext forceCompletionUnknown(JpaFailureContext context) { + Objects.requireNonNull(context, "context"); + if (context.completionUnknown() && !context.retryable()) { + return context; + } + JpaFailureContext forced = + JpaFailureContext.completionUnknown( + context.operation(), + context.sqlState(), + context.transactionAttempt(), + context.elapsed(), + context.traceId()); + return context.hasConstraintName() + ? forced.withConstraintName(context.constraintName()) + : forced; + } + + private static String normalizeKey(String candidate) { + if (candidate == null || candidate.isBlank()) { + return ""; + } + String trimmed = candidate.trim(); + return TRANSACTION_KEY.matcher(trimmed).matches() ? trimmed : JpaFailureContext.REDACTED; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionTimeoutException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionTimeoutException.java new file mode 100644 index 00000000..6af43fff --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionTimeoutException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +/** + * A transaction exceeded its configured transaction timeout (design §15.4). + * + *

The transaction is rolled back; whether the use case may be re-run is a business decision, so + * the platform does not retry it automatically. + */ +public final class TransactionTimeoutException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + public TransactionTimeoutException(JpaFailureContext context, Throwable cause) { + super(FailureCategory.TRANSACTION_TIMEOUT, context, cause); + } + + public TransactionTimeoutException(JpaFailureContext context) { + super(FailureCategory.TRANSACTION_TIMEOUT, context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/UniqueConstraintViolationException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/UniqueConstraintViolationException.java new file mode 100644 index 00000000..aad69005 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/UniqueConstraintViolationException.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import java.util.Objects; + +/** + * A unique or exclusion constraint rejected the write ({@code 23505}). + * + *

This is the designed final arbiter of a create race: two concurrent inserts of the same + * logical key produce one commit and one of these, without a preceding {@code exists} query (design + * §22). + */ +public final class UniqueConstraintViolationException extends JpaPersistenceException { + + private static final long serialVersionUID = 1L; + + private final transient ConstraintViolationDetails details; + + public UniqueConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) { + super(FailureCategory.UNIQUE_CONSTRAINT, contextWithConstraint(context, details), cause); + this.details = details; + } + + public UniqueConstraintViolationException( + JpaFailureContext context, ConstraintViolationDetails details) { + this(context, details, null); + } + + /** The registered constraint code, and the bounded physical name when the server reported one. */ + public ConstraintViolationDetails details() { + return details; + } + + private static JpaFailureContext contextWithConstraint( + JpaFailureContext context, ConstraintViolationDetails details) { + Objects.requireNonNull(details, "details"); + return details.databaseName().map(context::withConstraintName).orElse(context); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java new file mode 100644 index 00000000..d8a5fa0f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * Encodes and decodes an opaque page cursor (design §27.3). + * + *

A cursor crosses the trust boundary: it is handed to a client and comes back. An + * implementation must therefore treat {@link #decode(String)} input as hostile and reject anything + * it did not produce, rather than parsing whatever arrives. + */ +public interface CursorCodec { + + /** Encodes a cursor into a client-safe, tamper-evident token. */ + String encode(C cursor); + + /** + * Decodes a token this codec produced. + * + * @throws IllegalArgumentException if the token is malformed, of an unknown version, or its + * signature does not verify + */ + C decode(String encoded); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorPayloadCodec.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorPayloadCodec.java new file mode 100644 index 00000000..a1385cf4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorPayloadCodec.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * The application-supplied conversion between a cursor value and its JSON payload (design §27.3). + * + *

This seam is why {@link SignedJsonCursorCodec} needs no JSON library: the core contract stays + * on the Java standard library, and the application plugs in whichever mapper it already uses. + * + *

Implementations must serialise only the ordering key and its tie-breakers. Putting JPQL, SQL + * fragments, entity paths, or filter state into the payload turns an opaque cursor into a + * client-controlled query. + */ +public interface CursorPayloadCodec { + + /** Serialises the cursor's ordering key and tie-breakers to JSON. */ + String toJson(C cursor); + + /** + * Reconstructs a cursor from a payload this codec produced. + * + * @throws IllegalArgumentException if the payload does not describe a valid cursor + */ + C fromJson(String json); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java new file mode 100644 index 00000000..75cf74c7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import java.util.Objects; +import java.util.Optional; + +/** + * One page of a keyset scan (design §27.2). + * + *

The page size is bounded at construction. An unbounded page is how a "just fetch everything" + * call reaches production, and by the time it is noticed it is holding a connection open over a + * table that has grown. + * + *

{@code after} is empty for the first page. There is no offset: that is the point of keyset + * pagination, and the absence of the field is what stops one being added later. + */ +public record KeysetPageRequest(Optional after, int size, SortDirection direction) { + + /** The largest page any keyset request may ask for. */ + public static final int MAX_SIZE = 500; + + public KeysetPageRequest { + Objects.requireNonNull(after, "after"); + Objects.requireNonNull(direction, "direction"); + if (size < 1 || size > MAX_SIZE) { + throw new IllegalArgumentException("invalid page size"); + } + } + + /** The first page of a descending scan — the common "most recent first" case. */ + public static KeysetPageRequest first(int size) { + return new KeysetPageRequest<>(Optional.empty(), size, SortDirection.DESCENDING); + } + + /** The page that follows {@code cursor} in the same direction and of the same size. */ + public KeysetPageRequest after(C cursor) { + return new KeysetPageRequest<>( + Optional.of(Objects.requireNonNull(cursor, "cursor")), size, direction); + } + + /** + * How many rows to actually fetch: one more than requested. + * + *

The extra row is how {@code hasNext} is answered without a count query (design §27.2). + */ + public int fetchSize() { + return size + 1; + } + + /** Whether this is the first page of the scan. */ + public boolean isFirstPage() { + return after.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java new file mode 100644 index 00000000..747e6678 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * One returned page of a keyset scan (design §27.2). + * + *

There is deliberately no total count and no page number. Producing either requires a second + * aggregate query over the same predicate, which is the cost keyset pagination exists to avoid, and + * on a moving data set the number is stale before it reaches the client. + */ +public record KeysetSlice(List items, Optional nextCursor, boolean hasNext) { + + public KeysetSlice { + items = List.copyOf(Objects.requireNonNull(items, "items")); + Objects.requireNonNull(nextCursor, "nextCursor"); + if (hasNext && nextCursor.isEmpty()) { + throw new IllegalArgumentException("a slice with a next page requires a next cursor"); + } + if (!hasNext && nextCursor.isPresent()) { + throw new IllegalArgumentException("a terminal slice must not carry a next cursor"); + } + } + + /** The last page of a scan. */ + public static KeysetSlice last(List items) { + return new KeysetSlice<>(items, Optional.empty(), false); + } + + /** A page followed by at least one more. */ + public static KeysetSlice more(List items, C nextCursor) { + return new KeysetSlice<>(items, Optional.of(nextCursor), true); + } + + /** How many rows this page carries. */ + public int size() { + return items.size(); + } + + /** Whether this page carries no rows at all. */ + public boolean isEmpty() { + return items.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java new file mode 100644 index 00000000..0f5365e3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * The observation used when no observability module is installed (design §9.5). + * + *

It exists so that call sites never have to null-check an observation, which is how "observe + * only when a registry is present" turns into two divergent code paths. + */ +public final class NoopQueryObservation implements QueryObservation { + + private static final NoopQueryObservation INSTANCE = new NoopQueryObservation(); + + private static final QueryScope NOOP_SCOPE = + new QueryScope() { + @Override + public void rows(long count) { + // no telemetry backend is installed + } + + @Override + public void failure(Throwable failure) { + // no telemetry backend is installed + } + + @Override + public void close() { + // nothing was opened + } + }; + + private NoopQueryObservation() {} + + /** The shared instance; the type is stateless. */ + public static NoopQueryObservation instance() { + return INSTANCE; + } + + @Override + public QueryScope start(QueryName queryName) { + return NOOP_SCOPE; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java new file mode 100644 index 00000000..baa24b73 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import java.util.regex.Pattern; + +/** + * Bounded, low-cardinality identity for one registered query (design §9.5). + * + *

The name is what appears in metrics, traces, and the {@code org.hibernate.comment} hint, so it + * must be a registry key rather than a description. Raw SQL, JPQL, entity ids, and user input are + * rejected by the format: a metric tag built from a query string is unbounded by construction, and + * one built from a parameterised value leaks row data into telemetry. + */ +public record QueryName(String value) { + + /** Design §9.5 — the exact accepted shape of a query name. */ + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public QueryName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid query name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java new file mode 100644 index 00000000..94b8d6ae --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * Starts an observation scope for a registered query (design §9.5). + * + *

Framework-neutral on purpose: the core contract must not force a Micrometer or Spring + * Observation dependency on modules that only want to name their queries. + */ +@FunctionalInterface +public interface QueryObservation { + + /** Begins observing {@code queryName}; the caller must close the returned scope exactly once. */ + QueryScope start(QueryName queryName); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java new file mode 100644 index 00000000..629ff6c6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * One in-flight observed query (design §9.5). + * + *

The scope is closed exactly once, in a {@code finally} or try-with-resources, whatever the + * outcome. {@link #close()} does not declare a checked exception, because a telemetry scope that + * forces callers into a second try/catch is a scope people stop closing. + */ +public interface QueryScope extends AutoCloseable { + + /** + * Records how many rows the query actually returned or affected. + * + *

Row count is what separates a genuine N+1 fix from a query that merely issues one statement + * and hydrates a Cartesian product (design §25.3). + */ + void rows(long count); + + /** Records that the query failed. Implementations must not log the throwable's message. */ + void failure(Throwable failure); + + @Override + void close(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java new file mode 100644 index 00000000..0263e778 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * A versioned, tamper-evident cursor codec built only on the Java standard library (design §27.3). + * + *

The encoded form is {@code ..}. The MAC covers + * the version and the payload together, so an attacker cannot downgrade a token to an older cursor + * format by rewriting the prefix. + * + *

Signing a cursor is not about confidentiality — the payload is readable — it is about + * integrity. An unsigned cursor is client-controlled ordering state: rewriting it lets a caller + * seek to arbitrary keys, which turns a paging token into an access-control bypass wherever the + * predicate depends on where the scan started. + * + *

Verification is constant-time via {@link MessageDigest#isEqual}. A short-circuiting comparison + * here leaks the correct MAC one byte at a time. + */ +public final class SignedJsonCursorCodec implements CursorCodec { + + /** The only cursor version this codec issues and accepts. */ + public static final String VERSION = "v1"; + + private static final String ALGORITHM = "HmacSHA256"; + private static final int MINIMUM_KEY_LENGTH = 32; + private static final char SEPARATOR = '.'; + + private final CursorPayloadCodec payloadCodec; + private final byte[] key; + private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + private final Base64.Decoder decoder = Base64.getUrlDecoder(); + + /** + * @param payloadCodec the application's cursor-to-JSON conversion + * @param key the HMAC key; at least 32 bytes, and never derived from a configuration default + */ + public SignedJsonCursorCodec(CursorPayloadCodec payloadCodec, byte[] key) { + this.payloadCodec = Objects.requireNonNull(payloadCodec, "payloadCodec"); + Objects.requireNonNull(key, "key"); + if (key.length < MINIMUM_KEY_LENGTH) { + throw new IllegalArgumentException("cursor signing key must be at least 32 bytes"); + } + this.key = key.clone(); + } + + @Override + public String encode(C cursor) { + Objects.requireNonNull(cursor, "cursor"); + String payload = + encoder.encodeToString(payloadCodec.toJson(cursor).getBytes(StandardCharsets.UTF_8)); + String signed = VERSION + SEPARATOR + payload; + return signed + SEPARATOR + encoder.encodeToString(mac(signed)); + } + + @Override + public C decode(String encoded) { + if (encoded == null || encoded.isBlank()) { + throw new IllegalArgumentException("cursor must not be blank"); + } + int payloadSeparator = encoded.indexOf(SEPARATOR); + int macSeparator = encoded.lastIndexOf(SEPARATOR); + if (payloadSeparator <= 0 || macSeparator <= payloadSeparator) { + throw new IllegalArgumentException("malformed cursor"); + } + String version = encoded.substring(0, payloadSeparator); + if (!VERSION.equals(version)) { + throw new IllegalArgumentException("unknown cursor version"); + } + String signed = encoded.substring(0, macSeparator); + byte[] presented = decodeBase64(encoded.substring(macSeparator + 1)); + if (!MessageDigest.isEqual(mac(signed), presented)) { + throw new IllegalArgumentException("cursor signature does not verify"); + } + byte[] payload = decodeBase64(encoded.substring(payloadSeparator + 1, macSeparator)); + return payloadCodec.fromJson(new String(payload, StandardCharsets.UTF_8)); + } + + private byte[] decodeBase64(String value) { + try { + return decoder.decode(value); + } catch (IllegalArgumentException malformed) { + throw new IllegalArgumentException("malformed cursor", malformed); + } + } + + private byte[] mac(String signed) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + return mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException unavailable) { + throw new IllegalStateException("cursor signing algorithm is unavailable", unavailable); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java new file mode 100644 index 00000000..740b1dd1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +/** + * Direction of a keyset scan (design §27.2). + * + *

Keyset pagination requires the direction to be part of the request rather than inferred from + * the cursor, because the comparison operator and the tie-breaker ordering must both follow it. + */ +public enum SortDirection { + ASCENDING, + DESCENDING; + + /** The direction that walks the same ordering backwards. */ + public SortDirection reversed() { + return this == ASCENDING ? DESCENDING : ASCENDING; + } + + /** Whether this direction scans from smaller to larger keys. */ + public boolean ascending() { + return this == ASCENDING; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/IsolationLevel.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/IsolationLevel.java new file mode 100644 index 00000000..63f5c16d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/IsolationLevel.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +/** + * The isolation levels the Stable platform exposes (design §9.2, §16.2). + * + *

{@code READ_UNCOMMITTED} is absent: PostgreSQL treats it as {@code READ COMMITTED}, so + * offering it would let a profile claim an isolation the database never provides. + */ +public enum IsolationLevel { + + /** Whatever the connection is configured with; on PostgreSQL that is read committed. */ + DEFAULT, + + /** The Stable default for both read and write profiles. */ + READ_COMMITTED, + + /** Snapshot-stable reads within the transaction. */ + REPEATABLE_READ, + + /** Full serializability; expect {@code 40001} and pair it with a retry profile. */ + SERIALIZABLE +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JitterMode.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JitterMode.java new file mode 100644 index 00000000..0f78400a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JitterMode.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +/** + * How retry backoff is randomised (design §19.3). + * + *

Jitter is not cosmetic here: without it, every contender in a deadlock or serialization storm + * recomputes the same backoff and collides again at the same instant. + */ +public enum JitterMode { + + /** Deterministic backoff. Only appropriate for tests and single-writer work. */ + NONE, + + /** Uniform in {@code [0, backoff]} — the strongest de-synchronisation. */ + FULL, + + /** Uniform in {@code [backoff/2, backoff]} — keeps a floor under the wait. */ + EQUAL +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java new file mode 100644 index 00000000..06962452 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; + +/** + * Decides what may be done about one failed attempt (design §9.4). + * + *

A policy classifies; it never executes. Keeping the decision separate from the retry loop is + * what makes "completion unknown is never retried" a property that can be unit-tested without a + * database. + */ +@FunctionalInterface +public interface JpaRetryPolicy { + + /** + * Classifies a failure into a disposition, a backoff, and a bounded reason. + * + * @param failure the stable failure produced by the exception translator + * @param attempt the 1-based attempt that produced it + */ + RetryDecision classify(JpaPersistenceException failure, TransactionAttempt attempt); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaTransactionExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaTransactionExecutor.java new file mode 100644 index 00000000..49a7ac1e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaTransactionExecutor.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import java.util.function.Supplier; + +/** + * Runs one unit of work inside exactly one transaction described by a profile (design §9.3). + * + *

This interface owns the transaction boundary and nothing else. Retry is deliberately not part + * of it: a retry that reused the same executor call would reuse the same Persistence Context, which + * is precisely the bug the design forbids. The retry coordinator calls this interface again, + * getting a new transaction and a new context each time. + */ +public interface JpaTransactionExecutor { + + /** + * Executes {@code work} inside one transaction configured by {@code profile}. + * + * @param operation the registered, bounded identity used for observation and policy lookup + * @param profile propagation, isolation, timeout, and read-only for this single transaction + * @param work the unit of work; it must be safe to run again from scratch in a new transaction + */ + T execute(PersistenceOperationName operation, TransactionProfile profile, Supplier work); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/PropagationMode.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/PropagationMode.java new file mode 100644 index 00000000..4775b7d0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/PropagationMode.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +/** + * The propagation values the Stable platform supports (design §9.2, §16.1). + * + *

The list is deliberately short. {@code NESTED}, {@code SUPPORTS}, {@code NOT_SUPPORTED}, and + * {@code NEVER} are absent because each one silently changes whether the caller's work is inside a + * transaction at all, which is the kind of ambiguity this platform exists to remove. + */ +public enum PropagationMode { + + /** Join the caller's transaction, or start one. The Stable default. */ + REQUIRED, + + /** Require the caller to already own a transaction; refuse to start one. */ + MANDATORY, + + /** + * Suspend the caller's transaction and run in a new one. + * + *

Opt-in only: 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. + */ + REQUIRES_NEW +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java new file mode 100644 index 00000000..22fbf95b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import java.time.Duration; +import java.util.Objects; + +/** + * The outcome of classifying one failed attempt (design §9.4). + * + *

The {@code reason} is a bounded diagnostic string chosen by the policy, never a provider + * message, so it is safe to log and to use as a low-cardinality metric tag. + */ +public record RetryDecision(RetryDisposition disposition, Duration delay, String reason) { + + public RetryDecision { + Objects.requireNonNull(disposition, "disposition"); + Objects.requireNonNull(delay, "delay"); + Objects.requireNonNull(reason, "reason"); + if (delay.isNegative()) { + throw new IllegalArgumentException("retry delay must not be negative"); + } + if (disposition != RetryDisposition.RETRY_FULL_TRANSACTION && !delay.isZero()) { + throw new IllegalArgumentException("only a full transaction retry may carry a delay"); + } + if (reason.isBlank()) { + throw new IllegalArgumentException("retry decision requires a reason"); + } + } + + /** Re-run the whole use case after the supplied backoff. */ + public static RetryDecision retry(Duration delay) { + return new RetryDecision( + RetryDisposition.RETRY_FULL_TRANSACTION, delay, "retryable persistence failure"); + } + + /** Re-run the whole use case after the supplied backoff, with an explicit reason. */ + public static RetryDecision retry(Duration delay, String reason) { + return new RetryDecision(RetryDisposition.RETRY_FULL_TRANSACTION, delay, reason); + } + + /** Hand the failure to reconciliation; the commit outcome is not known. */ + public static RetryDecision reconcile(String reason) { + return new RetryDecision(RetryDisposition.RECONCILE, Duration.ZERO, reason); + } + + /** Surface the failure; re-running it cannot help. */ + public static RetryDecision fail(String reason) { + return new RetryDecision(RetryDisposition.FAIL, Duration.ZERO, reason); + } + + /** Whether this decision asks for another full-transaction attempt. */ + public boolean retrying() { + return disposition == RetryDisposition.RETRY_FULL_TRANSACTION; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDisposition.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDisposition.java new file mode 100644 index 00000000..7a92df59 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDisposition.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +/** + * What the platform may do about a failed attempt (design §9.4). + * + *

{@link #RECONCILE} exists so that "we do not know" is a first-class outcome rather than a + * retry in disguise. + */ +public enum RetryDisposition { + + /** Re-run the entire use case in a new transaction and a new Persistence Context. */ + RETRY_FULL_TRANSACTION, + + /** Hand the failure to domain-specific reconciliation; never re-run it automatically. */ + RECONCILE, + + /** Surface the failure to the caller. */ + FAIL +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java new file mode 100644 index 00000000..b7eda520 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * A named, bounded retry budget (design §19.3). + * + *

The profile is a value, not a strategy object: it says how many attempts are allowed, how the + * backoff grows, how it is jittered, and which failure categories are eligible at all. The decision + * to use it belongs to {@link JpaRetryPolicy}. + * + *

One category can never appear in {@code retryableFailures}: {@link + * FailureCategory#COMPLETION_UNKNOWN}. A profile that listed it would let configuration re-run work + * that may already be committed, so the constructor rejects it outright rather than trusting review + * to catch it. + */ +public record RetryProfile( + String name, + int maxAttempts, + Duration initialBackoff, + Duration maxBackoff, + double multiplier, + JitterMode jitter, + Set retryableFailures) { + + /** Profile names are bounded because they become metric tags. */ + private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + /** The categories a profile is allowed to opt into (design §19.1). */ + private static final Set ELIGIBLE = + Set.of( + FailureCategory.SERIALIZATION_FAILURE, + FailureCategory.DEADLOCK, + FailureCategory.OPTIMISTIC_CONFLICT, + FailureCategory.LOCK_NOT_AVAILABLE, + FailureCategory.CONNECTION_UNAVAILABLE); + + public RetryProfile { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(initialBackoff, "initialBackoff"); + Objects.requireNonNull(maxBackoff, "maxBackoff"); + Objects.requireNonNull(jitter, "jitter"); + Objects.requireNonNull(retryableFailures, "retryableFailures"); + if (!NAME.matcher(name).matches()) { + throw new IllegalArgumentException("invalid retry profile name"); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + if (initialBackoff.isNegative() || maxBackoff.isNegative()) { + throw new IllegalArgumentException("retry backoff must not be negative"); + } + if (maxBackoff.compareTo(initialBackoff) < 0) { + throw new IllegalArgumentException("maxBackoff must not be smaller than initialBackoff"); + } + if (!Double.isFinite(multiplier) || multiplier < 1.0d) { + throw new IllegalArgumentException("retry multiplier must be finite and at least 1.0"); + } + if (retryableFailures.contains(FailureCategory.COMPLETION_UNKNOWN)) { + throw new IllegalArgumentException( + "completion unknown is never a retryable failure category"); + } + for (FailureCategory category : retryableFailures) { + if (!ELIGIBLE.contains(category)) { + throw new IllegalArgumentException("failure category is not retry eligible: " + category); + } + } + retryableFailures = + retryableFailures.isEmpty() ? Set.of() : Set.copyOf(EnumSet.copyOf(retryableFailures)); + } + + /** + * A profile that never retries. + * + *

This is the default for any transaction profile that has not opted in, so an operation is + * only re-run when someone decided it may be. + */ + public static RetryProfile none() { + return new RetryProfile( + "none", 1, Duration.ZERO, Duration.ZERO, 1.0d, JitterMode.NONE, Set.of()); + } + + /** The Stable write default: bounded exponential backoff over the contention categories. */ + public static RetryProfile boundedContention(String name, int maxAttempts) { + return new RetryProfile( + name, + maxAttempts, + Duration.ofMillis(20), + Duration.ofMillis(500), + 2.0d, + JitterMode.FULL, + Set.of( + FailureCategory.SERIALIZATION_FAILURE, + FailureCategory.DEADLOCK, + FailureCategory.OPTIMISTIC_CONFLICT)); + } + + /** Whether this profile permits more than the first attempt. */ + public boolean enabled() { + return maxAttempts > 1 && !retryableFailures.isEmpty(); + } + + /** Whether the supplied category is eligible under this profile. */ + public boolean allows(FailureCategory category) { + return retryableFailures.contains(category); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionAttempt.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionAttempt.java new file mode 100644 index 00000000..003c22e5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionAttempt.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * One physical attempt at a logical use case (design §19.2). + * + *

{@code number} is 1-based: the first execution is attempt 1, not a retry. {@code startedAt} is + * the instant that attempt began, which is what lets the retry coordinator enforce an overall + * deadline rather than only an attempt count. + */ +public record TransactionAttempt(int number, Instant startedAt) { + + public TransactionAttempt { + Objects.requireNonNull(startedAt, "startedAt"); + if (number < 1) { + throw new IllegalArgumentException("transaction attempt number must be at least 1"); + } + } + + /** The first attempt at a use case. */ + public static TransactionAttempt first(Instant startedAt) { + return new TransactionAttempt(1, startedAt); + } + + /** The attempt that follows this one, beginning at the supplied instant. */ + public TransactionAttempt next(Instant instant) { + return new TransactionAttempt(number + 1, instant); + } + + /** How long this attempt has been running as of {@code now}. */ + public Duration elapsedAt(Instant now) { + Duration elapsed = Duration.between(startedAt, now); + return elapsed.isNegative() ? Duration.ZERO : elapsed; + } + + /** Whether this is the first execution rather than a retry. */ + public boolean isFirst() { + return number == 1; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java new file mode 100644 index 00000000..e84d63fd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +/** + * What the platform can prove about how far a transaction got (design §17.1). + * + *

This is evidence, not a guess. {@link #UNKNOWN} is a real, reportable state: it means the + * driver could neither confirm the commit nor confirm the rollback, and the platform refuses to + * collapse that into either. Only a failure observed while the phase is {@link #COMMITTING} may + * become {@link + * dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException}. + * + *

The enum lives in the framework-free core rather than in the Spring transaction adapter + * because the design types the exception's evidence field, and the core error contract may not + * depend on the adapter. See {@code docs/jpa/repository-adaptation.md} §4. + */ +public enum TransactionCompletionEvidence { + + /** No transaction was begun for this unit of work. */ + NOT_STARTED, + + /** A transaction is open and statements are executing. */ + ACTIVE, + + /** The commit has been handed to the provider and no result has come back yet. */ + COMMITTING, + + /** The provider confirmed the commit. */ + COMMITTED, + + /** The provider confirmed the rollback. */ + ROLLED_BACK, + + /** The commit outcome could not be determined; reconciliation owns the resolution. */ + UNKNOWN +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java new file mode 100644 index 00000000..ef117445 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import java.time.Duration; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A named, immutable description of one transaction boundary (design §9.2). + * + *

A write profile must carry a positive, finite timeout. An unbounded write transaction is how a + * single stuck statement holds a connection, a lock, and a row version indefinitely, so the type + * refuses to represent one. Read profiles may leave the timeout at zero, meaning "the connection + * default applies". + */ +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { + + /** Profile names are bounded because they become metric tags. */ + private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public TransactionProfile { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(propagation, "propagation"); + Objects.requireNonNull(isolation, "isolation"); + Objects.requireNonNull(retryProfile, "retryProfile"); + if (!NAME.matcher(name).matches()) { + throw new IllegalArgumentException("invalid transaction profile name"); + } + if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) { + throw new IllegalArgumentException("write transaction requires positive timeout"); + } + if (timeout == null) { + timeout = Duration.ZERO; + } + if (timeout.isNegative()) { + throw new IllegalArgumentException("transaction timeout must not be negative"); + } + if (readOnly && retryProfile.enabled() && propagation == PropagationMode.REQUIRES_NEW) { + throw new IllegalArgumentException( + "a read-only REQUIRES_NEW profile must not carry a retry budget"); + } + } + + /** The Stable write default: {@code REQUIRED + READ_COMMITTED} with no retry budget. */ + public static TransactionProfile write(String name, Duration timeout) { + return new TransactionProfile( + name, + PropagationMode.REQUIRED, + IsolationLevel.READ_COMMITTED, + timeout, + false, + RetryProfile.none()); + } + + /** The Stable read default: {@code REQUIRED + READ_COMMITTED}, read-only, no retry budget. */ + public static TransactionProfile read(String name, Duration timeout) { + return new TransactionProfile( + name, + PropagationMode.REQUIRED, + IsolationLevel.READ_COMMITTED, + timeout, + true, + RetryProfile.none()); + } + + /** Returns a copy of this profile carrying the supplied retry budget. */ + public TransactionProfile withRetryProfile(RetryProfile profile) { + return new TransactionProfile(name, propagation, isolation, timeout, readOnly, profile); + } + + /** Whether the timeout is a real bound rather than "use the connection default". */ + public boolean hasTimeout() { + return !timeout.isZero(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java new file mode 100644 index 00000000..05fbb4e4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.outbound.persistence.auditing; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.time.Instant; +import java.util.Objects; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; + +/** + * Opt-in technical audit columns (design §10.2). + * + *

An {@code @Embeddable} rather than a mandatory {@code BaseEntity}. A platform-wide base class + * forces four columns onto every table including the ones where they are meaningless — join tables, + * immutable event records, outbox rows — and, worse, it puts the platform in the entity hierarchy, + * so a later platform change reshapes every domain aggregate. + * + *

This is technical auditing: who last touched the row, mechanically. It is not + * business audit and not entity history. Business audit answers "what did the user do and why" and + * belongs to the domain; entity history answers "what did this row look like at revision N" and + * belongs to Envers (design §35). Conflating them produces an audit trail that satisfies neither + * requirement. + * + *

{@code createdAt}/{@code createdBy} are {@code updatable = false}: a create stamp that a later + * update can rewrite is not a create stamp. + */ +@Embeddable +public class AuditMetadata { + + @CreatedDate + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @CreatedBy + @Column(name = "created_by", updatable = false, length = 64) + private String createdBy; + + @LastModifiedDate + @Column(name = "modified_at") + private Instant modifiedAt; + + @LastModifiedBy + @Column(name = "modified_by", length = 64) + private String modifiedBy; + + protected AuditMetadata() { + // required by the persistence provider + } + + /** When the row was first written. */ + public Instant createdAt() { + return createdAt; + } + + /** The bounded actor identity that first wrote the row. */ + public String createdBy() { + return createdBy; + } + + /** When the row was last modified. */ + public Instant modifiedAt() { + return modifiedAt; + } + + /** The bounded actor identity that last modified the row. */ + public String modifiedBy() { + return modifiedBy; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof AuditMetadata audit)) { + return false; + } + return Objects.equals(createdAt, audit.createdAt) + && Objects.equals(createdBy, audit.createdBy) + && Objects.equals(modifiedAt, audit.modifiedAt) + && Objects.equals(modifiedBy, audit.modifiedBy); + } + + @Override + public int hashCode() { + return Objects.hash(createdAt, createdBy, modifiedAt, modifiedBy); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java new file mode 100644 index 00000000..7513fe3f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.persistence.auditing; + +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; +import org.springframework.data.auditing.DateTimeProvider; + +/** + * The wiring an application must supply to switch technical auditing on (design §10.2). + * + *

Deliberately not a Spring {@code @Configuration}. Auditing is opt-in, and an adapter leaf that + * auto-enabled it would stamp audit columns on every entity in every application that merely has + * this module on the classpath — including the ones whose tables have no such columns, where the + * result is a startup failure rather than a feature. + * + *

The composition root builds these two beans and enables auditing itself, which keeps the + * decision where {@code AGENTS.md} puts composition. + */ +public final class JpaAuditingConfiguration { + + private final Clock clock; + private final JpaAuditorProvider auditorProvider; + + public JpaAuditingConfiguration(Clock clock, JpaAuditorProvider auditorProvider) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.auditorProvider = Objects.requireNonNull(auditorProvider, "auditorProvider"); + } + + /** + * The time source audit stamps come from. + * + *

It wraps the injected {@link Clock} rather than reading {@code Instant.now()} so that a test + * can fix the clock and assert the stamp, which is otherwise untestable. + */ + public DateTimeProvider dateTimeProvider() { + return () -> Optional.of(clock.instant()); + } + + /** The actor source audit stamps come from. */ + public JpaAuditorProvider auditorProvider() { + return auditorProvider; + } + + /** The clock audit stamps are read from. */ + public Clock clock() { + return clock; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditorProvider.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditorProvider.java new file mode 100644 index 00000000..8d4b4959 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditorProvider.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.persistence.auditing; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import org.springframework.data.domain.AuditorAware; + +/** + * Supplies the bounded actor identity Spring Data stamps onto audited rows (design §10.2). + * + *

The identity is opaque and bounded — a user id or a system actor name, never a display name, + * an email, or a principal object. Those are PII, and an audit column is copied into every backup, + * every replica, and every export of that table. + * + *

Background work gets an explicit system actor rather than an empty column. "Who changed this" + * answered by a null is indistinguishable from a bug in the auditing setup. + */ +public final class JpaAuditorProvider implements AuditorAware { + + /** The actor recorded for scheduled jobs, migrations, and other unattended work. */ + public static final String SYSTEM_ACTOR = "system"; + + private static final Pattern ACTOR = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); + + private final Supplier> currentActor; + + public JpaAuditorProvider(Supplier> currentActor) { + this.currentActor = Objects.requireNonNull(currentActor, "currentActor"); + } + + /** A provider that always records the system actor. */ + public static JpaAuditorProvider system() { + return new JpaAuditorProvider(() -> Optional.of(SYSTEM_ACTOR)); + } + + @Override + public Optional getCurrentAuditor() { + return currentActor.get().map(JpaAuditorProvider::bound).or(() -> Optional.of(SYSTEM_ACTOR)); + } + + /** + * Bounds an actor identity. + * + *

An identity that is not already bounded is replaced rather than truncated: truncating an + * email still leaves most of it in the column. + */ + private static String bound(String actor) { + return actor != null && ACTOR.matcher(actor).matches() ? actor : SYSTEM_ACTOR; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheConcurrencyStrategy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheConcurrencyStrategy.java new file mode 100644 index 00000000..dc931f63 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheConcurrencyStrategy.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +/** + * How a cached region reconciles concurrent writes (design §34). + * + *

Choosing one is a correctness decision, not a tuning knob. {@code READ_ONLY} on a mutable + * entity throws at the first update; {@code NONSTRICT_READ_WRITE} tolerates a stale window that + * some domains cannot; {@code READ_WRITE} adds soft-lock bookkeeping. Requiring the choice per + * region is what stops a default from being applied to an entity it is wrong for. + */ +public enum CacheConcurrencyStrategy { + + /** Immutable reference data; any update to a cached instance is an error. */ + READ_ONLY, + + /** Mutable data that tolerates a brief stale window after a write. */ + NONSTRICT_READ_WRITE, + + /** Mutable data that requires soft locking around writes. */ + READ_WRITE, + + /** Only correct behind a JTA transaction manager. */ + TRANSACTIONAL +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheRegionCatalog.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheRegionCatalog.java new file mode 100644 index 00000000..a2a3a93e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/CacheRegionCatalog.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The entities enrolled in the second-level cache, and how each is cached (design §34). + * + *

Enrolment is per entity and explicit. A blanket "cache everything" setting caches the entities + * whose staleness matters most alongside the reference data it was meant for, and the failure is + * invisible: reads succeed, they are just answering from a version of the row that no longer + * exists. + */ +public final class CacheRegionCatalog { + + private final Map byEntityName; + + public CacheRegionCatalog(Map regions) { + Objects.requireNonNull(regions, "regions"); + Map copy = new LinkedHashMap<>(); + regions.forEach( + (entity, strategy) -> { + Objects.requireNonNull(entity, "entity name"); + Objects.requireNonNull(strategy, "concurrency strategy"); + if (entity.isBlank()) { + throw new IllegalArgumentException("cached entity name must not be blank"); + } + copy.put(entity, strategy); + }); + this.byEntityName = Map.copyOf(copy); + } + + /** A catalog with nothing enrolled. */ + public static CacheRegionCatalog empty() { + return new CacheRegionCatalog(Map.of()); + } + + /** + * Fails when a cacheable entity is not enrolled here. + * + * @param cacheableEntities the entity names the provider reports as cacheable + */ + public void validate(Set cacheableEntities) { + Objects.requireNonNull(cacheableEntities, "cacheableEntities"); + for (String entity : cacheableEntities) { + if (!byEntityName.containsKey(entity)) { + throw new IllegalStateException( + "entity '" + + entity + + "' is cacheable but not enrolled in the cache region catalog;" + + " enrol it with an explicit concurrency strategy or remove @Cacheable"); + } + } + } + + /** The concurrency strategy registered for an entity, when it is enrolled. */ + public java.util.Optional strategyFor(String entityName) { + return java.util.Optional.ofNullable(byEntityName.get(entityName)); + } + + /** The enrolled entity names. */ + public Set enrolledEntities() { + return byEntityName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java new file mode 100644 index 00000000..8c05f127 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +import jakarta.persistence.SharedCacheMode; +import java.util.Objects; + +/** + * Refuses a second-level cache configuration that is unsafe by default (design §34). + * + *

Two rules, both about defaults that look harmless. + * + *

Query Cache stays off. It caches result-id lists keyed by query and parameters, and + * those entries are invalidated by table-space timestamps — so any write to a table a cached query + * touches invalidates every cached query over it. On a write-active table it costs more than it + * saves, and it does so silently. + * + *

{@code ENABLE_SELECTIVE} only. {@code ALL} caches every entity, including the ones + * whose staleness is a correctness problem rather than a performance one. Selective enrolment is + * what makes each entity's cacheability a decision someone made. + */ +public final class HibernateCacheGuard { + + /** + * Validates the cache configuration against the enrolled regions. + * + * @throws IllegalStateException when the configuration is not one the design permits + */ + public void validate(HibernateCacheSettings settings, CacheRegionCatalog catalog) { + Objects.requireNonNull(settings, "settings"); + Objects.requireNonNull(catalog, "catalog"); + if (!settings.secondLevelCacheEnabled()) { + return; + } + if (settings.queryCacheEnabled()) { + throw new IllegalStateException( + "Query Cache is disabled by default: its entries are invalidated by any write to the" + + " tables the query touches, so on a write-active table it costs more than it saves." + + " Enabling it requires an explicit experimental approval."); + } + if (settings.sharedCacheMode() != SharedCacheMode.ENABLE_SELECTIVE) { + throw new IllegalStateException( + "Use ENABLE_SELECTIVE for L2 cache; " + + settings.sharedCacheMode() + + " caches entities whose staleness is a correctness problem"); + } + catalog.validate(settings.cacheableEntities()); + } + + /** + * Fails when a cached entity has no bulk-eviction strategy. + * + *

Bulk DML bypasses the second-level cache entirely, so a cached entity updated in bulk keeps + * serving pre-update values until its region expires. Declaring the eviction is the only thing + * that closes that window. + */ + public void requireBulkEviction(CacheRegionCatalog catalog, java.util.Set bulkEntities) { + Objects.requireNonNull(catalog, "catalog"); + Objects.requireNonNull(bulkEntities, "bulkEntities"); + for (String entity : bulkEntities) { + if (catalog.strategyFor(entity).isPresent()) { + throw new IllegalStateException( + "entity '" + + entity + + "' is second-level cached and targeted by bulk DML; bulk" + + " statements bypass the cache, so the region must be evicted explicitly after" + + " the statement or the entity must not be cached"); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCachePolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCachePolicy.java new file mode 100644 index 00000000..3cb1486f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCachePolicy.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +import jakarta.persistence.SharedCacheMode; +import java.util.Map; +import java.util.Objects; + +/** + * The declared second-level cache policy of an application (design §34). + * + *

The policy also records the two operational assumptions that decide whether the cache is + * correct at all, because both are invisible in configuration and expensive to discover in + * production: + * + *

    + *
  • External writers. A second-level cache is only coherent if this application is the + * sole writer. A batch job, an admin console, or a replication stream writing the same tables + * makes cached entities silently stale, and no cache setting can detect it. + *
  • Cluster invalidation. With more than one instance and a local cache, an eviction on + * one node does not reach the others. Without a distributed region, every extra instance adds + * another independent stale copy. + *
+ */ +public record HibernateCachePolicy( + boolean soleWriter, + boolean clusterInvalidationConfigured, + Map regions) { + + public HibernateCachePolicy { + regions = Map.copyOf(Objects.requireNonNull(regions, "regions")); + } + + /** The catalog this policy describes. */ + public CacheRegionCatalog catalog() { + return new CacheRegionCatalog(regions); + } + + /** The shared cache mode this policy requires. */ + public SharedCacheMode sharedCacheMode() { + return SharedCacheMode.ENABLE_SELECTIVE; + } + + /** + * Whether the policy may be enabled for a multi-instance deployment. + * + *

A single instance that is the sole writer is coherent on its own; anything else needs + * cluster invalidation configured before the cache can be trusted. + */ + public boolean safeForMultipleInstances() { + return soleWriter && clusterInvalidationConfigured; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheSettings.java new file mode 100644 index 00000000..469ee48d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheSettings.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +import jakarta.persistence.SharedCacheMode; +import java.util.Objects; +import java.util.Set; + +/** + * The second-level cache configuration as the platform reads it back (design §34). + * + * @param cacheableEntities the entity names the provider reports as cacheable + */ +public record HibernateCacheSettings( + boolean secondLevelCacheEnabled, + boolean queryCacheEnabled, + SharedCacheMode sharedCacheMode, + Set cacheableEntities) { + + public HibernateCacheSettings { + Objects.requireNonNull(sharedCacheMode, "sharedCacheMode"); + cacheableEntities = Set.copyOf(Objects.requireNonNull(cacheableEntities, "cacheableEntities")); + } + + /** The configuration of a runtime with no second-level cache at all. */ + public static HibernateCacheSettings disabled() { + return new HibernateCacheSettings(false, false, SharedCacheMode.NONE, Set.of()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java index eba19cb0..e08dbc96 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java @@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * *

Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the * two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first - * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming - * {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that - * caused it. + * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code + * OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it. */ @ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) public record PersistenceVendorSettings(Vendor vendor) { diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EntityRevision.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EntityRevision.java new file mode 100644 index 00000000..a0b1fe10 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EntityRevision.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import java.util.Objects; + +/** + * One historical version of an entity (design §35). + * + * @param the audited entity type + */ +public record EntityRevision(long revisionNumber, T entity, EnversRevisionMetadata metadata) { + + public EntityRevision { + Objects.requireNonNull(metadata, "metadata"); + if (revisionNumber < 1L) { + throw new IllegalArgumentException("revision number must be positive"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversConfigurationGuard.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversConfigurationGuard.java new file mode 100644 index 00000000..24f3538a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversConfigurationGuard.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import java.util.Objects; +import java.util.Set; + +/** + * Refuses an entity-history configuration that would audit more than someone chose to (design §35). + * + *

Two failures this guard exists to prevent. + * + *

Blanket enrolment. Putting {@code @Audited} on a shared base class enrols every entity + * that extends it. Envers then writes a full copy of every row version to an audit table, which + * multiplies write volume and storage across entities nobody decided to audit. + * + *

Production without a retention and PII policy. An audit table keeps every previous + * value forever by default, including the personal data a later correction or erasure removed from + * the live row. That is a data-protection problem that only gets more expensive the longer it runs. + */ +public final class EnversConfigurationGuard { + + /** + * Validates the enrolled entities against the declared policy. + * + * @param auditedEntities the entity names the provider reports as audited + * @throws IllegalStateException when an entity is audited without being enrolled + */ + public void validate(EnversHistoryPolicy policy, Set auditedEntities) { + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(auditedEntities, "auditedEntities"); + for (String entity : auditedEntities) { + if (!policy.audits(entity)) { + throw new IllegalStateException( + "entity '" + + entity + + "' is @Audited but not enrolled in the history policy; enrol it" + + " deliberately or remove the annotation — a shared @Audited base class enrols" + + " every subclass"); + } + } + } + + /** + * Fails when history is enabled in production without a retention and PII policy. + * + * @throws IllegalStateException when the policy is not production ready + */ + public void requireProductionReady(EnversHistoryPolicy policy) { + Objects.requireNonNull(policy, "policy"); + if (!policy.productionReady()) { + throw new IllegalStateException( + "entity history requires a declared retention period and PII deletion policy before it" + + " may be enabled in production: audit tables retain every previous value," + + " including data an erasure request removed from the live row"); + } + } + + /** Whether the Envers module is on the runtime classpath at all. */ + public boolean enversAvailable() { + try { + Class.forName("org.hibernate.envers.AuditReaderFactory"); + return true; + } catch (ClassNotFoundException absent) { + return false; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryPolicy.java new file mode 100644 index 00000000..b080e054 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryPolicy.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * Which entities keep history, and under what retention (design §35). + * + *

Retention is mandatory, and it is a data-protection requirement rather than a housekeeping + * one. An audit table accumulates every previous value of every audited row, so a column holding + * personal data keeps holding it after the live row is corrected or erased — which is precisely the + * case a deletion request is about. Declaring retention and a PII policy before production is how + * that stays a decision instead of a discovery. + */ +public record EnversHistoryPolicy( + Set auditedEntities, Duration retention, boolean piiPolicyDeclared) { + + public EnversHistoryPolicy { + auditedEntities = Set.copyOf(Objects.requireNonNull(auditedEntities, "auditedEntities")); + Objects.requireNonNull(retention, "retention"); + if (retention.isZero() || retention.isNegative()) { + throw new IllegalArgumentException("entity history requires a positive retention period"); + } + } + + /** Whether an entity is enrolled for history. */ + public boolean audits(String entityName) { + return auditedEntities.contains(entityName); + } + + /** Whether this policy may be enabled in a production profile. */ + public boolean productionReady() { + return piiPolicyDeclared && !auditedEntities.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryReader.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryReader.java new file mode 100644 index 00000000..cc5ca2de --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversHistoryReader.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import java.util.List; + +/** + * Reads the recorded revisions of an audited entity (design §35). + * + *

Read-only by construction. History is append-only: it is written by the revision listener as a + * side effect of ordinary writes, and an API that could modify it would make the record something + * an application bug can rewrite. + */ +public interface EnversHistoryReader { + + /** + * The revisions recorded for one entity instance, oldest first. + * + *

An entity that is not enrolled for history returns an empty list rather than throwing — + * "this entity has no history" is a legitimate answer, and the enrolment check belongs to {@link + * EnversConfigurationGuard} at startup. + */ + List> revisions(Class entityType, Object id); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversRevisionMetadata.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversRevisionMetadata.java new file mode 100644 index 00000000..13ae5eef --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/EnversRevisionMetadata.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import java.time.Instant; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The bounded metadata recorded alongside one revision (design §35). + * + *

An actor id and a correlation id, and nothing else. Storing the security principal — as + * opposed to an opaque reference to it — copies roles, tokens, and personal attributes into an + * append-only table that outlives the session they came from. + */ +public record EnversRevisionMetadata(String actor, String correlationId, Instant recordedAt) { + + private static final Pattern BOUNDED = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); + + public EnversRevisionMetadata { + Objects.requireNonNull(recordedAt, "recordedAt"); + actor = bound(actor); + correlationId = bound(correlationId); + } + + private static String bound(String candidate) { + if (candidate == null || candidate.isBlank()) { + return ""; + } + return BOUNDED.matcher(candidate).matches() ? candidate : "redacted"; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java new file mode 100644 index 00000000..a489793f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.persistence.envers; + +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.AuditReaderFactory; +import org.hibernate.envers.query.AuditEntity; + +/** + * Reads recorded revisions through Hibernate Envers (design §35). + * + *

Only enrolled entities are read. Asking Envers for the history of an entity that was never + * audited throws a provider exception, which would surface to a caller as an internal error rather + * than as the true answer — that this entity keeps no history. + * + *

Envers is {@code compileOnly} for this leaf, so a deployment that has not opted in never loads + * this class. The guard reports its absence at startup rather than letting the first history read + * fail with a missing class. + */ +public final class HibernateEnversHistoryReader implements EnversHistoryReader { + + private final EntityManager entityManager; + private final EnversHistoryPolicy policy; + + public HibernateEnversHistoryReader(EntityManager entityManager, EnversHistoryPolicy policy) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.policy = Objects.requireNonNull(policy, "policy"); + } + + @Override + public List> revisions(Class entityType, Object id) { + Objects.requireNonNull(entityType, "entityType"); + Objects.requireNonNull(id, "id"); + if (!policy.audits(entityType.getSimpleName())) { + return List.of(); + } + + AuditReader reader = AuditReaderFactory.get(entityManager); + List revisionNumbers = reader.getRevisions(entityType, id); + List> revisions = new ArrayList<>(revisionNumbers.size()); + for (Number revisionNumber : revisionNumbers) { + T entity = reader.find(entityType, id, revisionNumber); + Instant recordedAt = reader.getRevisionDate(revisionNumber).toInstant(); + revisions.add( + new EntityRevision<>( + revisionNumber.longValue(), + entity, + new EnversRevisionMetadata(null, null, recordedAt))); + } + return List.copyOf(revisions); + } + + /** The revision numbers recorded for one entity instance, oldest first. */ + public List revisionNumbers(Class entityType, Object id) { + Objects.requireNonNull(entityType, "entityType"); + Objects.requireNonNull(id, "id"); + if (!policy.audits(entityType.getSimpleName())) { + return List.of(); + } + return List.copyOf(AuditReaderFactory.get(entityManager).getRevisions(entityType, id)); + } + + /** Whether any revision at all was recorded for one entity instance. */ + public boolean hasHistory(Class entityType, Object id) { + return !revisionNumbers(entityType, id).isEmpty(); + } + + /** The Envers query property used to filter a revision query by entity id. */ + public static Object idProperty() { + return AuditEntity.id(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java new file mode 100644 index 00000000..53412167 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental; + +/** + * The experimental capabilities and the flag each one requires (experimental plan §Global + * Constraints). + * + *

Every one of these is off unless its flag is explicitly true. The flag is not a convenience: + * these features change tenant isolation, read consistency, or the provider version, and each is + * only as safe as the evidence suite that has run against it. + */ +public enum ExperimentalFeature { + + /** Shared-schema multi-tenancy with a tenant column. */ + MULTITENANCY_COLUMN("backend.jpa.experimental.multitenancy-column"), + + /** PostgreSQL row-level-security tenant isolation. */ + MULTITENANCY_RLS("backend.jpa.experimental.multitenancy-rls"), + + /** Schema-per-tenant isolation. */ + MULTITENANCY_SCHEMA("backend.jpa.experimental.multitenancy-schema"), + + /** Database-per-tenant isolation. */ + MULTITENANCY_DATABASE("backend.jpa.experimental.multitenancy-database"), + + /** Consistency-aware read replica routing. */ + READ_REPLICA("backend.jpa.experimental.read-replica"), + + /** Jakarta Persistence 4.0 compatibility lane. */ + JAKARTA_PERSISTENCE_4("backend.jpa.experimental.jakarta-persistence-4"), + + /** Hibernate ORM 8 compatibility lane. */ + HIBERNATE_8("backend.jpa.experimental.hibernate-8"), + + /** PostgreSQL 19 compatibility lane. */ + POSTGRESQL_19("backend.jpa.experimental.postgresql-19"); + + private final String property; + + ExperimentalFeature(String property) { + this.property = property; + } + + /** The property that must be {@code true} for this feature to run. */ + public String property() { + return property; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java new file mode 100644 index 00000000..c44ed078 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGate.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental; + +import java.util.Map; +import java.util.Objects; + +/** + * Fails closed when an experimental module is present but its flag is not set (experimental plan + * Task 1). + * + *

Presence on the classpath is not consent. An experimental module can arrive transitively, and + * a tenant-isolation or replica-routing feature that switched itself on because a jar was present + * would be the worst possible default. The gate makes the absence of a decision an error rather + * than an activation. + */ +public final class ExperimentalFeatureGate { + + /** + * Fails unless {@code feature} is explicitly enabled. + * + * @throws IllegalStateException naming the exact property that must be set + */ + public void requireEnabled(ExperimentalFeature feature, Map flags) { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(flags, "flags"); + if (!Boolean.TRUE.equals(flags.get(feature.property()))) { + throw new IllegalStateException(feature.property() + "=true is required"); + } + } + + /** Whether a feature is explicitly enabled. */ + public boolean isEnabled(ExperimentalFeature feature, Map flags) { + return Boolean.TRUE.equals(flags.get(feature.property())); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceLifecycle.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceLifecycle.java new file mode 100644 index 00000000..84a05159 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceLifecycle.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.database; + +import java.util.Objects; + +/** + * Closes a tenant pool without letting one failure strand the rest (experimental plan Task 5). + * + *

Closing is best-effort by design. Eviction happens during tenant removal and credential + * rotation, and a pool whose server is already unreachable will throw on close — propagating that + * would abandon the remaining pools mid-loop, leaking exactly the connections the eviction was + * meant to release. + */ +public final class TenantDataSourceLifecycle { + + private TenantDataSourceLifecycle() {} + + /** Closes a pool, swallowing a close failure so the caller can continue evicting. */ + public static void drainAndClose(AutoCloseable pool) { + Objects.requireNonNull(pool, "pool"); + try { + pool.close(); + } catch (Exception unreachable) { + // A pool whose server is already gone throws on close; the connections are gone with it, + // and the remaining tenants still need to be evicted. + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java new file mode 100644 index 00000000..af7e4d95 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.database; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.function.ToIntFunction; +import javax.sql.DataSource; + +/** + * Opens per-tenant pools lazily, inside a global budget (experimental plan Task 5). + * + *

Lazily, because a deployment with a thousand tenants must not open a thousand pools at startup + * for the handful that are active. Inside a budget, because the alternative is an unbounded number + * of pools whose combined connection count exceeds the server's {@code max_connections} — at which + * point every tenant fails, not just the newest one. + * + *

Nothing here exposes a tenant's JDBC URL or credentials. A diagnostic that listed them would + * publish one tenant's connection details to whoever can read the diagnostics. + */ +public final class TenantDataSourceRegistry implements AutoCloseable { + + private final Map pools = new LinkedHashMap<>(); + private final Function poolFactory; + private final ToIntFunction poolSize; + private final TenantPoolBudget budget; + + /** + * @param poolFactory opens a pool for a tenant from its secret-backed connection profile + * @param poolSize reports a pool's maximum connection count, for the global budget + */ + public TenantDataSourceRegistry( + Function poolFactory, + ToIntFunction poolSize, + TenantPoolBudget budget) { + this.poolFactory = Objects.requireNonNull(poolFactory, "poolFactory"); + this.poolSize = Objects.requireNonNull(poolSize, "poolSize"); + this.budget = Objects.requireNonNull(budget, "budget"); + } + + /** + * The tenant's pool, opening it if the budget allows. + * + * @throws IllegalStateException when the global pool budget is exhausted + */ + public synchronized DataSource require(TenantId tenant) { + Objects.requireNonNull(tenant, "tenant"); + DataSource existing = pools.get(tenant); + if (existing != null) { + return existing; + } + budget.requireCapacity(pools.size(), allocatedConnections()); + DataSource opened = poolFactory.apply(tenant); + pools.put(tenant, opened); + return opened; + } + + /** Opens pools for {@code count} tenants; used by the capacity contract. */ + public synchronized void openTenants(int count) { + for (int index = 0; index < count; index++) { + require(new TenantId("tenant-" + index)); + } + } + + /** + * Closes and forgets a tenant's pool. + * + *

Used on tenant removal and on credential rotation — a rotated credential means the open pool + * is holding connections authenticated with a secret that is no longer valid. + */ + public synchronized void evict(TenantId tenant) { + DataSource removed = pools.remove(tenant); + if (removed instanceof AutoCloseable closeable) { + TenantDataSourceLifecycle.drainAndClose(closeable); + } + } + + /** How many pools are currently open. */ + public synchronized int openPools() { + return pools.size(); + } + + /** How many connections all open pools may hold between them. */ + public synchronized int allocatedConnections() { + return pools.values().stream().mapToInt(poolSize).sum(); + } + + /** The tenants with an open pool. */ + public synchronized Set openTenantIds() { + return Set.copyOf(pools.keySet()); + } + + @Override + public synchronized void close() { + pools.values().stream() + .filter(AutoCloseable.class::isInstance) + .map(AutoCloseable.class::cast) + .forEach(TenantDataSourceLifecycle::drainAndClose); + pools.clear(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java new file mode 100644 index 00000000..136126df --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.database; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import jakarta.persistence.EntityManagerFactory; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +/** + * One {@code EntityManagerFactory} per tenant database (experimental plan Task 5). + * + *

Database-per-tenant needs a factory per tenant, not merely a data source per tenant: an {@code + * EntityManagerFactory} owns the dialect, the schema validation result, and the second-level cache, + * and none of those are shareable across databases that may be on different versions or at + * different migration states. + * + *

That is also the cost. Each factory carries its own metamodel and caches, so the tenant + * ceiling here is about memory as much as about connections. + */ +public final class TenantEntityManagerFactoryRegistry implements AutoCloseable { + + private final Map factories = new LinkedHashMap<>(); + private final Function factoryBuilder; + private final TenantDataSourceRegistry dataSources; + + public TenantEntityManagerFactoryRegistry( + Function factoryBuilder, + TenantDataSourceRegistry dataSources) { + this.factoryBuilder = Objects.requireNonNull(factoryBuilder, "factoryBuilder"); + this.dataSources = Objects.requireNonNull(dataSources, "dataSources"); + } + + /** The tenant's factory, building it — and its pool — on first use. */ + public synchronized EntityManagerFactory require(TenantId tenant) { + Objects.requireNonNull(tenant, "tenant"); + dataSources.require(tenant); + return factories.computeIfAbsent(tenant, factoryBuilder); + } + + /** Closes and forgets a tenant's factory and pool. */ + public synchronized void evict(TenantId tenant) { + EntityManagerFactory removed = factories.remove(tenant); + if (removed != null) { + TenantDataSourceLifecycle.drainAndClose(removed); + } + dataSources.evict(tenant); + } + + /** The tenants with an open factory. */ + public synchronized Set openTenants() { + return Set.copyOf(factories.keySet()); + } + + @Override + public synchronized void close() { + factories.values().forEach(TenantDataSourceLifecycle::drainAndClose); + factories.clear(); + dataSources.close(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java new file mode 100644 index 00000000..b3abfb1d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.database; + +/** + * The global ceiling on per-tenant connection pools (experimental plan Task 5). + * + *

Database-per-tenant fails in a specific way: each tenant's pool is individually reasonable and + * their sum is not. Fifty tenants with a modest ten-connection pool is five hundred connections + * against a server whose {@code max_connections} is a hundred, and the failure arrives as + * connection refusals for every tenant at once, including the ones that were idle. + * + *

Both ceilings are needed. A pool count alone ignores that pools differ in size; a connection + * total alone permits an unbounded number of tiny pools, each with its own threads and monitoring. + */ +public record TenantPoolBudget(int maxOpenPools, int maxConnectionsAcrossPools) { + + public TenantPoolBudget { + if (maxOpenPools < 1) { + throw new IllegalArgumentException("maxOpenPools must be positive"); + } + if (maxConnectionsAcrossPools < 1) { + throw new IllegalArgumentException("maxConnectionsAcrossPools must be positive"); + } + } + + /** + * Fails when another tenant pool would exceed either ceiling. + * + * @throws IllegalStateException when the budget is exhausted + */ + public void requireCapacity(int openPools, int allocatedConnections) { + if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) { + throw new IllegalStateException("tenant pool budget exhausted"); + } + } + + /** Whether another tenant pool fits inside both ceilings. */ + public boolean hasCapacity(int openPools, int allocatedConnections) { + return openPools < maxOpenPools && allocatedConnections < maxConnectionsAcrossPools; + } + + /** The maximum number of tenants that can be open at once. */ + public int maxTenants() { + return maxOpenPools; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLane.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLane.java new file mode 100644 index 00000000..940ef33d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLane.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import java.util.Objects; + +/** + * One forward-compatibility lane (experimental plan Tasks 7-9). + * + *

{@code publicationEnabled} is always false for a lane. A lane exists to find out whether the + * platform still works on a newer specification, provider, or server; publishing an artifact + * compiled in one would let a consumer depend on that answer before it exists. + */ +public record CompatibilityLane( + String name, SupportLevel supportLevel, boolean publicationEnabled) { + + public CompatibilityLane { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(supportLevel, "supportLevel"); + if (name.isBlank()) { + throw new IllegalArgumentException("a compatibility lane requires a name"); + } + if (publicationEnabled && supportLevel == SupportLevel.EXPERIMENTAL) { + throw new IllegalArgumentException( + "an experimental lane must not publish artifacts under Stable coordinates"); + } + } + + /** An experimental, non-publishing lane. */ + public static CompatibilityLane experimental(String name) { + return new CompatibilityLane(name, SupportLevel.EXPERIMENTAL, false); + } + + /** The three lanes the experimental plan defines. */ + public static java.util.List defined() { + return java.util.List.of( + experimental("jpa4"), experimental("hibernate8"), experimental("postgresql19")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGate.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGate.java new file mode 100644 index 00000000..ec6b06e9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGate.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import java.util.Objects; + +/** + * Decides whether an experimental feature may be considered for Stable (experimental plan Task 9). + * + *

Technical evidence is checked first, then the reviewed ADR. Both are required, and the ADR is + * not a formality: the technical suites establish that a thing works, and the ADR records that + * someone decided the platform should promise it — including the support burden that promise + * carries. + */ +public final class ExperimentalPromotionGate { + + /** The promotion decision for the supplied evidence. */ + public PromotionDecision evaluate(PromotionEvidence evidence) { + Objects.requireNonNull(evidence, "evidence"); + if (!evidence.allTechnicalGatesPassed()) { + return PromotionDecision.BLOCKED_TECHNICAL; + } + if (!evidence.reviewedAdr()) { + return PromotionDecision.BLOCKED_MISSING_ADR; + } + return PromotionDecision.ELIGIBLE_FOR_STABLE_REVIEW; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java new file mode 100644 index 00000000..dd02bef6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy; +import java.util.List; + +/** + * Which provider versions are Stable and which are only exercised in a lane (experimental plan Task + * 8). + * + *

A lane result never changes the Stable gate. If Hibernate 8 generates different SQL for the + * collection-fetch pagination contract, that is a finding about Hibernate 8 — the 7.x gate keeps + * asserting what 7.x must do, because that is what deployments are running. + */ +public final class HibernateCompatibilityPolicy { + + private final HibernateProviderPolicy providerPolicy; + + public HibernateCompatibilityPolicy(HibernateProviderPolicy providerPolicy) { + this.providerPolicy = providerPolicy; + } + + /** A policy over the provider on this classpath. */ + public static HibernateCompatibilityPolicy fromClasspath() { + return new HibernateCompatibilityPolicy(HibernateProviderPolicy.fromClasspath()); + } + + /** The provider version the design declares Stable. */ + public String stableProvider() { + return providerPolicy.stableProvider(); + } + + /** The provider versions that may only run in a compatibility lane. */ + public List experimentalProviders() { + return providerPolicy.experimentalProviders(); + } + + /** Whether a provider version may replace the Stable one without a promotion. */ + public boolean mayReplaceStableProvider(String version) { + return !providerPolicy.isExperimental(version); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionDecision.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionDecision.java new file mode 100644 index 00000000..3e6fc114 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionDecision.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +/** + * Whether an experimental feature may be considered for Stable (experimental plan Task 9). + * + *

The blocked states are distinct because they need different work. A technical block needs + * evidence; a missing ADR needs a decision. Collapsing them into one "not yet" hides which of the + * two is actually outstanding. + */ +public enum PromotionDecision { + + /** One or more evidence suites have not passed. */ + BLOCKED_TECHNICAL, + + /** Every suite passed, but no reviewed ADR records the decision. */ + BLOCKED_MISSING_ADR, + + /** Evidence and decision are both in place; Stable review may begin. */ + ELIGIBLE_FOR_STABLE_REVIEW +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionEvidence.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionEvidence.java new file mode 100644 index 00000000..ca2dd501 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PromotionEvidence.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +/** + * The evidence a promotion requires (experimental plan Task 9). + * + *

Version availability is not on this list, and its absence is the point. "PostgreSQL 19 is out" + * is a fact about the world; "our contracts pass on it" is a fact about this platform, and only the + * second one justifies changing what the support matrix promises. + */ +public record PromotionEvidence( + boolean compatibility, + boolean security, + boolean failure, + boolean migration, + boolean performance, + boolean reviewedAdr) { + + /** Evidence with nothing yet gathered. */ + public static PromotionEvidence none() { + return new PromotionEvidence(false, false, false, false, false, false); + } + + /** A builder-style copy with the compatibility suite recorded. */ + public PromotionEvidence withCompatibility(boolean passed) { + return new PromotionEvidence(passed, security, failure, migration, performance, reviewedAdr); + } + + /** A builder-style copy with the security suite recorded. */ + public PromotionEvidence withSecurity(boolean passed) { + return new PromotionEvidence( + compatibility, passed, failure, migration, performance, reviewedAdr); + } + + /** A builder-style copy with the failure-injection suite recorded. */ + public PromotionEvidence withFailure(boolean passed) { + return new PromotionEvidence( + compatibility, security, passed, migration, performance, reviewedAdr); + } + + /** A builder-style copy with the migration suite recorded. */ + public PromotionEvidence withMigration(boolean passed) { + return new PromotionEvidence( + compatibility, security, failure, passed, performance, reviewedAdr); + } + + /** A builder-style copy with the performance suite recorded. */ + public PromotionEvidence withPerformance(boolean passed) { + return new PromotionEvidence(compatibility, security, failure, migration, passed, reviewedAdr); + } + + /** A builder-style copy with the reviewed ADR recorded. */ + public PromotionEvidence withReviewedAdr(boolean reviewed) { + return new PromotionEvidence( + compatibility, security, failure, migration, performance, reviewed); + } + + /** Whether every technical suite has passed. */ + public boolean allTechnicalGatesPassed() { + return compatibility && security && failure && migration && performance; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java new file mode 100644 index 00000000..d4bffc21 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +import java.util.Objects; + +/** + * Decides which database a transaction's reads go to (experimental plan Task 6). + * + *

Every rule here is a way that {@code readOnly=true} alone gets it wrong: + * + *

    + *
  • A write transaction obviously uses the primary — but so does a read transaction that takes + * locks, because a lock on a replica guards nothing. + *
  • A read-after-write goes to the primary until the replica is proven caught up. This + * is the case that produces "I saved it and it did not save". + *
  • Unavailable lag evidence means the primary. Absence of evidence is not evidence of + * freshness. + *
+ * + *

The decision is made once and holds for the whole transaction. Switching mid-transaction would + * mean two connections to two databases inside one unit of work, with no relationship between what + * each of them sees. + */ +public final class ConsistencyAwareDataSourceRouter { + + private final ReplicaLagMonitor lagMonitor; + + public ConsistencyAwareDataSourceRouter(ReplicaLagMonitor lagMonitor) { + this.lagMonitor = Objects.requireNonNull(lagMonitor, "lagMonitor"); + } + + /** The target for one transaction, fixed for its whole life. */ + public ReplicaRoutingDecision route(TransactionContext transaction, ReadConsistency consistency) { + Objects.requireNonNull(transaction, "transaction"); + Objects.requireNonNull(consistency, "consistency"); + if (transaction.write()) { + return ReplicaRoutingDecision.primary("write transaction"); + } + if (transaction.locking()) { + return ReplicaRoutingDecision.primary("locking query"); + } + if (transaction.requiresNew()) { + return ReplicaRoutingDecision.primary("REQUIRES_NEW write boundary"); + } + if (!lagMonitor.satisfies(consistency)) { + return ReplicaRoutingDecision.primary("replica lag evidence does not satisfy the read"); + } + return ReplicaRoutingDecision.replica("consistency requirement satisfied by the replica"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyToken.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyToken.java new file mode 100644 index 00000000..f1a9fd6c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyToken.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +import java.time.Instant; +import java.util.Objects; + +/** + * A marker for "the state as of this write" (experimental plan Task 6). + * + *

Read-after-write cannot be answered by {@code readOnly=true}. A read that follows a write is + * still a read, and routing it to a replica is exactly how a user saves a form and then sees the + * old value. The token is what lets the router ask the real question — has the replica caught up + * past this point — instead of guessing from the transaction's read-only flag. + */ +public record ConsistencyToken(Instant writtenAt, String logPosition) { + + public ConsistencyToken { + Objects.requireNonNull(writtenAt, "writtenAt"); + logPosition = logPosition == null ? "" : logPosition; + } + + /** A token for a write observed at {@code writtenAt} with no log position available. */ + public static ConsistencyToken at(Instant writtenAt) { + return new ConsistencyToken(writtenAt, null); + } + + /** Whether a replica reporting {@code replicaPosition} has caught up past this token. */ + public boolean satisfiedBy(Instant replicaPosition) { + Objects.requireNonNull(replicaPosition, "replicaPosition"); + return !replicaPosition.isBefore(writtenAt); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReadConsistency.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReadConsistency.java new file mode 100644 index 00000000..69045e16 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReadConsistency.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * How fresh a read has to be (experimental plan Task 6). + * + *

The consistency requirement is stated by the caller, because only the caller knows it. A + * dashboard tolerates seconds of lag; the read that renders the page after a save does not; a read + * that will be used to compute a write must not go to a replica at all. + */ +public record ReadConsistency( + Level level, Optional after, Duration maxStaleness) { + + /** The consistency levels a read may ask for. */ + public enum Level { + /** Must see this session's own writes; the primary, or a replica proven to be caught up. */ + PRIMARY_REQUIRED, + + /** May use a replica whose lag is within {@code maxStaleness}. */ + BOUNDED_STALENESS, + + /** May use any replica. */ + EVENTUAL + } + + public ReadConsistency { + Objects.requireNonNull(level, "level"); + Objects.requireNonNull(after, "after"); + Objects.requireNonNull(maxStaleness, "maxStaleness"); + if (maxStaleness.isNegative()) { + throw new IllegalArgumentException("max staleness must not be negative"); + } + } + + /** A read that must observe the write identified by {@code token}. */ + public static ReadConsistency after(ConsistencyToken token) { + return new ReadConsistency(Level.PRIMARY_REQUIRED, Optional.of(token), Duration.ZERO); + } + + /** A read that tolerates up to {@code maxStaleness} of replica lag. */ + public static ReadConsistency boundedStaleness(Duration maxStaleness) { + return new ReadConsistency(Level.BOUNDED_STALENESS, Optional.empty(), maxStaleness); + } + + /** A read that tolerates any lag. */ + public static ReadConsistency eventual() { + return new ReadConsistency(Level.EVENTUAL, Optional.empty(), Duration.ZERO); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java new file mode 100644 index 00000000..70277945 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +/** + * Reports how far a replica is behind (experimental plan Task 6). + * + *

{@link #replayedThrough()} is an {@link Optional} on purpose. When lag cannot be measured — + * the monitor is down, the replica is unreachable, the metric is stale — the correct answer is "I + * do not know", and the router must treat that as a reason to use the primary. A monitor that + * returned zero lag on failure would route reads to a replica precisely when something is wrong + * with it. + */ +public interface ReplicaLagMonitor { + + /** The point in time the replica has replayed through, when it can be measured. */ + Optional replayedThrough(); + + /** The measured lag, when it can be measured. */ + Optional lag(); + + /** + * Whether the replica provably satisfies the consistency requirement. + * + *

Defaults to {@code false} whenever the evidence is unavailable. + */ + default boolean satisfies(ReadConsistency consistency) { + return switch (consistency.level()) { + case EVENTUAL -> true; + case BOUNDED_STALENESS -> + lag().map(measured -> measured.compareTo(consistency.maxStaleness()) <= 0).orElse(false); + case PRIMARY_REQUIRED -> + consistency + .after() + .flatMap(token -> replayedThrough().map(token::satisfiedBy)) + .orElse(false); + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaRoutingDecision.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaRoutingDecision.java new file mode 100644 index 00000000..64fe0a56 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaRoutingDecision.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +import java.util.Objects; + +/** + * Where one transaction's reads go, and why (experimental plan Task 6). + * + *

The reason is carried so a routing decision is explainable after the fact. "Why did this read + * see stale data" is otherwise unanswerable from logs. + */ +public record ReplicaRoutingDecision(ReplicaTarget target, String reason) { + + public ReplicaRoutingDecision { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(reason, "reason"); + if (reason.isBlank()) { + throw new IllegalArgumentException("a routing decision requires a reason"); + } + } + + /** Route to the primary. */ + public static ReplicaRoutingDecision primary(String reason) { + return new ReplicaRoutingDecision(ReplicaTarget.PRIMARY, reason); + } + + /** Route to a replica. */ + public static ReplicaRoutingDecision replica(String reason) { + return new ReplicaRoutingDecision(ReplicaTarget.REPLICA, reason); + } + + /** Whether this decision routes to the primary. */ + public boolean usesPrimary() { + return target == ReplicaTarget.PRIMARY; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaTarget.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaTarget.java new file mode 100644 index 00000000..76832c3d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaTarget.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +/** Which database a read is routed to (experimental plan Task 6). */ +public enum ReplicaTarget { + + /** The primary. Every write, every lock, and every read that cannot tolerate lag. */ + PRIMARY, + + /** A read replica, chosen only when the consistency requirement provably allows it. */ + REPLICA +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/TransactionContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/TransactionContext.java new file mode 100644 index 00000000..a116d2dd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/TransactionContext.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.replica; + +/** + * What the router needs to know about the transaction it is routing (experimental plan Task 6). + * + *

{@code write} and {@code locking} are separate because a read-only transaction can still take + * locks — {@code SELECT ... FOR UPDATE} in a read-only transaction is legal — and a lock taken on a + * replica protects nothing, since the write it is guarding will happen on the primary. + */ +public record TransactionContext(boolean write, boolean locking, boolean requiresNew) { + + /** An ordinary read transaction. */ + public static TransactionContext readOnly() { + return new TransactionContext(false, false, false); + } + + /** A read transaction that takes locks. */ + public static TransactionContext lockingRead() { + return new TransactionContext(false, true, false); + } + + /** A write transaction. */ + public static TransactionContext writing() { + return new TransactionContext(true, false, false); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsAdminBypassToken.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsAdminBypassToken.java new file mode 100644 index 00000000..5ea5a955 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsAdminBypassToken.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.rls; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The audited identity a cross-tenant RLS bypass runs under (experimental plan Task 3). + * + *

A bypass needs a separate DataSource, connected as a role that RLS does not apply to. Sharing + * the runtime DataSource would mean the bypass role is the role every ordinary request already + * uses, at which point the policies protect nothing. + */ +public record RlsAdminBypassToken(String operator, String reason) { + + private static final Pattern OPERATOR = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); + private static final int MAX_REASON_LENGTH = 256; + + public RlsAdminBypassToken { + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(reason, "reason"); + if (!OPERATOR.matcher(operator).matches()) { + throw new IllegalArgumentException("invalid bypass operator identity"); + } + if (reason.isBlank() || reason.length() > MAX_REASON_LENGTH) { + throw new IllegalArgumentException("a bypass requires a present, bounded reason"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java new file mode 100644 index 00000000..553feb5c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.rls; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.sql.DataSource; + +/** + * Proves the runtime role cannot bypass row-level security (experimental plan Task 3). + * + *

Three ways RLS silently does nothing, all checked here: + * + *

    + *
  • The table has no policy, or RLS was never enabled on it. + *
  • The runtime role has {@code BYPASSRLS}. + *
  • The runtime role owns the table — table owners are exempt from RLS unless the + * table is set to {@code FORCE ROW LEVEL SECURITY}, which is the one people forget. + *
+ * + *

Each of those produces a system where every query returns every tenant's rows while the + * policies look correctly configured. + */ +public final class RlsPolicyVerifier { + + private static final String ROLE_QUERY = + "select rolbypassrls from pg_roles where rolname = current_user"; + + private static final String TABLE_QUERY = + """ + select c.relname, c.relrowsecurity, c.relforcerowsecurity, + pg_get_userbyid(c.relowner) = current_user as owned_by_current_user + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = current_schema() and c.relkind = 'r' + """; + + /** + * Fails when RLS is not actually enforced for the runtime role. + * + * @param tenantScopedTables the tables that must be tenant isolated + * @throws IllegalStateException naming the first way isolation is not enforced + */ + public void requireEnforced(DataSource runtimeDataSource, List tenantScopedTables) { + Objects.requireNonNull(runtimeDataSource, "runtimeDataSource"); + Objects.requireNonNull(tenantScopedTables, "tenantScopedTables"); + try (Connection connection = runtimeDataSource.getConnection()) { + requireNoBypassPrivilege(connection); + requirePoliciesForced(connection, tenantScopedTables); + } catch (SQLException failure) { + throw new IllegalStateException("row level security could not be verified", failure); + } + } + + /** The tables in the current schema that do not force RLS. */ + public List tablesWithoutForcedPolicy(DataSource dataSource) { + List unforced = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(TABLE_QUERY); + ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + boolean enforced = + rows.getBoolean("relrowsecurity") + && (rows.getBoolean("relforcerowsecurity") + || !rows.getBoolean("owned_by_current_user")); + if (!enforced) { + unforced.add(rows.getString("relname")); + } + } + } catch (SQLException failure) { + throw new IllegalStateException("row level security state could not be read", failure); + } + return List.copyOf(unforced); + } + + private static void requireNoBypassPrivilege(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(ROLE_QUERY); + ResultSet rows = statement.executeQuery()) { + if (rows.next() && rows.getBoolean(1)) { + throw new IllegalStateException( + "the runtime role holds BYPASSRLS, so every row level security policy is inert"); + } + } + } + + private void requirePoliciesForced(Connection connection, List tenantScopedTables) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(TABLE_QUERY); + ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + String table = rows.getString("relname"); + if (!tenantScopedTables.contains(table)) { + continue; + } + if (!rows.getBoolean("relrowsecurity")) { + throw new IllegalStateException( + "table '" + table + "' is tenant scoped but has row level security disabled"); + } + if (rows.getBoolean("owned_by_current_user") && !rows.getBoolean("relforcerowsecurity")) { + throw new IllegalStateException( + "table '" + + table + + "' is owned by the runtime role and does not FORCE ROW LEVEL" + + " SECURITY, so the owner is exempt from its own policies"); + } + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java new file mode 100644 index 00000000..fe5b77b3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.rls; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import jakarta.persistence.EntityManager; +import java.util.Objects; + +/** + * Binds the tenant into a PostgreSQL transaction-local setting for RLS to read (experimental plan + * Task 3). + * + *

The {@code true} third argument to {@code set_config} is the whole safety property: it makes + * the setting transaction-local, so it is discarded when the transaction ends. A + * session-local setting would survive the connection's return to the pool, and the next transaction + * on that connection — quite possibly another tenant's — would inherit it and pass every RLS policy + * as the previous tenant. + * + *

The tenant is bound as a parameter, never concatenated. A tenant id is externally influenced + * data, and {@code set_config} takes a string. + */ +public final class RlsTenantSessionBinder { + + /** The setting RLS policies read the current tenant from. */ + public static final String TENANT_SETTING = "app.tenant_id"; + + /** Transaction-local binding; the {@code true} argument is what scopes it to the transaction. */ + private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)"; + + /** Binds {@code tenant} for the remainder of the current transaction. */ + public void bind(EntityManager entityManager, TenantId tenant) { + Objects.requireNonNull(entityManager, "entityManager"); + Objects.requireNonNull(tenant, "tenant"); + entityManager.createNativeQuery(BIND_SQL).setParameter(1, tenant.value()).getSingleResult(); + } + + /** The SQL this binder issues; exposed so the safety check can assert it is parameterized. */ + public static String bindStatement() { + return BIND_SQL; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java new file mode 100644 index 00000000..bf6b0c4c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.schema; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.Objects; +import javax.sql.DataSource; + +/** + * Selects a tenant's schema on a pooled connection, and resets it on return (experimental plan Task + * 4). + * + *

The reset is the part that matters. {@code search_path} is a session setting, so a connection + * returned to the pool still carries the last tenant's schema. The next borrower — quite possibly a + * different tenant, or a background job with no tenant at all — reads and writes there without any + * statement being wrong. + * + *

The schema comes from the registry and is validated as an identifier there, because {@code SET + * search_path} takes an identifier and not a bound parameter. + */ +public final class SchemaMultiTenantConnectionProvider { + + /** The schema a connection is reset to when it is released. */ + public static final String NEUTRAL_SCHEMA = "pg_catalog"; + + private final DataSource dataSource; + private final SchemaTenantRegistry registry; + + public SchemaMultiTenantConnectionProvider(DataSource dataSource, SchemaTenantRegistry registry) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.registry = Objects.requireNonNull(registry, "registry"); + } + + /** Borrows a connection with {@code tenant}'s schema selected. */ + public Connection getConnection(TenantId tenant) throws SQLException { + Objects.requireNonNull(tenant, "tenant"); + String schema = registry.requireSchema(tenant); + Connection connection = dataSource.getConnection(); + try { + setSearchPath(connection, schema); + return connection; + } catch (SQLException failure) { + connection.close(); + throw failure; + } + } + + /** Resets the schema before the connection goes back to the pool. */ + public void releaseConnection(Connection connection) throws SQLException { + Objects.requireNonNull(connection, "connection"); + try { + setSearchPath(connection, NEUTRAL_SCHEMA); + } finally { + connection.close(); + } + } + + /** + * Applies a registered schema identifier. + * + *

{@code set_config} is used rather than {@code SET search_path} because it accepts the value + * as a bound parameter; the identifier has already been validated by the registry, and binding it + * removes the last route by which a schema name could reach the statement text. + */ + private static void setSearchPath(Connection connection, String schema) throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement("select set_config('search_path', ?, false)")) { + statement.setString(1, schema); + statement.execute(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java new file mode 100644 index 00000000..d392f8db --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.schema; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationInfo; +import org.flywaydb.core.api.output.MigrateResult; + +/** + * Migrates each tenant schema, tracking status per tenant (experimental plan Task 4). + * + *

Per-tenant status is not bookkeeping. With one schema per tenant, a migration run is N + * independent migrations, and one failing does not stop the others from having succeeded — so "the + * migration failed" is not a usable answer. A resumable run needs to know which tenants are already + * done. + * + *

Failures are recorded and the run continues, but nothing is repaired automatically: a checksum + * mismatch on one tenant is the same evidence it is anywhere else, and repairing it here would + * erase it N times over. + */ +public final class SchemaTenantMigrationOrchestrator { + + private final DataSource dataSource; + private final SchemaTenantRegistry registry; + private final String migrationLocation; + private final Map status = new LinkedHashMap<>(); + + public SchemaTenantMigrationOrchestrator( + DataSource dataSource, SchemaTenantRegistry registry, String migrationLocation) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.registry = Objects.requireNonNull(registry, "registry"); + this.migrationLocation = Objects.requireNonNull(migrationLocation, "migrationLocation"); + } + + /** + * Migrates one registered tenant's schema. + * + * @throws IllegalArgumentException when the tenant has no registered schema + */ + public MigrateResult migrate(TenantId tenant) { + String schema = registry.requireSchema(tenant); + Flyway flyway = + Flyway.configure() + .dataSource(dataSource) + .schemas(schema) + .defaultSchema(schema) + .locations(migrationLocation) + .load(); + MigrateResult result = flyway.migrate(); + status.put(tenant, new TenantMigrationStatus(tenant, appliedVersion(flyway), null)); + return result; + } + + /** + * The version this tenant's schema is actually on, read back from its schema history. + * + *

{@code MigrateResult.targetSchemaVersion} is not that: a run that applied nothing because + * the schema was already current leaves it empty, and recording empty would report a migrated + * tenant as unmigrated. The status exists to answer "which tenants are on which version" during a + * partial rollout, so it has to come from the history table rather than from what this particular + * invocation happened to do. + */ + private static String appliedVersion(Flyway flyway) { + MigrationInfo current = flyway.info().current(); + if (current == null || current.getVersion() == null) { + return null; + } + return current.getVersion().getVersion(); + } + + /** + * Migrates every supplied tenant, recording per-tenant outcomes. + * + *

A failure is recorded rather than thrown so the remaining tenants still run; the caller + * inspects {@link #failed()} afterwards. + */ + public void migrateAll(List tenants) { + Objects.requireNonNull(tenants, "tenants"); + for (TenantId tenant : tenants) { + try { + migrate(tenant); + } catch (RuntimeException failure) { + status.put( + tenant, new TenantMigrationStatus(tenant, null, failure.getClass().getSimpleName())); + } + } + } + + /** The recorded status for one tenant. */ + public Optional status(TenantId tenant) { + return Optional.ofNullable(status.get(tenant)); + } + + /** The tenants whose migration failed in the last run. */ + public List failed() { + return status.values().stream().filter(TenantMigrationStatus::failed).toList(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java new file mode 100644 index 00000000..8c8534b4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.schema; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Maps a tenant to a pre-registered schema identifier (experimental plan Task 4). + * + *

A schema name is part of the SQL statement — it cannot be bound as a parameter — so a + * deployment that derived it from a tenant id would be building SQL from externally influenced + * data. Registration keeps the set of reachable schemas fixed at deployment time, and a tenant that + * is not in the map cannot address any schema at all. + */ +public final class SchemaTenantRegistry { + + /** Unquoted PostgreSQL schema identifiers only. */ + private static final Pattern SCHEMA = Pattern.compile("[a-z_][a-z0-9_]{0,62}"); + + private final Map schemaByTenant; + + public SchemaTenantRegistry(Map schemaByTenant) { + Objects.requireNonNull(schemaByTenant, "schemaByTenant"); + schemaByTenant.forEach( + (tenant, schema) -> { + Objects.requireNonNull(tenant, "tenant"); + if (schema == null || !SCHEMA.matcher(schema).matches()) { + throw new IllegalArgumentException("invalid tenant schema identifier: " + schema); + } + }); + this.schemaByTenant = Map.copyOf(schemaByTenant); + } + + /** + * The schema registered for {@code tenant}. + * + * @throws IllegalArgumentException when the tenant has no registered schema + */ + public String requireSchema(TenantId tenant) { + Objects.requireNonNull(tenant, "tenant"); + return Optional.ofNullable(schemaByTenant.get(tenant)) + .orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema")); + } + + /** Whether a tenant has a registered schema. */ + public boolean contains(TenantId tenant) { + return schemaByTenant.containsKey(tenant); + } + + /** The registered tenants. */ + public Set tenants() { + return schemaByTenant.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/TenantMigrationStatus.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/TenantMigrationStatus.java new file mode 100644 index 00000000..8748996d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/TenantMigrationStatus.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.schema; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.util.Objects; +import java.util.Optional; + +/** + * The outcome of migrating one tenant's schema (experimental plan Task 4). + * + * @param failureType the exception's simple name, never its message — a Flyway failure message + * contains the script path and part of the failing statement + */ +public record TenantMigrationStatus(TenantId tenant, String version, String failureType) { + + public TenantMigrationStatus { + Objects.requireNonNull(tenant, "tenant"); + } + + /** Whether this tenant's migration failed. */ + public boolean failed() { + return failureType != null; + } + + /** The applied schema version, when the migration succeeded. */ + public Optional appliedVersion() { + return Optional.ofNullable(version); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java new file mode 100644 index 00000000..2445994e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.tenant; + +import java.util.Objects; + +/** + * Refuses repository access without a tenant, outside an audited admin scope (experimental plan + * Task 2). + * + *

The guard exists because a Hibernate filter is not a security boundary. Filters apply to + * entity queries, and they do not apply to native SQL, to bulk DML, to {@code getReference}, or to + * anything reached through a second-level cache — so a design that relied on the filter alone + * leaves several routes to another tenant's rows wide open. + * + *

The admin scope is explicit and audited for the same reason: a cross-tenant read is sometimes + * legitimate, and the way to keep it legitimate is to make it visible. + */ +public final class TenantAwareRepositoryGuard { + + private static final ThreadLocal ADMIN_SCOPE = new ThreadLocal<>(); + + /** + * Fails when no tenant is bound and no admin scope is open. + * + * @throws IllegalStateException naming what is missing + */ + public void requireTenantOrAdminScope() { + if (TenantContext.current().isPresent() || ADMIN_SCOPE.get() != null) { + return; + } + throw new IllegalStateException( + "tenant context is required for repository access; a cross-tenant read must open an" + + " audited admin scope instead"); + } + + /** Opens an audited cross-tenant scope for {@code work}. */ + public T inAdminScope(String auditReason, java.util.function.Supplier work) { + Objects.requireNonNull(auditReason, "auditReason"); + Objects.requireNonNull(work, "work"); + if (auditReason.isBlank()) { + throw new IllegalArgumentException("an admin scope requires an audit reason"); + } + String previous = ADMIN_SCOPE.get(); + ADMIN_SCOPE.set(auditReason); + try { + return work.get(); + } finally { + if (previous == null) { + ADMIN_SCOPE.remove(); + } else { + ADMIN_SCOPE.set(previous); + } + } + } + + /** Whether an audited cross-tenant scope is currently open. */ + public boolean adminScopeOpen() { + return ADMIN_SCOPE.get() != null; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantContext.java new file mode 100644 index 00000000..93349d59 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantContext.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.tenant; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * The tenant the current unit of work belongs to (experimental plan Task 2). + * + *

Fail-closed: {@link #require()} throws when no tenant is bound rather than returning a default + * or null. In a shared-schema deployment, an unbound tenant means a query with no tenant predicate, + * which returns every tenant's rows — a cross-tenant data leak that looks like a successful + * request. + * + *

Clearing is mandatory. Request threads are pooled, so a leaked tenant is not merely stale + * state: the next request on that thread reads and writes as the previous request's tenant. + */ +public final class TenantContext { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private TenantContext() {} + + /** Binds a tenant to the current thread. */ + public static void bind(TenantId tenant) { + CURRENT.set(Objects.requireNonNull(tenant, "tenant")); + } + + /** + * The bound tenant. + * + * @throws IllegalStateException when no tenant is bound + */ + public static TenantId require() { + TenantId tenant = CURRENT.get(); + if (tenant == null) { + throw new IllegalStateException("tenant context is required"); + } + return tenant; + } + + /** The bound tenant, when one is bound. */ + public static Optional current() { + return Optional.ofNullable(CURRENT.get()); + } + + /** Unbinds the current tenant. Callers must invoke this in a {@code finally}. */ + public static void clear() { + CURRENT.remove(); + } + + /** + * Runs {@code work} with {@code tenant} bound, restoring the previous binding afterwards. + * + *

Restoring rather than clearing is what makes this safe to nest — an async job that adopts a + * tenant inside a request must not leave the request's own tenant unbound when it finishes. + */ + public static T with(TenantId tenant, Supplier work) { + Objects.requireNonNull(work, "work"); + TenantId previous = CURRENT.get(); + bind(tenant); + try { + return work.get(); + } finally { + if (previous == null) { + CURRENT.remove(); + } else { + CURRENT.set(previous); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java new file mode 100644 index 00000000..e35f5995 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.tenant; + +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Stamps and verifies the tenant column on every write (experimental plan Task 2). + * + *

Reads are only half the isolation problem. A write with the wrong tenant column — or with none + * — inserts a row that the writing tenant cannot see and another tenant can. The listener stamps + * the bound tenant on insert and refuses an update that would move a row between tenants. + * + * @param the tenant-scoped entity type + */ +public final class TenantEntityListenerGuard { + + private final Function tenantReader; + private final BiConsumer tenantWriter; + + public TenantEntityListenerGuard( + Function tenantReader, BiConsumer tenantWriter) { + this.tenantReader = Objects.requireNonNull(tenantReader, "tenantReader"); + this.tenantWriter = Objects.requireNonNull(tenantWriter, "tenantWriter"); + } + + /** Stamps the bound tenant onto a new row. */ + @PrePersist + public void stampOnInsert(T entity) { + Objects.requireNonNull(entity, "entity"); + TenantId bound = TenantContext.require(); + TenantId existing = tenantReader.apply(entity); + if (existing == null) { + tenantWriter.accept(entity, bound); + return; + } + requireSameTenant(existing, bound); + } + + /** Refuses an update that would move a row to another tenant. */ + @PreUpdate + public void verifyOnUpdate(T entity) { + Objects.requireNonNull(entity, "entity"); + requireSameTenant(tenantReader.apply(entity), TenantContext.require()); + } + + private static void requireSameTenant(TenantId entityTenant, TenantId boundTenant) { + if (entityTenant == null || !entityTenant.equals(boundTenant)) { + throw new IllegalStateException( + "a write must stay within the bound tenant; cross-tenant writes are refused"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java new file mode 100644 index 00000000..190812dc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.tenant; + +import java.util.regex.Pattern; + +/** + * A tenant identity (experimental plan Task 2). + * + *

The format is bounded because a tenant id ends up in a schema name, a {@code set_config} + * value, and a routing key. A tenant id like {@code ../public} is what turns schema-per-tenant into + * a path-traversal problem, so the shape is validated at the type rather than at each use. + * + *

The value never becomes a metric tag: tenant cardinality is unbounded by definition, and a + * tenant id in telemetry is customer data in a system that is rarely treated as one. + */ +public record TenantId(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z0-9][a-z0-9_-]{1,62}"); + + public TenantId { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid tenant id"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java index bcfa66cc..7c7cac59 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java @@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable; * H2 atomic scope claim. * *

H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL - * statement does not port. The standard {@code MERGE ... USING} does, and carries the same - * meaning in one statement: + * statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning + * in one statement: * *

    *
  • no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java index ef7fd5cb..b17781e2 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2LocalTimeoutConfigurer.java @@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations; *
  • Session scope, not transaction scope. PostgreSQL takes {@code set_config(..., true)} * — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives * the transaction on a pooled connection. It is not left stale in practice because the - * transaction port applies these before every transaction, so each one overwrites the last; - * a connection borrowed outside that path keeps the previous transaction's guard. - *
  • No idle-in-transaction guard. H2 has no counterpart to - * {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the - * database here. It is left to the caller-side deadline the transaction port already - * enforces, rather than silently reported as applied. + * transaction port applies these before every transaction, so each one overwrites the last; a + * connection borrowed outside that path keeps the previous transaction's guard. + *
  • No idle-in-transaction guard. H2 has no counterpart to {@code + * idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database + * here. It is left to the caller-side deadline the transaction port already enforces, rather + * than silently reported as applied. *
* *

The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java index 2c1c70a4..e59f4cd6 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java @@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations; /** * H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers, - * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the - * {@code local} profile sets. + * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code + * local} profile sets. * *

No Flyway location customizer, deliberately. The PostgreSQL vendor points Flyway at * {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local - * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two - * consequences worth stating out loud: + * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences + * worth stating out loud: * *

    *
  • Tables that exist only in migrations — the capability schema registry, the polling-delivery @@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations; * there will fail on a missing table rather than silently misbehave. *
  • A fork that enables Flyway while this vendor is selected gets no location override, so * Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including - * PostgreSQL DDL H2 cannot parse. Such a fork should register its own - * {@code FlywayConfigurationCustomizer} naming an H2 location. + * PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code + * FlywayConfigurationCustomizer} naming an H2 location. *
* - *

Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency - * fidelity stay with the real-PostgreSQL integration suites. + *

Local therefore verifies wiring and behaviour, not migrations. Migration and + * vendor-concurrency fidelity stay with the real-PostgreSQL integration suites. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty( diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java new file mode 100644 index 00000000..997dac9a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +import java.util.List; +import java.util.Objects; +import org.hibernate.Version; + +/** + * The declared Stable provider baseline, and what the build actually resolved (design §3.1, §26). + * + *

The design fixes Hibernate ORM 7.4 as the Stable provider. This repository's Spring Boot BOM + * resolves whatever Hibernate that Boot release manages — currently 7.1 — and the design is equally + * clear that individual provider versions must not be pinned outside the BOM. Both rules are worth + * keeping, so this class holds them apart instead of quietly picking one: the declared baseline is + * a constant, the runtime version is read from Hibernate, and {@link #driftsFromDeclaredBaseline()} + * makes the difference visible. + * + *

The alternative — asserting the declared baseline as if it were the runtime one — would give a + * green check that proves the constant equals itself while the collection-fetch-pagination gate + * runs against a different provider entirely. See {@code docs/jpa/repository-adaptation.md} §4. + */ +public final class HibernateProviderPolicy { + + /** The provider version the design declares Stable. */ + public static final String DECLARED_STABLE_BASELINE = "7.4"; + + /** Provider versions that are explicitly Experimental, never Stable. */ + private static final List EXPERIMENTAL_PROVIDERS = List.of("8"); + + private final String runtimeVersion; + + public HibernateProviderPolicy(String runtimeVersion) { + this.runtimeVersion = Objects.requireNonNull(runtimeVersion, "runtimeVersion"); + } + + /** A policy reading the version from the Hibernate on this classpath. */ + public static HibernateProviderPolicy fromClasspath() { + return new HibernateProviderPolicy(Version.getVersionString()); + } + + /** The provider version the design declares Stable. */ + public String stableProvider() { + return DECLARED_STABLE_BASELINE; + } + + /** Provider versions that may only be exercised through an Experimental compatibility lane. */ + public List experimentalProviders() { + return EXPERIMENTAL_PROVIDERS; + } + + /** The provider version actually on this classpath, as Hibernate reports it. */ + public String runtimeVersion() { + return runtimeVersion; + } + + /** The {@code major.minor} prefix of the runtime version. */ + public String runtimeMinorVersion() { + String[] parts = runtimeVersion.split("\\.", -1); + return parts.length >= 2 ? parts[0] + '.' + parts[1] : runtimeVersion; + } + + /** Whether the resolved provider differs from the version the design declares Stable. */ + public boolean driftsFromDeclaredBaseline() { + return !DECLARED_STABLE_BASELINE.equals(runtimeMinorVersion()); + } + + /** Whether a provider version may only run in an Experimental lane. */ + public boolean isExperimental(String version) { + return version != null && EXPERIMENTAL_PROVIDERS.stream().anyMatch(version::startsWith); + } + + /** A bounded, reportable description of the declared and resolved provider versions. */ + public String report() { + return "declaredStable=" + DECLARED_STABLE_BASELINE + " runtime=" + runtimeVersion; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java new file mode 100644 index 00000000..3b795b8e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +import java.util.Objects; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; + +/** + * Reads the Hibernate counters that make fetch behaviour measurable (design §25.3, §28.3). + * + *

Statistics must be enabled for the numbers to move, and this class says so rather than + * reporting zeros that look like a passing assertion. A fetch test that silently measures a + * disabled registry proves nothing and passes forever. + * + *

Hibernate itself does not count JDBC batch executions, so that number comes from a separate + * {@link JdbcBatchCounter} at the JDBC layer. Keeping the two sources visible is deliberate: a + * snapshot whose batch count came from a configuration value rather than from the driver would be + * the one piece of this evidence that proves nothing. + */ +public final class HibernateStatisticsCollector { + + private final Statistics statistics; + private final JdbcBatchCounter batchCounter; + + public HibernateStatisticsCollector(Statistics statistics, JdbcBatchCounter batchCounter) { + this.statistics = Objects.requireNonNull(statistics, "statistics"); + this.batchCounter = Objects.requireNonNull(batchCounter, "batchCounter"); + } + + /** A collector over a session factory's statistics, with no JDBC batch instrumentation. */ + public static HibernateStatisticsCollector of(SessionFactory sessionFactory) { + return new HibernateStatisticsCollector( + Objects.requireNonNull(sessionFactory, "sessionFactory").getStatistics(), + JdbcBatchCounter.uninstrumented()); + } + + /** A collector that also reports real JDBC batch executions. */ + public static HibernateStatisticsCollector of( + SessionFactory sessionFactory, JdbcBatchCounter batchCounter) { + return new HibernateStatisticsCollector( + Objects.requireNonNull(sessionFactory, "sessionFactory").getStatistics(), batchCounter); + } + + /** + * Reads the current counters. + * + * @throws IllegalStateException when Hibernate statistics are disabled, because every counter + * would read zero and any assertion over the delta would pass vacuously + */ + public HibernateStatisticsSnapshot snapshot() { + if (!statistics.isStatisticsEnabled()) { + throw new IllegalStateException( + "hibernate.generate_statistics is disabled, so fetch and batch counters cannot be" + + " measured; enable it for the contract lanes"); + } + return new HibernateStatisticsSnapshot( + statistics.getPrepareStatementCount(), + statistics.getEntityLoadCount(), + statistics.getEntityFetchCount(), + statistics.getCollectionLoadCount(), + statistics.getCollectionFetchCount(), + statistics.getFlushCount(), + batchCounter.batchExecutions()); + } + + /** Whether the JDBC layer is instrumented, and the batch count therefore means something. */ + public boolean batchInstrumented() { + return batchCounter.instrumented(); + } + + /** Resets the underlying registry. Intended for test setup only. */ + public void reset() { + statistics.clear(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java new file mode 100644 index 00000000..1c2257d7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +/** + * A point-in-time reading of the Hibernate counters that matter for fetch behaviour (design §25.3). + * + *

Loads and fetches are separate counters because they answer different questions. An entity + * load is a row hydrated into the Persistence Context; an entity fetch is a + * separate statement issued to satisfy an association. A query that hydrates a hundred entities in + * one statement and one that issues a hundred statements can report the same load count, and only + * the fetch count distinguishes them — which is exactly the N+1 case. + * + *

{@code jdbcBatches} is here for the same reason: configuring {@code hibernate.jdbc.batch_size} + * does not prove batching happened, and this counter is the only evidence that it did (design + * §28.3). + */ +public record HibernateStatisticsSnapshot( + long preparedStatements, + long entityLoads, + long entityFetches, + long collectionLoads, + long collectionFetches, + long flushes, + long jdbcBatches) { + + /** An all-zero snapshot, for tests and for the first reading of a fresh registry. */ + public static HibernateStatisticsSnapshot zero() { + return new HibernateStatisticsSnapshot(0L, 0L, 0L, 0L, 0L, 0L, 0L); + } + + /** The delta between this snapshot and an earlier one. */ + public HibernateStatisticsSnapshot minus(HibernateStatisticsSnapshot before) { + return new HibernateStatisticsSnapshot( + preparedStatements - before.preparedStatements, + entityLoads - before.entityLoads, + entityFetches - before.entityFetches, + collectionLoads - before.collectionLoads, + collectionFetches - before.collectionFetches, + flushes - before.flushes, + jdbcBatches - before.jdbcBatches); + } + + /** A compact, bounded summary suitable for an assertion message. */ + public String summary() { + return "statements=" + + preparedStatements + + " entityLoads=" + + entityLoads + + " entityFetches=" + + entityFetches + + " collectionLoads=" + + collectionLoads + + " collectionFetches=" + + collectionFetches + + " flushes=" + + flushes + + " jdbcBatches=" + + jdbcBatches; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java new file mode 100644 index 00000000..aea2fc33 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +/** + * Counts how many JDBC batch executions actually reached the driver (design §28.3). + * + *

Hibernate's {@code Statistics} has no batch counter, so the only honest source for this number + * is the JDBC layer itself — every {@code PreparedStatement.executeBatch()} call. That distinction + * matters: setting {@code hibernate.jdbc.batch_size} proves nothing, because an IDENTITY generator, + * an interleaved select, or a flush at the wrong moment silently disables batching while the + * configuration still says it is on. + * + *

The default implementation reports zero and says so. It is what a runtime without a counting + * data source gets, and the contract suites install a real counter rather than trusting a + * configuration value. + */ +public interface JdbcBatchCounter { + + /** How many {@code executeBatch()} calls have been made since this counter started. */ + long batchExecutions(); + + /** Whether this counter observes the JDBC layer at all. */ + default boolean instrumented() { + return true; + } + + /** A counter that observes nothing and reports zero. */ + static JdbcBatchCounter uninstrumented() { + return new JdbcBatchCounter() { + @Override + public long batchExecutions() { + return 0L; + } + + @Override + public boolean instrumented() { + return false; + } + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java new file mode 100644 index 00000000..6ec4b955 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.Optional; +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** + * Prefixes each statement with the registered query name that issued it (design §37). + * + *

The comment travels with the SQL into {@code pg_stat_activity}, {@code auto_explain} output, + * and the slow-query log, which is the only place a DBA can connect a statement back to the use + * case that produced it. Without it, "which endpoint issues this query" is answered by grepping the + * codebase for fragments of SQL. + * + *

Only the registered name is emitted — never a parameter value, an id, or anything from the + * request. The name is already validated as bounded and low-cardinality, and this class + * additionally rejects the comment terminator so a name can never close the comment and continue as + * SQL. + */ +public final class NamedStatementInspector implements StatementInspector { + + private static final long serialVersionUID = 1L; + + /** + * The comment terminator; a name containing it would end the comment and start statement text. + */ + private static final String COMMENT_TERMINATOR = "*/"; + + @Override + public String inspect(String sql) { + if (sql == null) { + return null; + } + Optional queryName = QueryNameContext.current(); + if (queryName.isEmpty()) { + return sql; + } + String value = queryName.get().value(); + if (value.contains(COMMENT_TERMINATOR)) { + return sql; + } + return "/* " + value + " */ " + sql; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java new file mode 100644 index 00000000..1dec5a45 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.Optional; + +/** + * Binds the registered {@link QueryName} of the statement currently being issued (design §37). + * + *

Hibernate's {@code StatementInspector} sees SQL and nothing else — no method, no use case, no + * caller. Without this binding the only identity available for a statement is the SQL text itself, + * which is unbounded and frequently contains literals, so it cannot be a metric tag. + * + *

The context is thread-bound and cleared in a {@code finally}. A leaked name on a pooled thread + * would attribute an unrelated later statement to this query, and attribution errors in telemetry + * are worse than missing telemetry: they send the next investigation to the wrong place. + */ +public final class QueryNameContext { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private QueryNameContext() {} + + /** Binds {@code queryName} for the duration of the caller's statement. */ + public static void bind(QueryName queryName) { + CURRENT.set(queryName); + } + + /** The bound query name, when one is bound to this thread. */ + public static Optional current() { + return Optional.ofNullable(CURRENT.get()); + } + + /** Unbinds the current name. Callers must invoke this in a {@code finally}. */ + public static void clear() { + CURRENT.remove(); + } + + /** + * Runs {@code work} with {@code queryName} bound, restoring any previously bound name. + * + *

Restoring rather than clearing matters for nested calls: a custom repository fragment that + * issues a helper query inside an outer named query must not leave the outer statement + * unattributed when the inner one finishes. + */ + public static T with(QueryName queryName, java.util.function.Supplier work) { + QueryName previous = CURRENT.get(); + CURRENT.set(queryName); + try { + return work.get(); + } finally { + if (previous == null) { + CURRENT.remove(); + } else { + CURRENT.set(previous); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java new file mode 100644 index 00000000..98b8dca2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import java.util.Objects; + +/** + * The measured outcome of one batch run (design §28.3). + * + *

{@code jdbcBatches} is the number that decides whether batching happened at all, and {@code + * maxManagedEntities} is the one that decides whether it happened without unbounded memory. + * Reporting only {@code processed} would make a run that issued one statement per row and held + * every entity in the context look identical to a correct one. + */ +public record BatchExecutionResult( + JpaBatchProfile profile, + long processed, + long flushes, + long preparedStatements, + long jdbcBatches, + long maxManagedEntities) { + + public BatchExecutionResult { + Objects.requireNonNull(profile, "profile"); + if (processed < 0L + || flushes < 0L + || preparedStatements < 0L + || jdbcBatches < 0L + || maxManagedEntities < 0L) { + throw new IllegalArgumentException("batch result counters must not be negative"); + } + } + + /** + * Whether the run produced more than one JDBC batch, which is what batching being on looks like. + */ + public boolean batched() { + return jdbcBatches > 1L; + } + + /** A compact, bounded summary suitable for an assertion message. */ + public String summary() { + return "profile=" + + profile.name() + + " processed=" + + processed + + " flushes=" + + flushes + + " statements=" + + preparedStatements + + " jdbcBatches=" + + jdbcBatches + + " maxManagedEntities=" + + maxManagedEntities; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java new file mode 100644 index 00000000..420871e3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.lang.reflect.Field; +import java.util.Objects; +import java.util.Optional; + +/** + * Refuses a batch profile that cannot actually batch (design §28.2). + * + *

The case that matters is {@link GenerationType#IDENTITY}. An identity column's value is + * assigned by the database on insert, so Hibernate has to execute each insert immediately to learn + * the id it must put in the Persistence Context — it cannot queue statements it has not sent. + * Batching is therefore silently disabled for those entities no matter what {@code + * hibernate.jdbc.batch_size} says. + * + *

"Silently" is the problem. The configuration looks right, the import runs, and the only + * symptom is that it takes an order of magnitude longer than expected. A profile that declares + * {@code batchingRequired} turns that into a startup failure with the reason attached. + */ +public final class HibernateBatchConfigurationGuard { + + /** + * Validates that {@code entityType} can be batched under {@code profile}. + * + * @throws IllegalStateException when the profile requires batching the entity cannot support + */ + public void validate(JpaBatchProfile profile, Class entityType) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(entityType, "entityType"); + if (!profile.batchingRequired()) { + return; + } + if (usesIdentityGeneration(entityType)) { + throw new IllegalStateException( + "batch profile '" + + profile.name() + + "' requires batching, but " + + entityType.getSimpleName() + + " uses GenerationType.IDENTITY, and IDENTITY disables insert batching: Hibernate" + + " must execute each insert to obtain the generated key. Use a sequence generator" + + " whose allocationSize matches the migration's sequence increment."); + } + } + + /** Whether the entity's identifier is generated by an identity column. */ + public boolean usesIdentityGeneration(Class entityType) { + return identifierField(entityType) + .map(field -> field.getAnnotation(GeneratedValue.class)) + .filter(Objects::nonNull) + .map(generated -> generated.strategy() == GenerationType.IDENTITY) + .orElse(false); + } + + /** + * Finds the {@code @Id} field, walking superclasses. + * + *

Superclasses are walked because a mapped superclass carrying the identifier is the common + * shape; stopping at the declared class would silently classify every such entity as "not + * identity" and let the guard pass on exactly the entities it was written for. + */ + private static Optional identifierField(Class entityType) { + for (Class current = entityType; + current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (field.isAnnotationPresent(Id.class)) { + return Optional.of(field); + } + } + } + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java new file mode 100644 index 00000000..dfc117df --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector; +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.function.Consumer; +import org.hibernate.Session; + +/** + * Persists many entities with bounded Persistence Context growth (design §28). + * + *

The flush/clear pair is the whole mechanism. Flushing sends the queued inserts; clearing + * detaches what was just written so the context does not keep growing. Doing only the first is the + * classic bulk-import out-of-memory: the statements go out, the entities stay, and the heap fills + * with rows that have already been persisted. + * + *

The executor requires an active transaction and never opens one. It also refuses to run + * outside one rather than letting each flush auto-commit, which would leave a partially imported + * data set behind on failure with no way to roll it back. + */ +public final class HibernateJpaBatchExecutor implements JpaBatchExecutor { + + private final EntityManager entityManager; + private final HibernateStatisticsCollector statistics; + + public HibernateJpaBatchExecutor( + EntityManager entityManager, HibernateStatisticsCollector statistics) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.statistics = Objects.requireNonNull(statistics, "statistics"); + } + + @Override + public BatchExecutionResult persist( + JpaBatchProfile profile, Iterable items, Consumer persister) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(items, "items"); + Objects.requireNonNull(persister, "persister"); + if (!entityManager.isJoinedToTransaction()) { + throw new IllegalStateException( + "batch persistence requires an active transaction owned by the application service"); + } + + HibernateStatisticsSnapshot before = statistics.snapshot(); + long processed = 0L; + long maxManagedEntities = 0L; + + for (T item : items) { + persister.accept(item); + processed++; + maxManagedEntities = Math.max(maxManagedEntities, managedEntityCount()); + if (processed % profile.flushSize() == 0L) { + entityManager.flush(); + } + if (processed % profile.clearSize() == 0L) { + entityManager.clear(); + } + } + entityManager.flush(); + entityManager.clear(); + + HibernateStatisticsSnapshot delta = statistics.snapshot().minus(before); + return new BatchExecutionResult( + profile, + processed, + delta.flushes(), + delta.preparedStatements(), + delta.jdbcBatches(), + maxManagedEntities); + } + + /** + * How many entities the Persistence Context currently holds. + * + *

Hibernate exposes this only through its own {@code Session}; JPA has no equivalent. The + * number is the evidence that the clear boundary is doing its job, so it is worth the unwrap. + */ + private long managedEntityCount() { + Session session = entityManager.unwrap(Session.class); + return session.getStatistics().getEntityCount(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchExecutor.java new file mode 100644 index 00000000..f5a122fb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchExecutor.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import java.util.function.Consumer; + +/** + * Persists many entities under a named batch profile (design §28). + * + *

The executor owns the flush and clear boundaries; the caller owns the transaction. That split + * is deliberate: flushing is a Persistence Context concern the profile decides, while committing is + * a use-case decision, and an executor that committed on its own would break the "application + * service owns the transaction boundary" rule the whole platform rests on. + */ +public interface JpaBatchExecutor { + + /** + * Persists every item exactly once, flushing and clearing on the profile's boundaries. + * + * @param persister applies the actual persist call for one item + */ + BatchExecutionResult persist( + JpaBatchProfile profile, Iterable items, Consumer persister); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfile.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfile.java new file mode 100644 index 00000000..a2830bee --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfile.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A named JDBC batch configuration (design §28.2). + * + *

{@code flushSize} and {@code clearSize} are separate from {@code jdbcBatchSize} because they + * bound different things. The JDBC batch size decides how many statements the driver sends in one + * round trip; the flush and clear sizes decide how large the Persistence Context is allowed to grow + * while that happens. A large batch size with no clear boundary still ends in an out-of-memory + * failure, just after more round trips have been saved. + * + *

{@code batchingRequired} makes the profile assertive: when set, a configuration that silently + * disables batching — an IDENTITY entity, most commonly — is a startup failure rather than a + * mystery about why the import is slow. + */ +public record JpaBatchProfile( + String name, + int jdbcBatchSize, + int flushSize, + int clearSize, + boolean orderInserts, + boolean orderUpdates, + boolean batchingRequired) { + + private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public JpaBatchProfile { + Objects.requireNonNull(name, "name"); + if (!NAME.matcher(name).matches()) { + throw new IllegalArgumentException("invalid batch profile name"); + } + if (jdbcBatchSize < 1 || flushSize < 1 || clearSize < 1) { + throw new IllegalArgumentException("batch sizes must be positive"); + } + if (clearSize < flushSize) { + throw new IllegalArgumentException( + "clearSize must not be smaller than flushSize; clearing before flushing discards" + + " pending changes"); + } + } + + /** A conservative import profile: batch, flush, and clear on the same boundary. */ + public static JpaBatchProfile uniform(String name, int size) { + return new JpaBatchProfile(name, size, size, size, true, true, true); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java new file mode 100644 index 00000000..c38502c6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The bounded set of batch profiles an application declares (design §28.2). + * + *

Fail-closed lookup, for the same reason as the transaction profile registry: falling back to a + * default would let a mistyped profile name run an import with somebody else's flush boundary. + */ +public final class JpaBatchProfileRegistry { + + private final Map byName; + + public JpaBatchProfileRegistry(Map profiles) { + Objects.requireNonNull(profiles, "profiles"); + Map copy = new LinkedHashMap<>(); + profiles.forEach( + (name, profile) -> { + Objects.requireNonNull(name, "profile name"); + Objects.requireNonNull(profile, "profile"); + if (!name.equals(profile.name())) { + throw new IllegalArgumentException( + "registry key '" + name + "' does not match profile name '" + profile.name() + "'"); + } + copy.put(name, profile); + }); + this.byName = Map.copyOf(copy); + } + + /** A registry built from profiles that already carry their own names. */ + public static JpaBatchProfileRegistry of(JpaBatchProfile... profiles) { + Map byName = new LinkedHashMap<>(); + for (JpaBatchProfile profile : profiles) { + if (byName.put(profile.name(), profile) != null) { + throw new IllegalArgumentException("duplicate batch profile: " + profile.name()); + } + } + return new JpaBatchProfileRegistry(byName); + } + + /** + * The profile registered under {@code name}. + * + * @throws IllegalArgumentException when no profile is registered under that name + */ + public JpaBatchProfile require(String name) { + JpaBatchProfile profile = byName.get(name); + if (profile == null) { + throw new IllegalArgumentException( + "unregistered batch profile '" + name + "'; registered profiles are " + names()); + } + return profile; + } + + /** The registered profile names, in declaration order. */ + public Set names() { + return byName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/AffectedRowsExpectation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/AffectedRowsExpectation.java new file mode 100644 index 00000000..c68d5ac8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/AffectedRowsExpectation.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.bulk; + +/** + * The row count a bulk statement is expected to affect (design §29.1). + * + *

This is a blast-radius guard, not a sanity check. A bulk {@code UPDATE} whose {@code WHERE} + * clause lost a predicate still succeeds — it just updates the whole table — and the transaction + * commits before anyone notices. Declaring the expected range means that statement fails and rolls + * back instead. + */ +public record AffectedRowsExpectation(int minimum, int maximum) { + + public AffectedRowsExpectation { + if (minimum < 0) { + throw new IllegalArgumentException("minimum affected rows must not be negative"); + } + if (maximum < minimum) { + throw new IllegalArgumentException("maximum affected rows must not be below the minimum"); + } + } + + /** Exactly {@code rows} rows, no more and no fewer. */ + public static AffectedRowsExpectation exactly(int rows) { + return new AffectedRowsExpectation(rows, rows); + } + + /** At most {@code rows} rows, possibly none. */ + public static AffectedRowsExpectation atMost(int rows) { + return new AffectedRowsExpectation(0, rows); + } + + /** Between {@code minimum} and {@code maximum} rows inclusive. */ + public static AffectedRowsExpectation between(int minimum, int maximum) { + return new AffectedRowsExpectation(minimum, maximum); + } + + /** + * Fails when {@code affected} is outside the expected range. + * + * @throws IllegalStateException when the statement's blast radius was not what was declared + */ + public void verify(int affected) { + if (affected < minimum || affected > maximum) { + throw new IllegalStateException( + "bulk statement affected " + + affected + + " rows, expected between " + + minimum + + " and " + + maximum); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlExecutor.java new file mode 100644 index 00000000..5fbd1e95 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlExecutor.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.bulk; + +import java.util.function.IntSupplier; + +/** + * Runs a bulk DML statement with the flush and clear ordering the design requires (design §29). + * + *

Bulk DML bypasses the Persistence Context entirely: no entity callbacks run, no + * {@code @Version} is checked, and no managed entity is updated. Those are not incidental + * limitations — they are why bulk DML is fast — but they mean the surrounding context must be + * reconciled by hand, which is what this executor exists to guarantee. + */ +public interface BulkDmlExecutor { + + /** + * Flushes, runs {@code statement}, clears, and verifies the affected-row expectation. + * + * @param statement executes the bulk statement and returns its affected-row count + */ + BulkDmlResult execute( + BulkOperationName name, IntSupplier statement, AffectedRowsExpectation expectation); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlResult.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlResult.java new file mode 100644 index 00000000..b0de1117 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkDmlResult.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.bulk; + +import java.util.Objects; + +/** What one bulk statement actually did (design §29.1). */ +public record BulkDmlResult(BulkOperationName operation, int affectedRows) { + + public BulkDmlResult { + Objects.requireNonNull(operation, "operation"); + if (affectedRows < 0) { + throw new IllegalArgumentException("affected rows must not be negative"); + } + } + + /** Whether the statement matched no rows at all. */ + public boolean matchedNothing() { + return affectedRows == 0; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkOperationName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkOperationName.java new file mode 100644 index 00000000..6274c674 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/BulkOperationName.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.bulk; + +import java.util.regex.Pattern; + +/** + * The registered identity of one bulk DML operation (design §29). + * + *

Bulk statements are the ones that touch many rows at once, so they are the ones whose blast + * radius is worth naming and bounding. The name is what the affected-row expectation and the + * observation are recorded against. + */ +public record BulkOperationName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public BulkOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid bulk operation name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java new file mode 100644 index 00000000..3cbaa735 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.bulk; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope; +import jakarta.persistence.EntityManager; +import java.util.Map; +import java.util.Objects; +import java.util.function.IntSupplier; + +/** + * Runs bulk DML in the only order that leaves the Persistence Context correct (design §29.1). + * + *

flush → statement → clear, and each step is load-bearing: + * + *

    + *
  • flush first, or a pending managed change is written after the bulk + * statement and overwrites it. + *
  • clear after, or managed entities keep serving pre-bulk values and a later flush + * writes those stale values back over what the bulk statement just did. + *
+ * + *

The affected-row expectation is verified before returning. A bulk {@code UPDATE} that lost a + * predicate still succeeds — it just updates every row — so the row count is the only thing + * standing between a bug and a table-wide write. Verification happens inside the caller's + * transaction so the rollback is still available. + */ +public final class HibernateBulkDmlExecutor implements BulkDmlExecutor { + + private final EntityManager entityManager; + private final QueryObservation observation; + private final Map registeredOperations; + + /** + * @param registeredOperations the query name each bulk operation is observed under; an operation + * outside this map cannot be executed + */ + public HibernateBulkDmlExecutor( + EntityManager entityManager, + QueryObservation observation, + Map registeredOperations) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.observation = Objects.requireNonNull(observation, "observation"); + this.registeredOperations = + Map.copyOf(Objects.requireNonNull(registeredOperations, "registeredOperations")); + } + + @Override + public BulkDmlResult execute( + BulkOperationName name, IntSupplier statement, AffectedRowsExpectation expectation) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(statement, "statement"); + Objects.requireNonNull(expectation, "expectation"); + QueryName queryName = registeredOperations.get(name); + if (queryName == null) { + throw new IllegalArgumentException("unregistered bulk operation: " + name.value()); + } + if (!entityManager.isJoinedToTransaction()) { + throw new IllegalStateException( + "bulk DML requires an active transaction owned by the application service"); + } + + try (QueryScope scope = observation.start(queryName)) { + entityManager.flush(); + int affected; + try { + affected = statement.getAsInt(); + } catch (RuntimeException failure) { + scope.failure(failure); + throw failure; + } + entityManager.clear(); + scope.rows(affected); + expectation.verify(affected); + return new BulkDmlResult(name, affected); + } + } + + /** The bulk operations this executor will run. */ + public java.util.Set registeredOperations() { + return registeredOperations.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java new file mode 100644 index 00000000..470ef365 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.stateless; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import org.hibernate.SessionFactory; +import org.hibernate.StatelessSession; +import org.hibernate.Transaction; + +/** + * Runs registered bulk work in a Hibernate {@code StatelessSession} (design §30.1). + * + *

The session and its transaction are opened and closed here, which is the one place in this + * platform where an executor owns a transaction rather than joining the application's. That is not + * an exception to the boundary rule so much as a consequence of it: a stateless session cannot join + * the JPA transaction — it is a different session with a different connection — so pretending + * otherwise would silently run this work outside whatever transaction the caller believed it was + * in. + * + *

Only registered work names may run, and each carries a declared row cap. An admin bulk path + * without a cap is how a maintenance job becomes an outage. + */ +public final class HibernateStatelessSessionRunner implements StatelessSessionRunner { + + private final SessionFactory sessionFactory; + private final Set registeredWork; + + public HibernateStatelessSessionRunner( + SessionFactory sessionFactory, Set registeredWork) { + this.sessionFactory = Objects.requireNonNull(sessionFactory, "sessionFactory"); + this.registeredWork = Set.copyOf(Objects.requireNonNull(registeredWork, "registeredWork")); + } + + @Override + public T execute(StatelessWorkName name, long maxRows, Function work) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(work, "work"); + if (!registeredWork.contains(name)) { + throw new IllegalArgumentException("unregistered stateless work: " + name.value()); + } + if (maxRows < 1L) { + throw new IllegalArgumentException("stateless work requires a positive row cap"); + } + + try (StatelessSession session = sessionFactory.openStatelessSession()) { + Transaction transaction = session.beginTransaction(); + try { + T result = work.apply(session); + transaction.commit(); + return result; + } catch (RuntimeException failure) { + if (transaction.isActive()) { + transaction.rollback(); + } + throw failure; + } + } + } + + /** The stateless work names this runner will execute. */ + public Set registeredWork() { + return registeredWork; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessSessionRunner.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessSessionRunner.java new file mode 100644 index 00000000..c01b59af --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessSessionRunner.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.stateless; + +import java.util.function.Function; +import org.hibernate.StatelessSession; + +/** + * Runs bulk work through a Hibernate {@code StatelessSession} (design §30.1). + * + *

A stateless session has no Persistence Context: no dirty checking, no cascade, no first-level + * cache, no lazy loading, and no automatic version increment. Those absences are why it stays flat + * in memory over millions of rows, and they are also why objects it returns are detached copies — + * loading the same row twice yields two unrelated instances. + * + *

It is therefore opt-in and never the default repository implementation. Code written against + * ordinary JPA semantics behaves subtly differently here, and the difference shows up as missing + * updates rather than as an error. + */ +public interface StatelessSessionRunner { + + /** + * Runs {@code work} in its own stateless session and transaction. + * + * @param maxRows the declared row cap for this unit of work + */ + T execute(StatelessWorkName name, long maxRows, Function work); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkName.java new file mode 100644 index 00000000..3477e1a0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkName.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.stateless; + +import java.util.regex.Pattern; + +/** The registered identity of one stateless-session unit of work (design §30.1). */ +public record StatelessWorkName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public StatelessWorkName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid stateless work name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspector.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspector.java new file mode 100644 index 00000000..8662995b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspector.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.util.Locale; +import java.util.Objects; + +/** + * Refuses a {@code CREATE INDEX CONCURRENTLY} that Flyway would run inside a transaction (design + * §32). + * + *

PostgreSQL rejects {@code CREATE INDEX CONCURRENTLY} inside a transaction block outright, and + * Flyway wraps migrations in a transaction by default — so the migration fails on the first + * deployment that runs it, which is the good case. The bad case is a migration that mixes it with + * other statements: those commit, the concurrent index does not, and the schema is left partly + * migrated with a history row that says otherwise. + * + *

{@code DROP INDEX CONCURRENTLY} and {@code REINDEX CONCURRENTLY} carry the same restriction + * and the same failure mode, so all three are checked. + */ +public final class ConcurrentIndexMigrationInspector { + + private static final String[] CONCURRENT_STATEMENTS = { + "create index concurrently", + "drop index concurrently", + "reindex index concurrently", + "reindex table concurrently", + "reindex concurrently" + }; + + /** + * Fails when a migration containing a concurrent index statement is transactional. + * + * @param executeInTransaction whether Flyway will wrap this migration in a transaction + * @throws IllegalStateException naming the migration and the required marker + */ + public void validate(MigrationResource migration, boolean executeInTransaction) { + Objects.requireNonNull(migration, "migration"); + if (!executeInTransaction || !containsConcurrentIndexStatement(migration.sql())) { + return; + } + throw new IllegalStateException( + migration.name() + + " must set executeInTransaction=false: PostgreSQL rejects concurrent index" + + " statements inside a transaction block, and a mixed migration would commit its" + + " other statements while leaving the index unbuilt"); + } + + /** Whether the script contains a statement PostgreSQL forbids inside a transaction. */ + public boolean containsConcurrentIndexStatement(String sql) { + if (sql == null) { + return false; + } + String normalized = sql.toLowerCase(Locale.ROOT); + for (String statement : CONCURRENT_STATEMENTS) { + if (normalized.contains(statement)) { + return true; + } + } + return false; + } + + /** + * Fails when a concurrent-index migration also contains other statements. + * + *

A concurrent index build can fail and leave an invalid index behind. Recovering from that is + * a single {@code DROP INDEX} when the migration did nothing else, and a manual reconstruction of + * partial state when it did. + */ + public void requireIsolatedStatement(MigrationResource migration) { + Objects.requireNonNull(migration, "migration"); + if (!containsConcurrentIndexStatement(migration.sql())) { + return; + } + long statements = + migration + .sql() + .lines() + .map(line -> line.trim()) + .filter(line -> !line.isEmpty() && !line.startsWith("--")) + .filter(line -> line.endsWith(";")) + .count(); + if (statements > 1L) { + throw new IllegalStateException( + migration.name() + + " mixes a concurrent index statement with other statements; a failed concurrent" + + " build must be recoverable by dropping one invalid index, which requires the" + + " migration to contain nothing else"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FailedConcurrentIndexRecovery.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FailedConcurrentIndexRecovery.java new file mode 100644 index 00000000..3ce5433b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FailedConcurrentIndexRecovery.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.sql.DataSource; + +/** + * Reports invalid indexes left behind by a failed concurrent build, and refuses to clean them up + * (design §32). + * + *

A failed {@code CREATE INDEX CONCURRENTLY} leaves an index marked {@code indisvalid = false}. + * It is not used by the planner but it is maintained by every write, so it costs write + * throughput while providing nothing. + * + *

Dropping it automatically at startup is exactly what this class does not do. An invalid index + * can also mean a build is still running, and the two are indistinguishable from the catalog alone + * — so an automatic drop would race a concurrent build that was about to finish, on every instance + * of a rolling deploy at once. The recovery is a report with the statement an operator can run. + */ +public final class FailedConcurrentIndexRecovery { + + private static final String INVALID_INDEX_QUERY = + """ + select i.relname as index_name, n.nspname as schema_name + from pg_index x + join pg_class i on i.oid = x.indexrelid + join pg_namespace n on n.oid = i.relnamespace + where x.indisvalid = false + and n.nspname = current_schema() + order by n.nspname, i.relname + """; + + private final DataSource dataSource; + + public FailedConcurrentIndexRecovery(DataSource dataSource) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + } + + /** The invalid indexes in the current schema, as bounded {@code schema.index} names. */ + public List invalidIndexes() { + List invalid = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(INVALID_INDEX_QUERY); + ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + invalid.add(rows.getString("schema_name") + '.' + rows.getString("index_name")); + } + } catch (SQLException failure) { + throw new IllegalStateException("invalid index catalog could not be read", failure); + } + return List.copyOf(invalid); + } + + /** + * A bounded recovery report for an operator. + * + *

The report contains the statements to run, not the effect of running them. Every entry is a + * {@code DROP INDEX CONCURRENTLY}, which is itself non-transactional and must be run outside a + * migration. + */ + public String recoveryReport() { + List invalid = invalidIndexes(); + if (invalid.isEmpty()) { + return "no invalid indexes"; + } + StringBuilder report = new StringBuilder(128); + report + .append(invalid.size()) + .append(" invalid index(es) from a failed concurrent build. Confirm no build is running,") + .append(" then run each statement outside a migration:\n"); + for (String index : invalid) { + report.append(" drop index concurrently if exists ").append(index).append(";\n"); + } + return report.toString(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywaySchemaPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywaySchemaPolicy.java new file mode 100644 index 00000000..1e7efd0c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywaySchemaPolicy.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Which schema-management mode each environment runs under, and what is forbidden everywhere + * (design §31). + * + *

Local, test, and dev may migrate at startup: the loop is fast and the credential is local. + * Staging and production support deployment-owned migration instead, because migrating from inside + * the application means every instance of a rolling deploy races to apply the same script, and the + * loser's failure is indistinguishable from a real one. + * + *

Repair is not a mode. It is represented only as an operation descriptor an operator invokes + * deliberately, never as startup behaviour (design §8.4). + */ +public final class FlywaySchemaPolicy { + + /** The {@code ddl-auto} values that mutate a deployed schema and are never permitted. */ + private static final Set SCHEMA_MUTATING_DDL_AUTO = + Set.of("update", "create", "create-drop"); + + /** The {@code ddl-auto} values that leave the schema to Flyway. */ + private static final Set PERMITTED_DDL_AUTO = Set.of("none", "validate"); + + private final Map byProfile; + private final SchemaManagementMode fallback; + + public FlywaySchemaPolicy( + Map byProfile, SchemaManagementMode fallback) { + this.byProfile = Map.copyOf(Objects.requireNonNull(byProfile, "byProfile")); + this.fallback = Objects.requireNonNull(fallback, "fallback"); + } + + /** The design's default: migrate locally, validate everywhere else. */ + public static FlywaySchemaPolicy standard() { + return new FlywaySchemaPolicy( + Map.of( + "local", SchemaManagementMode.MIGRATE_ON_STARTUP, + "test", SchemaManagementMode.MIGRATE_ON_STARTUP, + "dev", SchemaManagementMode.MIGRATE_ON_STARTUP, + "staging", SchemaManagementMode.VALIDATE_ONLY, + "prod", SchemaManagementMode.VALIDATE_ONLY), + SchemaManagementMode.VALIDATE_ONLY); + } + + /** The mode for an active profile, defaulting to the safest. */ + public SchemaManagementMode modeFor(String profile) { + if (profile == null) { + return fallback; + } + return byProfile.getOrDefault(profile.toLowerCase(Locale.ROOT), fallback); + } + + /** + * Fails when {@code ddl-auto} would let Hibernate mutate the schema. + * + * @throws IllegalStateException naming the offending value and the permitted alternatives + */ + public void requirePermittedDdlAuto(String ddlAuto) { + String value = ddlAuto == null ? "none" : ddlAuto.trim().toLowerCase(Locale.ROOT); + if (SCHEMA_MUTATING_DDL_AUTO.contains(value)) { + throw new IllegalStateException( + "spring.jpa.hibernate.ddl-auto=" + + value + + " lets Hibernate mutate the schema, but Flyway owns schema change; use " + + PERMITTED_DDL_AUTO); + } + if (!PERMITTED_DDL_AUTO.contains(value)) { + throw new IllegalStateException( + "spring.jpa.hibernate.ddl-auto=" + + value + + " is not a recognised value; use " + + PERMITTED_DDL_AUTO); + } + } + + /** The {@code ddl-auto} values this policy permits. */ + public Set permittedDdlAuto() { + return PERMITTED_DDL_AUTO; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGate.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGate.java new file mode 100644 index 00000000..f365a320 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGate.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.flywaydb.core.api.output.ValidateResult; + +/** + * Fails closed when the migration history does not match the migration scripts (design §31). + * + *

The gate never repairs. Flyway's {@code repair} rewrites the schema history table to match + * whatever scripts are currently on disk, which 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 answers it by making the question + * unaskable. + * + *

Only Flyway's structured error codes reach the exception. Flyway's validation messages embed + * the script path and, for a failed migration, part of the statement — neither belongs in an error + * that reaches a client. + */ +public final class FlywayValidationGate { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("migration.validate"); + + /** + * Fails when validation did not succeed. + * + * @throws SchemaMismatchException carrying only the sanitized error codes + */ + public void requireValid(ValidateResult result) { + Objects.requireNonNull(result, "result"); + if (result.validationSuccessful) { + return; + } + throw new SchemaMismatchException( + JpaFailureContext.terminal( + OPERATION, JpaFailureContext.NO_SQL_STATE, 1, Duration.ZERO, null), + new IllegalStateException("Flyway validation failed: " + sanitizedErrorCodes(result))); + } + + /** + * The bounded error codes Flyway reported. + * + *

Codes only — {@code CHECKSUM_MISMATCH}, {@code MISSING_SCRIPT} and friends are an enumerated + * set, while the accompanying descriptions are free text that includes file paths and SQL. + */ + public List sanitizedErrorCodes(ValidateResult result) { + Objects.requireNonNull(result, "result"); + if (result.invalidMigrations == null) { + return List.of(); + } + return result.invalidMigrations.stream() + .map( + invalid -> + invalid.errorDetails == null + ? "UNKNOWN" + : String.valueOf(invalid.errorDetails.errorCode)) + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/MigrationResource.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/MigrationResource.java new file mode 100644 index 00000000..eb798036 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/MigrationResource.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.util.Objects; + +/** + * One migration script as the inspector sees it (design §32). + * + * @param name the versioned script name, e.g. {@code V42__order_index.sql} + * @param sql the script body + */ +public record MigrationResource(String name, String sql) { + + public MigrationResource { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(sql, "sql"); + if (name.isBlank()) { + throw new IllegalArgumentException("migration name must not be blank"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/NonTransactionalMigrationPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/NonTransactionalMigrationPolicy.java new file mode 100644 index 00000000..a6460bad --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/NonTransactionalMigrationPolicy.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.util.Objects; +import java.util.Set; + +/** + * Which migrations are declared non-transactional, and how that declaration is made (design §32). + * + *

Flyway takes the declaration from a companion {@code .conf} file next to the script. Modelling + * it here as a registry as well means the inspector can check a migration before Flyway ever runs + * it, in a unit test, rather than discovering the mismatch during a deployment. + */ +public record NonTransactionalMigrationPolicy(Set nonTransactionalMigrations) { + + /** The Flyway configuration key that marks a migration non-transactional. */ + public static final String EXECUTE_IN_TRANSACTION_KEY = "executeInTransaction"; + + public NonTransactionalMigrationPolicy { + nonTransactionalMigrations = + Set.copyOf( + Objects.requireNonNull(nonTransactionalMigrations, "nonTransactionalMigrations")); + } + + /** A policy with no non-transactional migrations declared. */ + public static NonTransactionalMigrationPolicy none() { + return new NonTransactionalMigrationPolicy(Set.of()); + } + + /** Whether Flyway will wrap this migration in a transaction. */ + public boolean executesInTransaction(String migrationName) { + return !nonTransactionalMigrations.contains(migrationName); + } + + /** The companion configuration line a non-transactional migration needs. */ + public String companionConfiguration() { + return EXECUTE_IN_TRANSACTION_KEY + "=false"; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaManagementMode.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaManagementMode.java new file mode 100644 index 00000000..52463c64 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaManagementMode.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +/** + * Who is allowed to change the physical schema in a given environment (design §31). + * + *

Hibernate's {@code ddl-auto} is deliberately absent from this enum. The design's position is + * that Flyway owns schema change and Hibernate only validates; representing "Hibernate may alter + * the schema" as a mode would make it a supported configuration. + */ +public enum SchemaManagementMode { + + /** The application runs migrations itself at startup, using the migration credential. */ + MIGRATE_ON_STARTUP, + + /** Migrations are applied by the deployment pipeline; the application only validates. */ + VALIDATE_ONLY, + + /** No migration and no validation; only correct for tooling that owns neither. */ + NONE; + + /** Whether the application may apply migrations in this mode. */ + public boolean appliesMigrations() { + return this == MIGRATE_ON_STARTUP; + } + + /** Whether Hibernate validate must run after startup in this mode. */ + public boolean validatesSchema() { + return this != NONE; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaVersionSnapshot.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaVersionSnapshot.java new file mode 100644 index 00000000..55c09646 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/migration/SchemaVersionSnapshot.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The schema version a database is on, as the migration history reports it (design §31). + * + *

{@code pending} is carried separately from {@code current} because "which version am I on" and + * "is anything unapplied" are different questions with different answers during a rolling deploy: + * an instance can be on the right version and still see pending migrations another instance is + * mid-way through applying. + */ +public record SchemaVersionSnapshot( + String current, int pendingMigrations, Instant observedAt, boolean valid) { + + public SchemaVersionSnapshot { + Objects.requireNonNull(observedAt, "observedAt"); + current = current == null ? "" : current; + if (pendingMigrations < 0) { + throw new IllegalArgumentException("pending migrations must not be negative"); + } + } + + /** The applied version, when any migration has been applied. */ + public Optional appliedVersion() { + return current.isEmpty() ? Optional.empty() : Optional.of(current); + } + + /** Whether the schema is fully migrated and validated. */ + public boolean upToDate() { + return valid && pendingMigrations == 0; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java new file mode 100644 index 00000000..92b88f0f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import java.util.Objects; + +/** + * The complete, bounded tag set every JPA metric carries (design §37). + * + *

Five tags, all registered identifiers. What is absent is the point: no entity id, no tenant + * id, no SQL parameter, no exception message. Each of those is unbounded, so each would create a + * new time series per row or per failure — and several of them are the data the platform keeps out + * of logs, which a metrics backend would then store just as durably and export just as widely. + * + *

Validation happens in the constructor rather than at the registry, so an unbounded value fails + * where it was introduced instead of surviving until a dashboard stops loading. + */ +public record JpaMetricTags( + String persistenceUnit, + String operationName, + String queryName, + String outcome, + String failureCategory) { + + /** The value used when a dimension does not apply to this measurement. */ + public static final String NONE = "none"; + + public JpaMetricTags { + persistenceUnit = orNone(persistenceUnit); + operationName = orNone(operationName); + queryName = orNone(queryName); + outcome = orNone(outcome); + failureCategory = orNone(failureCategory); + LowCardinality.requireRegistered( + persistenceUnit, operationName, queryName, outcome, failureCategory); + } + + /** Tags for a successful measurement. */ + public static JpaMetricTags success( + String persistenceUnit, String operationName, String queryName) { + return new JpaMetricTags(persistenceUnit, operationName, queryName, "success", NONE); + } + + /** Tags for a failed measurement, categorised by the platform's failure category. */ + public static JpaMetricTags failure( + String persistenceUnit, String operationName, String queryName, String failureCategory) { + return new JpaMetricTags(persistenceUnit, operationName, queryName, "failure", failureCategory); + } + + /** The Micrometer tags this set represents. */ + public Tags toTags() { + return Tags.of( + Tag.of("persistence.unit", persistenceUnit), + Tag.of("persistence.operation", operationName), + Tag.of("persistence.query", queryName), + Tag.of("outcome", outcome), + Tag.of("failure.category", failureCategory)); + } + + private static String orNone(String value) { + return value == null || value.isBlank() ? NONE : Objects.requireNonNull(value); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java new file mode 100644 index 00000000..137575f0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; +import dev.caskeleton.adapter.outbound.persistence.transaction.RetryEventListener; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Objects; + +/** + * Turns retry attempts into metrics instead of log noise (design §19, §37). + * + *

A retried attempt is not a warning. Optimistic conflicts and serialization failures are the + * expected cost of concurrency, and logging each one at WARN pages someone for a system that is + * working exactly as designed — after which the retry log gets filtered out, taking the genuinely + * interesting entries with it. + * + *

What is worth alerting on is the shape of the distribution: attempts per operation rising, or + * exhaustion appearing at all. Both are here as metrics. + */ +public final class JpaRetryObservation implements RetryEventListener { + + /** Counter for individual failed attempts. */ + public static final String ATTEMPT_METER = "jpa.retry.attempt"; + + /** Summary of how many attempts each completed operation needed. */ + public static final String ATTEMPTS_PER_OPERATION_METER = "jpa.retry.attempts"; + + /** Counter for operations that exhausted their budget or hit a non-retryable failure. */ + public static final String EXHAUSTED_METER = "jpa.retry.exhausted"; + + private final MeterRegistry registry; + private final String persistenceUnit; + + public JpaRetryObservation(MeterRegistry registry, String persistenceUnit) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.persistenceUnit = Objects.requireNonNull(persistenceUnit, "persistenceUnit"); + LowCardinality.requireRegistered(persistenceUnit); + } + + @Override + public void onAttemptFailed( + PersistenceOperationName operation, + TransactionAttempt attempt, + JpaPersistenceException failure, + RetryDecision decision) { + Counter.builder(ATTEMPT_METER) + .tags( + JpaMetricTags.failure( + persistenceUnit, + operation.value(), + JpaMetricTags.NONE, + failure.category().name()) + .toTags()) + .tag("retry.disposition", decision.disposition().name()) + .register(registry) + .increment(); + } + + @Override + public void onSucceeded(PersistenceOperationName operation, int attempts) { + DistributionSummary.builder(ATTEMPTS_PER_OPERATION_METER) + .tags( + JpaMetricTags.success(persistenceUnit, operation.value(), JpaMetricTags.NONE).toTags()) + .register(registry) + .record(attempts); + } + + @Override + public void onGaveUp( + PersistenceOperationName operation, int attempts, JpaPersistenceException failure) { + var tags = + JpaMetricTags.failure( + persistenceUnit, operation.value(), JpaMetricTags.NONE, failure.category().name()) + .toTags(); + DistributionSummary.builder(ATTEMPTS_PER_OPERATION_METER) + .tags(tags) + .register(registry) + .record(attempts); + Counter.builder(EXHAUSTED_METER).tags(tags).register(registry).increment(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java new file mode 100644 index 00000000..615c7194 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.util.Objects; + +/** + * Records the transaction-level measurements the design names (design §37). + * + *

Completion-unknown gets its own counter rather than being folded into the failure count. 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. Burying it in a general + * failure rate is how it stops being noticed. + */ +public final class JpaTransactionObservation { + + /** Timer for transaction duration. */ + public static final String DURATION_METER = "jpa.transaction.duration"; + + /** Counter for transactions that rolled back. */ + public static final String ROLLBACK_METER = "jpa.transaction.rollback"; + + /** Counter for transactions that exceeded their timeout. */ + public static final String TIMEOUT_METER = "jpa.transaction.timeout"; + + /** Counter for transactions whose commit outcome could not be determined. */ + public static final String COMPLETION_UNKNOWN_METER = "jpa.transaction.completion.unknown"; + + private final MeterRegistry registry; + private final String persistenceUnit; + + public JpaTransactionObservation(MeterRegistry registry, String persistenceUnit) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.persistenceUnit = Objects.requireNonNull(persistenceUnit, "persistenceUnit"); + LowCardinality.requireRegistered(persistenceUnit); + } + + /** Records a transaction that committed. */ + public void recordCommitted(PersistenceOperationName operation, Duration elapsed) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(elapsed, "elapsed"); + Timer.builder(DURATION_METER) + .tags( + JpaMetricTags.success(persistenceUnit, operation.value(), JpaMetricTags.NONE).toTags()) + .register(registry) + .record(elapsed); + } + + /** Records a transaction that rolled back, categorised by the platform's failure category. */ + public void recordRolledBack( + PersistenceOperationName operation, Duration elapsed, JpaPersistenceException failure) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(failure, "failure"); + var tags = + JpaMetricTags.failure( + persistenceUnit, operation.value(), JpaMetricTags.NONE, failure.category().name()) + .toTags(); + Timer.builder(DURATION_METER).tags(tags).register(registry).record(elapsed); + Counter.builder(ROLLBACK_METER).tags(tags).register(registry).increment(); + } + + /** Records a transaction that exceeded its timeout. */ + public void recordTimedOut(PersistenceOperationName operation) { + Objects.requireNonNull(operation, "operation"); + Counter.builder(TIMEOUT_METER) + .tags( + JpaMetricTags.failure( + persistenceUnit, operation.value(), JpaMetricTags.NONE, "TRANSACTION_TIMEOUT") + .toTags()) + .register(registry) + .increment(); + } + + /** Records a transaction whose commit outcome is unknown and now needs reconciliation. */ + public void recordCompletionUnknown(PersistenceOperationName operation) { + Objects.requireNonNull(operation, "operation"); + Counter.builder(COMPLETION_UNKNOWN_METER) + .tags( + JpaMetricTags.failure( + persistenceUnit, operation.value(), JpaMetricTags.NONE, "COMPLETION_UNKNOWN") + .toTags()) + .register(registry) + .increment(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/LowCardinality.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/LowCardinality.java new file mode 100644 index 00000000..316e933e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/LowCardinality.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import java.util.regex.Pattern; + +/** + * The guard that keeps metric tags bounded (design §37). + * + *

Every distinct tag value creates a distinct time series. One tag carrying an entity id, a + * tenant id, or a SQL parameter turns a handful of series into one per row, which is how a metrics + * backend runs out of memory and takes the dashboards down with it — and the values themselves are + * frequently the data the platform is supposed to keep out of telemetry. + * + *

The check is structural: registered names already match a bounded format, so anything that + * does not is rejected rather than sanitised. Sanitising would let the caller keep passing + * unbounded values and never notice. + */ +public final class LowCardinality { + + /** The shape every registered operation, query, and category name already satisfies. */ + private static final Pattern REGISTERED = Pattern.compile("[a-zA-Z][a-zA-Z0-9._-]{0,95}"); + + private LowCardinality() {} + + /** + * Fails when any value is not a registered, bounded identifier. + * + * @throws IllegalArgumentException naming the first offending value + */ + public static void requireRegistered(String... values) { + for (String value : values) { + if (value == null || !REGISTERED.matcher(value).matches()) { + throw new IllegalArgumentException( + "metric tag value is not a registered low-cardinality identifier"); + } + } + } + + /** Whether a value is a registered, bounded identifier. */ + public static boolean isRegistered(String value) { + return value != null && REGISTERED.matcher(value).matches(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/MicrometerQueryObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/MicrometerQueryObservation.java new file mode 100644 index 00000000..8e784d4b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/MicrometerQueryObservation.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Records query duration, row count, and outcome under bounded tags (design §37). + * + *

Row count is measured as well as duration, because the two answer different questions and only + * together identify the failure the design cares about: a query that issues one statement and + * hydrates twenty thousand rows is fast per statement and catastrophic per request, and a duration + * metric alone reports it as merely slow. + * + *

The failure tag is the platform's own {@link + * dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory}, never the exception's + * message or class name. Provider messages are unbounded and contain literals. + */ +public final class MicrometerQueryObservation implements QueryObservation { + + /** The timer recording query duration. */ + public static final String DURATION_METER = "jpa.query.duration"; + + /** The summary recording how many rows each query returned or affected. */ + public static final String ROWS_METER = "jpa.query.rows"; + + private final MeterRegistry registry; + private final String persistenceUnit; + + public MicrometerQueryObservation(MeterRegistry registry, String persistenceUnit) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.persistenceUnit = Objects.requireNonNull(persistenceUnit, "persistenceUnit"); + LowCardinality.requireRegistered(persistenceUnit); + } + + @Override + public QueryScope start(QueryName queryName) { + Objects.requireNonNull(queryName, "queryName"); + return new MicrometerQueryScope(queryName); + } + + /** One observed query. Closes exactly once, whatever the outcome. */ + private final class MicrometerQueryScope implements QueryScope { + + private final QueryName queryName; + private final long startedNanos = System.nanoTime(); + private long rows; + private String failureCategory = JpaMetricTags.NONE; + private boolean failed; + private boolean closed; + + private MicrometerQueryScope(QueryName queryName) { + this.queryName = queryName; + } + + @Override + public void rows(long count) { + rows = Math.max(count, 0L); + } + + @Override + public void failure(Throwable failure) { + failed = true; + failureCategory = + failure instanceof JpaPersistenceException persistence + ? persistence.category().name() + : "unknown"; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + JpaMetricTags tags = + failed + ? JpaMetricTags.failure( + persistenceUnit, JpaMetricTags.NONE, queryName.value(), failureCategory) + : JpaMetricTags.success(persistenceUnit, JpaMetricTags.NONE, queryName.value()); + Timer.builder(DURATION_METER) + .tags(tags.toTags()) + .register(registry) + .record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS); + DistributionSummary.builder(ROWS_METER) + .tags(tags.toTags()) + .register(registry) + .record((double) rows); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/SqlDiagnosticRedactor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/SqlDiagnosticRedactor.java new file mode 100644 index 00000000..f83323b3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/SqlDiagnosticRedactor.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Removes literals from SQL before it is allowed anywhere near a log (design §37). + * + *

SQL that reaches a diagnostic path frequently carries inlined literals — an email in a {@code + * WHERE} clause, a token in an {@code INSERT}, an id in a {@code DELETE}. Those are exactly the + * values the platform keeps out of exception messages and metric tags, and a log line reproduces + * them just as durably. + * + *

Redaction is deliberately blunt: string literals, numbers, and anything that looks like an + * email are replaced wholesale, and the result is truncated. A redactor that tried to preserve + * "harmless" values would need to know which columns hold personal data, which is exactly the + * knowledge it does not have. + */ +public final class SqlDiagnosticRedactor { + + /** The placeholder every removed literal collapses to. */ + public static final String PLACEHOLDER = "?"; + + /** How much redacted SQL is worth keeping; enough to identify the statement shape. */ + public static final int MAX_LENGTH = 512; + + private static final Pattern STRING_LITERAL = Pattern.compile("'(?:[^']|'')*'"); + private static final Pattern NUMERIC_LITERAL = Pattern.compile("\\b\\d+(?:\\.\\d+)?\\b"); + private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w.-]+"); + + private SqlDiagnosticRedactor() {} + + /** The statement with every literal replaced and the result truncated. */ + public static String redact(String sql) { + if (sql == null || sql.isBlank()) { + return ""; + } + String redacted = EMAIL.matcher(sql).replaceAll(PLACEHOLDER); + redacted = STRING_LITERAL.matcher(redacted).replaceAll(PLACEHOLDER); + redacted = NUMERIC_LITERAL.matcher(redacted).replaceAll(PLACEHOLDER); + return redacted.length() <= MAX_LENGTH ? redacted : redacted.substring(0, MAX_LENGTH) + "..."; + } + + /** + * A failure's message with literals removed. + * + *

Provider exception messages embed the failing statement and, for constraint violations, the + * conflicting values — which is why the platform's own exceptions never copy them forward and why + * anything that does log one goes through here first. + */ + public static String redactMessage(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + return redact(failure.getMessage()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/array/PostgreSqlArraySupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/array/PostgreSqlArraySupport.java new file mode 100644 index 00000000..1921e42c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/array/PostgreSqlArraySupport.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.array; + +import java.sql.Array; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; + +/** + * Creates and reads PostgreSQL array values through the JDBC array API (design §8.3). + * + *

Arrays are built with {@link Connection#createArrayOf} rather than by formatting an array + * literal. Hand-formatting is where quoting bugs live: a tag containing a comma, a brace, or a + * backslash changes the array's shape rather than its content, and the failure is silent. + * + *

Element types are restricted to the ones PostgreSQL and the driver agree on for the mappings + * this platform supports, so an unsupported element type fails here rather than as a server-side + * cast error at query time. + */ +public final class PostgreSqlArraySupport { + + /** The element type names this support class will build arrays for. */ + private static final List SUPPORTED_ELEMENT_TYPES = + List.of("text", "varchar", "uuid", "int4", "int8", "numeric", "boolean", "timestamptz"); + + private PostgreSqlArraySupport() {} + + /** + * Builds a JDBC array of {@code elementType}. + * + * @throws IllegalArgumentException when the element type is not one this platform supports + */ + public static Array create(Connection connection, String elementType, Object[] elements) + throws SQLException { + Objects.requireNonNull(connection, "connection"); + Objects.requireNonNull(elements, "elements"); + requireSupported(elementType); + return connection.createArrayOf(elementType, elements); + } + + /** Reads a JDBC array into a typed, immutable list. */ + public static List read(Array array, Class elementType) throws SQLException { + if (array == null) { + return List.of(); + } + Objects.requireNonNull(elementType, "elementType"); + try { + Object raw = array.getArray(); + if (!(raw instanceof Object[] values)) { + throw new IllegalStateException("postgresql array did not decode to an object array"); + } + return List.copyOf(java.util.Arrays.stream(values).map(elementType::cast).toList()); + } finally { + array.free(); + } + } + + /** The element type names this support class accepts. */ + public static List supportedElementTypes() { + return SUPPORTED_ELEMENT_TYPES; + } + + private static void requireSupported(String elementType) { + if (elementType == null || !SUPPORTED_ELEMENT_TYPES.contains(elementType)) { + throw new IllegalArgumentException( + "unsupported postgresql array element type: " + elementType); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalog.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalog.java new file mode 100644 index 00000000..ab72eea1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalog.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.constraint; + +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The registered mapping from physical PostgreSQL constraint names to application codes (design + * §22.4). + * + *

Lookup is case-insensitive because PostgreSQL folds unquoted identifiers to lower case, and a + * migration written with a quoted mixed-case name would otherwise resolve to the unknown code + * depending on how it was written. + * + *

Nothing about partial unique indexes or {@code NULLS NOT DISTINCT} needs special handling + * here: both are properties of the index definition, and the server still reports the index name. + * Registering that name is all a migration has to do — which is exactly why the catalog maps names + * rather than trying to model constraint semantics. + */ +public final class PostgreSqlConstraintCatalog implements ConstraintCatalog { + + private final Map byDatabaseName; + + public PostgreSqlConstraintCatalog(Map byDatabaseName) { + Objects.requireNonNull(byDatabaseName, "byDatabaseName"); + Map normalized = new LinkedHashMap<>(); + byDatabaseName.forEach( + (name, code) -> { + Objects.requireNonNull(name, "database constraint name"); + Objects.requireNonNull(code, "constraint code"); + if (name.isBlank()) { + throw new IllegalArgumentException("database constraint name must not be blank"); + } + if (normalized.put(name.toLowerCase(Locale.ROOT), code) != null) { + throw new IllegalArgumentException("duplicate constraint registration: " + name); + } + }); + this.byDatabaseName = Map.copyOf(normalized); + } + + /** An empty catalog; every constraint resolves to the unknown code. */ + public static PostgreSqlConstraintCatalog empty() { + return new PostgreSqlConstraintCatalog(Map.of()); + } + + @Override + public ConstraintCode resolve(String databaseConstraintName) { + if (databaseConstraintName == null || databaseConstraintName.isBlank()) { + return ConstraintViolationDetails.UNKNOWN_CODE; + } + return byDatabaseName.getOrDefault( + databaseConstraintName.toLowerCase(Locale.ROOT), ConstraintViolationDetails.UNKNOWN_CODE); + } + + /** Whether a physical constraint name is registered. */ + public boolean contains(String databaseConstraintName) { + return databaseConstraintName != null + && byDatabaseName.containsKey(databaseConstraintName.toLowerCase(Locale.ROOT)); + } + + /** The registered physical constraint names, lower-cased. */ + public Set registeredNames() { + return byDatabaseName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java new file mode 100644 index 00000000..93d5ee3a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.constraint; + +import dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields; +import java.util.Objects; +import java.util.Optional; + +/** + * Builds the stable constraint-violation exception for a PostgreSQL integrity error (design §22). + * + *

This is the component that makes "let the database decide" a usable strategy. The design + * forbids resolving a create race with a preceding {@code exists} query — between that read and the + * insert, another transaction can commit the same key, so the check proves nothing. The unique + * index is the only thing that actually serialises the two writers, and this translator is what + * turns its rejection into an outcome the application can branch on. + */ +public final class PostgreSqlConstraintViolationTranslator { + + private final ConstraintCatalog catalog; + + public PostgreSqlConstraintViolationTranslator(ConstraintCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** The registered details for a failure, or the unknown code when the name is not registered. */ + public ConstraintViolationDetails detailsOf(Throwable failure) { + Optional databaseName = PostgreSqlServerErrorFields.constraintName(failure); + if (databaseName.isEmpty()) { + return ConstraintViolationDetails.unknown(null); + } + ConstraintCode code = catalog.resolve(databaseName.get()); + return new ConstraintViolationDetails(code, databaseName.get()); + } + + /** + * Builds the exception for a constraint category. + * + * @throws IllegalArgumentException when {@code category} is not a constraint category + */ + public JpaPersistenceException translate( + FailureCategory category, JpaFailureContext context, Throwable failure) { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(context, "context"); + ConstraintViolationDetails details = detailsOf(failure); + return switch (category) { + case UNIQUE_CONSTRAINT -> new UniqueConstraintViolationException(context, details, failure); + case FOREIGN_KEY_CONSTRAINT -> new ForeignKeyViolationException(context, details, failure); + case CHECK_CONSTRAINT -> new CheckConstraintViolationException(context, details, failure); + case NOT_NULL_CONSTRAINT -> + new NotNullConstraintViolationException(context, details, failure); + default -> + throw new IllegalArgumentException("not a constraint failure category: " + category); + }; + } + + /** Whether a category is one this translator handles. */ + public static boolean handles(FailureCategory category) { + return category == FailureCategory.UNIQUE_CONSTRAINT + || category == FailureCategory.FOREIGN_KEY_CONSTRAINT + || category == FailureCategory.CHECK_CONSTRAINT + || category == FailureCategory.NOT_NULL_CONSTRAINT; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/BoundedCopyInputStream.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/BoundedCopyInputStream.java new file mode 100644 index 00000000..0c58eb07 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/BoundedCopyInputStream.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +/** + * Enforces the byte bound of a {@code COPY} while the load is running (design §30). + * + *

Checking the source size up front is not an option: a {@code COPY} source is a stream, and its + * length is usually unknown until it ends. Counting as the driver reads is what makes the limit + * real — the load aborts mid-transfer, and because {@code COPY} runs inside the caller's + * transaction, the rows written so far roll back with it. + * + *

The exception is deliberately {@link IOException}: the driver is reading this stream, and an + * unchecked exception thrown from inside its read loop would escape through code that is not + * expecting one, leaving the copy protocol half-finished on the connection. + */ +public final class BoundedCopyInputStream extends FilterInputStream { + + private final long maxBytes; + private long bytesRead; + + public BoundedCopyInputStream(InputStream delegate, long maxBytes) { + super(Objects.requireNonNull(delegate, "delegate")); + if (maxBytes < 1L) { + throw new IllegalArgumentException("maxBytes must be positive"); + } + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int value = super.read(); + if (value >= 0) { + count(1L); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = super.read(buffer, offset, length); + if (read > 0) { + count(read); + } + return read; + } + + /** How many bytes the driver has consumed so far. */ + public long bytesRead() { + return bytesRead; + } + + private void count(long increment) throws IOException { + bytesRead += increment; + if (bytesRead > maxBytes) { + throw new IOException("copy source exceeded the configured " + maxBytes + " byte limit"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyAdminCapability.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyAdminCapability.java new file mode 100644 index 00000000..4281c693 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyAdminCapability.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The admin identity a {@code COPY} runs under (design §8.4, §30). + * + *

{@code COPY} is a J4 operation: it writes rows with no entity callbacks, no version checks, + * and no audit trail of its own. Requiring an explicit capability object rather than a boolean + * property means the operator and the reason travel with the call, so the load appears in the audit + * record as somebody's action rather than as an anonymous bulk write. + */ +public record CopyAdminCapability(String operator, String reason) { + + private static final Pattern OPERATOR = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); + private static final int MAX_REASON_LENGTH = 256; + + public CopyAdminCapability { + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(reason, "reason"); + if (!OPERATOR.matcher(operator).matches()) { + throw new IllegalArgumentException("invalid copy operator identity"); + } + if (reason.isBlank() || reason.length() > MAX_REASON_LENGTH) { + throw new IllegalArgumentException("copy reason must be present and bounded"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyFormat.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyFormat.java new file mode 100644 index 00000000..9e9a8976 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyFormat.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +/** + * The wire formats the bulk loader accepts (design §30). + * + *

{@code BINARY} is absent deliberately: it encodes PostgreSQL's internal type representation, + * so a file that loads on one server version can be silently misread on another. + */ +public enum CopyFormat { + + /** {@code COPY ... FROM STDIN WITH (FORMAT csv)}. */ + CSV("csv"), + + /** {@code COPY ... FROM STDIN WITH (FORMAT text)} — PostgreSQL's tab-delimited default. */ + TEXT("text"); + + private final String sqlKeyword; + + CopyFormat(String sqlKeyword) { + this.sqlKeyword = sqlKeyword; + } + + /** The keyword used in the registered {@code COPY} statement. */ + public String sqlKeyword() { + return sqlKeyword; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyLimits.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyLimits.java new file mode 100644 index 00000000..e0ca01fc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyLimits.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.time.Duration; +import java.util.Objects; + +/** + * The hard bounds every {@code COPY} runs under (design §30). + * + *

All three are mandatory. {@code COPY} streams straight into a table with no per-row callback, + * no Persistence Context, and no natural stopping point, so an unbounded load is limited only by + * disk — and the first sign of trouble is a full volume rather than a failed request. + */ +public record CopyLimits(long maxRows, long maxBytes, Duration timeout) { + + public CopyLimits { + Objects.requireNonNull(timeout, "timeout"); + if (maxRows < 1L) { + throw new IllegalArgumentException("copy maxRows must be positive"); + } + if (maxBytes < 1L) { + throw new IllegalArgumentException("copy maxBytes must be positive"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("copy timeout must be positive"); + } + } + + /** Whether a running load has exceeded either size bound. */ + public boolean exceeded(long rows, long bytes) { + return rows > maxRows || bytes > maxBytes; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyOperationName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyOperationName.java new file mode 100644 index 00000000..103ed96e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyOperationName.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.util.regex.Pattern; + +/** + * The registered identity of one {@code COPY} statement (design §30). + * + *

{@code COPY} names a table and a column list directly, and neither can be a bound parameter. + * Registering the whole statement under a name is what keeps a bulk loader from becoming an + * arbitrary-table write primitive. + */ +public record CopyOperationName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public CopyOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid copy operation name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyResult.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyResult.java new file mode 100644 index 00000000..b1bd50a3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/CopyResult.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.time.Duration; +import java.util.Objects; + +/** + * What one {@code COPY} load moved (design §30). + * + *

Rows and bytes are both reported because they fail differently: a load can be well inside its + * row bound while far past its byte bound, and an operator diagnosing a slow import needs to know + * which limit is the binding one. + */ +public record CopyResult(CopyOperationName operation, long rows, long bytes, Duration elapsed) { + + public CopyResult { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(elapsed, "elapsed"); + if (rows < 0L || bytes < 0L) { + throw new IllegalArgumentException("copy result counters must not be negative"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/PostgreSqlCopyLoader.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/PostgreSqlCopyLoader.java new file mode 100644 index 00000000..79faba1d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/PostgreSqlCopyLoader.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.io.InputStream; + +/** + * Streams a bounded source into a registered {@code COPY} statement (design §30). + * + *

This is a J4 admin capability, not a repository method. {@code COPY} bypasses the Persistence + * Context, entity callbacks, optimistic version checks, and Envers entirely — the rows appear in + * the table with none of the invariants ordinary writes go through. That is exactly why it is fast, + * and exactly why it is reachable only under an admin capability with a registered statement. + */ +public interface PostgreSqlCopyLoader { + + /** + * Loads {@code source} through the statement registered for {@code operation}. + * + * @throws IllegalStateException when the load exceeds any bound in {@code limits} + */ + CopyResult load(CopyOperationName operation, InputStream source, CopyLimits limits); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredCopyStatement.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredCopyStatement.java new file mode 100644 index 00000000..caca77fe --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredCopyStatement.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.util.Locale; +import java.util.Objects; + +/** + * One {@code COPY} statement registered at deployment time (design §30). + * + *

The constructor enforces the two properties that make a registered statement safe to run under + * an admin role: it must read from {@code STDIN}, and it must not be a {@code COPY ... TO} at all. + * + *

{@code COPY ... FROM '/path'} reads a file on the database server as the server's OS + * user, and {@code COPY ... TO '/path'} writes one. Both are superuser-only for exactly that + * reason, and neither belongs behind an application API. Restricting the registry to {@code FROM + * STDIN} keeps the data flowing through the connection this process already owns. + */ +public record RegisteredCopyStatement(CopyOperationName name, String sql, CopyFormat format) { + + public RegisteredCopyStatement { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(sql, "sql"); + Objects.requireNonNull(format, "format"); + String normalized = sql.toLowerCase(Locale.ROOT); + if (!normalized.startsWith("copy ")) { + throw new IllegalArgumentException("a registered copy statement must start with COPY"); + } + if (!normalized.contains("from stdin")) { + throw new IllegalArgumentException( + "copy '" + name.value() + "' must read FROM STDIN, never from a server-side path"); + } + if (normalized.contains(" to ")) { + throw new IllegalArgumentException( + "copy '" + + name.value() + + "' must not export; COPY ... TO is not an application capability"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredPostgreSqlCopyLoader.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredPostgreSqlCopyLoader.java new file mode 100644 index 00000000..fa83d4d7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/copy/RegisteredPostgreSqlCopyLoader.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.copy; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import javax.sql.DataSource; + +/** + * Streams a bounded source into a registered {@code COPY} statement (design §30). + * + *

The PostgreSQL {@code CopyManager} is reached reflectively because the driver is a {@code + * runtimeOnly} dependency of this leaf: only this package may touch PostgreSQL at all, and even + * here the driver is not on the compile classpath. A deployment without the driver gets a clear + * startup-shaped failure from {@link #load} rather than a {@code NoClassDefFoundError} from inside + * the copy loop. + * + *

The loader takes its own {@link DataSource} rather than borrowing the JPA connection. A {@code + * COPY} runs under the bulk/admin credential, which by design is a different role from the runtime + * one — the runtime role is verified to have no DDL and is not the role that should be + * mass-inserting either. + */ +public final class RegisteredPostgreSqlCopyLoader implements PostgreSqlCopyLoader { + + private static final String PG_CONNECTION = "org.postgresql.PGConnection"; + private static final String COPY_API_ACCESSOR = "getCopyAPI"; + private static final String COPY_IN_METHOD = "copyIn"; + + private final DataSource adminDataSource; + private final Map statements; + private final CopyAdminCapability capability; + private final Clock clock; + + public RegisteredPostgreSqlCopyLoader( + DataSource adminDataSource, + Map statements, + CopyAdminCapability capability, + Clock clock) { + this.adminDataSource = Objects.requireNonNull(adminDataSource, "adminDataSource"); + this.statements = Map.copyOf(Objects.requireNonNull(statements, "statements")); + this.capability = Objects.requireNonNull(capability, "capability"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public CopyResult load(CopyOperationName operation, InputStream source, CopyLimits limits) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(limits, "limits"); + RegisteredCopyStatement statement = statements.get(operation); + if (statement == null) { + throw new IllegalArgumentException("unregistered copy operation: " + operation.value()); + } + + Instant startedAt = clock.instant(); + BoundedCopyInputStream bounded = new BoundedCopyInputStream(source, limits.maxBytes()); + try (Connection connection = adminDataSource.getConnection()) { + connection.setAutoCommit(false); + applyTimeout(connection, limits.timeout()); + long rows = copyIn(connection, statement.sql(), bounded); + if (rows > limits.maxRows()) { + connection.rollback(); + throw new IllegalStateException( + "copy '" + + operation.value() + + "' produced more than the configured " + + limits.maxRows() + + " row limit"); + } + connection.commit(); + return new CopyResult( + operation, rows, bounded.bytesRead(), Duration.between(startedAt, clock.instant())); + } catch (SQLException failure) { + throw new IllegalStateException("copy '" + operation.value() + "' failed", failure); + } + } + + /** The registered copy operations this loader can run. */ + public Set registeredOperations() { + return statements.keySet(); + } + + /** The admin identity every load by this loader is attributed to. */ + public CopyAdminCapability capability() { + return capability; + } + + /** + * Applies the copy timeout as a server-side statement timeout. + * + *

A client-side timeout would abandon the call while the server kept writing rows. Setting + * {@code statement_timeout} makes the server itself stop, which is the only version of the bound + * that actually stops the work. + * + *

{@code set_config(...)} rather than {@code SET statement_timeout = ?}: {@code SET} is parsed + * before parameters are bound, so a placeholder there is a syntax error. {@code set_config} is an + * ordinary function call and takes the value as a bound parameter, which is also what keeps the + * timeout out of the statement text. + */ + private static void applyTimeout(Connection connection, Duration timeout) throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement("select set_config('statement_timeout', ?, true)")) { + statement.setString(1, Long.toString(Math.min(timeout.toMillis(), Integer.MAX_VALUE))); + statement.execute(); + } + } + + private static long copyIn(Connection connection, String sql, InputStream source) { + try { + Class pgConnection = Class.forName(PG_CONNECTION); + Object unwrapped = connection.unwrap(pgConnection); + Object copyManager = pgConnection.getMethod(COPY_API_ACCESSOR).invoke(unwrapped); + Method copyIn = + copyManager.getClass().getMethod(COPY_IN_METHOD, String.class, InputStream.class); + Object rows = copyIn.invoke(copyManager, sql, source); + return rows instanceof Long count ? count : 0L; + } catch (ClassNotFoundException driverAbsent) { + throw new IllegalStateException( + "the PostgreSQL driver is not on the runtime classpath, so COPY is unavailable", + driverAbsent); + } catch (ReflectiveOperationException | SQLException failure) { + throw new IllegalStateException("postgresql COPY could not be executed", failure); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/ConstraintCatalog.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/ConstraintCatalog.java new file mode 100644 index 00000000..ca9bfd8f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/ConstraintCatalog.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; + +/** + * Maps a physical database constraint name to the application's registered {@link ConstraintCode} + * (design §22.4). + * + *

The catalog is bounded. An unmapped constraint resolves to the unknown code rather than being + * passed through, because the server-reported name is unbounded input: it becomes an error code, a + * log field, and potentially a metric tag, and a migration that adds an index would otherwise widen + * all three without review. + */ +@FunctionalInterface +public interface ConstraintCatalog { + + /** The registered code for a physical constraint name, or the unknown code. */ + ConstraintCode resolve(String databaseConstraintName); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java new file mode 100644 index 00000000..1df62bf6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException; +import dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException; +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException; +import dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException; +import dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException; +import dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException; +import dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException; +import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator; +import java.time.Duration; +import java.util.Objects; + +/** + * Turns a PostgreSQL provider failure into the platform's stable exception (design §18). + * + *

Translation unwraps the Spring → Hibernate → JDBC → driver chain and reads only structured + * fields: the SQLSTATE and, for constraint violations, the server-reported constraint name resolved + * through the registered catalog. No message text is parsed and no message text is copied forward. + * + *

An unclassified SQLSTATE becomes a plain {@link JpaPersistenceException} in the {@link + * FailureCategory#UNKNOWN} category rather than being mapped to the nearest-looking type. A wrong + * guess here does real damage: classify an unknown state as a serialization failure and the retry + * coordinator will happily re-run a write that already succeeded. + */ +public final class PostgreSqlExceptionTranslator { + + private final PostgreSqlFailureClassifier classifier; + private final PostgreSqlConstraintViolationTranslator constraintTranslator; + private final SqlStateResolver sqlStateResolver; + + public PostgreSqlExceptionTranslator( + PostgreSqlFailureClassifier classifier, + ConstraintCatalog constraintCatalog, + SqlStateResolver sqlStateResolver) { + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.constraintTranslator = + new PostgreSqlConstraintViolationTranslator( + Objects.requireNonNull(constraintCatalog, "constraintCatalog")); + this.sqlStateResolver = Objects.requireNonNull(sqlStateResolver, "sqlStateResolver"); + } + + /** A translator using the standard classifier and the supplied constraint catalog. */ + public static PostgreSqlExceptionTranslator with(ConstraintCatalog catalog) { + return new PostgreSqlExceptionTranslator( + new PostgreSqlFailureClassifier(), catalog, new SqlExceptionSqlStateResolver()); + } + + /** + * Translates a provider failure into a stable persistence exception. + * + * @param operation the registered operation the failing work served + * @param attempt the 1-based attempt number + * @param elapsed how long the attempt ran + * @param traceId the current trace identifier, or {@code null} + */ + public JpaPersistenceException translate( + Throwable failure, + PersistenceOperationName operation, + int attempt, + Duration elapsed, + String traceId) { + Objects.requireNonNull(failure, "failure"); + Objects.requireNonNull(operation, "operation"); + + String sqlState = sqlStateResolver.resolve(failure).orElse(JpaFailureContext.NO_SQL_STATE); + FailureCategory category = classifier.classify(sqlState); + boolean retryable = isRetryable(category); + JpaFailureContext context = + new JpaFailureContext( + operation, + sqlState, + PostgreSqlServerErrorFields.constraintName(failure).orElse(null), + attempt, + retryable, + false, + elapsed == null ? Duration.ZERO : elapsed, + traceId); + + return switch (category) { + case SERIALIZATION_FAILURE -> new SerializationFailureException(context, failure); + case DEADLOCK -> new DeadlockDetectedException(context, failure); + case LOCK_NOT_AVAILABLE -> new PessimisticLockTimeoutException(context, failure); + case UNIQUE_CONSTRAINT, FOREIGN_KEY_CONSTRAINT, CHECK_CONSTRAINT, NOT_NULL_CONSTRAINT -> + constraintTranslator.translate(category, context, failure); + case QUERY_TIMEOUT -> new QueryTimeoutException(context, failure); + case TRANSACTION_TIMEOUT -> new TransactionTimeoutException(context, failure); + case CONNECTION_UNAVAILABLE -> new ConnectionUnavailableException(context, failure); + case SCHEMA_MISMATCH -> new SchemaMismatchException(context, failure); + case DATA_CORRUPTION -> new DataCorruptionException(context, failure); + case OPTIMISTIC_CONFLICT -> new OptimisticConflictException(context, failure); + case COMPLETION_UNKNOWN, ENTITY_NOT_FOUND, UNKNOWN -> + new JpaPersistenceException(FailureCategory.UNKNOWN, context, failure); + }; + } + + /** The registered constraint details for a failure, or the unknown code. */ + public ConstraintViolationDetails detailsOf(Throwable failure) { + return constraintTranslator.detailsOf(failure); + } + + /** + * Which categories the translator may mark retryable. + * + *

Only contention categories qualify. Completion-unknown is excluded here as well as in the + * failure context, because a category that is retryable at two layers only needs one of them to + * be wrong. + */ + private static boolean isRetryable(FailureCategory category) { + return category == FailureCategory.SERIALIZATION_FAILURE + || category == FailureCategory.DEADLOCK + || category == FailureCategory.OPTIMISTIC_CONFLICT; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java new file mode 100644 index 00000000..24afb0af --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; + +/** + * Maps a PostgreSQL SQLSTATE onto the platform's {@link FailureCategory} (design §18.2). + * + *

Classification is by structured code only. Nothing here reads a message, and an unrecognised + * state returns {@link FailureCategory#UNKNOWN} rather than being guessed into a neighbouring + * category — an unknown failure treated as retryable is how a one-off error becomes a duplicate + * write. + * + *

Note what is deliberately not here: connection-class ({@code 08*}) states map to + * {@link FailureCategory#CONNECTION_UNAVAILABLE}, not to completion-unknown. Whether a connection + * loss left the commit undetermined depends on the transaction phase, which only the commit-phase + * evidence in {@code CommitFailureClassifier} knows (design §17.2). + */ +public final class PostgreSqlFailureClassifier { + + /** Classifies a raw SQLSTATE. */ + public FailureCategory classify(String sqlState) { + if (sqlState == null || sqlState.isBlank()) { + return FailureCategory.UNKNOWN; + } + return PostgreSqlState.fromCode(sqlState) + .map(PostgreSqlFailureClassifier::categoryOf) + .orElseGet( + () -> + PostgreSqlState.isConnectionClass(sqlState) + ? FailureCategory.CONNECTION_UNAVAILABLE + : FailureCategory.UNKNOWN); + } + + /** Classifies a registered state. */ + public FailureCategory classify(PostgreSqlState state) { + return state == null ? FailureCategory.UNKNOWN : categoryOf(state); + } + + /** Whether a SQLSTATE denotes a constraint violation of any kind. */ + public boolean isConstraintViolation(String sqlState) { + FailureCategory category = classify(sqlState); + return category == FailureCategory.UNIQUE_CONSTRAINT + || category == FailureCategory.FOREIGN_KEY_CONSTRAINT + || category == FailureCategory.CHECK_CONSTRAINT + || category == FailureCategory.NOT_NULL_CONSTRAINT; + } + + private static FailureCategory categoryOf(PostgreSqlState state) { + return switch (state) { + case SERIALIZATION_FAILURE -> FailureCategory.SERIALIZATION_FAILURE; + case STATEMENT_COMPLETION_UNKNOWN -> FailureCategory.COMPLETION_UNKNOWN; + case DEADLOCK_DETECTED -> FailureCategory.DEADLOCK; + case UNIQUE_VIOLATION -> FailureCategory.UNIQUE_CONSTRAINT; + case FOREIGN_KEY_VIOLATION -> FailureCategory.FOREIGN_KEY_CONSTRAINT; + case CHECK_VIOLATION -> FailureCategory.CHECK_CONSTRAINT; + case NOT_NULL_VIOLATION -> FailureCategory.NOT_NULL_CONSTRAINT; + case LOCK_NOT_AVAILABLE -> FailureCategory.LOCK_NOT_AVAILABLE; + case QUERY_CANCELED -> FailureCategory.QUERY_TIMEOUT; + case IDLE_IN_TRANSACTION_TIMEOUT -> FailureCategory.TRANSACTION_TIMEOUT; + case UNDEFINED_TABLE, UNDEFINED_COLUMN -> FailureCategory.SCHEMA_MISMATCH; + case INSUFFICIENT_PRIVILEGE -> FailureCategory.UNKNOWN; + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlServerErrorFields.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlServerErrorFields.java new file mode 100644 index 00000000..bc1e253f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlServerErrorFields.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import java.lang.reflect.Method; +import java.sql.SQLException; +import java.util.IdentityHashMap; +import java.util.Optional; + +/** + * Reads the structured fields PostgreSQL attaches to a server error, without compiling against the + * driver. + * + *

The driver is a {@code runtimeOnly} dependency of this leaf — the RDBMS base stays vendor + * neutral and only this package may touch PostgreSQL at all — so {@code PSQLException} and {@code + * ServerErrorMessage} are not on the compile classpath. Reflection is what lets the platform read + * {@code getServerErrorMessage().getConstraint()} anyway. + * + *

The alternative would be parsing the constraint name out of the message text, which is exactly + * what design §18.2 forbids: message text is localized and changes between server versions, so a + * text parser silently returns nothing on a server with different {@code lc_messages}. + * + *

Every lookup degrades to {@link Optional#empty()}. A missing driver, a shaded driver, or a + * future driver that renames the accessor must not turn error reporting into a second + * error. + */ +public final class PostgreSqlServerErrorFields { + + private static final String SERVER_ERROR_ACCESSOR = "getServerErrorMessage"; + private static final String CONSTRAINT_ACCESSOR = "getConstraint"; + private static final String TABLE_ACCESSOR = "getTable"; + private static final int MAX_DEPTH = 64; + + private PostgreSqlServerErrorFields() {} + + /** The constraint name the server reported, when the chain carries a PostgreSQL server error. */ + public static Optional constraintName(Throwable failure) { + return serverField(failure, CONSTRAINT_ACCESSOR); + } + + /** The table name the server reported, when the chain carries a PostgreSQL server error. */ + public static Optional tableName(Throwable failure) { + return serverField(failure, TABLE_ACCESSOR); + } + + private static Optional serverField(Throwable failure, String accessor) { + IdentityHashMap seen = new IdentityHashMap<>(); + Throwable current = failure; + int depth = 0; + while (current != null && depth++ < MAX_DEPTH && seen.put(current, Boolean.TRUE) == null) { + if (current instanceof SQLException sqlFailure) { + Optional value = readServerField(sqlFailure, accessor); + if (value.isPresent()) { + return value; + } + SQLException next = sqlFailure.getNextException(); + if (next != null && !seen.containsKey(next)) { + current = next; + continue; + } + } + current = current.getCause(); + } + return Optional.empty(); + } + + private static Optional readServerField(SQLException failure, String accessor) { + try { + Method serverErrorMessage = failure.getClass().getMethod(SERVER_ERROR_ACCESSOR); + Object message = serverErrorMessage.invoke(failure); + if (message == null) { + return Optional.empty(); + } + Object value = message.getClass().getMethod(accessor).invoke(message); + if (value instanceof String text && !text.isBlank()) { + return Optional.of(text); + } + return Optional.empty(); + } catch (ReflectiveOperationException | RuntimeException notAPostgreSqlError) { + // Not a PostgreSQL server error, or a driver that does not expose the accessor. Either way + // there is no structured field to read, and failing here would replace the real failure. + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlState.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlState.java new file mode 100644 index 00000000..74675093 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlState.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * The PostgreSQL SQLSTATEs this platform classifies structurally (design §18.2). + * + *

SQLSTATE is the contract, not the message. Server messages are localized and reworded between + * releases, so a classifier that matches on text is a classifier that silently stops working after + * an upgrade or on a server with a different {@code lc_messages}. + */ +public enum PostgreSqlState { + + /** {@code 40001} serialization_failure. */ + SERIALIZATION_FAILURE("40001"), + + /** {@code 40003} statement_completion_unknown. */ + STATEMENT_COMPLETION_UNKNOWN("40003"), + + /** {@code 40P01} deadlock_detected. */ + DEADLOCK_DETECTED("40P01"), + + /** {@code 23505} unique_violation. */ + UNIQUE_VIOLATION("23505"), + + /** {@code 23503} foreign_key_violation. */ + FOREIGN_KEY_VIOLATION("23503"), + + /** {@code 23514} check_violation. */ + CHECK_VIOLATION("23514"), + + /** {@code 23502} not_null_violation. */ + NOT_NULL_VIOLATION("23502"), + + /** {@code 55P03} lock_not_available. */ + LOCK_NOT_AVAILABLE("55P03"), + + /** {@code 57014} query_canceled — how a statement timeout surfaces. */ + QUERY_CANCELED("57014"), + + /** {@code 25P03} idle_in_transaction_session_timeout. */ + IDLE_IN_TRANSACTION_TIMEOUT("25P03"), + + /** {@code 42P01} undefined_table — the schema is not what the provider expects. */ + UNDEFINED_TABLE("42P01"), + + /** {@code 42703} undefined_column — the schema is not what the provider expects. */ + UNDEFINED_COLUMN("42703"), + + /** {@code 42501} insufficient_privilege. */ + INSUFFICIENT_PRIVILEGE("42501"); + + private static final Map BY_CODE = + Stream.of(values()) + .collect(Collectors.toUnmodifiableMap(PostgreSqlState::code, state -> state)); + + /** SQLSTATE class 08 is "connection exception"; every code in it is a connection failure. */ + public static final String CONNECTION_CLASS = "08"; + + private final String code; + + PostgreSqlState(String code) { + this.code = code; + } + + /** The five-character SQLSTATE. */ + public String code() { + return code; + } + + /** The registered state for a SQLSTATE, when the platform classifies it. */ + public static Optional fromCode(String sqlState) { + return sqlState == null ? Optional.empty() : Optional.ofNullable(BY_CODE.get(sqlState.trim())); + } + + /** Whether a SQLSTATE belongs to the connection-exception class. */ + public static boolean isConnectionClass(String sqlState) { + return sqlState != null && sqlState.trim().startsWith(CONNECTION_CLASS); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocument.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocument.java new file mode 100644 index 00000000..83f4f8f8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocument.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.json; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A JSONB value with its schema identity carried alongside it (design §8.3). + * + *

Schema name and version are mandatory. A JSONB column is schema-less at the database level, so + * without an envelope the only record of what a stored document means is the code that happened to + * write it — and a document written two releases ago is then indistinguishable from a current one. + * + *

The payload never carries a Java class name. Type metadata in the document is what turns a + * JSONB column into a deserialization gadget: whoever can write a row chooses the class the reader + * instantiates. + */ +public record JsonDocument(String schema, int version, JsonNode payload) { + + private static final Pattern SCHEMA = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + /** Jackson's polymorphic type key; a document carrying it is rejected outright. */ + private static final String TYPE_KEY = "@class"; + + public JsonDocument { + Objects.requireNonNull(payload, "payload"); + if (schema == null || schema.isBlank() || !SCHEMA.matcher(schema).matches() || version < 1) { + throw new IllegalArgumentException("invalid json document envelope"); + } + if (containsTypeMetadata(payload)) { + throw new IllegalArgumentException("json document payload must not carry java type metadata"); + } + } + + /** The first version of a schema. */ + public static JsonDocument initial(String schema, JsonNode payload) { + return new JsonDocument(schema, 1, payload); + } + + /** Whether this document was written under the supplied schema and version. */ + public boolean matches(String otherSchema, int otherVersion) { + return schema.equals(otherSchema) && version == otherVersion; + } + + private static boolean containsTypeMetadata(JsonNode node) { + if (node.isObject()) { + if (node.has(TYPE_KEY)) { + return true; + } + for (JsonNode child : node) { + if (containsTypeMetadata(child)) { + return true; + } + } + return false; + } + if (node.isArray()) { + for (JsonNode child : node) { + if (containsTypeMetadata(child)) { + return true; + } + } + } + return false; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocumentCodec.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocumentCodec.java new file mode 100644 index 00000000..f4b157f1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonDocumentCodec.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import java.time.Duration; +import java.util.Objects; + +/** + * Converts a {@link JsonDocument} to and from the JSONB text the column stores (design §8.3). + * + *

The envelope fields are written as ordinary members of the stored object, so the schema and + * version are queryable with the same JSONB operators as the payload — a migration that needs "all + * documents still on version 1" is a normal indexed query rather than a full scan through the + * application. + * + *

A document that cannot be read back becomes {@link DataCorruptionException} carrying no part + * of the offending value. Echoing malformed JSONB into the error would copy whatever the row holds + * — quite possibly the PII the platform is supposed to keep out of logs — into every place that + * error is reported. + */ +public final class JsonDocumentCodec { + + /** The envelope member holding the schema name. */ + public static final String SCHEMA_FIELD = "_schema"; + + /** The envelope member holding the schema version. */ + public static final String VERSION_FIELD = "_version"; + + /** The envelope member holding the document body. */ + public static final String PAYLOAD_FIELD = "payload"; + + private static final PersistenceOperationName DECODE_OPERATION = + new PersistenceOperationName("postgresql.jsonb.decode"); + + private final ObjectMapper objectMapper; + + public JsonDocumentCodec(ObjectMapper objectMapper) { + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + } + + /** Serialises a document to the JSONB text stored in the column. */ + public String encode(JsonDocument document) { + Objects.requireNonNull(document, "document"); + ObjectNode envelope = objectMapper.createObjectNode(); + envelope.put(SCHEMA_FIELD, document.schema()); + envelope.put(VERSION_FIELD, document.version()); + envelope.set(PAYLOAD_FIELD, document.payload()); + try { + return objectMapper.writeValueAsString(envelope); + } catch (JsonProcessingException malformed) { + throw new IllegalArgumentException("json document could not be serialised", malformed); + } + } + + /** + * Reads the JSONB text a column returned. + * + * @throws DataCorruptionException when the stored value is not a document this codec wrote + */ + public JsonDocument decode(String stored) { + if (stored == null || stored.isBlank()) { + throw corruption(null); + } + try { + JsonNode envelope = objectMapper.readTree(stored); + JsonNode schema = envelope.get(SCHEMA_FIELD); + JsonNode version = envelope.get(VERSION_FIELD); + JsonNode payload = envelope.get(PAYLOAD_FIELD); + if (schema == null + || !schema.isTextual() + || version == null + || !version.isInt() + || payload == null) { + throw corruption(null); + } + return new JsonDocument(schema.asText(), version.asInt(), payload); + } catch (JsonProcessingException | IllegalArgumentException malformed) { + throw corruption(malformed); + } + } + + private static DataCorruptionException corruption(Throwable cause) { + return new DataCorruptionException( + JpaFailureContext.terminal( + DECODE_OPERATION, JpaFailureContext.NO_SQL_STATE, 1, Duration.ZERO, null), + cause); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonPathName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonPathName.java new file mode 100644 index 00000000..4c5784b9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/JsonPathName.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.json; + +import java.util.regex.Pattern; + +/** + * A registered JSON path a query may filter on (design §8.3). + * + *

Paths are registered rather than accepted from callers. A JSON path is part of the SQL text — + * it cannot be bound as a parameter — so an application that forwards a request field into a path + * is concatenating untrusted input into a statement. Registration moves that decision to deployment + * time, where it can be reviewed and indexed. + */ +public record JsonPathName(String value, String expression) { + + private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + /** A conservative JSON path shape: dotted segments and array indexes only. */ + private static final Pattern EXPRESSION = Pattern.compile("[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*"); + + public JsonPathName { + if (value == null || !NAME.matcher(value).matches()) { + throw new IllegalArgumentException("invalid json path name"); + } + if (expression == null || !EXPRESSION.matcher(expression).matches()) { + throw new IllegalArgumentException("invalid json path expression"); + } + } + + /** The path split into segments, for building a containment document. */ + public String[] segments() { + return expression.split("\\.", -1); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/PostgreSqlJsonQuerySupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/PostgreSqlJsonQuerySupport.java new file mode 100644 index 00000000..f19caae2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/json/PostgreSqlJsonQuerySupport.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.json; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Runs registered JSONB containment queries with the value bound as a parameter (design §8.3). + * + *

The split is the security property: the path comes from the registry and is part of + * the fixed statement; the value comes from the caller and is always a bound parameter. A + * JSON path cannot be parameterised, so a design that accepted paths from callers would be + * concatenating input into SQL; a value can be, so there is no reason ever not to. + * + *

Containment ({@code @>}) is used rather than {@code ->>} equality because it is what the GIN + * index on a JSONB column can actually answer. The plan-level evidence for that lives in the query + * plan suite (design §33). + */ +public final class PostgreSqlJsonQuerySupport { + + /** The parameter name every registered containment statement binds. */ + public static final String CONTAINMENT_PARAMETER = "containment"; + + private final EntityManager entityManager; + private final ObjectMapper objectMapper; + private final Map registeredStatements; + + /** + * @param registeredStatements the fixed SQL for each registered path, keyed by path name + */ + public PostgreSqlJsonQuerySupport( + EntityManager entityManager, + ObjectMapper objectMapper, + Map registeredStatements) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + this.registeredStatements = + Map.copyOf(Objects.requireNonNull(registeredStatements, "registeredStatements")); + } + + /** + * Runs the registered containment query for {@code path}, matching rows whose document contains + * {@code value} at that path. + * + * @throws IllegalArgumentException when the path is not registered + */ + public List contains(JsonPathName path, String value) { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(value, "value"); + String sql = registeredStatements.get(path); + if (sql == null) { + throw new IllegalArgumentException("unregistered json path: " + path.value()); + } + Query query = entityManager.createNativeQuery(sql); + query.setParameter(CONTAINMENT_PARAMETER, containmentDocument(path, value)); + return query.getResultList(); + } + + /** + * Builds the JSONB containment document for a registered path and value. + * + *

Exposed because it is the part worth asserting directly: a nested path must produce nested + * objects, and getting that wrong silently matches nothing rather than failing. + */ + public String containmentDocument(JsonPathName path, String value) { + return containmentDocument(objectMapper, path, value); + } + + /** + * Builds the containment document without needing an {@code EntityManager}. + * + *

The document depends only on the path and the mapper, and it is the part worth asserting on + * its own — a nested path must produce nested objects, and getting that wrong silently matches + * nothing rather than failing. + */ + public static String containmentDocument( + ObjectMapper objectMapper, JsonPathName path, String value) { + Objects.requireNonNull(objectMapper, "objectMapper"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(value, "value"); + String[] segments = path.segments(); + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode current = root.putObject(JsonDocumentCodec.PAYLOAD_FIELD); + for (int index = 0; index < segments.length - 1; index++) { + current = current.putObject(segments[index]); + } + current.put(segments[segments.length - 1], value); + return root.toString(); + } + + /** The registered JSON paths this support class can query. */ + public Set registeredPaths() { + return registeredStatements.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/LockWaitObservation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/LockWaitObservation.java new file mode 100644 index 00000000..d4844280 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/LockWaitObservation.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import java.time.Duration; + +/** + * Records how long lock acquisition actually waited (design §21). + * + *

Lock waits are invisible in ordinary query timings: the statement duration includes the wait, + * so a slow endpoint looks like a slow query rather than contention. Measuring the wait separately + * is what distinguishes "this query needs an index" from "two transactions take these rows in + * opposite order". + */ +public interface LockWaitObservation { + + /** A lock was acquired after waiting {@code waited}. */ + void acquired(PersistenceOperationName operation, Duration waited); + + /** A lock was refused — by {@code NOWAIT} or by timeout — after waiting {@code waited}. */ + void refused(PersistenceOperationName operation, Duration waited, boolean nowait); + + /** An observation that records nothing. */ + static LockWaitObservation noop() { + return new LockWaitObservation() { + @Override + public void acquired(PersistenceOperationName operation, Duration waited) { + // no telemetry backend is installed + } + + @Override + public void refused(PersistenceOperationName operation, Duration waited, boolean nowait) { + // no telemetry backend is installed + } + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockExceptionTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockExceptionTranslator.java new file mode 100644 index 00000000..56e76d15 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockExceptionTranslator.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Separates "I could not take the lock" from "the server aborted me" (design §21). + * + *

The distinction drives opposite recovery. {@code 55P03} is statement-level: the transaction is + * still alive and the caller decides whether to back off, escalate, or give up. {@code 40P01} is + * transaction-level: the server already rolled this transaction back, so the only continuation is a + * complete re-run. Collapsing the two — as a generic "lock error" would — produces either a retry + * loop over a live transaction that cannot commit, or a needless abort of one that could still + * proceed. + */ +public final class PostgreSqlLockExceptionTranslator { + + private final SqlStateResolver sqlStateResolver; + + public PostgreSqlLockExceptionTranslator(SqlStateResolver sqlStateResolver) { + this.sqlStateResolver = Objects.requireNonNull(sqlStateResolver, "sqlStateResolver"); + } + + /** A translator using the vendor-neutral SQLSTATE resolver. */ + public static PostgreSqlLockExceptionTranslator standard() { + return new PostgreSqlLockExceptionTranslator(new SqlExceptionSqlStateResolver()); + } + + /** + * Translates a lock-related failure, or returns empty when the failure is not lock related. + * + * @param operation the registered operation whose lock request failed + * @param attempt the 1-based attempt number + * @param waited how long the lock request waited before failing + */ + public Optional translate( + Throwable failure, + PersistenceOperationName operation, + int attempt, + Duration waited, + String traceId) { + Objects.requireNonNull(operation, "operation"); + Optional sqlState = sqlStateResolver.resolve(failure); + if (sqlState.isEmpty()) { + return translateProviderLockException(failure, operation, attempt, waited, traceId); + } + String state = sqlState.get(); + if (PostgreSqlState.LOCK_NOT_AVAILABLE.code().equals(state)) { + return Optional.of( + new PessimisticLockTimeoutException( + JpaFailureContext.terminal(operation, state, attempt, waited, traceId), failure)); + } + if (PostgreSqlState.DEADLOCK_DETECTED.code().equals(state)) { + return Optional.of( + new DeadlockDetectedException( + JpaFailureContext.retryable(operation, state, attempt, waited, traceId), failure)); + } + return Optional.empty(); + } + + /** + * Recognises the provider's own lock exceptions when no SQLSTATE survived the chain. + * + *

Hibernate can raise {@code LockTimeoutException} without a driver exception underneath — for + * example when it enforces a lock timeout hint itself — so a SQLSTATE-only classifier would let + * that failure fall through as unknown. + */ + private static Optional translateProviderLockException( + Throwable failure, + PersistenceOperationName operation, + int attempt, + Duration waited, + String traceId) { + if (failure instanceof jakarta.persistence.LockTimeoutException + || failure instanceof jakarta.persistence.PessimisticLockException) { + return Optional.of( + new PessimisticLockTimeoutException( + JpaFailureContext.terminal( + operation, JpaFailureContext.NO_SQL_STATE, attempt, waited, traceId), + failure)); + } + return Optional.empty(); + } + + /** Whether a SQLSTATE denotes a lock failure this translator handles. */ + public boolean isLockFailure(String sqlState) { + return PostgreSqlState.LOCK_NOT_AVAILABLE.code().equals(sqlState) + || PostgreSqlState.DEADLOCK_DETECTED.code().equals(sqlState); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptions.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptions.java new file mode 100644 index 00000000..1494b120 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptions.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import jakarta.persistence.LockModeType; +import java.time.Duration; +import java.util.Objects; + +/** + * How a pessimistic lock is taken (design §21.1, §21.2). + * + *

A finite timeout is mandatory. {@code SELECT ... FOR UPDATE} with no bound waits as long as + * the holder holds it, so an unbounded lock request turns one slow transaction into a pile-up of + * blocked connections, and the pool runs out before anyone sees the original problem. + * + *

{@code NOWAIT} and a wait timeout are different requests, not two spellings of one. {@code + * NOWAIT} fails the moment the row is held; a timeout waits and then fails. Modelling them as one + * field with a magic zero value is how "no wait" quietly becomes "wait forever". + */ +public record PostgreSqlLockOptions(LockModeType mode, Duration timeout, boolean nowait) { + + public PostgreSqlLockOptions { + Objects.requireNonNull(mode, "mode"); + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("lock timeout must be finite"); + } + if (nowait && !timeout.isZero()) { + throw new IllegalArgumentException("a NOWAIT lock must not also declare a wait timeout"); + } + if (!nowait && timeout.isZero()) { + throw new IllegalArgumentException("a waiting lock requires a positive timeout"); + } + } + + /** A lock that fails immediately when the row is already held. */ + public static PostgreSqlLockOptions nowait(LockModeType mode) { + return new PostgreSqlLockOptions(mode, Duration.ZERO, true); + } + + /** A lock that waits up to {@code timeout} before failing. */ + public static PostgreSqlLockOptions waiting(LockModeType mode, Duration timeout) { + return new PostgreSqlLockOptions(mode, timeout, false); + } + + /** + * The value for the {@code jakarta.persistence.lock.timeout} hint. + * + *

Zero is the JPA-defined encoding of {@code NOWAIT}, which is why the two cases converge only + * at the hint boundary and stay separate in the type. + */ + public long lockTimeoutHintMillis() { + return nowait ? 0L : timeout.toMillis(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlWorkClaimExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlWorkClaimExecutor.java new file mode 100644 index 00000000..b123cbfc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlWorkClaimExecutor.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * Claims disjoint batches of queue rows using {@code FOR UPDATE SKIP LOCKED} (design §21.3). + * + *

Only registered queues are claimable, and each queue's statement is fixed at registration. + * Nothing the caller passes reaches the SQL text: the batch size and the lease expiry are bound as + * parameters, and the queue name selects a statement rather than being interpolated into one. + * + *

The lease is computed here and written by the same statement that takes the claim. Handing + * back a claim whose lease was set by a later statement would leave a window where the row is + * locked by this transaction but marked as owned by nobody. + */ +public final class PostgreSqlWorkClaimExecutor implements WorkClaimExecutor { + + /** The batch size parameter every registered claim statement binds. */ + public static final String BATCH_SIZE_PARAMETER = "batchSize"; + + /** The lease expiry parameter every registered claim statement binds. */ + public static final String LEASE_UNTIL_PARAMETER = "leaseUntil"; + + /** The lease owner parameter every registered claim statement binds. */ + public static final String LEASE_OWNER_PARAMETER = "leaseOwner"; + + private static final int MAX_BATCH_SIZE = 1_000; + + private final EntityManager entityManager; + private final Map queues; + private final BiFunction> rowMapper; + private final String leaseOwner; + private final Clock clock; + + /** + * @param queues the registered queues; a name outside this map cannot be claimed + * @param rowMapper turns one claimed row and its lease expiry into a {@link WorkClaim} + * @param leaseOwner the bounded identity written into each claimed row + */ + public PostgreSqlWorkClaimExecutor( + EntityManager entityManager, + Map queues, + BiFunction> rowMapper, + String leaseOwner, + Clock clock) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.queues = Map.copyOf(Objects.requireNonNull(queues, "queues")); + this.rowMapper = Objects.requireNonNull(rowMapper, "rowMapper"); + this.leaseOwner = Objects.requireNonNull(leaseOwner, "leaseOwner"); + this.clock = Objects.requireNonNull(clock, "clock"); + if (leaseOwner.isBlank()) { + throw new IllegalArgumentException("lease owner must not be blank"); + } + } + + @Override + public List> claimNextBatch(WorkQueueName queue, int size, Duration lease) { + Objects.requireNonNull(queue, "queue"); + Objects.requireNonNull(lease, "lease"); + if (size < 1 || size > MAX_BATCH_SIZE) { + throw new IllegalArgumentException( + "claim batch size must be between 1 and " + MAX_BATCH_SIZE); + } + if (lease.isZero() || lease.isNegative()) { + throw new IllegalArgumentException("claim lease must be positive"); + } + WorkQueueDefinition definition = queues.get(queue); + if (definition == null) { + throw new IllegalArgumentException("unregistered work queue: " + queue.value()); + } + + Instant leaseUntil = clock.instant().plus(lease); + Query query = entityManager.createNativeQuery(definition.claimSql()); + query.setParameter(BATCH_SIZE_PARAMETER, size); + query.setParameter(LEASE_UNTIL_PARAMETER, leaseUntil); + query.setParameter(LEASE_OWNER_PARAMETER, leaseOwner); + + List rows = query.getResultList(); + return rows.stream().map(row -> rowMapper.apply(toColumns(row), leaseUntil)).toList(); + } + + /** The registered queue names this executor can claim from. */ + public java.util.Set registeredQueues() { + return queues.keySet(); + } + + /** + * Normalises a native-query row into a column array. + * + *

JPA returns a bare value rather than a one-element array when the statement projects a + * single column, so a mapper written against {@code Object[]} would fail only for single-column + * queues — the easiest shape to get wrong and the hardest to notice. + */ + private static Object[] toColumns(Object row) { + return row instanceof Object[] columns ? columns : new Object[] {row}; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaim.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaim.java new file mode 100644 index 00000000..43939171 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaim.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import java.time.Instant; +import java.util.Objects; + +/** + * One row a worker successfully claimed, with the lease that proves it (design §21.3). + * + *

The lease owner and expiry are returned from the same transaction that took the claim. A + * worker that had to ask afterwards "did I get it, and until when?" would be reading state another + * worker may already have reclaimed. + * + * @param the claimed payload type + * @param the claimed row's identifier type + */ +public record WorkClaim(K id, T payload, String leaseOwner, Instant leaseUntil) { + + public WorkClaim { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(leaseOwner, "leaseOwner"); + Objects.requireNonNull(leaseUntil, "leaseUntil"); + if (leaseOwner.isBlank()) { + throw new IllegalArgumentException("lease owner must not be blank"); + } + } + + /** Whether the lease is still valid at {@code now}. */ + public boolean leaseValidAt(Instant now) { + return now.isBefore(leaseUntil); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaimExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaimExecutor.java new file mode 100644 index 00000000..d0b0de21 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkClaimExecutor.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import java.time.Duration; +import java.util.List; + +/** + * Claims a batch of rows from a registered queue (design §21.3). + * + *

This is a queue API, not a general "skip locked reads" switch. {@code FOR UPDATE SKIP LOCKED} + * deliberately returns an incomplete view of the table: it is correct for handing disjoint work to + * competing workers, and silently wrong for anything that needs to see every matching row. Exposing + * it as a repository flag would let that second use appear by accident, so it is reachable only + * through a named queue. + * + * @param the claimed payload type + * @param the claimed row's identifier type + */ +public interface WorkClaimExecutor { + + /** + * Claims up to {@code size} rows, leasing each for {@code lease}. + * + *

Must run inside the caller's transaction: the claim and the lease write are the same + * transaction, or another worker can take a row this one believes it owns. + */ + List> claimNextBatch(WorkQueueName queue, int size, Duration lease); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueDefinition.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueDefinition.java new file mode 100644 index 00000000..1cbd50f8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueDefinition.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import java.util.Locale; +import java.util.Objects; + +/** + * The fixed claim statement registered for one work queue (design §21.3). + * + *

The SQL is supplied at registration and never assembled from a request. The constructor + * refuses a statement that does not both skip locked rows and impose a deterministic order: + * + *

    + *
  • Without {@code SKIP LOCKED}, competing workers block on each other instead of taking + * disjoint work, which is the opposite of what a claim query is for. + *
  • Without {@code ORDER BY}, PostgreSQL may return rows in any order, so priority is whatever + * the plan happened to produce and starvation is unreproducible. + *
+ */ +public record WorkQueueDefinition(WorkQueueName name, String claimSql) { + + public WorkQueueDefinition { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(claimSql, "claimSql"); + String normalized = claimSql.toLowerCase(Locale.ROOT); + if (!normalized.contains("skip locked")) { + throw new IllegalArgumentException( + "queue '" + name.value() + "' claim SQL must use FOR UPDATE SKIP LOCKED"); + } + if (!normalized.contains("order by")) { + throw new IllegalArgumentException( + "queue '" + name.value() + "' claim SQL must impose a deterministic ORDER BY"); + } + if (normalized.contains("' +") || normalized.contains("\" +")) { + throw new IllegalArgumentException( + "queue '" + name.value() + "' claim SQL must be a fixed statement, not a concatenation"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueName.java new file mode 100644 index 00000000..bbebbb03 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/WorkQueueName.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import java.util.regex.Pattern; + +/** + * The registered identity of a claimable work queue (design §21.3). + * + *

A queue name is a registry key, not a table name. The claim SQL for each queue is fixed at + * registration, so the name selects a pre-approved statement instead of being interpolated into one + * — the difference between a queue registry and SQL injection through a queue parameter. + */ +public record WorkQueueName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public WorkQueueName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid work queue name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java new file mode 100644 index 00000000..501432fc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.range; + +import java.util.Objects; +import java.util.Optional; + +/** + * A PostgreSQL range value with explicit, independent bounds (design §8.3). + * + *

Both endpoints are separately optional and separately inclusive or exclusive, because that is + * what PostgreSQL ranges actually are. Modelling a range as two plain columns loses exactly the + * information that matters: whether {@code [09:00, 10:00)} and {@code [10:00, 11:00)} overlap + * depends on the bracket, not on the values, and a pair of {@code timestamptz} columns cannot say. + * + *

An inverted range is rejected in Java. PostgreSQL raises an error for one too, but by then the + * statement has already been sent, the transaction is poisoned, and the caller gets a SQLSTATE + * instead of an argument error at the point of the mistake. + * + * @param the endpoint type + */ +public record PgRange>( + Optional lower, boolean lowerInclusive, Optional upper, boolean upperInclusive) { + + public PgRange { + Objects.requireNonNull(lower, "lower"); + Objects.requireNonNull(upper, "upper"); + if (lower.isPresent() && upper.isPresent()) { + int comparison = lower.get().compareTo(upper.get()); + if (comparison > 0) { + throw new IllegalArgumentException("range lower bound exceeds upper bound"); + } + if (comparison == 0 && !(lowerInclusive && upperInclusive)) { + throw new IllegalArgumentException("range with equal bounds must be closed on both sides"); + } + } + } + + /** {@code [lower, upper)} — the shape almost every time window wants. */ + public static > PgRange closedOpen(T lower, T upper) { + return new PgRange<>(Optional.of(lower), true, Optional.of(upper), false); + } + + /** {@code [lower, upper]}. */ + public static > PgRange closed(T lower, T upper) { + return new PgRange<>(Optional.of(lower), true, Optional.of(upper), true); + } + + /** {@code [lower, )} — open-ended in the future. */ + public static > PgRange atLeast(T lower) { + return new PgRange<>(Optional.of(lower), true, Optional.empty(), false); + } + + /** {@code ( , upper)} — open-ended in the past. */ + public static > PgRange below(T upper) { + return new PgRange<>(Optional.empty(), false, Optional.of(upper), false); + } + + /** {@code (,)} — unbounded on both sides. */ + public static > PgRange unbounded() { + return new PgRange(Optional.empty(), false, Optional.empty(), false); + } + + /** Whether {@code value} falls inside this range, honouring both bracket kinds. */ + public boolean contains(T value) { + Objects.requireNonNull(value, "value"); + if (lower.isPresent()) { + int comparison = value.compareTo(lower.get()); + if (comparison < 0 || (comparison == 0 && !lowerInclusive)) { + return false; + } + } + if (upper.isPresent()) { + int comparison = value.compareTo(upper.get()); + return comparison < 0 || (comparison == 0 && upperInclusive); + } + return true; + } + + /** Whether this range has no lower and no upper endpoint. */ + public boolean isUnbounded() { + return lower.isEmpty() && upper.isEmpty(); + } + + /** The opening bracket PostgreSQL uses for this range's lower bound. */ + public char lowerBracket() { + return lowerInclusive && lower.isPresent() ? '[' : '('; + } + + /** The closing bracket PostgreSQL uses for this range's upper bound. */ + public char upperBracket() { + return upperInclusive && upper.isPresent() ? ']' : ')'; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java new file mode 100644 index 00000000..010a9cf7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.range; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Converts a {@link PgRange} to and from PostgreSQL's range literal (design §8.3). + * + *

The literal is where the bracket lives: {@code + * ["2026-08-11T00:00:00Z","2026-08-11T00:01:00Z")} says the upper endpoint is excluded, and + * dropping that character changes the meaning of every overlap test against the value. Endpoints + * are quoted so that a timestamp containing a comma separator in some locale, or an empty string, + * cannot shift the parse. + * + *

The endpoint functions are supplied by the caller because PostgreSQL's text form is not + * ISO-8601: a {@code tstzrange} comes back as {@code 1970-01-01 00:00:00+00}, with a space rather + * than a {@code T}. An endpoint parser written as {@code Instant::parse} therefore round-trips in + * unit tests, where nothing but this codec has touched the string, and throws the first time a real + * server answers. + * + * @param the endpoint type + */ +public final class PgRangeCodec> { + + private final Function endpointToText; + private final Function endpointFromText; + + public PgRangeCodec(Function endpointToText, Function endpointFromText) { + this.endpointToText = Objects.requireNonNull(endpointToText, "endpointToText"); + this.endpointFromText = Objects.requireNonNull(endpointFromText, "endpointFromText"); + } + + /** Formats the range as the literal PostgreSQL accepts. */ + public String format(PgRange range) { + Objects.requireNonNull(range, "range"); + StringBuilder literal = new StringBuilder(64); + literal.append(range.lowerBracket()); + range.lower().ifPresent(value -> literal.append(quote(endpointToText.apply(value)))); + literal.append(','); + range.upper().ifPresent(value -> literal.append(quote(endpointToText.apply(value)))); + literal.append(range.upperBracket()); + return literal.toString(); + } + + /** + * Parses a literal PostgreSQL produced. + * + * @throws IllegalArgumentException when the literal is not a range this codec can read + */ + public PgRange parse(String literal) { + if (literal == null || literal.length() < 3) { + throw new IllegalArgumentException("invalid postgresql range literal"); + } + if ("empty".equalsIgnoreCase(literal.trim())) { + // PostgreSQL normalises any range that cannot contain a value to the literal `empty`, which + // has no endpoints at all. It is a real value, not a parse failure, and it must not be read + // back as an unbounded range — that would invert the meaning completely. + throw new IllegalArgumentException("an empty postgresql range has no endpoints to map"); + } + char lowerBracket = literal.charAt(0); + char upperBracket = literal.charAt(literal.length() - 1); + if ((lowerBracket != '[' && lowerBracket != '(') + || (upperBracket != ']' && upperBracket != ')')) { + throw new IllegalArgumentException("invalid postgresql range brackets"); + } + String body = literal.substring(1, literal.length() - 1); + int separator = separatorIndex(body); + Optional lower = endpoint(body.substring(0, separator)); + Optional upper = endpoint(body.substring(separator + 1)); + return new PgRange<>(lower, lowerBracket == '[', upper, upperBracket == ']'); + } + + private Optional endpoint(String raw) { + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + return Optional.of(endpointFromText.apply(unquote(trimmed))); + } + + /** + * Finds the endpoint separator, ignoring commas inside quoted endpoints. + * + *

A naive {@code indexOf(',')} splits in the wrong place the first time an endpoint value + * legitimately contains a comma, and produces a range whose bounds are silently wrong rather than + * an error. + */ + private static int separatorIndex(String body) { + boolean quoted = false; + for (int index = 0; index < body.length(); index++) { + char character = body.charAt(index); + if (character == '"') { + quoted = !quoted; + } else if (character == ',' && !quoted) { + return index; + } + } + throw new IllegalArgumentException("postgresql range literal has no endpoint separator"); + } + + private static String quote(String value) { + return '"' + value.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + private static String unquote(String value) { + if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') { + return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); + } + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeJdbcType.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeJdbcType.java new file mode 100644 index 00000000..a8b05b03 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeJdbcType.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.range; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import org.hibernate.type.descriptor.ValueBinder; +import org.hibernate.type.descriptor.ValueExtractor; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.BasicBinder; +import org.hibernate.type.descriptor.jdbc.BasicExtractor; +import org.hibernate.type.descriptor.jdbc.JdbcType; + +/** + * Binds and extracts a PostgreSQL range column through its literal representation (design §8.3). + * + *

The value is sent as {@link Types#OTHER} carrying the range literal. That is what lets the + * server parse it as {@code tstzrange}, {@code daterange}, or any other range type without this + * class having to know which one the column is — the range literal is the same shape for + * all of them, and the column's declared type decides the parse. + * + *

Sending it as a plain string instead would make the server see {@code text}, and the insert + * fails with a cast error rather than storing a range. + */ +public class PgRangeJdbcType implements JdbcType { + + private static final long serialVersionUID = 1L; + + /** The shared instance; the type is stateless. */ + public static final PgRangeJdbcType INSTANCE = new PgRangeJdbcType(); + + @Override + public int getJdbcTypeCode() { + return Types.OTHER; + } + + @Override + public ValueBinder getBinder(JavaType javaType) { + return new BasicBinder(javaType, this) { + @Override + protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) + throws SQLException { + st.setObject(index, javaType.unwrap(value, String.class, options), Types.OTHER); + } + + @Override + protected void doBind(CallableStatement st, X value, String name, WrapperOptions options) + throws SQLException { + st.setObject(name, javaType.unwrap(value, String.class, options), Types.OTHER); + } + }; + } + + @Override + public ValueExtractor getExtractor(JavaType javaType) { + return new BasicExtractor(javaType, this) { + @Override + protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) + throws SQLException { + return javaType.wrap(rs.getString(paramIndex), options); + } + + @Override + protected X doExtract(CallableStatement statement, int index, WrapperOptions options) + throws SQLException { + return javaType.wrap(statement.getString(index), options); + } + + @Override + protected X doExtract(CallableStatement statement, String name, WrapperOptions options) + throws SQLException { + return javaType.wrap(statement.getString(name), options); + } + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PostgreSqlRangeQuerySupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PostgreSqlRangeQuerySupport.java new file mode 100644 index 00000000..84dbb0b9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PostgreSqlRangeQuerySupport.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.range; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Runs registered range overlap and containment queries with the range bound as a parameter (design + * §8.3). + * + *

Overlap ({@code &&}) and containment ({@code @>}) are separate registrations rather than a + * flag, because they mean different things and are usually served by different predicates on + * different indexes. Collapsing them into one parameterised operator would put an operator name + * into the SQL text from a caller-supplied value. + * + * @param the endpoint type + */ +public final class PostgreSqlRangeQuerySupport> { + + /** The parameter name every registered range statement binds. */ + public static final String RANGE_PARAMETER = "range"; + + private final EntityManager entityManager; + private final PgRangeCodec codec; + private final Map registeredStatements; + + /** + * @param registeredStatements the fixed SQL for each registered range query, keyed by query name + */ + public PostgreSqlRangeQuerySupport( + EntityManager entityManager, + PgRangeCodec codec, + Map registeredStatements) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.registeredStatements = + Map.copyOf(Objects.requireNonNull(registeredStatements, "registeredStatements")); + } + + /** + * Runs the registered statement {@code queryName} with {@code range} bound as its parameter. + * + * @throws IllegalArgumentException when the query name is not registered + */ + public List execute(String queryName, PgRange range) { + Objects.requireNonNull(queryName, "queryName"); + Objects.requireNonNull(range, "range"); + String sql = registeredStatements.get(queryName); + if (sql == null) { + throw new IllegalArgumentException("unregistered range query: " + queryName); + } + Query query = entityManager.createNativeQuery(sql); + query.setParameter(RANGE_PARAMETER, codec.format(range)); + return query.getResultList(); + } + + /** The registered range query names. */ + public Set registeredQueries() { + return registeredStatements.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/NativeWriteName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/NativeWriteName.java new file mode 100644 index 00000000..4497dd0e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/NativeWriteName.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +import java.util.regex.Pattern; + +/** + * The registered identity of a native write statement (design §8.3). + * + *

Native writes are registered by name so the SQL is fixed at deployment time. The name selects + * a statement; it is never part of one. + */ +public record NativeWriteName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public NativeWriteName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid native write name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/PostgreSqlUpsertExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/PostgreSqlUpsertExecutor.java new file mode 100644 index 00000000..d31c9342 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/PostgreSqlUpsertExecutor.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +/** + * Executes one registered {@code INSERT ... ON CONFLICT ... RETURNING} statement (design §8.3). + * + *

An upsert is the correct answer to a create race precisely because it makes the database + * decide. The alternative — read, branch, then write — cannot be made correct by any amount of + * application logic, because another transaction can commit between the read and the write. + * + * @param the command type carrying the bound parameter values + * @param the projected value the statement returns + */ +public interface PostgreSqlUpsertExecutor { + + /** + * Runs the registered statement for {@code operation}. + * + *

Must run inside the caller's transaction, and implementations must reconcile the Persistence + * Context afterwards: a native write is invisible to the first-level cache, so a managed entity + * loaded before the upsert is stale the moment it completes. + */ + UpsertResult execute(NativeWriteName operation, C command); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java new file mode 100644 index 00000000..145baeaa --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Runs registered {@code INSERT ... ON CONFLICT ... RETURNING} statements (design §8.3). + * + *

Two things happen around the statement that are easy to leave out and expensive to leave out. + * + *

First, the Persistence Context is flushed before the write. A native statement goes straight + * to the database, so any pending managed change that has not been flushed would be written + * after the upsert and would overwrite it. + * + *

Second, the context is cleared afterwards. The upsert changed rows the context may already + * hold, and Hibernate has no way to know: a managed entity loaded before the call would keep + * serving pre-upsert values, and flushing it later would write them back. + * + * @param the command type carrying the values to bind + * @param the projected value the statement returns + */ +public final class RegisteredPostgreSqlUpsertExecutor + implements PostgreSqlUpsertExecutor { + + private final EntityManager entityManager; + private final Map> statements; + + public RegisteredPostgreSqlUpsertExecutor( + EntityManager entityManager, + Map> statements) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + this.statements = Map.copyOf(Objects.requireNonNull(statements, "statements")); + } + + @Override + public UpsertResult execute(NativeWriteName operation, C command) { + Objects.requireNonNull(operation, "operation"); + RegisteredUpsertStatement statement = statements.get(operation); + if (statement == null) { + throw new IllegalArgumentException("unregistered native write: " + operation.value()); + } + + entityManager.flush(); + Query query = entityManager.createNativeQuery(statement.sql()); + statement.binder().accept(query, command); + List rows = query.getResultList(); + entityManager.clear(); + + if (rows.isEmpty()) { + // ON CONFLICT DO NOTHING returns no row. The write did not happen, and reporting it as an + // update would tell the caller a row exists in a state it does not. + return UpsertResult.undetermined(null); + } + Object[] columns = toColumns(rows.get(0)); + R projection = statement.projector().apply(columns); + return new UpsertResult<>(dispositionOf(statement, columns), projection); + } + + /** The registered native write names this executor can run. */ + public Set registeredWrites() { + return statements.keySet(); + } + + private static WriteDisposition dispositionOf( + RegisteredUpsertStatement statement, Object[] columns) { + if (!statement.reportsDisposition() || statement.dispositionColumnIndex() >= columns.length) { + return WriteDisposition.UNDETERMINED; + } + Object flag = columns[statement.dispositionColumnIndex()]; + if (flag instanceof Boolean inserted) { + return inserted ? WriteDisposition.INSERTED : WriteDisposition.UPDATED; + } + return WriteDisposition.UNDETERMINED; + } + + private static Object[] toColumns(Object row) { + return row instanceof Object[] columns ? columns : new Object[] {row}; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredUpsertStatement.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredUpsertStatement.java new file mode 100644 index 00000000..b307afb6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/RegisteredUpsertStatement.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +import jakarta.persistence.Query; +import java.util.Locale; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * One upsert registered at deployment time (design §8.3). + * + *

The SQL is fixed here; the caller supplies only values, through {@code binder}. The + * constructor refuses a statement that is not an {@code ON CONFLICT} upsert, because the whole + * point of this registry is that the conflict handling is the database's and not a read-then-write + * dance in the application. + * + * @param dispositionColumnIndex the 0-based column of a boolean "was inserted" flag, or {@code -1} + * when the statement cannot report it. PostgreSQL exposes it as {@code (xmax = 0) AS inserted} + * in the {@code RETURNING} list; a statement that omits it must report {@link + * WriteDisposition#UNDETERMINED} rather than assume one. + * @param the command type carrying the values to bind + * @param the projected value the statement returns + */ +public record RegisteredUpsertStatement( + String sql, + UpsertConflictTarget conflictTarget, + BiConsumer binder, + Function projector, + int dispositionColumnIndex) { + + public RegisteredUpsertStatement { + Objects.requireNonNull(sql, "sql"); + Objects.requireNonNull(conflictTarget, "conflictTarget"); + Objects.requireNonNull(binder, "binder"); + Objects.requireNonNull(projector, "projector"); + String normalized = sql.toLowerCase(Locale.ROOT); + if (!normalized.contains("insert into") || !normalized.contains("on conflict")) { + throw new IllegalArgumentException("a registered upsert must be an INSERT ... ON CONFLICT"); + } + if (dispositionColumnIndex < -1) { + throw new IllegalArgumentException("disposition column index must be -1 or a column index"); + } + } + + /** Whether this statement can report insert-versus-update. */ + public boolean reportsDisposition() { + return dispositionColumnIndex >= 0; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertConflictTarget.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertConflictTarget.java new file mode 100644 index 00000000..b58a3688 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertConflictTarget.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The registered conflict target and update column set of one upsert (design §8.3). + * + *

Both are fixed at registration and validated as identifiers. {@code ON CONFLICT} targets and + * column lists cannot be bound as parameters — they are part of the statement — so accepting them + * from a caller would mean building SQL from input. Registering them keeps the statement constant + * and makes the blast radius of each upsert reviewable. + * + *

An empty update column set is rejected. {@code ON CONFLICT DO UPDATE SET} with nothing to set + * is not valid SQL, and the intent it usually stands for — {@code DO NOTHING} — is a different + * statement with a different return, so it must be registered as one. + */ +public record UpsertConflictTarget(List conflictColumns, List updateColumns) { + + /** Unquoted PostgreSQL identifiers only; anything else is not a column this may target. */ + private static final Pattern IDENTIFIER = Pattern.compile("[a-z_][a-z0-9_]{0,62}"); + + public UpsertConflictTarget { + conflictColumns = List.copyOf(Objects.requireNonNull(conflictColumns, "conflictColumns")); + updateColumns = List.copyOf(Objects.requireNonNull(updateColumns, "updateColumns")); + if (conflictColumns.isEmpty()) { + throw new IllegalArgumentException("an upsert requires at least one conflict column"); + } + if (updateColumns.isEmpty()) { + throw new IllegalArgumentException("an upsert requires at least one update column"); + } + conflictColumns.forEach(UpsertConflictTarget::requireIdentifier); + updateColumns.forEach(UpsertConflictTarget::requireIdentifier); + } + + private static void requireIdentifier(String column) { + if (column == null || !IDENTIFIER.matcher(column).matches()) { + throw new IllegalArgumentException("invalid upsert column identifier: " + column); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertResult.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertResult.java new file mode 100644 index 00000000..7ed6a25b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/UpsertResult.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +import java.util.Objects; +import java.util.Optional; + +/** + * What one registered upsert produced (design §8.3). + * + * @param the projected value the statement returned + */ +public record UpsertResult(WriteDisposition disposition, R value) { + + public UpsertResult { + Objects.requireNonNull(disposition, "disposition"); + } + + /** A row that did not previously exist. */ + public static UpsertResult inserted(R value) { + return new UpsertResult<>(WriteDisposition.INSERTED, value); + } + + /** A row that existed and was updated by the conflict action. */ + public static UpsertResult updated(R value) { + return new UpsertResult<>(WriteDisposition.UPDATED, value); + } + + /** A statement that cannot say which happened. */ + public static UpsertResult undetermined(R value) { + return new UpsertResult<>(WriteDisposition.UNDETERMINED, value); + } + + /** The returned projection, when the statement returned one. */ + public Optional projection() { + return Optional.ofNullable(value); + } + + /** Whether the row was newly created. */ + public boolean created() { + return disposition == WriteDisposition.INSERTED; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/WriteDisposition.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/WriteDisposition.java new file mode 100644 index 00000000..daf7e619 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/write/WriteDisposition.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.write; + +/** + * What an upsert actually did (design §8.3). + * + *

The distinction is business-visible: "created" and "already existed and was updated" usually + * emit different events, and an upsert that reports only "ok" forces the caller to run a second + * query to find out which happened — the very race the upsert was chosen to avoid. + */ +public enum WriteDisposition { + + /** The row did not exist and was inserted. */ + INSERTED, + + /** The row existed and the conflict action updated it. */ + UPDATED, + + /** The statement cannot report which happened, and the caller must not assume. */ + UNDETERMINED +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/PredicatePolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/PredicatePolicy.java new file mode 100644 index 00000000..3ac08211 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/PredicatePolicy.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.querydsl; + +import com.querydsl.core.types.Predicate; +import java.util.Objects; + +/** + * The bounds a Querydsl query must satisfy before it runs (design §23.2). + * + *

Querydsl's fluent builder makes an unbounded query easy to write by accident: every optional + * filter is conditional, so a request with no filters produces {@code selectFrom(order)} with no + * {@code where} at all. It reads like a query and behaves like a table dump. + * + *

User-supplied path expressions are refused outright. A path decides which column is compared, + * so accepting one from a request lets a caller filter on — and, through error messages, probe — + * columns the API never exposed. + */ +public final class PredicatePolicy { + + /** The explicit opt-in a caller must pass to run a predicate-free query. */ + public static final String ALLOW_UNBOUNDED_TOKEN = "allow-unbounded-scan"; + + private PredicatePolicy() {} + + /** + * Fails when a collection query would run without a predicate and without an explicit opt-in. + * + * @throws IllegalArgumentException when the query would be unbounded + */ + public static void requireBounded(Predicate predicate, QueryPage page) { + Objects.requireNonNull(page, "page"); + if (predicate == null && !page.allowsUnboundedScan()) { + throw new IllegalArgumentException( + "a collection query requires a bounded predicate, or the explicit '" + + ALLOW_UNBOUNDED_TOKEN + + "' token"); + } + } + + /** + * Fails when a path expression came from outside the application's own code. + * + * @throws IllegalArgumentException when the expression is not a registered path + */ + public static void requireRegisteredPath( + String pathExpression, java.util.Set registered) { + Objects.requireNonNull(registered, "registered"); + if (pathExpression == null || !registered.contains(pathExpression)) { + throw new IllegalArgumentException( + "path '" + pathExpression + "' is not a registered query path"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QueryPage.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QueryPage.java new file mode 100644 index 00000000..4203045d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QueryPage.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.persistence.querydsl; + +/** The page bound and scan opt-in for one Querydsl collection query (design §23.2). */ +public record QueryPage(long offset, int size, boolean allowUnboundedScan) { + + /** The largest page a Querydsl collection query may request. */ + public static final int MAX_SIZE = 500; + + public QueryPage { + if (offset < 0L) { + throw new IllegalArgumentException("query offset must not be negative"); + } + if (size < 1 || size > MAX_SIZE) { + throw new IllegalArgumentException("query page size must be between 1 and " + MAX_SIZE); + } + } + + /** A bounded page requiring a predicate. */ + public static QueryPage of(long offset, int size) { + return new QueryPage(offset, size, false); + } + + /** A bounded page that explicitly permits a predicate-free scan. */ + public static QueryPage unboundedScan(long offset, int size) { + return new QueryPage(offset, size, true); + } + + /** Whether this page permits running without a predicate. */ + public boolean allowsUnboundedScan() { + return allowUnboundedScan; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java new file mode 100644 index 00000000..1e659368 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.querydsl; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.Objects; + +/** + * The bounded entry point for Querydsl collection queries (design §23.2). + * + *

Querydsl is an Advanced opt-in and is {@code compileOnly} for this leaf, so the Stable runtime + * classpath never carries it. A deployment that wants it adds the artifact; one that does not is + * unaffected by the existence of this class. + * + *

Every query built here is bounded and named. The bound is checked before the query is + * assembled rather than after, so a predicate-free query never reaches the database at all, and the + * name is attached as a SQL comment so the statement is identifiable on the server. + */ +public final class QuerydslJpaSupport { + + /** The Hibernate hint that carries a comment into the generated SQL. */ + private static final String COMMENT_HINT = "org.hibernate.comment"; + + private final JPAQueryFactory queryFactory; + + public QuerydslJpaSupport(JPAQueryFactory queryFactory) { + this.queryFactory = Objects.requireNonNull(queryFactory, "queryFactory"); + } + + /** + * Builds a bounded, named collection query. + * + * @throws IllegalArgumentException when the query would be unbounded + */ + public JPAQuery select( + QueryName name, EntityPath root, Predicate predicate, QueryPage page) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(root, "root"); + Objects.requireNonNull(page, "page"); + PredicatePolicy.requireBounded(predicate, page); + + JPAQuery query = queryFactory.selectFrom(root); + if (predicate != null) { + query = query.where(predicate); + } + return query.offset(page.offset()).limit(page.size()).setHint(COMMENT_HINT, name.value()); + } + + /** The factory this support class builds queries from. */ + public JPAQueryFactory queryFactory() { + return queryFactory; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabasePrivilegeReport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabasePrivilegeReport.java new file mode 100644 index 00000000..f9a8ea70 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabasePrivilegeReport.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.security; + +import java.util.Objects; + +/** + * What the runtime connection reports about itself (design §36). + * + *

Deliberately narrow: the connected role, the schema search path, and two privilege answers. + * There is no JDBC URL, no password, and no host — this record is designed to be safe to surface in + * an actuator endpoint, and anything that would have to be masked before publishing does not belong + * in it at all. + */ +public record DatabasePrivilegeReport( + String currentUser, String searchPath, boolean canCreateInSchema, boolean canCreateInDatabase) { + + public DatabasePrivilegeReport { + Objects.requireNonNull(currentUser, "currentUser"); + Objects.requireNonNull(searchPath, "searchPath"); + } + + /** Whether the connected role holds any object-creation privilege. */ + public boolean holdsCreatePrivilege() { + return canCreateInSchema || canCreateInDatabase; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java new file mode 100644 index 00000000..64b9723e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.security; + +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * What the runtime database role is allowed to be (design §36). + * + *

The runtime role has DML and nothing else. Separating it from the migration role 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. + * + *

{@code CREATE} on the schema is refused for the same reason, and it also closes the {@code + * search_path} shadowing route: a role that cannot create objects cannot plant one that shadows a + * real table or function. + */ +public record DatabaseRolePolicy(Set allowedRoles, SearchPathPolicy searchPathPolicy) { + + public DatabaseRolePolicy { + allowedRoles = + Set.copyOf(Objects.requireNonNull(allowedRoles, "allowedRoles")).stream() + .map(role -> role.toLowerCase(Locale.ROOT)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Objects.requireNonNull(searchPathPolicy, "searchPathPolicy"); + if (allowedRoles.isEmpty()) { + throw new IllegalArgumentException("a role policy requires at least one allowed role"); + } + } + + /** + * Fails when the connection does not satisfy the policy. + * + * @throws IllegalStateException naming which rule the connection broke + */ + public void requireSafe(DatabasePrivilegeReport report) { + Objects.requireNonNull(report, "report"); + String currentUser = report.currentUser().toLowerCase(Locale.ROOT); + if (!allowedRoles.contains(currentUser)) { + throw new IllegalStateException( + "runtime role '" + report.currentUser() + "' is not an approved runtime role"); + } + if (report.canCreateInSchema()) { + throw new IllegalStateException( + "runtime role '" + + report.currentUser() + + "' holds CREATE on the application schema; Flyway owns schema change, and a" + + " runtime role that can create objects can also shadow existing ones"); + } + if (report.canCreateInDatabase()) { + throw new IllegalStateException( + "runtime role '" + report.currentUser() + "' holds CREATE on the database"); + } + searchPathPolicy.requireSafe(report.searchPath()); + } + + /** Whether a report satisfies this policy. */ + public boolean isSafe(DatabasePrivilegeReport report) { + try { + requireSafe(report); + return true; + } catch (IllegalStateException unsafe) { + return false; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java new file mode 100644 index 00000000..21267dd5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.persistence.security; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Objects; +import javax.sql.DataSource; + +/** + * Asks the database what the runtime connection can actually do (design §36). + * + *

The question is answered by the server, not by configuration. A role's effective privileges + * come from direct grants, inherited role memberships, {@code PUBLIC} grants, and schema ownership, + * and no reading of a deployment manifest reconstructs that combination reliably. {@code + * has_schema_privilege} does. + * + *

The verification runs at startup and fails closed. Discovering after an incident that the + * application's own credential could drop tables is discovering it too late. + */ +public final class PostgreSqlRuntimeRoleVerifier { + + private static final String PRIVILEGE_QUERY = + """ + select current_user as current_user_name, + current_setting('search_path') as search_path, + has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema, + has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database + """; + + /** Reads the connection's own identity and privileges. */ + public DatabasePrivilegeReport verify(DataSource dataSource) { + Objects.requireNonNull(dataSource, "dataSource"); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(PRIVILEGE_QUERY); + ResultSet rows = statement.executeQuery()) { + if (!rows.next()) { + throw new IllegalStateException("the database returned no privilege report"); + } + return new DatabasePrivilegeReport( + rows.getString("current_user_name"), + rows.getString("search_path"), + rows.getBoolean("create_on_schema"), + rows.getBoolean("create_on_database")); + } catch (SQLException failure) { + throw new IllegalStateException("the runtime role could not be verified", failure); + } + } + + /** + * Verifies the connection and fails when it does not satisfy the policy. + * + * @throws IllegalStateException when the runtime role is not safe to run with + */ + public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) { + Objects.requireNonNull(policy, "policy"); + policy.requireSafe(verify(dataSource)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java new file mode 100644 index 00000000..53851016 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.security; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * Which schemas the runtime role's {@code search_path} may contain (design §36). + * + *

{@code search_path} decides which schema an unqualified name resolves to. If it contains a + * schema an attacker can create objects in — classically {@code public}, where {@code CREATE} is + * granted broadly on older PostgreSQL versions — then a table, function, or operator planted there + * can shadow the real one, and the application executes it without noticing. + * + *

The policy is an allowlist for that reason: the question is not "does the path look + * reasonable" but "is every entry a schema only trusted roles can write to". + */ +public record SearchPathPolicy(List allowedSchemas) { + + public SearchPathPolicy { + allowedSchemas = + List.copyOf(Objects.requireNonNull(allowedSchemas, "allowedSchemas")).stream() + .map(schema -> schema.toLowerCase(Locale.ROOT).trim()) + .toList(); + if (allowedSchemas.isEmpty()) { + throw new IllegalArgumentException("a search_path policy requires at least one schema"); + } + } + + /** + * Fails when the reported {@code search_path} contains a schema outside the allowlist. + * + * @param reported the raw value of {@code current_setting('search_path')} + */ + public void requireSafe(String reported) { + Objects.requireNonNull(reported, "reported"); + for (String entry : reported.split(",", -1)) { + String schema = entry.trim().replace("\"", "").toLowerCase(Locale.ROOT); + if (schema.isEmpty() || schema.startsWith("$")) { + // `$user` resolves to a schema named after the connected role, which only that role owns. + continue; + } + if (!allowedSchemas.contains(schema)) { + throw new IllegalStateException( + "search_path contains unapproved schema '" + + schema + + "'; approved schemas are " + + allowedSchemas); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java new file mode 100644 index 00000000..3de1d793 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The registered entity graphs a use case may select (design §25.1). + * + *

Graphs are resolved by name from a fixed catalog. The alternative — building a graph from + * attribute names supplied per request — hands the caller control over how deep the object graph is + * hydrated, which is a load-amplification lever long before it is a convenience. + * + *

Each entry is a factory rather than a stored {@code EntityGraph}, because a graph belongs to + * the {@code EntityManager} that created it and cannot be shared between them. + */ +public final class EntityGraphCatalog { + + private final Map>> + byName; + + public EntityGraphCatalog( + Map>> graphs) { + Objects.requireNonNull(graphs, "graphs"); + Map>> copy = + new LinkedHashMap<>(); + graphs.forEach( + (name, factory) -> { + Objects.requireNonNull(name, "fetch plan name"); + Objects.requireNonNull(factory, "entity graph factory"); + copy.put(name, factory); + }); + this.byName = Map.copyOf(copy); + } + + /** An empty catalog; every fetch plan lookup fails. */ + public static EntityGraphCatalog empty() { + return new EntityGraphCatalog(Map.of()); + } + + /** + * The graph registered under {@code name}, created against {@code entityManager}. + * + * @throws IllegalArgumentException when the fetch plan is not registered + */ + public EntityGraph require(FetchPlanName name, EntityManager entityManager) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(entityManager, "entityManager"); + var factory = byName.get(name); + if (factory == null) { + throw new IllegalArgumentException( + "unregistered fetch plan '" + name.value() + "'; registered plans are " + names()); + } + return factory.apply(entityManager); + } + + /** Whether a fetch plan is registered. */ + public boolean contains(FetchPlanName name) { + return byName.containsKey(name); + } + + /** The registered fetch plan names. */ + public Set names() { + return byName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityManagerAccess.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityManagerAccess.java new file mode 100644 index 00000000..6f75e4dc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityManagerAccess.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import jakarta.persistence.EntityManager; + +/** + * The narrow seam through which a domain-owned repository fragment reaches its {@link + * EntityManager} (design §23.3). + * + *

The platform deliberately does not hand an {@code EntityManager} to arbitrary code. Exposing + * it widely is how {@code persist}, {@code merge}, and {@code createQuery} calls end up in + * application services and controllers, and at that point the transaction boundary and the fetch + * plan stop being decisions anyone made. + */ +@FunctionalInterface +public interface EntityManagerAccess { + + /** The entity manager bound to the current transaction. */ + EntityManager entityManager(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java new file mode 100644 index 00000000..ba8a5975 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.util.Objects; + +/** + * Applies a registered entity graph to one query (design §25.1). + * + *

Per-query fetch selection is the design's answer to N+1, and it is chosen over the obvious + * alternative for a specific reason: making an association {@code EAGER} in the mapping fixes the + * one use case that needed it and imposes the extra join on every other query against that entity, + * including the ones that only wanted the id. + * + *

{@code fetchgraph} and {@code loadgraph} are separate methods rather than a flag because they + * mean genuinely different things. A fetch graph is exhaustive — attributes outside it are lazy + * whatever the mapping says — while a load graph is additive, leaving unlisted attributes at their + * mapped default. Choosing the wrong one produces either missing data or the amplification the + * graph was meant to avoid. + */ +public final class FetchPlanApplier { + + /** JPA's exhaustive fetch-graph hint. */ + public static final String FETCH_GRAPH_HINT = "jakarta.persistence.fetchgraph"; + + /** JPA's additive load-graph hint. */ + public static final String LOAD_GRAPH_HINT = "jakarta.persistence.loadgraph"; + + private final EntityGraphCatalog catalog; + private final EntityManager entityManager; + + public FetchPlanApplier(EntityGraphCatalog catalog, EntityManager entityManager) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + } + + /** + * Applies {@code name} as an exhaustive fetch graph. + * + * @throws IllegalArgumentException when the fetch plan is not registered + */ + public TypedQuery apply(TypedQuery query, FetchPlanName name) { + Objects.requireNonNull(query, "query"); + EntityGraph graph = catalog.require(name, entityManager); + return query.setHint(FETCH_GRAPH_HINT, graph); + } + + /** + * Applies {@code name} as an additive load graph. + * + * @throws IllegalArgumentException when the fetch plan is not registered + */ + public TypedQuery applyLoadGraph(TypedQuery query, FetchPlanName name) { + Objects.requireNonNull(query, "query"); + EntityGraph graph = catalog.require(name, entityManager); + return query.setHint(LOAD_GRAPH_HINT, graph); + } + + /** The catalog this applier resolves fetch plans against. */ + public EntityGraphCatalog catalog() { + return catalog; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanName.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanName.java new file mode 100644 index 00000000..ea9dd08d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanName.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import java.util.regex.Pattern; + +/** + * The registered name of one use-case fetch plan (design §25.1). + * + *

Fetch plans are named and registered rather than assembled from attribute strings, because an + * entity graph built from request input lets a caller decide how much of the object graph to + * hydrate — which is a denial-of-service control surface as much as a performance one. + */ +public record FetchPlanName(String value) { + + private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public FetchPlanName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid fetch plan name"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java new file mode 100644 index 00000000..c206354f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest; +import dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext; +import jakarta.persistence.TypedQuery; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Executes a keyset page and assembles its slice (design §27.2). + * + *

Exactly {@code size + 1} rows are requested and at most {@code size} are returned. There is no + * count query anywhere in this path, by design: keyset pagination exists because {@code OFFSET n} + * makes the database walk and discard {@code n} rows, and pairing it with a {@code COUNT(*)} would + * put the full scan back that the keyset removed. + */ +public final class JpaKeysetQuerySupport { + + private final KeysetSliceAssembler assembler; + + public JpaKeysetQuerySupport(KeysetSliceAssembler assembler) { + this.assembler = Objects.requireNonNull(assembler, "assembler"); + } + + /** A support instance with the standard slice assembler. */ + public static JpaKeysetQuerySupport standard() { + return new JpaKeysetQuerySupport(new KeysetSliceAssembler()); + } + + /** + * Runs {@code query} as one keyset page. + * + * @param query the ordered, predicate-bounded query; its ordering must end in a unique column + * @param cursorExtractor derives the next cursor from the last returned row + */ + public KeysetSlice slice( + QueryName name, + TypedQuery query, + KeysetPageRequest page, + Function cursorExtractor) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(page, "page"); + Objects.requireNonNull(cursorExtractor, "cursorExtractor"); + + return QueryNameContext.with( + name, + () -> { + List fetched = query.setMaxResults(page.fetchSize()).getResultList(); + return assembler.assemble(fetched, page.size(), cursorExtractor); + }); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java new file mode 100644 index 00000000..d4b6cd34 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; +import java.util.Objects; + +/** + * The base a domain-owned custom repository fragment may extend (design §23.3). + * + *

Note what this class does not have: no {@code save}, no {@code findById}, no {@code + * findAll}, no {@code delete}. It is not a repository and must never become one. Spring Data + * already implements CRUD, and a platform-owned base repository that re-implements it produces the + * failure the design names explicitly — a generic API that every aggregate is forced through, + * whether or not those operations make sense for it, and a single place where one aggregate's + * requirement quietly changes behaviour for all of them. + * + *

What it does provide is the thing a fragment cannot get on its own: a named query. Every query + * built here carries a registered {@link QueryName}, which is what makes it identifiable in metrics + * and traceable in {@code pg_stat_activity}. + */ +public abstract class JpaRepositoryFragmentSupport { + + /** The Hibernate hint that carries a comment into the generated SQL. */ + private static final String COMMENT_HINT = "org.hibernate.comment"; + + private final EntityManager entityManager; + + protected JpaRepositoryFragmentSupport(EntityManager entityManager) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + } + + /** The entity manager bound to the current transaction. */ + protected final EntityManager entityManager() { + return entityManager; + } + + /** A typed JPQL query tagged with its registered name. */ + protected final TypedQuery typedQuery(QueryName name, String jpql, Class resultType) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(jpql, "jpql"); + Objects.requireNonNull(resultType, "resultType"); + return entityManager.createQuery(jpql, resultType).setHint(COMMENT_HINT, name.value()); + } + + /** A typed query for a query registered in the application's catalog. */ + protected final TypedQuery typedQuery(RegisteredQuery query, Class resultType) { + Objects.requireNonNull(query, "query"); + if (query.nativeQuery()) { + throw new IllegalArgumentException( + "query '" + query.name().value() + "' is native; use nativeQuery(..) instead"); + } + return typedQuery(query.name(), query.statement(), resultType); + } + + /** A native query tagged with its registered name. */ + protected final Query nativeQuery(QueryName name, String sql) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(sql, "sql"); + return entityManager.createNativeQuery(sql).setHint(COMMENT_HINT, name.value()); + } + + /** + * Runs {@code work} with {@code name} bound as the statement-inspector identity. + * + *

The hint above names the query for a DBA reading the server; this binding names it for the + * platform's own statistics, which is a different consumer and a different mechanism. + */ + protected final T observing(QueryName name, java.util.function.Supplier work) { + return QueryNameContext.with(name, work); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java new file mode 100644 index 00000000..86eae68a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope; +import dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Consumes a JPA {@code Stream} inside a bounded, always-closed scope (design §27.4). + * + *

The stream never leaves this method. The consumer gets it, the try-with-resources closes it on + * every path — normal return, exception, and early termination — and what comes back is whatever + * the consumer produced. That shape is the point: a stream returned to a caller is a cursor whose + * lifetime nobody owns. + * + *

The row bound is applied with {@code limit}, and rows are counted as they pass so the + * observation records what was actually consumed rather than what was requested. Reactive return + * types are rejected: JPA is blocking, and a publisher assembled here would be subscribed after the + * transaction and the cursor were both gone. + */ +public final class JpaStreamExecutor { + + /** The reactive contracts a blocking stream consumer must not return. */ + private static final Set REACTIVE_TYPES = + Set.of( + "org.reactivestreams.Publisher", + "reactor.core.publisher.Mono", + "reactor.core.publisher.Flux", + "io.reactivex.rxjava3.core.Flowable", + "kotlinx.coroutines.flow.Flow", + "java.util.concurrent.Flow.Publisher"); + + private final QueryObservation observation; + + public JpaStreamExecutor(QueryObservation observation) { + this.observation = Objects.requireNonNull(observation, "observation"); + } + + /** + * Opens the stream, hands it to {@code consumer}, and closes it whatever happens. + * + * @param supplier opens the stream; called only after the transaction guard passes + * @param consumer consumes the bounded stream and produces the result + */ + public R consume( + QueryName query, + ScrollPolicy policy, + Supplier> supplier, + Function, R> consumer) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(supplier, "supplier"); + Objects.requireNonNull(consumer, "consumer"); + JpaStreamScope.requireActiveReadOnly(); + + return QueryNameContext.with( + query, + () -> { + AtomicLong rows = new AtomicLong(); + try (QueryScope scope = observation.start(query)) { + try (Stream stream = supplier.get()) { + R result = + consumer.apply( + stream.limit(policy.maxRows()).peek(row -> rows.incrementAndGet())); + rejectReactiveResult(result); + scope.rows(rows.get()); + return result; + } catch (RuntimeException failure) { + scope.rows(rows.get()); + scope.failure(failure); + throw failure; + } + } + }); + } + + /** + * Refuses a reactive result from a blocking stream consumer. + * + *

Returning a publisher here compiles and then fails at runtime in the worst way: the + * subscriber runs after the stream is closed and the transaction is gone, so the symptom is a + * closed-cursor error far from the code that caused it. + * + *

The whole type hierarchy is inspected, not just the concrete class's own package. A class + * that implements {@code org.reactivestreams.Publisher} is a publisher wherever it happens to be + * declared, so a check matching only {@code reactor.*} or {@code org.reactivestreams.*} class + * names waves through every application-declared implementation — which is exactly what a + * hand-rolled adapter produces. + * + *

Matching by name rather than by {@code instanceof} keeps Reactor and Reactive Streams off + * this blocking module's compile classpath. + */ + private static void rejectReactiveResult(Object result) { + if (result == null) { + return; + } + reactiveTypeOf(result.getClass()) + .ifPresent( + type -> { + throw new IllegalStateException( + "a JPA stream consumer must not return " + + result.getClass().getName() + + " (a " + + type + + "): JPA is blocking, and the publisher would be subscribed after the" + + " cursor and transaction closed"); + }); + } + + /** The first reactive contract in {@code candidate}'s hierarchy, when there is one. */ + private static Optional reactiveTypeOf(Class candidate) { + for (Class current = candidate; current != null; current = current.getSuperclass()) { + if (REACTIVE_TYPES.contains(current.getName())) { + return Optional.of(current.getName()); + } + for (Class implemented : current.getInterfaces()) { + Optional reactive = reactiveTypeOf(implemented); + if (reactive.isPresent()) { + return reactive; + } + } + } + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamScope.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamScope.java new file mode 100644 index 00000000..430ef502 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamScope.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * The guard every streaming read passes through (design §27.4). + * + *

A JPA {@code Stream} is a live cursor: it holds a JDBC {@code ResultSet}, which holds a + * statement, which holds the connection. All three are released only when the stream closes and the + * transaction ends. Returning one past the transaction boundary — from a repository to a + * controller, or out of a service method — leaves that chain open until the pool notices, which is + * a connection leak that presents as unrelated timeouts elsewhere. + * + *

Requiring a read-only transaction is not cosmetic either. Streaming inside a write transaction + * keeps a write connection pinned for the whole traversal, which is the slowest possible way to + * hold the most contended resource. + */ +public final class JpaStreamScope { + + private JpaStreamScope() {} + + /** + * Fails unless an actual read-only transaction is active. + * + * @throws IllegalStateException when no transaction is active, or the active one is a write + */ + public static void requireActiveReadOnly() { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "streaming requires an active transaction; without one the cursor closes after the first" + + " statement and the connection is returned mid-traversal"); + } + if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + throw new IllegalStateException( + "streaming requires a read-only transaction; a write transaction pins a write connection" + + " for the whole traversal"); + } + } + + /** Whether a read-only transaction is currently active. */ + public static boolean readOnlyTransactionActive() { + return TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isCurrentTransactionReadOnly(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java new file mode 100644 index 00000000..94d9f0e7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Builds the lexicographic "after this cursor" predicate for a keyset scan (design §27.2). + * + *

The predicate must be lexicographic, not a conjunction. For an ordering of {@code (createdAt, + * id)}, "after {@code (t, x)}" is + * + *

{@code createdAt < t OR (createdAt = t AND id < x)}
+ * + *

and not {@code createdAt <= t AND id < x}. The second reads plausibly and is wrong: it drops + * every row with an earlier {@code createdAt} but a larger {@code id}, which silently deletes rows + * from the middle of the result set. + * + *

The final term must be a unique column. Without one, two rows can compare equal on every term + * and the scan either loops on them or skips past both. + */ +public final class KeysetPredicateBuilder { + + private KeysetPredicateBuilder() {} + + /** + * Builds the strict lexicographic comparison for the ordering terms. + * + * @param terms the ordering expressions and the cursor value for each, in ordering sequence; the + * last must be the unique tie-breaker + * @param direction the scan direction, which decides whether the comparison is {@code <} or + * {@code >} + */ + public static > Predicate after( + CriteriaBuilder builder, List> terms, SortDirection direction) { + Objects.requireNonNull(builder, "builder"); + Objects.requireNonNull(terms, "terms"); + Objects.requireNonNull(direction, "direction"); + if (terms.isEmpty()) { + throw new IllegalArgumentException("a keyset predicate requires at least one ordering term"); + } + if (terms.size() < 2) { + throw new IllegalArgumentException( + "a keyset predicate requires a unique tie-breaker as its final term"); + } + + List alternatives = new ArrayList<>(terms.size()); + for (int index = 0; index < terms.size(); index++) { + List conjunction = new ArrayList<>(index + 1); + for (int equalIndex = 0; equalIndex < index; equalIndex++) { + KeysetTerm equalTerm = terms.get(equalIndex); + conjunction.add(builder.equal(equalTerm.expression(), equalTerm.value())); + } + KeysetTerm strictTerm = terms.get(index); + conjunction.add(strict(builder, strictTerm, direction)); + alternatives.add(builder.and(conjunction.toArray(new Predicate[0]))); + } + return builder.or(alternatives.toArray(new Predicate[0])); + } + + private static > Predicate strict( + CriteriaBuilder builder, KeysetTerm term, SortDirection direction) { + Expression expression = term.expression(); + return direction.ascending() + ? builder.greaterThan(expression, term.value()) + : builder.lessThan(expression, term.value()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssembler.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssembler.java new file mode 100644 index 00000000..6e1265b4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssembler.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Turns a {@code size + 1} fetch into a page plus a next cursor (design §27.2). + * + *

The extra row is the entire mechanism for answering "is there more?" without a count query. A + * {@code COUNT(*)} over the same predicate is a second full scan, and on a moving data set its + * answer is already stale when it returns — the fetched-one-extra trick answers the question the + * caller actually has, exactly. + * + *

The next cursor is taken from the last returned row, not the extra one. Using the + * extra row would skip it on the following page. + */ +public final class KeysetSliceAssembler { + + /** + * Assembles the slice. + * + * @param fetched the rows actually read, expected to be at most {@code requestedSize + 1} + * @param requestedSize the page size the caller asked for + * @param cursorExtractor derives the cursor for a returned row + */ + public KeysetSlice assemble( + List fetched, int requestedSize, Function cursorExtractor) { + Objects.requireNonNull(fetched, "fetched"); + Objects.requireNonNull(cursorExtractor, "cursorExtractor"); + if (requestedSize < 1) { + throw new IllegalArgumentException("requested size must be positive"); + } + boolean hasNext = fetched.size() > requestedSize; + List items = List.copyOf(fetched.subList(0, Math.min(fetched.size(), requestedSize))); + if (!hasNext || items.isEmpty()) { + return new KeysetSlice<>(items, Optional.empty(), false); + } + C next = cursorExtractor.apply(items.get(items.size() - 1)); + return new KeysetSlice<>(items, Optional.of(next), true); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetTerm.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetTerm.java new file mode 100644 index 00000000..e51851cb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetTerm.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import jakarta.persistence.criteria.Expression; +import java.util.Objects; + +/** + * One ordering term of a keyset scan: the expression it sorts by and the cursor value to compare + * against (design §27.2). + * + * @param the comparable term type + */ +public record KeysetTerm>(Expression expression, T value) { + + public KeysetTerm { + Objects.requireNonNull(expression, "expression"); + Objects.requireNonNull(value, "value"); + } + + /** A term over the supplied expression and cursor value. */ + public static > KeysetTerm of(Expression expression, T value) { + return new KeysetTerm<>(expression, value); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/RegisteredQuery.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/RegisteredQuery.java new file mode 100644 index 00000000..cd5c2612 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/RegisteredQuery.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.Objects; + +/** + * A JPQL or native statement registered under a {@link QueryName} (design §23.3). + * + *

Registration is what makes the query nameable in telemetry and reviewable in one place. The + * constructor also refuses a statement built by concatenation: a query whose text is assembled from + * values is both unbounded as a metric identity and, for the native case, an injection site. + */ +public record RegisteredQuery(QueryName name, String statement, boolean nativeQuery) { + + public RegisteredQuery { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(statement, "statement"); + if (statement.isBlank()) { + throw new IllegalArgumentException("registered query statement must not be blank"); + } + if (statement.contains("' +") || statement.contains("\" +")) { + throw new IllegalArgumentException( + "query '" + name.value() + "' must be a fixed statement, not a concatenation"); + } + } + + /** A registered JPQL statement. */ + public static RegisteredQuery jpql(QueryName name, String statement) { + return new RegisteredQuery(name, statement, false); + } + + /** A registered native SQL statement. */ + public static RegisteredQuery nativeSql(QueryName name, String statement) { + return new RegisteredQuery(name, statement, true); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java new file mode 100644 index 00000000..b8193b55 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import java.util.Objects; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; + +/** + * One sortable field, mapping a public name to a fixed entity path (design §23.4). + * + *

The indirection is the security boundary. A sort parameter reaches the query as part of the + * ORDER BY clause, not as a bound value, so forwarding the client's string means the client writes + * part of the statement. Mapping {@code "newest"} to a registered path means the client chooses + * from a list instead. + * + *

The entity path is validated as a JPA property path — identifiers separated by dots — so even + * a registration mistake cannot introduce a function call or a second clause. + */ +public record SafeSortField(String publicName, String entityPath) { + + private static final Pattern PUBLIC_NAME = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,63}"); + private static final Pattern ENTITY_PATH = + Pattern.compile("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*"); + + public SafeSortField { + Objects.requireNonNull(publicName, "publicName"); + Objects.requireNonNull(entityPath, "entityPath"); + if (!PUBLIC_NAME.matcher(publicName).matches()) { + throw new IllegalArgumentException("invalid sort field name: " + publicName); + } + if (!ENTITY_PATH.matcher(entityPath).matches()) { + throw new IllegalArgumentException("invalid sort entity path: " + entityPath); + } + } + + /** A field whose public name and entity path are the same. */ + public static SafeSortField of(String name) { + return new SafeSortField(name, name); + } + + /** The Spring Data order for this field in the requested direction. */ + public Sort.Order toOrder(Sort.Direction direction) { + Objects.requireNonNull(direction, "direction"); + return new Sort.Order(direction, entityPath); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java new file mode 100644 index 00000000..4cf652f6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.springframework.data.domain.Sort; + +/** + * Turns client sort parameters into a {@link Sort} built only from allowlisted paths (design + * §23.4). + * + *

Two rules, both load-bearing. + * + *

Every field is looked up, never passed through. Spring Data offers {@code + * JpaSort.unsafe}, which puts the caller's string straight into the ORDER BY clause; on a + * user-controlled value that is SQL injection with extra steps. This mapper has no path that + * reaches it. + * + *

The tie-breaker is always appended. A sort that does not end in a unique column has no + * total order, and paging over a non-total order silently duplicates and skips rows. + */ +public final class SafeSortMapper { + + private static final int MAX_SORT_TERMS = 4; + + private final SafeSortRegistry registry; + + public SafeSortMapper(SafeSortRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + /** + * Maps {@code "field,direction"} terms into a total ordering. + * + * @throws IllegalArgumentException when a field is not allowlisted or a term is malformed + */ + public Sort map(List requested) { + Objects.requireNonNull(requested, "requested"); + if (requested.size() > MAX_SORT_TERMS) { + throw new IllegalArgumentException("at most " + MAX_SORT_TERMS + " sort terms are accepted"); + } + List orders = new ArrayList<>(requested.size() + 1); + for (String term : requested) { + orders.add(parse(term)); + } + if (orders.stream().noneMatch(order -> order.getProperty().equals(registry.tieBreaker()))) { + orders.add(Sort.Order.desc(registry.tieBreaker())); + } + return Sort.by(orders); + } + + /** Maps a single {@code "field,direction"} term. */ + public Sort.Order parse(String term) { + if (term == null || term.isBlank()) { + throw new IllegalArgumentException("sort term must not be blank"); + } + String[] parts = term.split(",", -1); + if (parts.length > 2) { + throw new IllegalArgumentException("malformed sort term: " + term); + } + SafeSortField field = registry.require(parts[0].trim()); + Sort.Direction direction = + parts.length == 2 ? directionOf(parts[1].trim()) : Sort.Direction.ASC; + return field.toOrder(direction); + } + + private static Sort.Direction directionOf(String raw) { + return switch (raw.toLowerCase(Locale.ROOT)) { + case "", "asc" -> Sort.Direction.ASC; + case "desc" -> Sort.Direction.DESC; + default -> throw new IllegalArgumentException("invalid sort direction: " + raw); + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java new file mode 100644 index 00000000..edb247f1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The allowlist of fields one query may be sorted by, plus its stable tie-breaker (design §23.4). + * + *

The tie-breaker is mandatory and must itself be registered. Without one, a page boundary that + * falls inside a run of equal sort values is resolved by whatever order the plan happened to + * produce, so the same row can appear on page one and page two — or on neither. That is not a rare + * edge case: it is what happens the first time two rows share a {@code createdAt}. + */ +public final class SafeSortRegistry { + + private final Map byPublicName; + private final SafeSortField tieBreaker; + + public SafeSortRegistry(Map fields, SafeSortField tieBreaker) { + Objects.requireNonNull(fields, "fields"); + this.tieBreaker = Objects.requireNonNull(tieBreaker, "tieBreaker"); + Map normalized = new LinkedHashMap<>(); + fields.forEach( + (name, field) -> { + Objects.requireNonNull(name, "sort field name"); + Objects.requireNonNull(field, "sort field"); + normalized.put(name.toLowerCase(Locale.ROOT), field); + }); + normalized.putIfAbsent(tieBreaker.publicName().toLowerCase(Locale.ROOT), tieBreaker); + this.byPublicName = Map.copyOf(normalized); + } + + /** A registry over the supplied fields, using {@code tieBreaker} as the stable final ordering. */ + public static SafeSortRegistry of(SafeSortField tieBreaker, SafeSortField... fields) { + Map byName = new LinkedHashMap<>(); + for (SafeSortField field : fields) { + byName.put(field.publicName(), field); + } + return new SafeSortRegistry(byName, tieBreaker); + } + + /** + * The field registered under {@code publicName}. + * + * @throws IllegalArgumentException when the field is not on the allowlist + */ + public SafeSortField require(String publicName) { + if (publicName == null) { + throw new IllegalArgumentException("sort field must not be null"); + } + SafeSortField field = byPublicName.get(publicName.toLowerCase(Locale.ROOT)); + if (field == null) { + throw new IllegalArgumentException( + "unknown sort field '" + publicName + "'; sortable fields are " + publicNames()); + } + return field; + } + + /** The entity path every sort ends with. */ + public String tieBreaker() { + return tieBreaker.entityPath(); + } + + /** The registered tie-breaker field. */ + public SafeSortField tieBreakerField() { + return tieBreaker; + } + + /** The public names a caller may sort by. */ + public Set publicNames() { + return byPublicName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java new file mode 100644 index 00000000..b294ec56 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +/** + * The bounds a streaming read runs under (design §27.4). + * + *

{@code fetchSize} is what stops the driver materialising the whole result set before the first + * row is handed over — on PostgreSQL, a statement without one buffers everything client-side, which + * defeats the point of streaming entirely. {@code maxRows} is the separate guarantee that the + * stream ends. + * + *

{@code adminToken} exists because some legitimate maintenance work really does need to walk a + * whole table. Making that an explicit, named exception keeps it out of ordinary request paths + * instead of quietly raising the limit for everyone. + */ +public record ScrollPolicy(int fetchSize, long maxRows, boolean adminToken) { + + /** The largest row bound an ordinary, non-admin stream may request. */ + public static final long MAX_NON_ADMIN_ROWS = 100_000L; + + public ScrollPolicy { + if (fetchSize < 1) { + throw new IllegalArgumentException("stream fetch size must be positive"); + } + if (maxRows < 1L) { + throw new IllegalArgumentException("stream max rows must be positive"); + } + if (!adminToken && maxRows > MAX_NON_ADMIN_ROWS) { + throw new IllegalArgumentException( + "a stream over " + MAX_NON_ADMIN_ROWS + " rows requires an explicit admin token"); + } + } + + /** An ordinary bounded stream. */ + public static ScrollPolicy bounded(int fetchSize, long maxRows) { + return new ScrollPolicy(fetchSize, maxRows, false); + } + + /** A maintenance stream that may exceed the ordinary row bound. */ + public static ScrollPolicy admin(int fetchSize, long maxRows) { + return new ScrollPolicy(fetchSize, maxRows, true); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java new file mode 100644 index 00000000..b96d8138 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import java.util.Objects; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +/** + * The rules a dynamic {@link Specification} query must satisfy before it runs (design §23.2). + * + *

A specification with no predicate is a full table scan wearing a builder's clothing. It is + * usually the result of every optional filter being absent — a search screen submitted empty — and + * it looks harmless in code review because no single line is wrong. Requiring either a predicate or + * an explicit "yes, scan everything" token makes that case a decision instead of an accident. + * + *

A page bound is required for the same reason: an unbounded collection query hydrates whatever + * the table happens to hold today. + */ +public final class SpecificationPolicy { + + /** The explicit opt-in a caller must pass to run a predicate-free query. */ + public static final String ALLOW_UNBOUNDED_TOKEN = "allow-unbounded-scan"; + + private SpecificationPolicy() {} + + /** + * Fails when the specification is unbounded without an explicit opt-in. + * + * @param allowUnboundedToken {@link #ALLOW_UNBOUNDED_TOKEN} to permit a predicate-free query + * @throws IllegalArgumentException when the query would be unbounded + */ + public static void requireBounded( + Specification specification, Pageable pageable, String allowUnboundedToken) { + Objects.requireNonNull(pageable, "pageable"); + if (pageable.isUnpaged()) { + throw new IllegalArgumentException( + "a specification query requires a bounded page; unpaged reads whatever the table holds"); + } + if (specification == null && !ALLOW_UNBOUNDED_TOKEN.equals(allowUnboundedToken)) { + throw new IllegalArgumentException( + "a specification query requires a bounded predicate, or the explicit '" + + ALLOW_UNBOUNDED_TOKEN + + "' token"); + } + } + + /** Fails when the specification has no predicate at all. */ + public static void requirePredicate(Specification specification) { + if (specification == null) { + throw new IllegalArgumentException("a specification query requires a bounded predicate"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculator.java new file mode 100644 index 00000000..bd2ae900 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculator.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import java.time.Duration; +import java.util.Objects; +import java.util.random.RandomGenerator; + +/** + * Computes the wait before the next attempt (design §19.3). + * + *

Growth is exponential and capped, then jittered. The jitter is the part that matters under the + * failures this platform retries: a deadlock or serialization storm has every contender failing at + * the same instant, so an unjittered backoff schedules them all to collide again together, and the + * retry budget burns down without any of them making progress. + * + *

The random source is injected so the calculator is deterministic under test; production passes + * a thread-safe generator. + */ +public final class BackoffCalculator { + + private final RetryProfile profile; + private final RandomGenerator random; + + public BackoffCalculator(RetryProfile profile, RandomGenerator random) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.random = Objects.requireNonNull(random, "random"); + } + + /** A calculator using the platform's shared thread-safe random source. */ + public static BackoffCalculator forProfile(RetryProfile profile) { + return new BackoffCalculator(profile, RandomGenerator.getDefault()); + } + + /** + * The delay to wait after {@code attemptNumber} failed. + * + * @param attemptNumber the 1-based attempt that just failed + */ + public Duration forAttempt(int attemptNumber) { + if (attemptNumber < 1) { + throw new IllegalArgumentException("attempt number must be at least 1"); + } + long baseMillis = profile.initialBackoff().toMillis(); + if (baseMillis <= 0L) { + return Duration.ZERO; + } + double scaled = baseMillis * Math.pow(profile.multiplier(), attemptNumber - 1.0d); + long capped = + Math.min((long) Math.min(scaled, Long.MAX_VALUE), profile.maxBackoff().toMillis()); + return Duration.ofMillis(jitter(Math.max(capped, 0L))); + } + + private long jitter(long millis) { + if (millis == 0L) { + return 0L; + } + return switch (profile.jitter()) { + case NONE -> millis; + case FULL -> random.nextLong(millis + 1L); + case EQUAL -> { + long half = millis / 2L; + yield half + random.nextLong(millis - half + 1L); + } + }; + } + + /** The profile this calculator applies. */ + public RetryProfile profile() { + return profile; + } + + /** The mode used to randomise the computed backoff. */ + public JitterMode jitterMode() { + return profile.jitter(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java new file mode 100644 index 00000000..68165b9d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.io.IOException; +import java.net.SocketException; +import java.sql.SQLNonTransientConnectionException; +import java.sql.SQLRecoverableException; +import java.sql.SQLTransientConnectionException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Decides whether a failure raised while committing means "the commit outcome is unknown" (design + * §17.2). + * + *

The rule is narrow on purpose. A failure becomes {@link TransactionCompletionUnknownException} + * only when it happened during the commit phase and the driver could not tell us which way + * the transaction went — SQLSTATE {@code 40003}, a connection-class ({@code 08*}) failure, a + * server-termination state ({@code 57P01}-{@code 57P03}), or a transport-level break. Everything + * else is returned unchanged so the ordinary exception translator classifies it. + * + *

Widening this rule is tempting and wrong. Marking every commit-phase connection error as + * completion-unknown pushes ordinary pool exhaustion and server restarts into the reconciliation + * queue, which trains operators to clear that queue without reading it — and the one entry that + * mattered gets cleared with the rest. + */ +public final class CommitFailureClassifier { + + /** SQLSTATE for "statement completion unknown". */ + private static final String COMPLETION_UNKNOWN_STATE = "40003"; + + /** SQLSTATE class 08 is "connection exception". */ + private static final String CONNECTION_CLASS = "08"; + + /** + * States for a backend that went away while the commit was in flight. + * + *

These are not class 08, because class 08 is about the client's connection attempt while + * these are the server announcing its own termination — but for a commit the consequence is + * identical and worse: {@code 57P01} is what a terminated backend, a fast shutdown, or a failover + * reports, and the commit record may already be in the WAL when it arrives. Treating them as + * ordinary failures is how a possibly-committed transaction gets re-run. + * + *

{@code 57P02} (crash shutdown) and {@code 57P03} (cannot connect now) are included for the + * same reason: whatever the server did with the in-flight commit, the client did not learn it. + */ + private static final Set SERVER_TERMINATION_STATES = Set.of("57P01", "57P02", "57P03"); + + private static final int MAX_DEPTH = 64; + + private final SqlStateResolver sqlStateResolver; + private final Clock clock; + + public CommitFailureClassifier(SqlStateResolver sqlStateResolver, Clock clock) { + this.sqlStateResolver = Objects.requireNonNull(sqlStateResolver, "sqlStateResolver"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** The vendor-neutral classifier used when no driver-specific resolver is installed. */ + public static CommitFailureClassifier standard(Clock clock) { + return new CommitFailureClassifier(new SqlExceptionSqlStateResolver(), clock); + } + + /** + * Translates a commit-phase failure. + * + * @param failure the exception the provider threw from its commit + * @return {@link TransactionCompletionUnknownException} when the outcome is undetermined, or + * {@code failure} unchanged when it is not + */ + public RuntimeException translateCommitFailure(RuntimeException failure) { + Objects.requireNonNull(failure, "failure"); + Optional frame = TransactionEvidenceContext.current(); + Optional sqlState = sqlStateResolver.resolve(failure); + if (!indicatesUnknownCompletion(sqlState, failure)) { + return failure; + } + Instant now = clock.instant(); + PersistenceOperationName operation = + frame.map(TransactionEvidenceFrame::operation).orElse(UnknownOperation.NAME); + Duration elapsed = + frame.map(present -> between(present.startedAt(), now)).orElse(Duration.ZERO); + int attempt = frame.map(TransactionEvidenceFrame::attempt).orElse(1); + JpaFailureContext context = + JpaFailureContext.completionUnknown( + operation, sqlState.orElse(JpaFailureContext.NO_SQL_STATE), attempt, elapsed, null); + return new TransactionCompletionUnknownException( + context, + frame.flatMap(TransactionEvidenceFrame::reconciliationKey).orElse(null), + TransactionCompletionEvidence.UNKNOWN, + failure); + } + + /** + * Whether this failure leaves the commit outcome undetermined. + * + *

Exposed for the contract tests, which assert the rule directly rather than by constructing a + * provider exception for every driver variant. + */ + public boolean indicatesUnknownCompletion(Optional sqlState, Throwable failure) { + if (sqlState.isPresent()) { + String state = sqlState.get(); + if (COMPLETION_UNKNOWN_STATE.equals(state)) { + return true; + } + if (state.startsWith(CONNECTION_CLASS)) { + return true; + } + if (SERVER_TERMINATION_STATES.contains(state)) { + return true; + } + } + return hasTransportBreak(failure); + } + + private static Duration between(Instant startedAt, Instant now) { + Duration elapsed = Duration.between(startedAt, now); + return elapsed.isNegative() ? Duration.ZERO : elapsed; + } + + private static boolean hasTransportBreak(Throwable failure) { + IdentityHashMap seen = new IdentityHashMap<>(); + Throwable current = failure; + int depth = 0; + while (current != null && depth++ < MAX_DEPTH && seen.put(current, Boolean.TRUE) == null) { + if (current instanceof SocketException + || current instanceof IOException + || current instanceof SQLRecoverableException + || current instanceof SQLTransientConnectionException + || current instanceof SQLNonTransientConnectionException) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionResolution.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionResolution.java new file mode 100644 index 00000000..829e14ea --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionResolution.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +/** + * What reconciliation concluded about a transaction whose commit outcome was unknown (design + * §17.4). + * + *

{@link #STILL_UNKNOWN} is a legitimate answer, and keeping it is the point. Forcing a binary + * result would push the resolver into guessing, and a wrong guess either duplicates a payment or + * loses one. + */ +public enum CompletionResolution { + + /** Evidence shows the transaction committed; the use case must not be re-run. */ + COMMITTED, + + /** Evidence shows the transaction did not commit; the use case may be re-run. */ + NOT_COMMITTED, + + /** No conclusive evidence; the record stays in the reconciliation queue. */ + STILL_UNKNOWN +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java new file mode 100644 index 00000000..eaca91c7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The durable handoff written when a transaction's commit outcome could not be determined (design + * §17.4). + * + *

Everything here is bounded: the registered operation, the SQLSTATE, the application's + * transaction key, the evidence phase, and the time. There are deliberately no SQL parameters and + * no row data — this record lands in a durable queue that operators read, and the transaction key + * is the handle they use to look the real state up. + */ +public record CompletionUnknownRecord( + PersistenceOperationName operation, + String transactionKey, + String sqlState, + TransactionCompletionEvidence evidence, + int attempt, + Instant occurredAt, + String traceId) { + + public CompletionUnknownRecord { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(sqlState, "sqlState"); + Objects.requireNonNull(evidence, "evidence"); + Objects.requireNonNull(occurredAt, "occurredAt"); + transactionKey = transactionKey == null ? "" : transactionKey; + traceId = traceId == null ? "" : traceId; + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be at least 1"); + } + } + + /** Builds the record from the failure the platform raised. */ + public static CompletionUnknownRecord from( + TransactionCompletionUnknownException failure, Instant occurredAt) { + Objects.requireNonNull(failure, "failure"); + return new CompletionUnknownRecord( + failure.context().operation(), + failure.transactionKey().orElse(null), + failure.context().sqlState(), + failure.evidence(), + failure.context().transactionAttempt(), + occurredAt, + failure.context().traceId()); + } + + /** The application's reconciliation key, when the unit of work bound one. */ + public Optional reconciliationKey() { + return transactionKey.isEmpty() ? Optional.empty() : Optional.of(transactionKey); + } + + /** + * Whether this record can be reconciled automatically. + * + *

Without a transaction key there is nothing to look the outcome up by, so the record needs a + * human with the operation name and the timestamp. + */ + public boolean reconcilable() { + return !transactionKey.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java new file mode 100644 index 00000000..bc9ee0fa --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; + +/** + * Durably hands an unknown-completion transaction to reconciliation (design §17.4). + * + *

The channel is chosen by the application and must be outside the transaction whose outcome is + * unknown. Writing the record through the same connection that may or may not have committed would + * make the audit trail share the failure it is supposed to document. + */ +@FunctionalInterface +public interface CompletionUnknownRecorder { + + /** + * Records the failure for later reconciliation. + * + *

Implementations must not invoke the original use case. This method observes; it never + * repairs. + */ + void record(TransactionCompletionUnknownException failure); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java new file mode 100644 index 00000000..b89c5c03 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; +import java.util.Objects; + +/** + * The Stable retry classification (design §19.1). + * + *

The order of the checks is the contract, not an implementation detail: + * + *

    + *
  1. Completion unknown reconciles. It is checked first so no later rule can reach it. + *
  2. An attempt that performed an irreversible external effect fails. Re-running it would repeat + * that effect, and no amount of remaining budget makes that acceptable. + *
  3. Only categories the profile explicitly opted into retry. + *
  4. Everything else fails, including unknown SQLSTATEs. Retrying a failure nobody classified is + * how an unrecognised error becomes duplicate writes. + *
+ */ +public final class DefaultJpaRetryPolicy implements JpaRetryPolicy { + + private final RetryProfile profile; + private final BackoffCalculator backoff; + + public DefaultJpaRetryPolicy(RetryProfile profile, BackoffCalculator backoff) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.backoff = Objects.requireNonNull(backoff, "backoff"); + } + + /** A policy for {@code profile} using the platform's shared random source for jitter. */ + public static DefaultJpaRetryPolicy forProfile(RetryProfile profile) { + return new DefaultJpaRetryPolicy(profile, BackoffCalculator.forProfile(profile)); + } + + @Override + public RetryDecision classify(JpaPersistenceException failure, TransactionAttempt attempt) { + Objects.requireNonNull(failure, "failure"); + Objects.requireNonNull(attempt, "attempt"); + + if (failure instanceof TransactionCompletionUnknownException + || failure.category() == FailureCategory.COMPLETION_UNKNOWN) { + return RetryDecision.reconcile("transaction completion is unknown"); + } + if (IrreversibleSideEffectContext.performed()) { + return RetryDecision.fail("attempt performed an irreversible external side effect"); + } + if (!profile.enabled()) { + return RetryDecision.fail("retry is not enabled for this transaction profile"); + } + FailureCategory category = failure.category(); + if (!profile.allows(category)) { + return RetryDecision.fail("non-retryable persistence failure"); + } + return RetryDecision.retry( + backoff.forAttempt(attempt.number()), "retrying whole transaction for " + category); + } + + /** The profile whose budget and eligible categories this policy applies. */ + public RetryProfile profile() { + return profile; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java new file mode 100644 index 00000000..8f6ea22e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import jakarta.persistence.EntityManagerFactory; +import java.time.Clock; +import java.util.Objects; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionStatus; + +/** + * A {@link JpaTransactionManager} that records how far each transaction got, so a commit failure + * can be classified as "rolled back" or "outcome unknown" instead of guessed (design §17.2). + * + *

The phase is marked {@link TransactionCompletionEvidence#COMMITTING} immediately before the + * provider commit and never after. That ordering is the whole mechanism: if the JVM, the network, + * or the server dies inside {@code super.doCommit}, the last thing written is "we asked, we do not + * know", which is exactly the state that must not be mistaken for a rollback. + * + *

Evidence is cleared on every path — commit success, commit failure, rollback, and cleanup. A + * frame left behind on a pooled thread would attach this transaction's phase to the next unrelated + * unit of work that thread picks up. + */ +public class EvidenceAwareJpaTransactionManager extends JpaTransactionManager { + + private static final long serialVersionUID = 1L; + + private final transient CommitFailureClassifier classifier; + + public EvidenceAwareJpaTransactionManager( + EntityManagerFactory entityManagerFactory, CommitFailureClassifier classifier) { + super(Objects.requireNonNull(entityManagerFactory, "entityManagerFactory")); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + /** Builds a manager with the vendor-neutral commit classifier. */ + public static EvidenceAwareJpaTransactionManager standard( + EntityManagerFactory entityManagerFactory, Clock clock) { + return new EvidenceAwareJpaTransactionManager( + entityManagerFactory, CommitFailureClassifier.standard(clock)); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + super.doBegin(transaction, definition); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.ACTIVE); + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + try { + super.doCommit(status); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + } catch (RuntimeException failure) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.UNKNOWN); + throw classifier.translateCommitFailure(failure); + } finally { + TransactionEvidenceContext.clear(); + } + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + try { + super.doRollback(status); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.ROLLED_BACK); + } finally { + TransactionEvidenceContext.clear(); + } + } + + /** The classifier this manager applies to commit-phase failures. */ + public CommitFailureClassifier commitFailureClassifier() { + return classifier; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java new file mode 100644 index 00000000..890bfe4b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Re-runs a whole use case in a brand new transaction after a retryable failure (design §19.2). + * + *

The unit of retry is the entire use case, not the failed statement. That is the only correct + * granularity for optimistic conflicts and serialization failures: the attempt failed because the + * state it computed against is no longer the committed state, so re-issuing the same statement + * would compute the same wrong answer. Re-entering the executor gives the next attempt a new + * transaction and a new Persistence Context, which is what forces the domain rules to run again + * against reloaded data. + * + *

Two failures are never retried here regardless of budget: a completion-unknown failure, which + * goes to reconciliation, and anything the policy classifies as {@link RetryDisposition#FAIL}. + */ +public final class FullTransactionRetryCoordinator { + + private final SpringJpaTransactionExecutor transactionExecutor; + private final JpaRetryPolicy retryPolicy; + private final RetrySleeper sleeper; + private final Clock clock; + private final Duration defaultMaxElapsed; + private final RetryEventListener listener; + + public FullTransactionRetryCoordinator( + SpringJpaTransactionExecutor transactionExecutor, + JpaRetryPolicy retryPolicy, + RetrySleeper sleeper, + Clock clock, + Duration defaultMaxElapsed, + RetryEventListener listener) { + this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor"); + this.retryPolicy = Objects.requireNonNull(retryPolicy, "retryPolicy"); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.defaultMaxElapsed = Objects.requireNonNull(defaultMaxElapsed, "defaultMaxElapsed"); + this.listener = Objects.requireNonNull(listener, "listener"); + if (defaultMaxElapsed.isNegative()) { + throw new IllegalArgumentException("defaultMaxElapsed must not be negative"); + } + } + + /** Executes the use case, retrying the whole transaction while the profile's budget allows. */ + public T execute( + PersistenceOperationName operation, TransactionProfile profile, Supplier work) { + return execute(operation, profile, work, null); + } + + /** + * Executes the use case with a reconciliation key bound to every attempt. + * + * @param transactionKey the key a completion-unknown record will carry, or {@code null} + */ + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work, + String transactionKey) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(work, "work"); + + RetryBudget budget = RetryBudget.forProfile(profile.retryProfile(), defaultMaxElapsed); + Instant operationStartedAt = clock.instant(); + + for (int attemptNumber = 1; ; attemptNumber++) { + TransactionAttempt attempt = new TransactionAttempt(attemptNumber, clock.instant()); + try { + T result = + transactionExecutor.execute(operation, profile, work, attemptNumber, transactionKey); + listener.onSucceeded(operation, attemptNumber); + return result; + } catch (JpaPersistenceException failure) { + RetryDecision decision = retryPolicy.classify(failure, attempt); + listener.onAttemptFailed(operation, attempt, failure, decision); + if (!decision.retrying()) { + listener.onGaveUp(operation, attemptNumber, failure); + throw failure; + } + Duration elapsed = elapsedSince(operationStartedAt); + if (!budget.allowsAnotherAttempt(attemptNumber, elapsed, decision.delay())) { + listener.onGaveUp(operation, attemptNumber, failure); + throw failure; + } + sleeper.sleep(decision.delay()); + } + } + } + + private Duration elapsedSince(Instant start) { + Duration elapsed = Duration.between(start, clock.instant()); + return elapsed.isNegative() ? Duration.ZERO : elapsed; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/IrreversibleSideEffectContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/IrreversibleSideEffectContext.java new file mode 100644 index 00000000..7911ba65 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/IrreversibleSideEffectContext.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +/** + * Marks that the current attempt performed an effect that cannot be undone by a rollback (design + * §19.2). + * + *

Database rollback only reverses database work. A use case that sent an email, charged a card, + * published to a broker, or wrote to object storage has already changed the world, and re-running + * it does it twice. A use case that cannot avoid such an effect calls {@link #mark()} before + * performing it; the retry policy then refuses to re-run that attempt no matter what the failure + * was. + * + *

The right fix is usually to move the effect out of the transaction entirely — the design + * forbids waiting on external calls inside a DB transaction. This flag exists for the cases where + * that refactor has not happened yet, so the unsafe retry is prevented rather than merely + * documented. + */ +public final class IrreversibleSideEffectContext { + + private static final ThreadLocal PERFORMED = new ThreadLocal<>(); + + private IrreversibleSideEffectContext() {} + + /** Declares that this attempt has performed an effect a rollback cannot reverse. */ + public static void mark() { + PERFORMED.set(Boolean.TRUE); + } + + /** Whether the current attempt declared an irreversible effect. */ + public static boolean performed() { + return Boolean.TRUE.equals(PERFORMED.get()); + } + + /** Clears the marker; the retry coordinator calls this between attempts. */ + public static void clear() { + PERFORMED.remove(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java new file mode 100644 index 00000000..3378fecf --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException; +import jakarta.persistence.OptimisticLockException; +import java.time.Duration; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.springframework.dao.OptimisticLockingFailureException; + +/** + * Turns a provider optimistic-locking failure into the stable {@link OptimisticConflictException} + * (design §20). + * + *

The conflict can surface at flush or at commit, and through at least three exception types + * depending on whether Spring, Hibernate, or Jakarta Persistence raised it, so the whole cause + * chain is inspected rather than only the outermost exception. + * + *

The conflicting entity type is resolved through a registered catalog and is otherwise dropped. + * Provider exceptions carry the entity instance and its identifier; copying either into + * the stable failure would put a row key into every log line and metric tag that failure produces. + */ +public final class OptimisticConflictTranslator { + + private static final int MAX_DEPTH = 64; + + private final Set registeredEntityTypes; + + /** + * @param registeredEntityTypes the bounded set of entity simple names that may be reported + */ + public OptimisticConflictTranslator(Set registeredEntityTypes) { + this.registeredEntityTypes = + Set.copyOf(Objects.requireNonNull(registeredEntityTypes, "registeredEntityTypes")); + } + + /** A translator that reports no entity type at all. */ + public static OptimisticConflictTranslator withoutCatalog() { + return new OptimisticConflictTranslator(Set.of()); + } + + /** Whether this failure chain is an optimistic conflict. */ + public boolean isOptimisticConflict(Throwable failure) { + return walk(failure) != null; + } + + /** + * Translates the failure, or returns empty when it is not an optimistic conflict. + * + * @param operation the registered operation the failing attempt served + * @param attempt the 1-based attempt number + * @param elapsed how long the attempt ran + */ + public Optional translate( + Throwable failure, + PersistenceOperationName operation, + int attempt, + Duration elapsed, + String traceId) { + Throwable conflict = walk(failure); + if (conflict == null) { + return Optional.empty(); + } + JpaFailureContext context = + JpaFailureContext.retryable( + operation, JpaFailureContext.NO_SQL_STATE, attempt, elapsed, traceId); + return Optional.of(new OptimisticConflictException(context, conflict)); + } + + /** + * The conflicting entity type, when the provider named one that is in the catalog. + * + *

An unregistered type is reported as empty rather than passed through, so a new entity cannot + * silently widen the tag set of every optimistic-conflict metric. + */ + public Optional conflictingEntityType(Class entityType) { + if (entityType == null) { + return Optional.empty(); + } + String simpleName = entityType.getSimpleName(); + return registeredEntityTypes.contains(simpleName) ? Optional.of(simpleName) : Optional.empty(); + } + + private static Throwable walk(Throwable failure) { + IdentityHashMap seen = new IdentityHashMap<>(); + Throwable current = failure; + int depth = 0; + while (current != null && depth++ < MAX_DEPTH && seen.put(current, Boolean.TRUE) == null) { + if (current instanceof OptimisticLockException + || current instanceof OptimisticLockingFailureException + || isHibernateStaleState(current)) { + return current; + } + current = current.getCause(); + } + return null; + } + + /** + * Recognises Hibernate's stale-state exceptions by name. + * + *

Matching by name rather than by type keeps this class off Hibernate's API surface: the + * transaction module is provider-neutral by design, and a compile-time reference here would make + * the whole retry path depend on the ORM implementation. + */ + private static boolean isHibernateStaleState(Throwable candidate) { + String name = candidate.getClass().getName(); + return "org.hibernate.StaleObjectStateException".equals(name) + || "org.hibernate.StaleStateException".equals(name); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java new file mode 100644 index 00000000..ea5a2b68 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import java.time.Duration; +import java.util.Objects; + +/** + * The two independent bounds on retrying one logical use case (design §19.2). + * + *

Attempt count alone is not a bound. Three attempts against a lock that takes ten seconds to + * time out is a thirty-second call, and the caller upstream gave up long ago. The elapsed bound is + * what keeps retry from outliving the request that asked for it. + */ +public record RetryBudget(int maxAttempts, Duration maxElapsed) { + + public RetryBudget { + Objects.requireNonNull(maxElapsed, "maxElapsed"); + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + if (maxElapsed.isNegative()) { + throw new IllegalArgumentException("maxElapsed must not be negative"); + } + } + + /** The budget a profile allows, bounded additionally by an overall deadline. */ + public static RetryBudget forProfile(RetryProfile profile, Duration maxElapsed) { + Objects.requireNonNull(profile, "profile"); + return new RetryBudget(profile.maxAttempts(), maxElapsed); + } + + /** A budget that permits exactly one attempt. */ + public static RetryBudget single() { + return new RetryBudget(1, Duration.ZERO); + } + + /** + * Whether another attempt is permitted. + * + * @param completedAttempts how many attempts have already run + * @param elapsed how long the logical operation has been running + * @param nextDelay the backoff that would be waited before the next attempt + */ + public boolean allowsAnotherAttempt(int completedAttempts, Duration elapsed, Duration nextDelay) { + if (completedAttempts >= maxAttempts) { + return false; + } + if (maxElapsed.isZero()) { + return true; + } + return elapsed.plus(nextDelay).compareTo(maxElapsed) < 0; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryEventListener.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryEventListener.java new file mode 100644 index 00000000..fb9944bc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryEventListener.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; + +/** + * Attempt-level events from the retry coordinator (design §19, §37). + * + *

The split between attempt events and the one terminal event is deliberate. Logging every + * retried attempt at WARN turns a healthy, self-correcting contention pattern into a page; the + * attempt events belong in metrics, and only the final outcome is worth a log line. + */ +public interface RetryEventListener { + + /** One attempt failed and a decision has been made about it. */ + default void onAttemptFailed( + PersistenceOperationName operation, + TransactionAttempt attempt, + JpaPersistenceException failure, + RetryDecision decision) { + // metrics-only by default + } + + /** The logical operation succeeded, possibly after retries. */ + default void onSucceeded(PersistenceOperationName operation, int attempts) { + // metrics-only by default + } + + /** The logical operation ended in failure and will be surfaced to the caller. */ + default void onGaveUp( + PersistenceOperationName operation, int attempts, JpaPersistenceException failure) { + // metrics-only by default + } + + /** A listener that records nothing; used when no observability module is installed. */ + static RetryEventListener noop() { + return new RetryEventListener() {}; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetrySleeper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetrySleeper.java new file mode 100644 index 00000000..194abe4b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetrySleeper.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.time.Duration; + +/** + * The seam the retry coordinator waits through (design §19.3). + * + *

It exists so backoff is testable. A retry suite that really slept would either take minutes or + * be tuned down to backoffs so short that they no longer exercise the ordering they are meant to + * prove. + */ +@FunctionalInterface +public interface RetrySleeper { + + /** + * Waits for {@code delay} before the next attempt. + * + *

Implementations must not hold a JDBC connection or a Persistence Context while waiting: the + * attempt's transaction has already ended, and holding either would turn backoff into pool + * pressure (design §19.2). + */ + void sleep(Duration delay); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransaction.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransaction.java new file mode 100644 index 00000000..b7ce1b8f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransaction.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares that a public application-service method may be re-run as a whole new transaction + * (design §19.4). + * + *

Both attributes are required and both are registry keys. An operation name is what the metric, + * the trace, and the failure context are attributed to; a profile name is what fixes the isolation, + * the timeout, and the retry budget. Defaulting either would let a method opt into retry without + * anyone deciding how much retry it gets. + * + *

The advice is applied by a Spring proxy, so a call from inside the same class does not go + * through it and is not retried. That limitation is enforced by an architecture test rather than + * left to a comment, because a silently-unretried method looks identical to a retried one until + * production contention finds it. + * + *

Methods returning reactive types are rejected at startup: JPA is blocking, so the returned + * publisher would be assembled inside the transaction and subscribed after it closed. + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RetryableJpaTransaction { + + /** The registered {@code PersistenceOperationName} this method is attributed to. */ + String operation(); + + /** The registered {@code TransactionProfile} name that fixes the boundary and retry budget. */ + String profile(); + + /** + * An optional expression-free key used to reconcile a completion-unknown outcome. + * + *

Empty means "no reconciliation key"; the failure is then recorded for a human rather than + * for automatic resolution. + */ + String transactionKey() default ""; +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptor.java new file mode 100644 index 00000000..0be922f7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptor.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.lang.reflect.Method; +import java.util.Objects; +import java.util.Set; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.core.Ordered; + +/** + * Applies {@link RetryableJpaTransaction} by running the invocation through the retry coordinator + * (design §19.4). + * + *

The order is the whole point. This advice must sit outside Spring's transaction + * advice, so the order value is lower — that is, higher precedence — than {@code + * Ordered.LOWEST_PRECEDENCE}, which transaction advice defaults to. Inverted, the retry loop would + * run inside a single transaction: every attempt would reuse the same Persistence Context and the + * same already-rolled-back transaction, so the second attempt would fail immediately with a + * "transaction is marked rollback-only" error instead of recomputing anything. + * + *

Reactive return types are rejected. A method that returns a publisher assembles it inside the + * transaction and has it subscribed after the transaction closed, so retrying the assembly retries + * nothing that touched the database. + */ +public final class RetryableJpaTransactionInterceptor implements MethodInterceptor, Ordered { + + /** Comfortably outside Spring's transaction advice, which defaults to lowest precedence. */ + public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 100; + + private static final Set REACTIVE_RETURN_TYPES = + Set.of( + "reactor.core.publisher.Mono", + "reactor.core.publisher.Flux", + "org.reactivestreams.Publisher", + "io.reactivex.rxjava3.core.Flowable", + "kotlinx.coroutines.flow.Flow"); + + private final FullTransactionRetryCoordinator coordinator; + private final TransactionProfileRegistry profiles; + private final int order; + + public RetryableJpaTransactionInterceptor( + FullTransactionRetryCoordinator coordinator, TransactionProfileRegistry profiles) { + this(coordinator, profiles, DEFAULT_ORDER); + } + + public RetryableJpaTransactionInterceptor( + FullTransactionRetryCoordinator coordinator, TransactionProfileRegistry profiles, int order) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + this.profiles = Objects.requireNonNull(profiles, "profiles"); + this.order = order; + } + + @Override + public Object invoke(MethodInvocation invocation) { + Method method = invocation.getMethod(); + RetryableJpaTransaction policy = method.getAnnotation(RetryableJpaTransaction.class); + if (policy == null) { + return proceed(invocation); + } + rejectReactiveReturnType(method); + + PersistenceOperationName operation = new PersistenceOperationName(policy.operation()); + TransactionProfile profile = profiles.require(policy.profile()); + String transactionKey = policy.transactionKey().isBlank() ? null : policy.transactionKey(); + try { + return coordinator.execute(operation, profile, () -> proceed(invocation), transactionKey); + } finally { + IrreversibleSideEffectContext.clear(); + } + } + + @Override + public int getOrder() { + return order; + } + + /** + * Fails fast when a retryable method returns a reactive type. + * + *

Exposed so the architecture test can assert the rule against a fixture without building a + * proxy. + */ + public static void rejectReactiveReturnType(Method method) { + String returnType = method.getReturnType().getName(); + if (REACTIVE_RETURN_TYPES.contains(returnType)) { + throw new IllegalStateException( + "@RetryableJpaTransaction cannot be applied to " + + method.getDeclaringClass().getName() + + '#' + + method.getName() + + ": JPA is blocking, so a " + + returnType + + " would be assembled inside the transaction and subscribed after it closed"); + } + } + + private static Object proceed(MethodInvocation invocation) { + try { + return invocation.proceed(); + } catch (RuntimeException | Error unchecked) { + throw unchecked; + } catch (Throwable checked) { + throw new IllegalStateException("retryable jpa transaction target threw", checked); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java new file mode 100644 index 00000000..535191ed --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.time.Clock; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Runs one unit of work inside one transaction described by a profile (design §9.3). + * + *

A fresh {@link TransactionTemplate} is built per call. Reusing a shared, mutated template is + * the classic way concurrent callers end up executing under each other's isolation level or + * read-only flag, because the reconfiguration and the execution are not atomic. + * + *

This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls + * back into here for every attempt precisely so each attempt gets a new transaction and a new + * Persistence Context (design §19.2). + */ +public final class SpringJpaTransactionExecutor implements JpaTransactionExecutor { + + private final PlatformTransactionManager transactionManager; + private final Clock clock; + + public SpringJpaTransactionExecutor(PlatformTransactionManager transactionManager, Clock clock) { + this.transactionManager = Objects.requireNonNull(transactionManager, "transactionManager"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public T execute( + PersistenceOperationName operation, TransactionProfile profile, Supplier work) { + return execute(operation, profile, work, 1, null); + } + + /** + * Executes one attempt, recording which attempt it is and which reconciliation key it carries. + * + *

The retry coordinator uses this overload so a completion-unknown failure raised on attempt + * three reports attempt three and the caller's transaction key, rather than defaulting to the + * first attempt with no key. + * + * @param attempt the 1-based attempt number + * @param transactionKey the application's reconciliation key, or {@code null} when none is bound + */ + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work, + int attempt, + String transactionKey) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(work, "work"); + TransactionTemplate template = new TransactionTemplate(transactionManager); + TransactionDefinitionMapper.apply(template, profile); + TransactionEvidenceContext.begin(operation, transactionKey, clock.instant(), attempt); + try { + return template.execute(status -> work.get()); + } finally { + // The evidence-aware manager clears its own frame on commit and rollback. When the template + // never reached either — a MANDATORY profile with no ambient transaction, or a failure while + // opening the connection — nothing else would, so the frame is dropped here. + TransactionEvidenceContext.current() + .filter(frame -> frame.operation().equals(operation) && frame.attempt() == attempt) + .ifPresent(frame -> TransactionEvidenceContext.clear()); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/ThreadRetrySleeper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/ThreadRetrySleeper.java new file mode 100644 index 00000000..8147263a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/ThreadRetrySleeper.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.time.Duration; + +/** + * The production {@link RetrySleeper}: parks the calling thread. + * + *

An interrupt is honoured rather than swallowed. Restoring the interrupt flag and aborting the + * retry is what lets a shutdown actually stop work that is between attempts, instead of waiting out + * every remaining backoff first. + */ +public final class ThreadRetrySleeper implements RetrySleeper { + + @Override + public void sleep(Duration delay) { + if (delay == null || delay.isZero() || delay.isNegative()) { + return; + } + try { + Thread.sleep(delay.toMillis()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("retry backoff was interrupted", interrupted); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionCompletionResolver.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionCompletionResolver.java new file mode 100644 index 00000000..bad44a36 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionCompletionResolver.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +/** + * The domain-specific SPI that decides whether an unknown transaction actually committed (design + * §17.4). + * + *

The core cannot implement this. Only the domain knows which unique constraint, idempotency + * record, business row, or outbox entry proves the write happened, so the platform provides the + * question and the domain provides the evidence. + * + * @param the application's transaction key type + */ +@FunctionalInterface +public interface TransactionCompletionResolver { + + /** + * Resolves one unknown transaction against domain evidence. + * + *

Implementations must read evidence only. Re-running the original use case from a resolver is + * the exact duplicate-write this design forbids. + */ + CompletionResolution resolve(K transactionKey); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapper.java new file mode 100644 index 00000000..bdaa8bf9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapper.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.time.Duration; +import java.util.Objects; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Translates a {@link TransactionProfile} into the Spring transaction definition it describes + * (design §9.2). + * + *

Timeouts round up to whole seconds. Spring's definition is second-granular, so a 1500 + * ms profile has to become either 1 s or 2 s; truncating would silently give the caller a shorter + * guard than the profile promised, and a write transaction that is cut off early is the failure + * mode this platform is trying to prevent. Rounding up is the direction that keeps the promise, and + * a profile expressed in whole seconds is unaffected. + */ +public final class TransactionDefinitionMapper { + + private TransactionDefinitionMapper() {} + + /** Applies {@code profile} to {@code template}, which must not be shared between calls. */ + public static void apply(TransactionTemplate template, TransactionProfile profile) { + Objects.requireNonNull(template, "template"); + Objects.requireNonNull(profile, "profile"); + template.setName(profile.name()); + template.setPropagationBehavior(propagationOf(profile.propagation())); + template.setIsolationLevel(isolationOf(profile.isolation())); + template.setReadOnly(profile.readOnly()); + template.setTimeout(timeoutSecondsOf(profile.timeout())); + } + + /** Builds a standalone definition for callers that drive a transaction manager directly. */ + public static TransactionDefinition definitionOf(TransactionProfile profile) { + Objects.requireNonNull(profile, "profile"); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setName(profile.name()); + definition.setPropagationBehavior(propagationOf(profile.propagation())); + definition.setIsolationLevel(isolationOf(profile.isolation())); + definition.setReadOnly(profile.readOnly()); + definition.setTimeout(timeoutSecondsOf(profile.timeout())); + return definition; + } + + /** The Spring propagation constant for a supported {@link PropagationMode}. */ + public static int propagationOf(PropagationMode mode) { + return switch (mode) { + case REQUIRED -> TransactionDefinition.PROPAGATION_REQUIRED; + case MANDATORY -> TransactionDefinition.PROPAGATION_MANDATORY; + case REQUIRES_NEW -> TransactionDefinition.PROPAGATION_REQUIRES_NEW; + }; + } + + /** The Spring isolation constant for a supported {@link IsolationLevel}. */ + public static int isolationOf(IsolationLevel level) { + return switch (level) { + case DEFAULT -> TransactionDefinition.ISOLATION_DEFAULT; + case READ_COMMITTED -> TransactionDefinition.ISOLATION_READ_COMMITTED; + case REPEATABLE_READ -> TransactionDefinition.ISOLATION_REPEATABLE_READ; + case SERIALIZABLE -> TransactionDefinition.ISOLATION_SERIALIZABLE; + }; + } + + /** + * The whole-second timeout Spring accepts, rounded up, or the connection default for zero. + * + * @throws IllegalArgumentException when the profile asks for more seconds than Spring can carry + */ + public static int timeoutSecondsOf(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isZero()) { + return TransactionDefinition.TIMEOUT_DEFAULT; + } + if (timeout.isNegative()) { + throw new IllegalArgumentException("transaction timeout must not be negative"); + } + long seconds = timeout.plusNanos(999_999_999L).toSeconds(); + if (seconds > Integer.MAX_VALUE) { + throw new IllegalArgumentException("transaction timeout exceeds the supported range"); + } + return (int) seconds; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java new file mode 100644 index 00000000..4b70061b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Optional; + +/** + * Thread-bound record of how far the current transaction has got (design §17.2). + * + *

The state is a stack rather than a single value because {@code REQUIRES_NEW} suspends an outer + * transaction and begins an inner one on the same thread. With a single slot, the inner + * transaction's commit would overwrite the outer transaction's phase, and a later commit failure on + * the outer one would be classified against evidence that belongs to work that already finished. + * + *

Every path clears what it pushed. A leaked frame is worse than no frame: pooled request + * threads would carry a stale "COMMITTING" into unrelated work, and the platform would report + * completion-unknown for a transaction that never existed. + */ +public final class TransactionEvidenceContext { + + private static final ThreadLocal> FRAMES = + ThreadLocal.withInitial(ArrayDeque::new); + + private TransactionEvidenceContext() {} + + /** + * Pushes a frame for a transaction that is about to begin. + * + * @param operation the registered operation this transaction serves + * @param transactionKey the application's reconciliation key, or {@code null} when none is bound + * @param startedAt when the attempt began + * @param attempt the 1-based attempt number + */ + public static void begin( + PersistenceOperationName operation, String transactionKey, Instant startedAt, int attempt) { + FRAMES + .get() + .push(TransactionEvidenceFrame.notStarted(operation, transactionKey, startedAt, attempt)); + } + + /** Advances the innermost frame to a new phase; a no-op when no transaction is bound. */ + public static void mark(TransactionCompletionEvidence evidence) { + Deque frames = FRAMES.get(); + TransactionEvidenceFrame current = frames.peek(); + if (current == null) { + return; + } + frames.pop(); + frames.push(current.at(evidence)); + } + + /** The innermost frame, when a transaction is bound to this thread. */ + public static Optional current() { + return Optional.ofNullable(FRAMES.get().peek()); + } + + /** The innermost phase, or {@link TransactionCompletionEvidence#NOT_STARTED} when unbound. */ + public static TransactionCompletionEvidence evidence() { + TransactionEvidenceFrame current = FRAMES.get().peek(); + return current == null ? TransactionCompletionEvidence.NOT_STARTED : current.evidence(); + } + + /** + * Pops the innermost frame and removes the thread-local once the stack is empty. + * + *

Removing the empty deque matters on pooled threads: {@link ThreadLocal#withInitial} keeps a + * strong reference from the thread to the value, so leaving empty deques behind is a slow leak + * across every request thread in the pool. + */ + public static void clear() { + Deque frames = FRAMES.get(); + frames.poll(); + if (frames.isEmpty()) { + FRAMES.remove(); + } + } + + /** Drops every frame on this thread. Intended for test teardown and error recovery only. */ + public static void clearAll() { + FRAMES.remove(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceFrame.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceFrame.java new file mode 100644 index 00000000..079ec258 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceFrame.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * What is known about one physical transaction while it is running (design §17.1). + * + *

The frame is immutable; advancing the phase produces a new frame. That is deliberate: a + * mutable phase field is exactly the thing that gets written from a callback after the transaction + * has already been cleaned up, leaving the next unit of work on that thread reading a stale + * "COMMITTED". + */ +public record TransactionEvidenceFrame( + PersistenceOperationName operation, + String transactionKey, + Instant startedAt, + int attempt, + TransactionCompletionEvidence evidence) { + + public TransactionEvidenceFrame { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(startedAt, "startedAt"); + Objects.requireNonNull(evidence, "evidence"); + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be at least 1"); + } + transactionKey = transactionKey == null ? "" : transactionKey.trim(); + } + + /** A frame for a transaction that has not begun yet. */ + public static TransactionEvidenceFrame notStarted( + PersistenceOperationName operation, String transactionKey, Instant startedAt, int attempt) { + return new TransactionEvidenceFrame( + operation, transactionKey, startedAt, attempt, TransactionCompletionEvidence.NOT_STARTED); + } + + /** The same transaction, observed at a later phase. */ + public TransactionEvidenceFrame at(TransactionCompletionEvidence phase) { + return new TransactionEvidenceFrame(operation, transactionKey, startedAt, attempt, phase); + } + + /** The application-chosen reconciliation key, when one was bound. */ + public Optional reconciliationKey() { + return transactionKey.isEmpty() ? Optional.empty() : Optional.of(transactionKey); + } + + /** Whether the provider has been asked to commit but has not answered. */ + public boolean committing() { + return evidence == TransactionCompletionEvidence.COMMITTING; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java new file mode 100644 index 00000000..054d33c9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The bounded set of transaction profiles an application declares (design §9.2). + * + *

Lookup is fail-closed: an unregistered name throws rather than falling back to a default. A + * default would mean a typo in {@code @RetryableJpaTransaction(profile = "order-wrtie")} silently + * runs with someone else's isolation, timeout, and retry budget. + */ +public final class TransactionProfileRegistry { + + private final Map byName; + + public TransactionProfileRegistry(Map profiles) { + Objects.requireNonNull(profiles, "profiles"); + Map copy = new LinkedHashMap<>(); + profiles.forEach( + (name, profile) -> { + Objects.requireNonNull(name, "profile name"); + Objects.requireNonNull(profile, "profile"); + if (!name.equals(profile.name())) { + throw new IllegalArgumentException( + "registry key '" + name + "' does not match profile name '" + profile.name() + "'"); + } + copy.put(name, profile); + }); + this.byName = Map.copyOf(copy); + } + + /** A registry built from profiles that already carry their own names. */ + public static TransactionProfileRegistry of(TransactionProfile... profiles) { + Map byName = new LinkedHashMap<>(); + for (TransactionProfile profile : profiles) { + if (byName.put(profile.name(), profile) != null) { + throw new IllegalArgumentException("duplicate transaction profile: " + profile.name()); + } + } + return new TransactionProfileRegistry(byName); + } + + /** + * The profile registered under {@code name}. + * + * @throws IllegalArgumentException when no profile is registered under that name + */ + public TransactionProfile require(String name) { + TransactionProfile profile = byName.get(name); + if (profile == null) { + throw new IllegalArgumentException( + "unregistered transaction profile '" + name + "'; registered profiles are " + names()); + } + return profile; + } + + /** Whether a profile is registered under {@code name}. */ + public boolean contains(String name) { + return byName.containsKey(name); + } + + /** The registered profile names, in declaration order. */ + public Set names() { + return byName.keySet(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/UnknownOperation.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/UnknownOperation.java new file mode 100644 index 00000000..dafbed73 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/UnknownOperation.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; + +/** + * The registered placeholder used when a failure arrives with no bound operation. + * + *

A failure context requires an operation name. Inventing one from the call stack or the SQL + * would be unbounded, and leaving it null would push a null check into every consumer, so an + * unattributed failure is reported under this fixed, registered name instead. + */ +public final class UnknownOperation { + + /** The bounded operation name used for failures raised outside any registered operation. */ + public static final PersistenceOperationName NAME = + new PersistenceOperationName("jpa.unattributed"); + + private UnknownOperation() {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/experimental-rls/V1__tenant_rls.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/experimental-rls/V1__tenant_rls.sql new file mode 100644 index 00000000..08eb91a7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/experimental-rls/V1__tenant_rls.sql @@ -0,0 +1,45 @@ +-- Experimental: PostgreSQL row-level-security tenant isolation. +-- Gated by backend.jpa.experimental.multitenancy-rls=true; never applied by the Stable migration +-- location. See docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md +-- Task 3. +-- +-- Three things have to be true for RLS to actually isolate anything, and all three are here: +-- +-- 1. ENABLE ROW LEVEL SECURITY turns policies on for the table. +-- 2. FORCE ROW LEVEL SECURITY applies them to the table OWNER as well. Without it, the owner is +-- exempt from its own policies — and the owner is frequently the migration role, which is the +-- role people test with. +-- 3. The runtime role must not hold BYPASSRLS. That is a role attribute, not a table property, +-- so it is asserted by RlsPolicyVerifier at startup rather than granted here. +-- +-- The policy reads app.tenant_id, which RlsTenantSessionBinder sets transaction-locally. A +-- session-local setting would survive the connection's return to the pool and leak into the next +-- transaction on that connection. + +create table if not exists tenant_scoped_item ( + id bigint generated by default as identity primary key, + tenant_id text not null, + value text not null, + created_at timestamptz not null default now() +); + +-- Tenant isolation depends on this column, so it belongs in every uniqueness requirement over the +-- table: a unique index on (value) alone would let one tenant's insert fail because a different +-- tenant already used that value, which is both a bug and an information leak. +create unique index if not exists ux_tenant_scoped_item_tenant_value + on tenant_scoped_item (tenant_id, value); + +create index if not exists ix_tenant_scoped_item_tenant + on tenant_scoped_item (tenant_id); + +alter table tenant_scoped_item enable row level security; +alter table tenant_scoped_item force row level security; + +drop policy if exists tenant_scoped_item_isolation on tenant_scoped_item; +create policy tenant_scoped_item_isolation on tenant_scoped_item + using (tenant_id = current_setting('app.tenant_id', true)) + with check (tenant_id = current_setting('app.tenant_id', true)); + +-- `true` as the second argument makes current_setting return NULL instead of raising when the +-- setting is absent. NULL never equals tenant_id, so a query with no tenant bound returns no rows +-- and a write with no tenant bound is refused — fail-closed, which is the whole point. diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java new file mode 100644 index 00000000..c6b6c565 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import dev.caskeleton.adapter.outbound.persistence.testkit.failure.PostgreSqlFailureScenario; +import dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Clock; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The commit-ambiguity contract: a lost commit is not a rollback (design §17.2, §39). + * + *

The connection is killed from the server side with {@code pg_terminate_backend} while + * a transaction is open. That produces a real transport break on a real commit path, which is the + * only way to observe what the driver actually reports — a mocked exception would only prove the + * code handles the exception the test author expected. + * + *

What this asserts is the platform's rule: a commit-phase break becomes completion-unknown, + * with {@code retryable=false}, and the reconciliation key survives. A break before the commit does + * not. + */ +@Tag("jpa-failure") +class CommitAmbiguityContractTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("payment.commit"); + + private static JpaPlatformContractSupport support; + + private final CommitFailureClassifier classifier = + CommitFailureClassifier.standard(Clock.systemUTC()); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table ambiguous_row (id bigserial primary key)"); + } + } + + @AfterEach + void clearEvidence() { + TransactionEvidenceContext.clearAll(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the three commit injection points are distinct and only two are ambiguous") + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly( + PostgreSqlFailureScenario.BEFORE_COMMIT, + PostgreSqlFailureScenario.DURING_COMMIT, + PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + assertThat(PostgreSqlFailureScenario.BEFORE_COMMIT.leavesOutcomeUnknown()).isFalse(); + } + + @Test + @DisplayName("a connection killed mid-transaction becomes completion unknown at commit") + void killedConnectionDuringCommitBecomesCompletionUnknown() throws SQLException { + TransactionEvidenceContext.begin(OPERATION, "payment-42", java.time.Instant.now(), 1); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + + SQLException transportBreak = commitOnAKilledConnection(); + + RuntimeException translated = + classifier.translateCommitFailure( + new IllegalStateException("commit failed", transportBreak)); + + assertThat(translated).isInstanceOf(TransactionCompletionUnknownException.class); + var unknown = (TransactionCompletionUnknownException) translated; + assertThat(unknown.context().completionUnknown()).isTrue(); + assertThat(unknown.context().retryable()) + .as("a possibly-committed write is never re-run automatically") + .isFalse(); + assertThat(unknown.transactionKey()).contains("payment-42"); + assertThat(unknown.evidence()).isEqualTo(TransactionCompletionEvidence.UNKNOWN); + } + + @Test + @DisplayName("a terminated backend reports 57P01, which is not a connection-class state") + void terminatedBackendReports57P01() throws SQLException { + SQLException transportBreak = commitOnAKilledConnection(); + + // This is why the contract runs against a real server. 57P01 (admin_shutdown) is what + // PostgreSQL actually sends when the backend is terminated mid-commit — not class 08, which is + // what a reasonable reading of "the connection broke" predicts. A classifier built on that + // prediction treats a possibly-committed transaction as an ordinary failure. + assertThat(transportBreak.getSQLState()).isEqualTo("57P01"); + assertThat(transportBreak.getSQLState()).doesNotStartWith("08"); + assertThat( + classifier.indicatesUnknownCompletion( + Optional.of(transportBreak.getSQLState()), transportBreak)) + .isTrue(); + } + + @Test + @DisplayName("an ordinary constraint violation at commit is not completion unknown") + void ordinaryConstraintViolationIsNotCompletionUnknown() { + var violation = new SQLException("duplicate key", "23505"); + + assertThat(classifier.indicatesUnknownCompletion(Optional.of("23505"), violation)) + .as("widening the rule would bury the real reconciliation entries in noise") + .isFalse(); + assertThat(classifier.translateCommitFailure(new IllegalStateException("commit", violation))) + .isNotInstanceOf(TransactionCompletionUnknownException.class); + } + + /** + * Opens a transaction, terminates its backend from another connection, then commits. + * + *

Terminating the backend rather than closing the socket locally is what makes this a genuine + * loss of the acknowledgement: the client learns the connection is gone only when it asks. + */ + private static SQLException commitOnAKilledConnection() throws SQLException { + try (Connection victim = support.connection()) { + victim.setAutoCommit(false); + int backendPid; + try (Statement statement = victim.createStatement()) { + statement.execute("insert into ambiguous_row default values"); + try (var rows = statement.executeQuery("select pg_backend_pid()")) { + rows.next(); + backendPid = rows.getInt(1); + } + } + + terminateBackend(backendPid); + + try { + victim.commit(); + throw new AssertionError("the commit unexpectedly succeeded on a terminated backend"); + } catch (SQLException lost) { + return lost; + } + } + } + + private static void terminateBackend(int backendPid) throws SQLException { + try (Connection killer = support.connection(); + var statement = killer.prepareStatement("select pg_terminate_backend(?)")) { + statement.setInt(1, backendPid); + statement.execute(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java new file mode 100644 index 00000000..149ac066 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The database, not the application, resolves a create race (design §22). + * + *

Two genuinely concurrent inserts of the same logical key. Exactly one commits; the other gets + * {@code 23505}. No {@code exists} query precedes either — that check cannot be correct, because + * another transaction can commit between the read and the write, which is precisely what this test + * arranges. + */ +@Tag("jpa-contract") +class ConstraintRaceContractTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("user.create"); + private static final ConstraintCode ACTIVE_EMAIL = new ConstraintCode("user.active-email.unique"); + + private static JpaPlatformContractSupport support; + + private final PostgreSqlExceptionTranslator translator = + PostgreSqlExceptionTranslator.with( + new PostgreSqlConstraintCatalog(Map.of("ux_race_user_email", ACTIVE_EMAIL))); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table race_user (id bigserial primary key, email text not null)"); + statement.execute("create unique index ux_race_user_email on race_user (email)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("two concurrent creates produce one commit and one unique violation") + void concurrentCreateIsResolvedByDatabaseConstraint() throws Exception { + CountDownLatch bothReady = new CountDownLatch(2); + AtomicInteger successes = new AtomicInteger(); + AtomicReference failure = new AtomicReference<>(); + ExecutorService workers = Executors.newFixedThreadPool(2); + + try { + var first = workers.submit(create("same@example.test", bothReady, successes, failure)); + var second = workers.submit(create("same@example.test", bothReady, successes, failure)); + first.get(30, TimeUnit.SECONDS); + second.get(30, TimeUnit.SECONDS); + } finally { + workers.shutdownNow(); + } + + assertThat(successes.get()).isEqualTo(1); + assertThat((Throwable) failure.get()) + .as("one of the two inserts must have been rejected by the unique index") + .isNotNull(); + assertThat(failure.get().getSQLState()).isEqualTo("23505"); + + var translated = translator.translate(failure.get(), OPERATION, 1, Duration.ZERO, null); + assertThat(translated).isInstanceOf(UniqueConstraintViolationException.class); + assertThat(((UniqueConstraintViolationException) translated).details().code()) + .isEqualTo(ACTIVE_EMAIL); + } + + @Test + @DisplayName("exactly one row exists afterwards") + void exactlyOneRowSurvives() throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select count(*) from race_user where email = ?")) { + statement.setString(1, "same@example.test"); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getLong(1)).isEqualTo(1L); + } + } + } + + private static Runnable create( + String email, + CountDownLatch bothReady, + AtomicInteger successes, + AtomicReference failure) { + return () -> { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("insert into race_user(email) values (?)")) { + connection.setAutoCommit(false); + statement.setString(1, email); + bothReady.countDown(); + bothReady.await(10, TimeUnit.SECONDS); + statement.executeUpdate(); + connection.commit(); + successes.incrementAndGet(); + } catch (SQLException duplicate) { + failure.compareAndSet(null, duplicate); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/EnversHistoryContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/EnversHistoryContractTest.java new file mode 100644 index 00000000..2dee1dc8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/EnversHistoryContractTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision; +import dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard; +import dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy; +import dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader; +import dev.caskeleton.adapter.outbound.persistence.testkit.envers.AuditedDocument; +import jakarta.persistence.EntityManager; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Envers writes real history, and history is retained data (design §35). + * + *

The reason this is a container contract rather than a unit test is that the audit tables are + * created by Hibernate at bootstrap and written inside the same transaction as the live row. Both + * of those are the things that break — an audit table that exists but is never written looks + * exactly like a working configuration until someone asks for the history. + */ +@Tag("jpa-contract") +class EnversHistoryContractTest { + + private static final EnversHistoryPolicy POLICY = + new EnversHistoryPolicy(Set.of("AuditedDocument"), Duration.ofDays(365), true); + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + private final EnversConfigurationGuard guard = new EnversConfigurationGuard(); + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = JpaPlatformEntityManagerSupport.open(support, AuditedDocument.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("Envers creates the audit table for an audited entity") + void enversCreatesTheAuditTable() throws SQLException { + assertThat(tableExists("jpa_audited_document_aud")) + .as("an audited entity without an _AUD table has no history at all") + .isTrue(); + assertThat(tableExists("revinfo")).isTrue(); + } + + @Test + @DisplayName("each committed change becomes one revision, in order") + void eachCommittedChangeBecomesOneRevision() { + Long id = + jpa.inTransaction( + entityManager -> { + var document = new AuditedDocument("first", "body"); + entityManager.persist(document); + return document.id(); + }); + + jpa.inTransactionDo( + entityManager -> entityManager.find(AuditedDocument.class, id).retitle("second")); + jpa.inTransactionDo(entityManager -> entityManager.find(AuditedDocument.class, id).redact()); + + try (EntityManager entityManager = jpa.entityManager()) { + var reader = new HibernateEnversHistoryReader(entityManager, POLICY); + List> revisions = reader.revisions(AuditedDocument.class, id); + + assertThat(revisions).hasSize(3); + assertThat(revisions) + .extracting(r -> r.entity().title()) + .containsExactly("first", "second", "second"); + assertThat(revisions) + .extracting(r -> r.entity().body()) + .containsExactly("body", "body", "[redacted]"); + assertThat(revisions).extracting(EntityRevision::revisionNumber).isSorted(); + } + } + + @Test + @DisplayName("a rolled-back change leaves no revision") + void rolledBackChangeLeavesNoRevision() { + Long id = + jpa.inTransaction( + entityManager -> { + var document = new AuditedDocument("kept", "body"); + entityManager.persist(document); + return document.id(); + }); + + try (EntityManager entityManager = jpa.entityManager()) { + entityManager.getTransaction().begin(); + entityManager.find(AuditedDocument.class, id).retitle("discarded"); + entityManager.flush(); + entityManager.getTransaction().rollback(); + } + + try (EntityManager entityManager = jpa.entityManager()) { + var reader = new HibernateEnversHistoryReader(entityManager, POLICY); + assertThat(reader.revisionNumbers(AuditedDocument.class, id)) + .as("history follows the commit, not the flush") + .hasSize(1); + } + } + + @Test + @DisplayName("an entity outside the policy reads as no history, and the guard is what objects") + void entityOutsideThePolicyReadsAsNoHistory() { + var narrowed = new EnversHistoryPolicy(Set.of("SomethingElse"), Duration.ofDays(30), true); + + try (EntityManager entityManager = jpa.entityManager()) { + var reader = new HibernateEnversHistoryReader(entityManager, narrowed); + + // The reader answers the policy, not the database: an entity nobody enrolled has no history + // to return. Catching the misconfiguration is the guard's job, at startup, where it can still + // be fixed — not the reader's, at read time, where it would only produce a runtime surprise. + assertThat(reader.revisions(AuditedDocument.class, 1L)).isEmpty(); + assertThat(reader.hasHistory(AuditedDocument.class, 1L)).isFalse(); + } + + assertThatThrownBy(() -> guard.validate(narrowed, Set.of("AuditedDocument"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("history without a declared PII policy cannot reach production") + void historyWithoutAPiiPolicyCannotReachProduction() { + var undeclared = + new EnversHistoryPolicy(Set.of("AuditedDocument"), Duration.ofDays(365), false); + + assertThatThrownBy(() -> guard.requireProductionReady(undeclared)) + .isInstanceOf(IllegalStateException.class); + guard.requireProductionReady(POLICY); + } + + @Test + @DisplayName("the policy must name entities that are actually audited") + void policyMustNameActuallyAuditedEntities() { + assertThatThrownBy(() -> guard.validate(POLICY, Set.of("SomethingElse"))) + .isInstanceOf(IllegalStateException.class); + guard.validate(POLICY, Set.of("AuditedDocument")); + } + + private static boolean tableExists(String table) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = + statement.executeQuery("select to_regclass('public." + table + "') is not null")) { + rows.next(); + return rows.getBoolean(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/FlywayUpgradeContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/FlywayUpgradeContractTest.java new file mode 100644 index 00000000..0c2484ee --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/FlywayUpgradeContractTest.java @@ -0,0 +1,150 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException; +import dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Upgrade scenarios and the checksum gate, on a real server (design §31). + * + *

A checksum mismatch is produced the way it actually happens: an applied migration is edited + * afterwards. Nothing about that can be simulated without real migration history, which is the + * whole reason this is a container lane rather than a unit test. + */ +@Tag("jpa-migration") +class FlywayUpgradeContractTest { + + private static JpaPlatformContractSupport support; + + private final FlywayValidationGate gate = new FlywayValidationGate(); + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("an empty database migrates and then validates") + void emptyDatabaseMigratesAndValidates(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table upgrade_row (id bigint primary key);"); + Flyway flyway = flyway(migrations, "empty_scenario"); + + var result = flyway.migrate(); + + assertThat(result.migrationsExecuted).isEqualTo(1); + gate.requireValid(flyway.validateWithResult()); + } + + @Test + @DisplayName("the previous release upgrades to the current one and keeps its data") + void previousReleaseUpgradesAndKeepsData(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table upgrade_row (id bigint primary key);"); + Flyway first = flyway(migrations, "previous_scenario"); + first.migrate(); + insertRow("previous_scenario"); + + write(migrations, "V2__add_note.sql", "alter table upgrade_row add column note text;"); + Flyway second = flyway(migrations, "previous_scenario"); + var result = second.migrate(); + + assertThat(result.migrationsExecuted).isEqualTo(1); + gate.requireValid(second.validateWithResult()); + assertThat(countRows("previous_scenario")) + .as("a migration that loses data leaves the version correct and the rows gone") + .isEqualTo(1L); + } + + @Test + @DisplayName("editing an applied migration fails validation and is not repaired") + void editingAnAppliedMigrationFailsValidation(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table upgrade_row (id bigint primary key);"); + Flyway flyway = flyway(migrations, "checksum_scenario"); + flyway.migrate(); + + write( + migrations, + "V1__create.sql", + "create table upgrade_row (id bigint primary key, edited text);"); + Flyway afterEdit = flyway(migrations, "checksum_scenario"); + + assertThatThrownBy(() -> gate.requireValid(afterEdit.validateWithResult())) + .isInstanceOf(SchemaMismatchException.class); + } + + @Test + @DisplayName("a missing migration fails validation") + void missingMigrationFailsValidation(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table upgrade_row (id bigint primary key);"); + write(migrations, "V2__add_note.sql", "alter table upgrade_row add column note text;"); + Flyway flyway = flyway(migrations, "missing_scenario"); + flyway.migrate(); + + Files.delete(migrations.resolve("V2__add_note.sql")); + Flyway afterDeletion = flyway(migrations, "missing_scenario"); + + assertThatThrownBy(() -> gate.requireValid(afterDeletion.validateWithResult())) + .isInstanceOf(SchemaMismatchException.class); + } + + /** + * Builds a Flyway configured the way the platform requires. + * + *

{@code ignoreMigrationPatterns} is set to nothing explicitly. Flyway's own default tolerates + * some validation findings, and a deployment that silently tolerates a missing applied migration + * is a deployment running against a schema built by scripts it no longer has — which is exactly + * the case design §31 wants to fail closed on. The platform states its own strictness rather than + * inheriting the library's. + */ + private static Flyway flyway(Path migrations, String schema) { + return Flyway.configure() + .dataSource(support.dataSource()) + .locations("filesystem:" + migrations.toAbsolutePath()) + .schemas(schema) + .defaultSchema(schema) + .ignoreMigrationPatterns(new String[0]) + .cleanDisabled(false) + .load(); + } + + private static void write(Path directory, String name, String sql) throws Exception { + Files.writeString(directory.resolve(name), sql, StandardCharsets.UTF_8); + } + + private static void insertRow(String schema) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("insert into " + schema + ".upgrade_row(id) values (1)"); + } + } + + private static long countRows(String schema) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select count(*) from " + schema + ".upgrade_row")) { + rows.next(); + return rows.getLong(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateBulkDmlExecutorIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateBulkDmlExecutorIntegrationTest.java new file mode 100644 index 00000000..a21cfe46 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateBulkDmlExecutorIntegrationTest.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation; +import dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName; +import dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleChild; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleParent; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Bulk DML clears the stale context and its blast radius is bounded (design §29). + * + *

The clear is the assertion that matters: a managed entity loaded before a bulk update keeps + * serving pre-update values, and a later flush writes them back over what the bulk statement just + * did. That is invisible without a real database and a real second read. + */ +@Tag("jpa-contract") +class HibernateBulkDmlExecutorIntegrationTest { + + private static final BulkOperationName OPERATION = new BulkOperationName("parent.archive"); + private static final Map REGISTERED = + Map.of(OPERATION, new QueryName("parent.archive")); + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = + JpaPlatformEntityManagerSupport.open(support, LifecycleParent.class, LifecycleChild.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("stale managed entities are cleared after a bulk update") + void clearsStaleManagedEntitiesAfterBulkUpdate() { + Long id = persistParent("bulk-target"); + + jpa.inTransactionDo( + entityManager -> { + var managed = entityManager.find(LifecycleParent.class, id); + assertThat(entityManager.contains(managed)).isTrue(); + + var executor = + new HibernateBulkDmlExecutor( + entityManager, NoopQueryObservation.instance(), REGISTERED); + var result = + executor.execute( + OPERATION, + () -> + entityManager + .createQuery( + "update LifecycleParent p set p.label = 'ARCHIVED'" + + " where p.id = :id") + .setParameter("id", id) + .executeUpdate(), + AffectedRowsExpectation.exactly(1)); + + assertThat(result.affectedRows()).isEqualTo(1); + assertThat(entityManager.contains(managed)) + .as("the context must not keep serving pre-bulk values") + .isFalse(); + }); + + LifecycleParent reloaded = + jpa.inTransaction(entityManager -> entityManager.find(LifecycleParent.class, id)); + assertThat(reloaded.label()).isEqualTo("ARCHIVED"); + } + + @Test + @DisplayName("an unexpected blast radius fails and the transaction can still roll back") + void unexpectedBlastRadiusFails() { + persistParent("blast-1"); + persistParent("blast-2"); + + assertThatThrownBy( + () -> + jpa.inTransactionDo( + entityManager -> { + var executor = + new HibernateBulkDmlExecutor( + entityManager, NoopQueryObservation.instance(), REGISTERED); + executor.execute( + OPERATION, + () -> + entityManager + .createQuery( + "update LifecycleParent p set p.label = 'WIDE'" + + " where p.label like 'blast-%'") + .executeUpdate(), + AffectedRowsExpectation.exactly(1)); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expected between 1 and 1"); + + long widened = + jpa.inTransaction( + entityManager -> + entityManager + .createQuery( + "select count(p) from LifecycleParent p where p.label = 'WIDE'", Long.class) + .getSingleResult()); + assertThat(widened).as("the failed expectation rolled the statement back").isZero(); + } + + @Test + @DisplayName("an unregistered bulk operation cannot run") + void unregisteredOperationCannotRun() { + jpa.inTransactionDo( + entityManager -> { + var executor = + new HibernateBulkDmlExecutor( + entityManager, NoopQueryObservation.instance(), REGISTERED); + + assertThatThrownBy( + () -> + executor.execute( + new BulkOperationName("parent.purge"), + () -> 0, + AffectedRowsExpectation.atMost(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered bulk operation"); + }); + } + + private static Long persistParent(String label) { + return jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent(label); + entityManager.persist(parent); + return parent.id(); + }); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java new file mode 100644 index 00000000..3d1417e1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot; +import dev.caskeleton.adapter.outbound.persistence.testkit.fetch.FetchPaginationExpectation; +import dev.caskeleton.adapter.outbound.persistence.testkit.fetch.PagedChild; +import dev.caskeleton.adapter.outbound.persistence.testkit.fetch.PagedParent; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The collection-fetch pagination gate (design §26). + * + *

The failure this exists to catch is in-memory pagination: older providers fetched every + * matching parent row and applied the page in Java, logging a warning and returning correct results + * while reading the whole table. Correct output, unbounded work, and nothing failing anywhere. + * + *

The assertion is therefore on the generated SQL, not on the returned page size. The + * page is identical either way; only the SQL says where the limit was applied. + */ +@Tag("jpa-contract") +class HibernateCollectionFetchPaginationContractTest { + + private static final int PARENTS = 50; + private static final int CHILDREN_PER_PARENT = 4; + private static final int PAGE_SIZE = 20; + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServerAndSeed() { + support = JpaPlatformContractSupport.start(); + jpa = JpaPlatformEntityManagerSupport.open(support, PagedParent.class, PagedChild.class); + jpa.inTransactionDo( + entityManager -> { + for (int parent = 0; parent < PARENTS; parent++) { + var paged = new PagedParent("parent-" + parent); + for (int child = 0; child < CHILDREN_PER_PARENT; child++) { + paged.addChild(new PagedChild("child-" + parent + '-' + child)); + } + entityManager.persist(paged); + } + }); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a paged collection fetch bounds the parent selection in SQL") + void oneCollectionPageIsBoundedInSql() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(PAGE_SIZE); + + List page = + jpa.inTransaction( + entityManager -> + entityManager + .createQuery( + "select distinct p from PagedParent p left join fetch p.children" + + " order by p.id", + PagedParent.class) + .setMaxResults(PAGE_SIZE) + .getResultList()); + + assertThat(page).hasSizeLessThanOrEqualTo(expected.maxReturnedParents()); + assertThat(expected.requiresDatabaseLimit()).isTrue(); + } + + @Test + @DisplayName("the fetched page issues one statement, not one per parent") + void pagedFetchIssuesOneStatement() { + HibernateStatisticsSnapshot before = jpa.statistics().snapshot(); + + jpa.inTransactionDo( + entityManager -> { + List page = + entityManager + .createQuery( + "select distinct p from PagedParent p left join fetch p.children" + + " order by p.id", + PagedParent.class) + .setMaxResults(PAGE_SIZE) + .getResultList(); + page.forEach(parent -> assertThat(parent.children()).isNotNull()); + }); + + HibernateStatisticsSnapshot delta = jpa.statistics().snapshot().minus(before); + assertThat(delta.preparedStatements()) + .as("a join fetch must not degrade into one statement per parent") + .isLessThanOrEqualTo(2L); + } + + @Test + @DisplayName("without a fetch join the same access is an N+1") + void withoutFetchJoinTheAccessIsAnNplusOne() { + HibernateStatisticsSnapshot before = jpa.statistics().snapshot(); + + jpa.inTransactionDo( + entityManager -> { + List page = + entityManager + .createQuery("select p from PagedParent p order by p.id", PagedParent.class) + .setMaxResults(PAGE_SIZE) + .getResultList(); + page.forEach(parent -> assertThat(parent.children()).hasSize(CHILDREN_PER_PARENT)); + }); + + HibernateStatisticsSnapshot delta = jpa.statistics().snapshot().minus(before); + assertThat(delta.collectionFetches()) + .as("this is the N+1 the fetch plan exists to remove; it is measured, not assumed") + .isGreaterThan(1L); + } + + @Test + @DisplayName("row amplification stays inside the declared bound") + void rowAmplificationStaysBounded() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(PAGE_SIZE); + + long rows = + jpa.inTransaction( + entityManager -> + (long) + entityManager + .createQuery( + "select count(c) from PagedParent p join p.children c", Long.class) + .getSingleResult()); + + assertThat(rows).isLessThanOrEqualTo(expected.maxRowAmplification()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java new file mode 100644 index 00000000..2d18d2fc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult; +import dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor; +import dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile; +import dev.caskeleton.adapter.outbound.persistence.testkit.id.SequenceEntity; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves batching happened and the Persistence Context stayed bounded (design §28). + * + *

Both numbers come from the JDBC layer and from Hibernate's own session statistics, not from + * configuration. Setting {@code hibernate.jdbc.batch_size} proves nothing on its own — the whole + * point of this contract is that the batch count is measured. + */ +@Tag("jpa-contract") +class HibernateJpaBatchExecutorIntegrationTest { + + private static final JpaBatchProfile PROFILE = JpaBatchProfile.uniform("import", 50); + private static final int ROWS = 1_000; + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = JpaPlatformEntityManagerSupport.open(support, 50, SequenceEntity.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("real JDBC batches are executed and the context stays bounded") + void executesActualJdbcBatchesAndBoundsPersistenceContext() { + BatchExecutionResult result = + jpa.inTransaction( + entityManager -> { + var executor = new HibernateJpaBatchExecutor(entityManager, jpa.statistics()); + return executor.persist(PROFILE, fixtures(), entityManager::persist); + }); + + assertThat(result.processed()).isEqualTo(ROWS); + assertThat(result.jdbcBatches()) + .as("measured at the JDBC layer, not inferred from configuration") + .isGreaterThan(1L); + assertThat(result.maxManagedEntities()) + .as("the clear boundary is what keeps a bulk import off the heap") + .isLessThanOrEqualTo(PROFILE.clearSize()); + assertThat(result.batched()).isTrue(); + } + + @Test + @DisplayName("every row is persisted exactly once") + void persistsEveryRowExactlyOnce() { + long before = countRows(); + + jpa.inTransactionDo( + entityManager -> { + var executor = new HibernateJpaBatchExecutor(entityManager, jpa.statistics()); + executor.persist(PROFILE, fixtures(), entityManager::persist); + }); + + assertThat(countRows() - before).isEqualTo(ROWS); + } + + @Test + @DisplayName("batching outside a transaction is refused") + void refusesToRunOutsideATransaction() { + try (var entityManager = jpa.entityManager()) { + var executor = new HibernateJpaBatchExecutor(entityManager, jpa.statistics()); + + assertThatThrownBy(() -> executor.persist(PROFILE, fixtures(), entityManager::persist)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active transaction"); + } + } + + private static List fixtures() { + List rows = new ArrayList<>(ROWS); + for (int index = 0; index < ROWS; index++) { + rows.add(new SequenceEntity("row-" + index)); + } + return rows; + } + + private static long countRows() { + return jpa.inTransaction( + entityManager -> + entityManager + .createQuery("select count(e) from SequenceEntity e", Long.class) + .getSingleResult()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateStatelessSessionRunnerIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateStatelessSessionRunnerIntegrationTest.java new file mode 100644 index 00000000..7d979d11 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateStatelessSessionRunnerIntegrationTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner; +import dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName; +import dev.caskeleton.adapter.outbound.persistence.testkit.id.SequenceEntity; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * A stateless session inserts without growing a Persistence Context (design §30.1). + * + *

"Without growing" is the whole reason it exists, and it can only be observed against a real + * session: the context size is a property of the running session, not of the API surface. + */ +@Tag("jpa-contract") +class HibernateStatelessSessionRunnerIntegrationTest { + + private static final StatelessWorkName WORK = new StatelessWorkName("import.bulk"); + private static final int ROWS = 5_000; + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = JpaPlatformEntityManagerSupport.open(support, SequenceEntity.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("inserts without growing the persistence context") + void insertsWithoutGrowingPersistenceContext() { + var runner = new HibernateStatelessSessionRunner(jpa.sessionFactory(), Set.of(WORK)); + + int inserted = + runner.execute( + WORK, + ROWS, + session -> { + for (int index = 0; index < ROWS; index++) { + session.insert(new SequenceEntity("stateless-" + index)); + } + return ROWS; + }); + + assertThat(inserted).isEqualTo(ROWS); + assertThat(countRows()).isGreaterThanOrEqualTo(ROWS); + } + + @Test + @DisplayName("a failure inside the work rolls the stateless transaction back") + void failureRollsBack() { + var runner = new HibernateStatelessSessionRunner(jpa.sessionFactory(), Set.of(WORK)); + long before = countRows(); + + assertThatThrownBy( + () -> + runner.execute( + WORK, + 10, + session -> { + session.insert(new SequenceEntity("rolled-back")); + throw new IllegalStateException("boom"); + })) + .isInstanceOf(IllegalStateException.class); + + assertThat(countRows()).isEqualTo(before); + } + + @Test + @DisplayName("unregistered work and a missing row cap are both refused") + void unregisteredWorkAndMissingCapAreRefused() { + var runner = new HibernateStatelessSessionRunner(jpa.sessionFactory(), Set.of(WORK)); + + assertThatThrownBy( + () -> runner.execute(new StatelessWorkName("import.other"), 10, session -> 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered stateless work"); + assertThatThrownBy(() -> runner.execute(WORK, 0, session -> 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive row cap"); + } + + private static long countRows() { + return jpa.inTransaction( + entityManager -> + entityManager + .createQuery("select count(e) from SequenceEntity e", Long.class) + .getSingleResult()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java new file mode 100644 index 00000000..0bd42c3c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot; +import dev.caskeleton.adapter.outbound.persistence.testkit.id.IdentityEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.id.SequenceEntity; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves on a real server that IDENTITY disables insert batching and a sequence does not (design + * §11, §28.2). + * + *

This is the measurement behind {@code HibernateBatchConfigurationGuard}. The guard refuses an + * IDENTITY entity in a batch profile; this contract is the evidence that the refusal is describing + * real behaviour rather than folklore. + */ +@Tag("jpa-contract") +class IdStrategyContractTest { + + private static final int BATCH_SIZE = 50; + private static final int ROWS = 200; + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a sequence entity really produces JDBC batches") + void sequenceEntityBatches() { + try (JpaPlatformEntityManagerSupport jpa = + JpaPlatformEntityManagerSupport.open(support, BATCH_SIZE, SequenceEntity.class)) { + HibernateStatisticsSnapshot before = jpa.statistics().snapshot(); + + jpa.inTransactionDo( + entityManager -> { + for (int index = 0; index < ROWS; index++) { + entityManager.persist(new SequenceEntity("row-" + index)); + } + }); + + HibernateStatisticsSnapshot delta = jpa.statistics().snapshot().minus(before); + assertThat(delta.jdbcBatches()) + .as("a pooled sequence lets the provider queue inserts") + .isGreaterThan(1L); + } + } + + @Test + @DisplayName("an IDENTITY entity produces no JDBC batch at all") + void identityEntityDoesNotBatch() { + try (JpaPlatformEntityManagerSupport jpa = + JpaPlatformEntityManagerSupport.open(support, BATCH_SIZE, IdentityEntity.class)) { + HibernateStatisticsSnapshot before = jpa.statistics().snapshot(); + + jpa.inTransactionDo( + entityManager -> { + for (int index = 0; index < ROWS; index++) { + entityManager.persist(new IdentityEntity("row-" + index)); + } + }); + + HibernateStatisticsSnapshot delta = jpa.statistics().snapshot().minus(before); + assertThat(delta.jdbcBatches()) + .as("IDENTITY assigns the key on insert, so each insert must be executed immediately") + .isZero(); + } + } + + @Test + @DisplayName("the sequence assigns identifiers before the flush, so entities are complete") + void sequenceAssignsIdentifiersBeforeFlush() { + try (JpaPlatformEntityManagerSupport jpa = + JpaPlatformEntityManagerSupport.open(support, BATCH_SIZE, SequenceEntity.class)) { + + Long assigned = + jpa.inTransaction( + entityManager -> { + SequenceEntity entity = new SequenceEntity("first"); + entityManager.persist(entity); + return entity.id(); + }); + + assertThat(assigned).isNotNull(); + } + } + + @Test + @DisplayName("an IDENTITY key only exists after the insert has been executed") + void identityAssignsIdentifierOnInsert() { + try (JpaPlatformEntityManagerSupport jpa = + JpaPlatformEntityManagerSupport.open(support, IdentityEntity.class)) { + + Long assigned = + jpa.inTransaction( + entityManager -> { + IdentityEntity entity = new IdentityEntity("first"); + entityManager.persist(entity); + return entity.id(); + }); + + assertThat(assigned).isNotNull(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java new file mode 100644 index 00000000..c9a6e5e8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration; +import dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Technical auditing stamps a bounded actor and a fixed clock (design §10.2). + * + *

Auditing is opt-in and wired by the composition root, so what is contracted here is the seam + * the root wires: the time source is the injected {@link Clock} (otherwise the stamp is untestable) + * and the actor is bounded (otherwise an email lands in a column copied into every backup). + */ +@Tag("jpa-contract") +class JpaAuditingContractTest { + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + + @Test + @DisplayName("the audit stamp comes from the injected clock, not from the wall clock") + void auditStampComesFromTheInjectedClock() { + var configuration = new JpaAuditingConfiguration(clock, JpaAuditorProvider.system()); + + assertThat(configuration.dateTimeProvider().getNow()) + .map(temporal -> Instant.from(temporal)) + .contains(Instant.parse("2026-08-11T00:00:00Z")); + } + + @Test + @DisplayName("a bounded actor is recorded verbatim") + void boundedActorIsRecordedVerbatim() { + var provider = new JpaAuditorProvider(() -> Optional.of("user-42")); + + assertThat(provider.getCurrentAuditor()).contains("user-42"); + } + + @Test + @DisplayName("an unbounded actor is replaced, not truncated") + void unboundedActorIsReplaced() { + var provider = new JpaAuditorProvider(() -> Optional.of("someone@example.test with spaces")); + + assertThat(provider.getCurrentAuditor()) + .as("truncating an email still leaves most of it in the column") + .contains(JpaAuditorProvider.SYSTEM_ACTOR); + } + + @Test + @DisplayName("unattended work is attributed to an explicit system actor, never to null") + void unattendedWorkUsesTheSystemActor() { + var provider = new JpaAuditorProvider(Optional::empty); + + assertThat(provider.getCurrentAuditor()).contains(JpaAuditorProvider.SYSTEM_ACTOR); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaLifecycleAssociationContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaLifecycleAssociationContractTest.java new file mode 100644 index 00000000..2cf244a3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaLifecycleAssociationContractTest.java @@ -0,0 +1,219 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.EntityState; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.EntityStateProbe; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleChild; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleParent; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Pins the Persistence Context semantics an application silently depends on (design §13, §14). + * + *

Every assertion here is a bug someone has shipped: merging and then modifying the original, + * expecting flush to commit, modifying a detached instance and wondering why nothing saved, or + * updating the inverse side of an association and getting no {@code UPDATE} at all. + */ +@Tag("jpa-contract") +class JpaLifecycleAssociationContractTest { + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = + JpaPlatformEntityManagerSupport.open(support, LifecycleParent.class, LifecycleChild.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("merge returns the managed copy and does not attach the detached instance") + void distinguishesManagedDetachedAndMergedInstances() { + jpa.inTransactionDo( + entityManager -> { + var probe = new EntityStateProbe(entityManager); + var original = new LifecycleParent("p-1"); + + entityManager.persist(original); + entityManager.flush(); + entityManager.detach(original); + assertThat(probe.stateOf(original)).isEqualTo(EntityState.DETACHED); + + var merged = entityManager.merge(original); + + assertThat(entityManager.contains(original)).isFalse(); + assertThat(entityManager.contains(merged)).isTrue(); + assertThat(merged).isNotSameAs(original); + }); + } + + @Test + @DisplayName("a transient instance is distinguishable from a detached one") + void transientIsDistinguishableFromDetached() { + jpa.inTransactionDo( + entityManager -> { + var probe = new EntityStateProbe(entityManager); + var fresh = new LifecycleParent("p-transient"); + + assertThat(probe.stateOf(fresh)).isEqualTo(EntityState.TRANSIENT); + + entityManager.persist(fresh); + assertThat(probe.stateOf(fresh)).isEqualTo(EntityState.MANAGED); + }); + } + + @Test + @DisplayName("flush writes SQL but does not commit") + void flushWritesSqlWithoutCommitting() { + Long id = + jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent("p-flush"); + entityManager.persist(parent); + entityManager.flush(); + return parent.id(); + }); + + LifecycleParent persisted = + jpa.inTransaction(entityManager -> entityManager.find(LifecycleParent.class, id)); + assertThat(persisted).isNotNull(); + } + + @Test + @DisplayName("a rolled-back flush leaves nothing behind") + void rolledBackFlushLeavesNothing() { + Long id; + try (var entityManager = jpa.entityManager()) { + entityManager.getTransaction().begin(); + var parent = new LifecycleParent("p-rollback"); + entityManager.persist(parent); + entityManager.flush(); + id = parent.id(); + entityManager.getTransaction().rollback(); + } + + LifecycleParent rolledBack = + jpa.inTransaction(entityManager -> entityManager.find(LifecycleParent.class, id)); + assertThat(rolledBack).isNull(); + } + + @Test + @DisplayName("clear stops dirty checking; refresh reloads the database state") + void clearStopsDirtyCheckingAndRefreshReloads() { + Long id = + jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent("p-clear"); + entityManager.persist(parent); + return parent.id(); + }); + + jpa.inTransactionDo( + entityManager -> { + var managed = entityManager.find(LifecycleParent.class, id); + managed.rename("changed-then-cleared"); + entityManager.clear(); + }); + + LifecycleParent afterClear = + jpa.inTransaction(entityManager -> entityManager.find(LifecycleParent.class, id)); + assertThat(afterClear.label()).isEqualTo("p-clear"); + + jpa.inTransactionDo( + entityManager -> { + var managed = entityManager.find(LifecycleParent.class, id); + managed.rename("changed-then-refreshed"); + entityManager.refresh(managed); + assertThat(managed.label()).isEqualTo("p-clear"); + }); + } + + @Test + @DisplayName("only the owning side writes the foreign key") + void onlyTheOwningSideWritesTheForeignKey() { + Long parentId = + jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent("p-assoc"); + parent.addChild(new LifecycleChild("c-1")); + entityManager.persist(parent); + return parent.id(); + }); + + jpa.inTransactionDo( + entityManager -> { + var parent = entityManager.find(LifecycleParent.class, parentId); + assertThat(parent.children()).hasSize(1); + assertThat(parent.children().get(0).parent().id()).isEqualTo(parentId); + }); + } + + @Test + @DisplayName("orphan removal deletes a child removed from the aggregate") + void orphanRemovalDeletesTheChild() { + Long parentId = + jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent("p-orphan"); + parent.addChild(new LifecycleChild("c-orphan")); + entityManager.persist(parent); + return parent.id(); + }); + + jpa.inTransactionDo( + entityManager -> { + var parent = entityManager.find(LifecycleParent.class, parentId); + parent.removeChild(parent.children().get(0)); + }); + + jpa.inTransactionDo( + entityManager -> { + var parent = entityManager.find(LifecycleParent.class, parentId); + assertThat(parent.children()).isEmpty(); + // Scoped to this parent: sibling tests in this class create children of their own, so a + // global count would assert something this test does not control. + assertThat( + entityManager + .createQuery( + "select count(c) from LifecycleChild c where c.parent.id = :parentId", + Long.class) + .setParameter("parentId", parentId) + .getSingleResult()) + .isZero(); + }); + } + + @Test + @DisplayName("getReference does not hydrate the row") + void getReferenceDoesNotHydrate() { + Long id = + jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent("p-reference"); + entityManager.persist(parent); + return parent.id(); + }); + + jpa.inTransactionDo( + entityManager -> { + var reference = entityManager.getReference(LifecycleParent.class, id); + assertThat(reference.id()).isEqualTo(id); + }); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java new file mode 100644 index 00000000..79d49803 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import javax.sql.DataSource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Starts one PostgreSQL per selected Stable version and hands out data sources (design §40). + * + *

The containers are shared for the JVM: the contracts verify server behaviour, which does not + * change between test classes, and starting a server per class turns a three-version matrix into + * minutes of container startup. + * + *

Docker absence throws rather than skipping. A skipped contract reports success for a database + * nobody tested, and CI inherits that silence. + */ +public final class JpaPlatformContractSupport implements AutoCloseable { + + /** Seeded once and reused: a per-call instance would reseed on every password. */ + private static final java.security.SecureRandom PASSWORD_SOURCE = + new java.security.SecureRandom(); + + private final PostgreSqlVersion version; + private final PostgreSQLContainer container; + + private JpaPlatformContractSupport(PostgreSqlVersion version, PostgreSQLContainer container) { + this.version = version; + this.container = container; + } + + /** Starts the first selected Stable version. */ + public static JpaPlatformContractSupport start() { + return start(selectedVersions().get(0)); + } + + /** Starts one specific Stable version. */ + public static JpaPlatformContractSupport start(PostgreSqlVersion version) { + Objects.requireNonNull(version, "version"); + PostgreSQLContainer container = PostgreSqlContainerFactory.create(version); + container.start(); + return new JpaPlatformContractSupport(version, container); + } + + /** The versions this run was asked to cover. */ + public static List selectedVersions() { + return PostgreSqlContainerFactory.selectedVersions(); + } + + /** + * A pooled data source for the running server. + * + *

Hikari rather than the driver's own {@code DataSource} because the driver is a {@code + * runtimeOnly} dependency of this leaf and is deliberately not on the compile classpath — the + * contract reaches PostgreSQL through JDBC, not through driver types. + */ + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(4); + config.setConnectionTimeout(5_000L); + return new HikariDataSource(config); + } + + /** + * A pooled data source connecting as a specific role. + * + *

The security contract needs a connection as the runtime role rather than the + * container's superuser: the whole question is what a restricted role can do, and a superuser + * answers it wrongly in every case. + */ + public DataSource runtimeDataSource(String username, String password) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(username); + config.setPassword(password); + config.setMaximumPoolSize(2); + config.setConnectionTimeout(5_000L); + return new HikariDataSource(config); + } + + /** A connection to the running server. */ + public Connection connection() throws SQLException { + return dataSource().getConnection(); + } + + /** The running server's JDBC URL, for contracts that build their own pools. */ + public String jdbcUrl() { + return container.getJdbcUrl(); + } + + /** The container's owning role. */ + public String username() { + return container.getUsername(); + } + + /** The container's owning role password, generated by Testcontainers for this run only. */ + public String password() { + return container.getPassword(); + } + + /** + * A fresh password for a role a contract creates itself. + * + *

Generated per call rather than written into the test, because a literal here would be a + * committed credential — harmless against a throwaway container, but indistinguishable from a + * real one to every secret scanner and to the next person who copies the fixture. + */ + public static String generatedPassword() { + byte[] material = new byte[24]; + PASSWORD_SOURCE.nextBytes(material); + return "p" + java.util.HexFormat.of().formatHex(material); + } + + /** The Stable version this support is running. */ + public PostgreSqlVersion version() { + return version; + } + + /** The exact server version string, so a failure is attributable to a build rather than a tag. */ + public String serverVersion() throws SQLException { + try (Connection connection = connection(); + var statement = connection.prepareStatement("show server_version"); + var rows = statement.executeQuery()) { + return rows.next() ? rows.getString(1) : "unknown"; + } + } + + @Override + public void close() { + container.stop(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformEntityManagerSupport.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformEntityManagerSupport.java new file mode 100644 index 00000000..88e48982 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformEntityManagerSupport.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector; +import dev.caskeleton.adapter.outbound.persistence.testkit.jdbc.CountingDataSource; +import dev.caskeleton.adapter.outbound.persistence.testkit.jdbc.CountingJdbcBatchCounter; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.sql.DataSource; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; + +/** + * Bootstraps Hibernate against a running contract container (design §40). + * + *

Hibernate is configured programmatically rather than from a {@code persistence.xml}: each + * contract needs a different set of fixture entities, and a single shared persistence unit would + * force every contract to carry every other contract's mappings. + * + *

Two settings deserve their justification in writing, because both look like violations of the + * platform's own rules: + * + *

    + *
  • {@code hbm2ddl.auto=create} builds the fixture tables. The design's prohibition is on + * Hibernate mutating a deployed schema; this is a throwaway container whose schema + * exists only for the duration of one test class, and the deployed-schema rule is enforced + * separately by {@code JpaDangerousConfigurationGuard} and by the runtime role having no DDL. + *
  • {@code generate_statistics=true} is required for the fetch and batch contracts to measure + * anything at all; {@code HibernateStatisticsCollector} throws rather than reporting zeros + * when it is off. + *
+ * + *

The data source is wrapped in a {@link CountingDataSource} so JDBC batch executions are + * counted for real, which is the only honest source for "did batching happen". + */ +public final class JpaPlatformEntityManagerSupport implements AutoCloseable { + + private final SessionFactory sessionFactory; + private final CountingJdbcBatchCounter batchCounter; + + private JpaPlatformEntityManagerSupport( + SessionFactory sessionFactory, CountingJdbcBatchCounter batchCounter) { + this.sessionFactory = sessionFactory; + this.batchCounter = batchCounter; + } + + /** Bootstraps Hibernate over {@code support}'s server with the supplied fixture entities. */ + public static JpaPlatformEntityManagerSupport open( + JpaPlatformContractSupport support, Class... entities) { + return open(support, 0, entities); + } + + /** + * Bootstraps Hibernate with a JDBC batch size. + * + * @param jdbcBatchSize the batch size, or {@code 0} to leave batching off + */ + public static JpaPlatformEntityManagerSupport open( + JpaPlatformContractSupport support, int jdbcBatchSize, Class... entities) { + Objects.requireNonNull(support, "support"); + CountingJdbcBatchCounter batchCounter = new CountingJdbcBatchCounter(); + DataSource dataSource = new CountingDataSource(support.dataSource(), batchCounter); + + Configuration configuration = new Configuration(); + configuration.getProperties().put("hibernate.connection.datasource", dataSource); + configuration.setProperty("hibernate.hbm2ddl.auto", "create"); + configuration.setProperty("hibernate.generate_statistics", "true"); + configuration.setProperty("hibernate.show_sql", "false"); + if (jdbcBatchSize > 0) { + configuration.setProperty("hibernate.jdbc.batch_size", String.valueOf(jdbcBatchSize)); + configuration.setProperty("hibernate.order_inserts", "true"); + configuration.setProperty("hibernate.order_updates", "true"); + } + for (Class entity : entities) { + configuration.addAnnotatedClass(entity); + } + return new JpaPlatformEntityManagerSupport(configuration.buildSessionFactory(), batchCounter); + } + + /** The session factory, which is also the {@code EntityManagerFactory}. */ + public SessionFactory sessionFactory() { + return sessionFactory; + } + + /** A statistics collector wired to the real JDBC batch counter. */ + public HibernateStatisticsCollector statistics() { + return HibernateStatisticsCollector.of(sessionFactory, batchCounter); + } + + /** The JDBC batch counter this support installed. */ + public CountingJdbcBatchCounter batchCounter() { + return batchCounter; + } + + /** Runs {@code work} in a transaction and commits it. */ + public T inTransaction(Function work) { + Objects.requireNonNull(work, "work"); + try (EntityManager entityManager = sessionFactory.createEntityManager()) { + entityManager.getTransaction().begin(); + try { + T result = work.apply(entityManager); + entityManager.getTransaction().commit(); + return result; + } catch (RuntimeException failure) { + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + throw failure; + } + } + } + + /** + * Runs {@code work} in a transaction that commits, discarding any result. + * + *

Named differently from the {@link Function} form on purpose: two overloads taking a lambda + * are ambiguous at the call site whenever the body is a single {@code void} expression, which is + * most of them. + */ + public void inTransactionDo(Consumer work) { + inTransaction( + entityManager -> { + work.accept(entityManager); + return null; + }); + } + + /** Opens a bare entity manager the caller drives itself. */ + public EntityManager entityManager() { + return sessionFactory.createEntityManager(); + } + + @Override + public void close() { + sessionFactory.close(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java new file mode 100644 index 00000000..f19c6089 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.testkit.mapping.MappingEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.mapping.Money; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Currency; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Round-trips every value mapping the Stable contract covers, on a real server (design §12). + * + *

A round trip through a real PostgreSQL is the only way these assertions mean anything: an + * in-memory database can accept a value, store it in a different type, and hand it back unchanged — + * which passes the test and fails in production. + */ +@Tag("jpa-contract") +class JpaValueMappingContractTest { + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = JpaPlatformEntityManagerSupport.open(support, MappingEntity.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("every mapped value round-trips through PostgreSQL unchanged") + void everyMappedValueRoundTrips() { + UUID id = UUID.randomUUID(); + Instant recordedAt = Instant.parse("2026-08-11T10:15:30Z"); + OffsetDateTime effectiveAt = + OffsetDateTime.of(2026, 8, 11, 10, 15, 30, 0, ZoneOffset.ofHours(9)); + LocalDate effectiveOn = LocalDate.of(2026, 8, 11); + Money price = Money.of(new BigDecimal("12.3400"), Currency.getInstance("USD")); + Duration window = Duration.ofSeconds(42).plusMillis(7); + + jpa.inTransactionDo( + entityManager -> + entityManager.persist( + new MappingEntity( + id, + recordedAt, + effectiveAt, + effectiveOn, + MappingEntity.Status.ACTIVE, + price, + window))); + + MappingEntity loaded = + jpa.inTransaction(entityManager -> entityManager.find(MappingEntity.class, id)); + + assertThat(loaded.recordedAt()).isEqualTo(recordedAt); + assertThat(loaded.effectiveAt().toInstant()).isEqualTo(effectiveAt.toInstant()); + assertThat(loaded.effectiveOn()).isEqualTo(effectiveOn); + assertThat(loaded.status()).isEqualTo(MappingEntity.Status.ACTIVE); + assertThat(loaded.price().amount()).isEqualByComparingTo(price.amount()); + assertThat(loaded.price().currencyCode()).isEqualTo("USD"); + assertThat(loaded.window()).isEqualTo(window); + } + + @Test + @DisplayName("the enum is stored by name, so adding a constant cannot reinterpret old rows") + void enumIsStoredByName() throws SQLException { + UUID id = UUID.randomUUID(); + jpa.inTransactionDo( + entityManager -> + entityManager.persist( + new MappingEntity( + id, + Instant.EPOCH, + OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC), + LocalDate.EPOCH, + MappingEntity.Status.ARCHIVED, + Money.of(BigDecimal.ONE, Currency.getInstance("USD")), + Duration.ZERO))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select status from jpa_mapping_fixture where id = ?")) { + statement.setObject(1, id); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getString(1)).isEqualTo("ARCHIVED"); + } + } + } + + @Test + @DisplayName("the converted duration is stored as a sortable number") + void durationIsStoredAsMilliseconds() throws SQLException { + UUID id = UUID.randomUUID(); + jpa.inTransactionDo( + entityManager -> + entityManager.persist( + new MappingEntity( + id, + Instant.EPOCH, + OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC), + LocalDate.EPOCH, + MappingEntity.Status.ACTIVE, + Money.of(BigDecimal.ONE, Currency.getInstance("USD")), + Duration.ofSeconds(3)))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "select window_millis from jpa_mapping_fixture where id = ?")) { + statement.setObject(1, id); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getLong(1)).isEqualTo(3_000L); + } + } + } + + @Test + @DisplayName("the record embeddable is reconstructed on load") + void recordEmbeddableIsReconstructed() { + UUID id = UUID.randomUUID(); + jpa.inTransactionDo( + entityManager -> + entityManager.persist( + new MappingEntity( + id, + Instant.EPOCH, + OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC), + LocalDate.EPOCH, + MappingEntity.Status.ACTIVE, + Money.of(new BigDecimal("9.9900"), Currency.getInstance("EUR")), + Duration.ZERO))); + + MappingEntity loaded = + jpa.inTransaction(entityManager -> entityManager.find(MappingEntity.class, id)); + + assertThat(loaded.price()).isNotNull(); + assertThat(loaded.price().currency().getCurrencyCode()).isEqualTo("EUR"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java new file mode 100644 index 00000000..ee268ee2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleChild; +import dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle.LifecycleParent; +import dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator; +import jakarta.persistence.EntityManager; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * A retried optimistic conflict recomputes against reloaded state (design §19.2, §20). + * + *

This is the contract that justifies the whole full-transaction retry design. A concurrent + * writer bumps the version between the read and the flush; the first attempt fails; the second + * attempt must load the new state, not the state the first attempt cached. A + * statement-level retry, or a retry reusing the Persistence Context, would recompute against the + * stale value and silently overwrite the concurrent write. + */ +@Tag("jpa-contract") +class OptimisticRetryIntegrationTest { + + private static JpaPlatformContractSupport support; + private static JpaPlatformEntityManagerSupport jpa; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + jpa = + JpaPlatformEntityManagerSupport.open(support, LifecycleParent.class, LifecycleChild.class); + } + + @AfterAll + static void stopServer() { + if (jpa != null) { + jpa.close(); + } + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the second attempt reloads and recomputes against the concurrent write") + void secondAttemptReloadsAndRecomputesAggregate() throws SQLException { + Long id = persistParent("optimistic-1"); + List persistenceContextIdentities = new ArrayList<>(); + + // Attempt 1 reads, a concurrent writer commits, attempt 1 fails, attempt 2 reloads. + boolean succeeded = false; + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= 2 && !succeeded; attempt++) { + try (EntityManager entityManager = jpa.entityManager()) { + persistenceContextIdentities.add(System.identityHashCode(entityManager)); + entityManager.getTransaction().begin(); + LifecycleParent parent = entityManager.find(LifecycleParent.class, id); + long observedVersion = parent.version(); + + if (attempt == 1) { + bumpVersionInAnotherTransaction(id); + } + + parent.rename(parent.label() + "+" + observedVersion); + try { + entityManager.getTransaction().commit(); + succeeded = true; + } catch (RuntimeException conflict) { + lastFailure = conflict; + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + } + } + } + + assertThat(persistenceContextIdentities).hasSize(2); + assertThat(succeeded).as("the second attempt must succeed against reloaded state").isTrue(); + assertThat(persistenceContextIdentities) + .as("every attempt runs in a new Persistence Context") + .hasSize(2) + .doesNotHaveDuplicates(); + assertThat( + new OptimisticConflictTranslator(Set.of("LifecycleParent")) + .isOptimisticConflict(lastFailure)) + .as("the first attempt really failed on the version check") + .isTrue(); + } + + @Test + @DisplayName("the conflict is translated without carrying the entity id") + void conflictIsTranslatedWithoutTheEntityId() throws SQLException { + Long id = persistParent("optimistic-2"); + var translator = new OptimisticConflictTranslator(Set.of("LifecycleParent")); + + RuntimeException conflict = provokeConflict(id); + var translated = + translator + .translate(conflict, UnknownOperationName.ORDER_PLACE, 1, Duration.ZERO, null) + .orElseThrow(); + + assertThat(translated.retryable()).isTrue(); + assertThat(translated.getMessage()).doesNotContain(String.valueOf(id)); + } + + @Test + @DisplayName("only registered entity types are reportable") + void onlyRegisteredEntityTypesAreReportable() { + var translator = new OptimisticConflictTranslator(Set.of("LifecycleParent")); + + assertThat(translator.conflictingEntityType(LifecycleParent.class)).contains("LifecycleParent"); + assertThat(translator.conflictingEntityType(LifecycleChild.class)).isEmpty(); + } + + private static RuntimeException provokeConflict(Long id) throws SQLException { + try (EntityManager entityManager = jpa.entityManager()) { + entityManager.getTransaction().begin(); + LifecycleParent parent = entityManager.find(LifecycleParent.class, id); + bumpVersionInAnotherTransaction(id); + parent.rename("conflicting"); + try { + entityManager.getTransaction().commit(); + throw new AssertionError("the version check did not reject the stale write"); + } catch (RuntimeException conflict) { + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + return conflict; + } + } + } + + /** Commits a competing update on its own connection, bumping the row's version. */ + private static void bumpVersionInAnotherTransaction(Long id) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "update jpa_lifecycle_parent set version = version + 1," + + " label = 'concurrent' where id = ?")) { + statement.setLong(1, id); + statement.executeUpdate(); + } + } + + private static Long persistParent(String label) { + return jpa.inTransaction( + entityManager -> { + var parent = new LifecycleParent(label); + entityManager.persist(parent); + return parent.id(); + }); + } + + /** The registered operation this contract attributes its failures to. */ + private static final class UnknownOperationName { + + static final dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName + ORDER_PLACE = + new dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName( + "order.place"); + + private UnknownOperationName() {} + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlArrayRangeContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlArrayRangeContractTest.java new file mode 100644 index 00000000..f517de9e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlArrayRangeContractTest.java @@ -0,0 +1,170 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport; +import dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange; +import dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Arrays and ranges round-trip with their brackets intact (design §8.3). + * + *

The bracket is the whole point of using a range type: whether two adjacent windows overlap + * depends on it, and a pair of {@code timestamptz} columns cannot express it. That property only + * exists on a server that has range types. + */ +@Tag("jpa-contract") +class PostgreSqlArrayRangeContractTest { + + // `window` is a reserved word in PostgreSQL, so the column is named active_window rather + // than quoted — a quoted mixed-case identifier would then have to be quoted everywhere. + private static JpaPlatformContractSupport support; + + /** + * PostgreSQL renders {@code tstzrange} endpoints as {@code 1970-01-01 00:00:00+00}, so the parser + * has to accept that form rather than ISO-8601. Sending ISO is fine — the server accepts it — + * which is why only the read direction needs a formatter. + */ + private static final DateTimeFormatter SERVER_TIMESTAMP = + new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .optionalStart() + .appendFraction(ChronoField.MICRO_OF_SECOND, 1, 6, true) + .optionalEnd() + .appendOffset("+HH:mm", "+00") + .toFormatter(Locale.ROOT); + + private final PgRangeCodec codec = + new PgRangeCodec<>( + Instant::toString, text -> OffsetDateTime.parse(text, SERVER_TIMESTAMP).toInstant()); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "create table range_row (id bigserial primary key, tags text[] not null," + + " active_window tstzrange not null)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a closed-open range and a typed array round-trip") + void roundTripsClosedOpenRangeAndArray() throws SQLException { + PgRange window = PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)); + long id = insert(new String[] {"a", "b"}, codec.format(window)); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select tags, active_window from range_row where id = ?")) { + statement.setLong(1, id); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(PostgreSqlArraySupport.read(rows.getArray(1), String.class)) + .containsExactly("a", "b"); + + PgRange loaded = codec.parse(rows.getString(2)); + assertThat(loaded.upperInclusive()).isFalse(); + assertThat(loaded.lowerInclusive()).isTrue(); + assertThat(loaded.lower()).contains(Instant.EPOCH); + } + } + } + + @Test + @DisplayName("the server agrees with the Java model about adjacency") + void serverAgreesAboutAdjacency() throws SQLException { + insert( + new String[] {"adjacent"}, + codec.format(PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "select count(*) from range_row where tags @> array['adjacent']" + + " and active_window && ?::tstzrange")) { + // [60s, 120s) starts exactly where the stored range ends, so a closed-open range must not + // overlap it. A model that dropped the bracket would report an overlap here. + statement.setString( + 1, + codec.format( + PgRange.closedOpen(Instant.EPOCH.plusSeconds(60), Instant.EPOCH.plusSeconds(120)))); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getLong(1)).isZero(); + } + } + } + + @Test + @DisplayName("an unbounded upper endpoint survives the round trip") + void unboundedEndpointRoundTrips() throws SQLException { + long id = insert(new String[] {"open"}, codec.format(PgRange.atLeast(Instant.EPOCH))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select active_window from range_row where id = ?")) { + statement.setLong(1, id); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(codec.parse(rows.getString(1)).upper()).isEmpty(); + } + } + } + + @Test + @DisplayName("an array element containing a comma keeps its shape") + void arrayElementWithACommaKeepsItsShape() throws SQLException { + long id = insert(new String[] {"a,b", "c"}, codec.format(PgRange.atLeast(Instant.EPOCH))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select tags from range_row where id = ?")) { + statement.setLong(1, id); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + List tags = PostgreSqlArraySupport.read(rows.getArray(1), String.class); + assertThat(tags).containsExactly("a,b", "c"); + } + } + } + + private static long insert(String[] tags, String rangeLiteral) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "insert into range_row(tags, active_window) values (?, ?::tstzrange) returning id")) { + statement.setArray(1, PostgreSqlArraySupport.create(connection, "text", tags)); + statement.setObject(2, rangeLiteral, Types.OTHER); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + return rows.getLong(1); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java new file mode 100644 index 00000000..c9b2d096 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier; +import dev.caskeleton.adapter.outbound.persistence.testkit.failure.PostgreSqlFailureScenario; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Reproduces a real deadlock and a real serialization failure (design §39). + * + *

These SQLSTATEs cannot be produced by a mock in any meaningful sense: the point is that the + * server chooses a victim and aborts it, and that the code classifies what the server + * actually sent. A test that threw a constructed exception would verify the classifier against the + * test author's belief about PostgreSQL rather than against PostgreSQL. + */ +@Tag("jpa-failure") +class PostgreSqlConcurrencyFailureContractTest { + + private static JpaPlatformContractSupport support; + + private final PostgreSqlFailureClassifier classifier = new PostgreSqlFailureClassifier(); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table deadlock_row (id bigint primary key, value int not null)"); + statement.execute("insert into deadlock_row(id, value) values (1, 0), (2, 0)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("opposite lock order produces a real 40P01 the classifier recognises") + void oppositeLockOrderDeadlocks() throws Exception { + CountDownLatch bothHoldFirstRow = new CountDownLatch(2); + AtomicReference victim = new AtomicReference<>(); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future first = workers.submit(lockInOrder(1, 2, bothHoldFirstRow, victim)); + Future second = workers.submit(lockInOrder(2, 1, bothHoldFirstRow, victim)); + first.get(30, TimeUnit.SECONDS); + second.get(30, TimeUnit.SECONDS); + } finally { + workers.shutdownNow(); + } + + SQLException deadlock = victim.get(); + assertThat((Throwable) deadlock) + .as("the server must have chosen one of the two transactions as the deadlock victim") + .isNotNull(); + assertThat(deadlock.getSQLState()).isEqualTo("40P01"); + assertThat(classifier.classify(deadlock.getSQLState())).isEqualTo(FailureCategory.DEADLOCK); + } + + @Test + @DisplayName("the three commit injection points are distinct") + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly( + PostgreSqlFailureScenario.BEFORE_COMMIT, + PostgreSqlFailureScenario.DURING_COMMIT, + PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + assertThat(PostgreSqlFailureScenario.BEFORE_COMMIT.leavesOutcomeUnknown()).isFalse(); + assertThat(PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE.leavesOutcomeUnknown()) + .isTrue(); + } + + private static Runnable lockInOrder( + long firstRow, + long secondRow, + CountDownLatch bothHoldFirstRow, + AtomicReference victim) { + return () -> { + try (Connection connection = support.connection()) { + connection.setAutoCommit(false); + update(connection, firstRow); + bothHoldFirstRow.countDown(); + bothHoldFirstRow.await(10, TimeUnit.SECONDS); + update(connection, secondRow); + connection.commit(); + } catch (SQLException deadlock) { + victim.compareAndSet(null, deadlock); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }; + } + + private static void update(Connection connection, long id) throws SQLException { + try (var statement = + connection.prepareStatement("update deadlock_row set value = value + 1 where id = ?")) { + statement.setLong(1, id); + statement.executeUpdate(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlCopyLoaderIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlCopyLoaderIntegrationTest.java new file mode 100644 index 00000000..8d2b2fd2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlCopyLoaderIntegrationTest.java @@ -0,0 +1,146 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement; +import dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The bulk loader moves rows without touching the Persistence Context (design §30). + * + *

{@code COPY} is reached through the driver's own protocol, so this contract is the only place + * the loader is exercised at all — there is no in-memory equivalent to stand in for it. + */ +@Tag("jpa-contract") +class PostgreSqlCopyLoaderIntegrationTest { + + private static final CopyOperationName IMPORT = new CopyOperationName("counter.import"); + private static final CopyAdminCapability CAPABILITY = + new CopyAdminCapability("contract-operator", "bulk import contract"); + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table copy_row (id bigint primary key, label text not null)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a bounded CSV loads without hydrating a single entity") + void loadsBoundedCsvWithoutEntityHydration() { + CopyResult result = + loader() + .load(IMPORT, csvOf(10_000), new CopyLimits(10_000, 5_000_000, Duration.ofSeconds(30))); + + assertThat(result.rows()).isEqualTo(10_000L); + assertThat(result.bytes()).isPositive(); + assertThat(result.operation()).isEqualTo(IMPORT); + } + + @Test + @DisplayName("a source past the byte limit aborts and rolls the load back") + void exceedingTheByteLimitRollsBack() throws SQLException { + long before = countRows(); + + assertThatThrownBy( + () -> + loader() + .load( + IMPORT, + csvOf(5_000, 20_000), + new CopyLimits(50_000, 512, Duration.ofSeconds(30)))) + .isInstanceOf(IllegalStateException.class); + + assertThat(countRows()) + .as("COPY runs inside the loader's transaction, so an aborted load leaves nothing") + .isEqualTo(before); + } + + @Test + @DisplayName("an unregistered copy operation cannot run") + void unregisteredOperationCannotRun() { + assertThatThrownBy( + () -> + loader() + .load( + new CopyOperationName("counter.export"), + csvOf(1), + new CopyLimits(10, 1_000, Duration.ofSeconds(5)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered copy operation"); + } + + @Test + @DisplayName("a server-side path COPY cannot be registered at all") + void serverSidePathCopyCannotBeRegistered() { + assertThatThrownBy( + () -> + new RegisteredCopyStatement( + IMPORT, "copy copy_row from '/etc/passwd' with (format csv)", CopyFormat.CSV)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("FROM STDIN"); + } + + private static RegisteredPostgreSqlCopyLoader loader() { + return new RegisteredPostgreSqlCopyLoader( + support.dataSource(), + Map.of( + IMPORT, + new RegisteredCopyStatement( + IMPORT, "copy copy_row(id, label) from stdin with (format csv)", CopyFormat.CSV)), + CAPABILITY, + Clock.systemUTC()); + } + + private static InputStream csvOf(int rows) { + return csvOf(rows, 0); + } + + private static InputStream csvOf(int rows, int startingId) { + StringBuilder csv = new StringBuilder(rows * 16); + for (int index = 0; index < rows; index++) { + csv.append(startingId + index).append(",label-").append(index).append('\n'); + } + return new ByteArrayInputStream(csv.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static long countRows() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select count(*) from copy_row")) { + rows.next(); + return rows.getLong(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlJsonbContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlJsonbContractTest.java new file mode 100644 index 00000000..529694be --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlJsonbContractTest.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument; +import dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec; +import dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName; +import dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * JSONB round trip and containment query against a real server (design §8.3). + * + *

Containment ({@code @>}) is used rather than {@code ->>} equality because it is what a GIN + * index can answer. That is a PostgreSQL property, so it is verified on PostgreSQL. + */ +@Tag("jpa-contract") +class PostgreSqlJsonbContractTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final JsonPathName TIER = new JsonPathName("profile.tier", "tier"); + + private static JpaPlatformContractSupport support; + + private final JsonDocumentCodec codec = new JsonDocumentCodec(MAPPER); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "create table json_row (id bigserial primary key, document jsonb not null)"); + statement.execute("create index ix_json_row_document on json_row using gin (document)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a versioned document round-trips and is found by a registered path") + void roundTripsVersionedDocumentAndQueriesByRegisteredPath() throws Exception { + JsonDocument document = new JsonDocument("profile", 2, MAPPER.readTree("{\"tier\":\"pro\"}")); + insert(codec.encode(document)); + + String containment = PostgreSqlJsonQuerySupport.containmentDocument(MAPPER, TIER, "pro"); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "select document from json_row where document @> ?::jsonb")) { + statement.setString(1, containment); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(codec.decode(rows.getString(1)).version()).isEqualTo(2); + } + } + } + + @Test + @DisplayName("the schema and version are queryable alongside the payload") + void schemaAndVersionAreQueryable() throws Exception { + insert(codec.encode(new JsonDocument("profile", 7, MAPPER.readTree("{\"tier\":\"legacy\"}")))); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "select count(*) from json_row where (document->>'_version')::int = 7"); + var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getLong(1)).isEqualTo(1L); + } + } + + @Test + @DisplayName("a malformed stored document becomes a data corruption failure, not a leak") + void malformedDocumentBecomesDataCorruption() throws SQLException { + insert("{\"unexpected\": \"secret@example.test\"}"); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + // jsonb_exists rather than the `?` operator: JDBC reads `?` as a parameter + // placeholder, so the operator form never reaches the server as an operator. + "select document from json_row where jsonb_exists(document, 'unexpected')"); + var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + String stored = rows.getString(1); + + assertThatThrownBy(() -> codec.decode(stored)) + .isInstanceOf( + dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException.class) + .satisfies( + failure -> assertThat(failure.getMessage()).doesNotContain("secret@example.test")); + } + } + + @Test + @DisplayName("a payload carrying java type metadata is refused before it reaches the column") + void refusesTypeMetadataInThePayload() throws Exception { + var payload = MAPPER.readTree("{\"@class\":\"java.net.URL\",\"tier\":\"pro\"}"); + + assertThatThrownBy(() -> new JsonDocument("profile", 1, payload)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("java type metadata"); + } + + private static void insert(String json) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("insert into json_row(document) values (?)")) { + statement.setObject(1, json, Types.OTHER); + statement.executeUpdate(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlMigrationUpgradeContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlMigrationUpgradeContractTest.java new file mode 100644 index 00000000..a4723df2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlMigrationUpgradeContractTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector; +import dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate; +import dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource; +import dev.caskeleton.adapter.outbound.persistence.testkit.migration.MigrationScenario; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves the migration gates against a real server (design §31, §32). + * + *

The concurrent-index rule in particular can only be demonstrated here: PostgreSQL rejects + * {@code CREATE INDEX CONCURRENTLY} inside a transaction block, and no in-memory database has that + * restriction to reject against. + */ +@Tag("jpa-migration") +class PostgreSqlMigrationUpgradeContractTest { + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table migration_target (id bigint primary key, note text)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the three required upgrade scenarios are declared") + void requiresEmptyPreviousAndOldestSupportedScenarios() { + assertThat(MigrationScenario.required()) + .extracting(MigrationScenario::name) + .containsExactlyInAnyOrder("empty", "previous-release", "oldest-supported"); + } + + @Test + @DisplayName("PostgreSQL itself rejects a concurrent index inside a transaction") + void serverRejectsConcurrentIndexInTransaction() throws SQLException { + try (Connection connection = support.connection()) { + connection.setAutoCommit(false); + try (Statement statement = connection.createStatement()) { + assertThatThrownBy( + () -> + statement.execute( + "create index concurrently ix_migration_target_note" + + " on migration_target(note)")) + .isInstanceOf(SQLException.class); + } + connection.rollback(); + } + } + + @Test + @DisplayName("the same statement succeeds outside a transaction") + void serverAcceptsConcurrentIndexOutsideTransaction() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(true); + statement.execute( + "create index concurrently if not exists ix_migration_target_note_ok" + + " on migration_target(note)"); + } + } + + @Test + @DisplayName("the inspector refuses the transactional form before it ever reaches the server") + void inspectorRefusesTransactionalConcurrentIndex() { + var inspector = new ConcurrentIndexMigrationInspector(); + var migration = + new MigrationResource( + "V42__note_index.sql", + "create index concurrently ix_migration_target_note on migration_target(note);"); + + assertThatThrownBy(() -> inspector.validate(migration, true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("executeInTransaction=false"); + } + + @Test + @DisplayName("a clean database validates against an empty migration location") + void cleanDatabaseValidates() { + var flyway = + org.flywaydb.core.Flyway.configure() + .dataSource(support.dataSource()) + .locations("classpath:db/migration/does-not-exist") + .ignoreMigrationPatterns("*:missing") + .load(); + + new FlywayValidationGate().requireValid(flyway.validateWithResult()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlPessimisticLockContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlPessimisticLockContractTest.java new file mode 100644 index 00000000..01c42e49 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlPessimisticLockContractTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException; +import dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Separates NOWAIT from a waiting lock timeout on a real server (design §21.1, §21.2). + * + *

The distinction only exists at the server: both are "the lock was not acquired" in Java, and + * only the elapsed time and the SQLSTATE say which happened. + */ +@Tag("jpa-contract") +class PostgreSqlPessimisticLockContractTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("order.lock"); + + private static JpaPlatformContractSupport support; + + private final PostgreSqlLockExceptionTranslator translator = + PostgreSqlLockExceptionTranslator.standard(); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table lock_row (id bigint primary key, value int not null)"); + statement.execute("insert into lock_row(id, value) values (1, 0)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("NOWAIT fails immediately with 55P03 while a held lock blocks") + void nowaitFailsImmediatelyWhileBlockingLockTimesOutSeparately() throws SQLException { + try (Connection holder = support.connection(); + Connection contender = support.connection()) { + holder.setAutoCommit(false); + lockRow(holder); + + contender.setAutoCommit(false); + Instant startedAt = Instant.now(); + SQLException refused = lockRowNowait(contender); + Duration waited = Duration.between(startedAt, Instant.now()); + + assertThat(refused.getSQLState()).isEqualTo("55P03"); + assertThat(waited) + .as("NOWAIT must not wait for the holder") + .isLessThan(Duration.ofSeconds(1)); + + var translated = translator.translate(refused, OPERATION, 1, waited, null).orElseThrow(); + assertThat(translated).isInstanceOf(PessimisticLockTimeoutException.class); + assertThat(translated.retryable()) + .as("a lock refusal leaves the transaction alive; it is the caller's decision") + .isFalse(); + + holder.rollback(); + contender.rollback(); + } + } + + @Test + @DisplayName("a waiting lock times out with 55P03 after its configured bound") + void waitingLockTimesOutAfterItsBound() throws SQLException { + try (Connection holder = support.connection(); + Connection contender = support.connection()) { + holder.setAutoCommit(false); + lockRow(holder); + + contender.setAutoCommit(false); + try (Statement statement = contender.createStatement()) { + statement.execute("set local lock_timeout = '250ms'"); + } + Instant startedAt = Instant.now(); + SQLException timedOut = lockRowExpectingRefusal(contender); + Duration waited = Duration.between(startedAt, Instant.now()); + + assertThat(timedOut.getSQLState()).isEqualTo("55P03"); + assertThat(waited) + .as("a waiting lock really waits, unlike NOWAIT") + .isGreaterThanOrEqualTo(Duration.ofMillis(200)); + + holder.rollback(); + contender.rollback(); + } + } + + @Test + @DisplayName("an uncontended lock is acquired without error") + void uncontendedLockSucceeds() throws SQLException { + try (Connection connection = support.connection()) { + connection.setAutoCommit(false); + lockRow(connection); + connection.rollback(); + } + } + + private static void lockRow(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("select id from lock_row where id = 1 for update"); + } + } + + /** Takes the same lock, expecting the server to refuse it. */ + private static SQLException lockRowExpectingRefusal(Connection connection) { + try (Statement statement = connection.createStatement()) { + statement.execute("select id from lock_row where id = 1 for update"); + throw new AssertionError("the contended lock was unexpectedly acquired"); + } catch (SQLException expected) { + return expected; + } + } + + private static SQLException lockRowNowait(Connection connection) { + try (Statement statement = connection.createStatement()) { + statement.execute("select id from lock_row where id = 1 for update nowait"); + throw new AssertionError("NOWAIT unexpectedly acquired a held lock"); + } catch (SQLException expected) { + return expected; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java new file mode 100644 index 00000000..be756f67 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.testkit.queryplan.NormalizedPlan; +import dev.caskeleton.adapter.outbound.persistence.testkit.queryplan.PostgreSqlExplainRunner; +import dev.caskeleton.adapter.outbound.persistence.testkit.queryplan.QueryPlanAssertions; +import dev.caskeleton.adapter.outbound.persistence.testkit.queryplan.QueryPlanExpectation; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Captures a real plan and asserts on its structure (design §33). + * + *

Structure, not cost: costs and timings differ on every execution and every machine, so a + * snapshot including them fails for reasons that have nothing to do with the query. What is stable + * is which node types appear, how far the planner's estimate was from reality, and whether a sort + * spilled to disk. + */ +@Tag("jpa-queryplan") +class PostgreSqlQueryPlanContractTest { + + private static JpaPlatformContractSupport support; + private static PostgreSqlExplainRunner runner; + + private final QueryPlanAssertions assertions = new QueryPlanAssertions(); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + runner = new PostgreSqlExplainRunner(support.dataSource()); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table plan_row (id bigint primary key, bucket int not null)"); + statement.execute( + "insert into plan_row(id, bucket) select generate_series(1, 5000)," + + " (random() * 10)::int"); + statement.execute("create index ix_plan_row_bucket on plan_row(bucket)"); + statement.execute("analyze plan_row"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("an indexed lookup produces a plan whose estimate is close to reality") + void indexedLookupHasAccurateEstimate() { + NormalizedPlan plan = runner.explain("select id from plan_row where id = ?", 42L); + + assertions.assertMatches( + plan, QueryPlanExpectation.estimateOnly(10.0d).withDiskSortForbidden()); + assertThat(plan.distinctNodeTypes()).isNotEmpty(); + } + + @Test + @DisplayName("a forbidden node type is reported with the whole plan") + void forbiddenNodeTypeIsReported() { + NormalizedPlan plan = runner.explain("select id from plan_row where bucket = ?", 3); + + assertThatThrownBy( + () -> + assertions.assertMatches( + plan, + QueryPlanExpectation.estimateOnly(10.0d) + .forbidding( + "Bitmap Heap Scan", "Index Scan", "Seq Scan", "Index Only Scan"))) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("plan "); + } + + @Test + @DisplayName("EXPLAIN ANALYZE refuses anything that is not a SELECT") + void refusesNonSelect() { + assertThatThrownBy(() -> runner.explain("update plan_row set bucket = 1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("only SELECT"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java new file mode 100644 index 00000000..c148a416 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport; +import dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy; +import dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier; +import dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Set; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves the runtime role really cannot execute DDL (design §36). + * + *

The point of running this against a real server is that effective privileges come from direct + * grants, inherited role memberships, {@code PUBLIC} grants, and schema ownership. No reading of a + * configuration file reconstructs that combination; only the server can answer it. + */ +@Tag("jpa-security") +class PostgreSqlSecurityContractTest { + + private static final String RUNTIME_ROLE = "contract_runtime"; + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create schema if not exists app"); + statement.execute("create table app.secured (id bigint primary key)"); + statement.execute("create role " + RUNTIME_ROLE + " login password 'contract_runtime'"); + statement.execute("revoke create on schema public from public"); + statement.execute("grant usage on schema app to " + RUNTIME_ROLE); + statement.execute("grant select, insert, update, delete on app.secured to " + RUNTIME_ROLE); + statement.execute("revoke create on schema app from " + RUNTIME_ROLE); + statement.execute("alter role " + RUNTIME_ROLE + " set search_path = app, pg_catalog"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the verifier reads the connection's own identity and privileges") + void verifierReadsPrivileges() { + DatabasePrivilegeReport report = + new PostgreSqlRuntimeRoleVerifier().verify(support.dataSource()); + + assertThat(report.currentUser()).isNotBlank(); + assertThat(report.searchPath()).isNotBlank(); + } + + @Test + @DisplayName("a runtime role with DML and no CREATE writes rows but cannot create tables") + void runtimeRoleCanWriteRowsButCannotCreateTable() throws SQLException { + DataSource runtime = support.runtimeDataSource(RUNTIME_ROLE, "contract_runtime"); + + try (Connection connection = runtime.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("insert into app.secured(id) values (1)"); + assertThatThrownBy(() -> statement.execute("create table app.forbidden(id bigint)")) + .isInstanceOf(SQLException.class) + .satisfies( + failure -> assertThat(((SQLException) failure).getSQLState()).isEqualTo("42501")); + } + } + + @Test + @DisplayName("the policy accepts the verified runtime role") + void policyAcceptsVerifiedRole() { + DataSource runtime = support.runtimeDataSource(RUNTIME_ROLE, "contract_runtime"); + var policy = + new DatabaseRolePolicy( + Set.of(RUNTIME_ROLE), new SearchPathPolicy(List.of("app", "pg_catalog"))); + + new PostgreSqlRuntimeRoleVerifier().requireSafe(runtime, policy); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSqlStateContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSqlStateContractTest.java new file mode 100644 index 00000000..9d32c02a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSqlStateContractTest.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves SQLSTATE classification against a real server (design §18.2, §22). + * + *

This is the contract H2 cannot satisfy. H2 reports {@code 23513} for a check violation where + * PostgreSQL reports {@code 23514}, and neither server's codes can be assumed from the other's — so + * a classifier verified on H2 is a classifier verified against the wrong table of constants. + */ +@Tag("jpa-contract") +class PostgreSqlSqlStateContractTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("user.create"); + private static final ConstraintCode ACTIVE_EMAIL = new ConstraintCode("user.active-email.unique"); + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "create table contract_user (id bigint primary key, email text not null," + + " age int not null check (age >= 0))"); + statement.execute("create unique index ux_contract_user_email on contract_user (email)"); + statement.execute( + "create table contract_order (id bigint primary key, user_id bigint not null" + + " references contract_user(id))"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a real unique violation carries 23505 and the physical constraint name") + void uniqueViolationIsClassifiedAndNamed() throws SQLException { + PostgreSqlExceptionTranslator translator = + PostgreSqlExceptionTranslator.with( + new PostgreSqlConstraintCatalog(Map.of("ux_contract_user_email", ACTIVE_EMAIL))); + + SQLException raised = insertDuplicateEmail(); + + assertThat(PostgreSqlServerErrorFields.constraintName(raised)) + .contains("ux_contract_user_email"); + var translated = translator.translate(raised, OPERATION, 1, Duration.ZERO, null); + assertThat(translated).isInstanceOf(UniqueConstraintViolationException.class); + assertThat(translated.category()).isEqualTo(FailureCategory.UNIQUE_CONSTRAINT); + assertThat(translated.retryable()).isFalse(); + assertThat(((UniqueConstraintViolationException) translated).details().code()) + .isEqualTo(ACTIVE_EMAIL); + } + + @Test + @DisplayName("an unregistered constraint resolves to the bounded unknown code") + void unregisteredConstraintIsBounded() throws SQLException { + PostgreSqlExceptionTranslator translator = + PostgreSqlExceptionTranslator.with(PostgreSqlConstraintCatalog.empty()); + + var translated = + translator.translate(insertDuplicateEmail(), OPERATION, 1, Duration.ZERO, null); + + assertThat(((UniqueConstraintViolationException) translated).details().registered()).isFalse(); + } + + @Test + @DisplayName("a real check violation is 23514 on PostgreSQL, not H2's 23513") + void checkViolationUsesPostgreSqlState() throws SQLException { + SQLException raised = insertNegativeAge(); + + assertThat(raised.getSQLState()).isEqualTo("23514"); + } + + @Test + @DisplayName("a real foreign key violation is 23503") + void foreignKeyViolationUsesPostgreSqlState() throws SQLException { + SQLException raised = insertOrphanOrder(); + + assertThat(raised.getSQLState()).isEqualTo("23503"); + } + + @Test + @DisplayName("the server version is recorded, not assumed from the image tag") + void serverVersionIsRecorded() throws SQLException { + assertThat(support.serverVersion()) + .startsWith(String.valueOf(support.version().majorVersion())); + } + + private static SQLException insertDuplicateEmail() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "insert into contract_user(id, email, age) values (1, 'a@example.test', 30)" + + " on conflict do nothing"); + try { + statement.execute( + "insert into contract_user(id, email, age) values (2, 'a@example.test', 30)"); + throw new AssertionError("the unique index did not reject the duplicate"); + } catch (SQLException expected) { + return expected; + } + } + } + + private static SQLException insertNegativeAge() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "insert into contract_user(id, email, age) values (3, 'b@example.test', -1)"); + throw new AssertionError("the check constraint did not reject the row"); + } catch (SQLException expected) { + return expected; + } + } + + private static SQLException insertOrphanOrder() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("insert into contract_order(id, user_id) values (1, 9999)"); + throw new AssertionError("the foreign key did not reject the row"); + } catch (SQLException expected) { + return expected; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java new file mode 100644 index 00000000..3c449fa7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * {@code ON CONFLICT} makes a concurrent upsert converge on one row (design §8.3). + * + *

The {@code (xmax = 0)} projection is what lets the platform report insert-versus-update + * without a second query. It is a PostgreSQL implementation detail, which is exactly why it is + * verified against PostgreSQL rather than assumed. + */ +@Tag("jpa-contract") +class PostgreSqlUpsertContractTest { + + private static final String UPSERT_SQL = + "insert into counters(key, value) values (?, ?)" + + " on conflict (key) do update set value = excluded.value" + + " returning value, (xmax = 0) as inserted"; + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table counters (key text primary key, value int not null)"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the first upsert reports an insert and the second reports an update") + void reportsInsertThenUpdate() throws SQLException { + assertThat(upsert("disposition", 1)).isTrue(); + assertThat(upsert("disposition", 2)).isFalse(); + } + + @Test + @DisplayName("two concurrent upserts leave exactly one logical row") + void concurrentUpsertReturnsOneLogicalRow() throws Exception { + CountDownLatch bothReady = new CountDownLatch(2); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(upsertConcurrently("race", 1, bothReady)); + var second = workers.submit(upsertConcurrently("race", 2, bothReady)); + first.get(30, TimeUnit.SECONDS); + second.get(30, TimeUnit.SECONDS); + } finally { + workers.shutdownNow(); + } + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select count(*) from counters where key = 'race'"); + var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getLong(1)).isEqualTo(1L); + } + } + + @Test + @DisplayName("the conflict action updates rather than failing") + void conflictActionUpdates() throws SQLException { + upsert("update-target", 10); + upsert("update-target", 42); + + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("select value from counters where key = ?")) { + statement.setString(1, "update-target"); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getInt(1)).isEqualTo(42); + } + } + } + + /** Returns whether the row was inserted, as reported by {@code (xmax = 0)}. */ + private static boolean upsert(String key, int value) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = connection.prepareStatement(UPSERT_SQL)) { + statement.setString(1, key); + statement.setInt(2, value); + try (var rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + return rows.getBoolean("inserted"); + } + } + } + + private static Runnable upsertConcurrently(String key, int value, CountDownLatch bothReady) { + return () -> { + try (Connection connection = support.connection(); + PreparedStatement statement = connection.prepareStatement(UPSERT_SQL)) { + statement.setString(1, key); + statement.setInt(2, value); + bothReady.countDown(); + bothReady.await(10, TimeUnit.SECONDS); + try (var rows = statement.executeQuery()) { + rows.next(); + } + } catch (SQLException | InterruptedException failure) { + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java new file mode 100644 index 00000000..88fda661 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Proves {@code FOR UPDATE SKIP LOCKED} hands competing workers disjoint rows (design §21.3). + * + *

Two connections, two open transactions, one queue. The second worker must not block on the + * first's rows and must not receive any of them — which is the entire behaviour a claim query is + * chosen for, and one that cannot be verified without two real concurrent transactions. + */ +@Tag("jpa-contract") +class PostgreSqlWorkClaimContractTest { + + private static final String CLAIM_SQL = + "select id from claim_queue where claimed = false order by priority, id limit ?" + + " for update skip locked"; + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "create table claim_queue (id bigint primary key, priority int not null," + + " claimed boolean not null default false)"); + for (int id = 1; id <= 20; id++) { + statement.execute( + "insert into claim_queue(id, priority) values (" + id + ", " + (id % 3) + ")"); + } + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("competing workers claim disjoint rows and neither blocks") + void competingWorkersClaimDisjointRows() throws SQLException { + try (Connection workerA = support.connection(); + Connection workerB = support.connection()) { + workerA.setAutoCommit(false); + workerB.setAutoCommit(false); + + List first = claim(workerA, 10); + List second = claim(workerB, 10); + + assertThat(first).hasSize(10); + assertThat(second).hasSize(10); + assertThat(first).doesNotContainAnyElementsOf(second); + + workerA.rollback(); + workerB.rollback(); + } + } + + @Test + @DisplayName("claims come back in the statement's deterministic order") + void claimsAreDeterministicallyOrdered() throws SQLException { + try (Connection worker = support.connection()) { + worker.setAutoCommit(false); + List claimed = claim(worker, 5); + + assertThat(claimed).isNotEmpty(); + worker.rollback(); + } + } + + private static List claim(Connection connection, int batchSize) throws SQLException { + List claimed = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(CLAIM_SQL)) { + statement.setInt(1, batchSize); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + claimed.add(rows.getLong(1)); + } + } + } + return claimed; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java new file mode 100644 index 00000000..366b1ac7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.outbound.persistence.platform; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier; +import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Runs the core semantic contracts against every selected Stable version (design §40). + * + *

The versions come from {@code -Pjpa.matrix.versions}; an unknown or empty selection is an + * error rather than an empty run. That is the difference between "the matrix passed" and "the + * matrix was never selected". + */ +@Tag("jpa-contract") +class StablePostgreSqlMatrixContractTest { + + private final PostgreSqlFailureClassifier classifier = new PostgreSqlFailureClassifier(); + + @Test + @DisplayName("every selected Stable version reports the same SQLSTATE semantics") + void everySelectedVersionAgreesOnSqlState() { + List selected = JpaPlatformContractSupport.selectedVersions(); + assertThat(selected).isNotEmpty(); + + for (PostgreSqlVersion version : selected) { + try (JpaPlatformContractSupport server = JpaPlatformContractSupport.start(version)) { + SQLException unique = provokeUniqueViolation(server); + + assertThat(unique.getSQLState()) + .as("PostgreSQL %s must report 23505 for a unique violation", version.majorVersion()) + .isEqualTo("23505"); + assertThat(classifier.classify(unique.getSQLState())) + .isEqualTo(FailureCategory.UNIQUE_CONSTRAINT); + } + } + } + + @Test + @DisplayName("the exact server version is recorded rather than assumed from the image tag") + void recordsTheExactServerVersion() throws SQLException { + for (PostgreSqlVersion version : JpaPlatformContractSupport.selectedVersions()) { + try (JpaPlatformContractSupport server = JpaPlatformContractSupport.start(version)) { + assertThat(server.serverVersion()) + .as("an image tag is mutable; the server's own answer is not") + .startsWith(String.valueOf(version.majorVersion())); + } + } + } + + @Test + @DisplayName("every selected version supports SKIP LOCKED") + void everySelectedVersionSupportsSkipLocked() throws SQLException { + for (PostgreSqlVersion version : JpaPlatformContractSupport.selectedVersions()) { + try (JpaPlatformContractSupport server = JpaPlatformContractSupport.start(version); + Connection connection = server.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table matrix_queue (id bigint primary key)"); + statement.execute("insert into matrix_queue(id) values (1)"); + connection.setAutoCommit(false); + try (var rows = + statement.executeQuery( + "select id from matrix_queue order by id for update skip locked")) { + assertThat(rows.next()).isTrue(); + } + connection.rollback(); + } + } + } + + private static SQLException provokeUniqueViolation(JpaPlatformContractSupport server) + throws AssertionError { + try (Connection connection = server.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table matrix_unique (id bigint primary key)"); + statement.execute("insert into matrix_unique(id) values (1)"); + statement.execute("insert into matrix_unique(id) values (1)"); + throw new AssertionError("the primary key did not reject the duplicate"); + } catch (SQLException expected) { + return expected; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/ReadAfterWriteRoutingContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/ReadAfterWriteRoutingContractTest.java new file mode 100644 index 00000000..e14ed2b1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/ReadAfterWriteRoutingContractTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.experimental; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext; +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 10 — read-after-write is a routing decision, not a lag statistic. + * + *

The single-node server here supplies the real WAL positions the decision is made from; the + * replica is modelled by holding a position deliberately behind. That is enough to assert the rule + * that matters — a read carrying a consistency token issued by a write is never served by a replica + * that has not reached it — without pretending a fabricated two-node cluster proves replication. + */ +@Tag("jpa-contract") +class ReadAfterWriteRoutingContractTest { + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("a write always goes to the primary") + void writeAlwaysGoesToThePrimary() { + var router = new ConsistencyAwareDataSourceRouter(caughtUp()); + + var decision = router.route(TransactionContext.writing(), ReadConsistency.eventual()); + + assertThat(decision.target()).isEqualTo(ReplicaTarget.PRIMARY); + assertThat(decision.usesPrimary()).isTrue(); + } + + @Test + @DisplayName("a locking read goes to the primary even under eventual consistency") + void lockingReadGoesToThePrimary() { + var router = new ConsistencyAwareDataSourceRouter(caughtUp()); + + assertThat(router.route(TransactionContext.lockingRead(), ReadConsistency.eventual()).target()) + .as("a replica cannot hold a lock the primary will honour") + .isEqualTo(ReplicaTarget.PRIMARY); + } + + @Test + @DisplayName("a read behind its own write is routed to the primary") + void readBehindItsOwnWriteIsRoutedToThePrimary() throws SQLException { + Instant writtenAt = commitAndReadServerTime(); + var token = ConsistencyToken.at(writtenAt); + var router = new ConsistencyAwareDataSourceRouter(stuckAt(writtenAt.minusSeconds(5))); + + var decision = router.route(TransactionContext.readOnly(), ReadConsistency.after(token)); + + assertThat(decision.target()).isEqualTo(ReplicaTarget.PRIMARY); + assertThat(decision.reason()).isNotBlank(); + } + + @Test + @DisplayName("a read whose replica has caught up is routed to the replica") + void readWhoseReplicaHasCaughtUpIsRoutedToTheReplica() throws SQLException { + Instant writtenAt = commitAndReadServerTime(); + var token = ConsistencyToken.at(writtenAt); + var router = new ConsistencyAwareDataSourceRouter(stuckAt(writtenAt.plusSeconds(1))); + + assertThat(router.route(TransactionContext.readOnly(), ReadConsistency.after(token)).target()) + .isEqualTo(ReplicaTarget.REPLICA); + } + + @Test + @DisplayName("bounded staleness compares against the measured lag, not a guess") + void boundedStalenessComparesAgainstTheMeasuredLag() throws SQLException { + Instant now = serverNow(); + var router = new ConsistencyAwareDataSourceRouter(stuckAt(now.minusSeconds(30))); + + assertThat( + router + .route( + TransactionContext.readOnly(), + ReadConsistency.boundedStaleness(Duration.ofSeconds(5))) + .target()) + .isEqualTo(ReplicaTarget.PRIMARY); + assertThat( + router + .route( + TransactionContext.readOnly(), + ReadConsistency.boundedStaleness(Duration.ofMinutes(5))) + .target()) + .isEqualTo(ReplicaTarget.REPLICA); + } + + private static ReplicaLagMonitor caughtUp() { + return new FixedLagMonitor(Instant.MAX, Duration.ZERO); + } + + private static ReplicaLagMonitor stuckAt(Instant position) { + return new FixedLagMonitor(position, Duration.between(position, Instant.now()).abs()); + } + + /** A monitor pinned to one measured position, so the routing rule is the only variable. */ + private record FixedLagMonitor(Instant replayedThroughAt, Duration measuredLag) + implements ReplicaLagMonitor { + + @Override + public java.util.Optional replayedThrough() { + return java.util.Optional.of(replayedThroughAt); + } + + @Override + public java.util.Optional lag() { + return java.util.Optional.of(measuredLag); + } + } + + /** Commits a row and returns the server's own commit clock, not the client's. */ + private static Instant commitAndReadServerTime() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table if not exists routing_row (id bigserial primary key)"); + statement.execute("insert into routing_row default values"); + } + return serverNow(); + } + + private static Instant serverNow() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select clock_timestamp()")) { + rows.next(); + return rows.getTimestamp(1).toInstant(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java new file mode 100644 index 00000000..7937b920 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java @@ -0,0 +1,247 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.experimental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier; +import dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 4 — the ways row-level security silently stops isolating. + * + *

Every assertion here is about a configuration that looks enabled. RLS that is enabled + * but not forced does not apply to the table owner; a pooled connection that keeps its previous + * tenant setting serves the previous tenant's rows; a policy on the table but not on a view reads + * around itself. None of those are visible without a real server enforcing real policies. + */ +@Tag("jpa-security") +class RlsIsolationFailureTest { + + private static final String RUNTIME_USER = "rls_app"; + private static final TenantId TENANT_A = new TenantId("tenant-a"); + private static final TenantId TENANT_B = new TenantId("tenant-b"); + + private static JpaPlatformContractSupport support; + private static String runtimePassword; + + private final RlsPolicyVerifier verifier = new RlsPolicyVerifier(); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + runtimePassword = JpaPlatformContractSupport.generatedPassword(); + try (Connection owner = support.connection(); + Statement statement = owner.createStatement()) { + statement.execute( + "create table rls_item (id bigserial primary key, tenant_id text not null," + + " value text not null)"); + statement.execute("alter table rls_item enable row level security"); + statement.execute( + "create policy rls_item_tenant on rls_item using" + + " (tenant_id = current_setting('app.tenant_id', true))"); + statement.execute("create user " + RUNTIME_USER + " with password '" + runtimePassword + "'"); + statement.execute("grant select, insert on rls_item to " + RUNTIME_USER); + statement.execute("grant usage on schema public to " + RUNTIME_USER); + statement.execute("grant usage, select on sequence rls_item_id_seq to " + RUNTIME_USER); + statement.execute("insert into rls_item(tenant_id, value) values ('tenant-a', 'a')"); + statement.execute("insert into rls_item(tenant_id, value) values ('tenant-b', 'b')"); + } + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("an unforced policy does not apply to the table owner") + void unforcedPolicyDoesNotApplyToTheOwner() throws SQLException { + try (Connection owner = support.connection()) { + bindSessionWide(owner, TENANT_A); + assertThat(valuesVisibleOn(owner)) + .as("the owner bypasses RLS unless the table forces it — the classic false green") + .containsExactlyInAnyOrder("a", "b"); + } + } + + @Test + @DisplayName("the verifier reports a table that is enabled but not forced") + void verifierReportsEnabledButNotForced() { + assertThat(verifier.tablesWithoutForcedPolicy(support.dataSource())).contains("rls_item"); + assertThatThrownBy(() -> verifier.requireEnforced(support.dataSource(), List.of("rls_item"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a forced policy isolates the runtime role per tenant") + void forcedPolicyIsolatesTheRuntimeRole() throws SQLException { + force(true); + try (var pool = freshRuntimePool()) { + try (Connection connection = pool.getConnection()) { + connection.setAutoCommit(false); + bindForTransaction(connection, TENANT_A); + assertThat(valuesVisibleOn(connection)).containsExactly("a"); + connection.commit(); + + bindForTransaction(connection, TENANT_B); + assertThat(valuesVisibleOn(connection)).containsExactly("b"); + connection.commit(); + } + assertThat(verifier.tablesWithoutForcedPolicy(support.dataSource())) + .doesNotContain("rls_item"); + } finally { + force(false); + } + } + + @Test + @DisplayName("a session-scoped binding survives the connection going back to the pool") + void sessionScopedBindingSurvivesReturnToThePool() throws SQLException { + force(true); + // One physical connection, so the second borrow provably gets the first borrower's session. + try (var pool = freshRuntimePool(1)) { + try (Connection first = pool.getConnection()) { + bindSessionWide(first, TENANT_B); + assertThat(valuesVisibleOn(first)).containsExactly("b"); + } + + try (Connection next = pool.getConnection()) { + assertThat(valuesVisibleOn(next)) + .as( + "this is the leak: set_config(..., false) is session-scoped, so the next request on" + + " this pooled connection reads the previous tenant's rows") + .containsExactly("b"); + } + } finally { + force(false); + } + } + + @Test + @DisplayName("an unbound tenant on a forced table sees nothing rather than everything") + void unboundTenantSeesNothing() throws SQLException { + force(true); + // A pool of its own: a connection borrowed from a shared pool could be carrying a binding some + // earlier contract left on it, and the assertion would then be about that instead. + try (var pool = freshRuntimePool(); + Connection connection = pool.getConnection()) { + assertThat(valuesVisibleOn(connection)) + .as("failing closed is the only acceptable behaviour for a missing tenant") + .isEmpty(); + } finally { + force(false); + } + } + + @Test + @DisplayName("a transaction-scoped binding does not leak to the next pooled user") + void transactionScopedBindingDoesNotLeak() throws SQLException { + force(true); + try (var pool = freshRuntimePool(1)) { + try (Connection first = pool.getConnection()) { + first.setAutoCommit(false); + bindForTransaction(first, TENANT_A); + assertThat(valuesVisibleOn(first)).containsExactly("a"); + first.commit(); + + assertThat(valuesVisibleOn(first)) + .as("set_config(..., true) ends with the transaction, which is what makes pooling safe") + .isEmpty(); + first.setAutoCommit(true); + } + + try (Connection next = pool.getConnection()) { + assertThat(valuesVisibleOn(next)) + .as("the next borrower must start with no tenant, not the previous one") + .isEmpty(); + } + } finally { + force(false); + } + } + + @Test + @DisplayName("the bind statement binds the tenant and scopes it to the transaction") + void bindStatementBindsTheTenantAndScopesIt() { + String sql = RlsTenantSessionBinder.bindStatement(); + + // The setting *name* is a literal and has to be; the tenant *value* is the untrusted half, and + // it is the one that must arrive as a parameter. + assertThat(sql).contains("set_config('" + RlsTenantSessionBinder.TENANT_SETTING + "'"); + assertThat(sql).containsOnlyOnce("?"); + assertThat(sql) + .as( + "the third argument decides session scope versus transaction scope, and pooling" + + " requires transaction scope") + .endsWith(", true)"); + } + + private static void force(boolean forced) throws SQLException { + try (Connection owner = support.connection(); + Statement statement = owner.createStatement()) { + statement.execute( + "alter table rls_item " + (forced ? "force" : "no force") + " row level security"); + } + } + + /** Session-scoped binding — the unsafe form, kept because two contracts are about its effect. */ + private static void bindSessionWide(Connection connection, TenantId tenant) throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement("select set_config('app.tenant_id', ?, false)")) { + statement.setString(1, tenant.value()); + statement.execute(); + } + } + + /** Transaction-scoped binding — the statement the production binder issues. */ + private static void bindForTransaction(Connection connection, TenantId tenant) + throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement(RlsTenantSessionBinder.bindStatement())) { + statement.setString(1, tenant.value()); + statement.execute(); + } + } + + private static HikariDataSource freshRuntimePool() { + return freshRuntimePool(2); + } + + private static HikariDataSource freshRuntimePool(int size) { + var config = new HikariConfig(); + config.setJdbcUrl(support.jdbcUrl()); + config.setUsername(RUNTIME_USER); + config.setPassword(runtimePassword); + config.setMaximumPoolSize(size); + config.setMinimumIdle(size); + return new HikariDataSource(config); + } + + private static List valuesVisibleOn(Connection connection) throws SQLException { + List values = new ArrayList<>(); + try (Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select value from rls_item order by value")) { + while (rows.next()) { + values.add(rows.getString(1)); + } + } + return values; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/SchemaTenantMigrationContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/SchemaTenantMigrationContractTest.java new file mode 100644 index 00000000..33ef5210 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/SchemaTenantMigrationContractTest.java @@ -0,0 +1,202 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.experimental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider; +import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator; +import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry; +import dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Experimental plan Task 6 — schema-per-tenant migration is partial by nature. + * + *

The failure this contract exists for is the half-migrated fleet: tenant 3 of 5 fails, and the + * question is whether the platform can still say which tenants are on which version. An + * all-or-nothing claim is not available — the schemas are separate databases as far as DDL is + * concerned — so the orchestrator has to report per tenant instead of throwing once. + */ +@Tag("jpa-migration") +class SchemaTenantMigrationContractTest { + + private static final TenantId TENANT_A = new TenantId("tenant-a"); + private static final TenantId TENANT_B = new TenantId("tenant-b"); + private static final TenantId TENANT_C = new TenantId("tenant-c"); + + private static JpaPlatformContractSupport support; + + private static final SchemaTenantRegistry REGISTRY = registryUnder("tenant"); + + /** + * A registry whose schemas are namespaced to one test. + * + *

Schemas outlive a {@code @TempDir}: the migrations are per-test but the server is per-class, + * so a shared schema name would let one test's applied version decide another test's assertion. + */ + private static SchemaTenantRegistry registryUnder(String prefix) { + return new SchemaTenantRegistry( + Map.of( + TENANT_A, prefix + "_a", + TENANT_B, prefix + "_b", + TENANT_C, prefix + "_c")); + } + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("every registered tenant migrates into its own schema") + void everyTenantMigratesIntoItsOwnSchema(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table tenant_row (id bigint primary key);"); + var registry = registryUnder("fleet"); + var orchestrator = orchestrator(registry, migrations); + + orchestrator.migrateAll(List.of(TENANT_A, TENANT_B, TENANT_C)); + + assertThat(orchestrator.failed()).isEmpty(); + for (TenantId tenant : List.of(TENANT_A, TENANT_B, TENANT_C)) { + assertThat(orchestrator.status(tenant)) + .get() + .extracting(TenantMigrationStatus::appliedVersion) + .isEqualTo(java.util.Optional.of("1")); + assertThat(tableExists(registry.requireSchema(tenant), "tenant_row")).isTrue(); + } + } + + @Test + @DisplayName("one tenant failing does not hide which tenants succeeded") + void oneTenantFailingDoesNotHideTheOthers(@TempDir Path migrations) throws Exception { + write(migrations, "V1__create.sql", "create table tenant_row (id bigint primary key);"); + var registry = registryUnder("partial"); + var orchestrator = orchestrator(registry, migrations); + orchestrator.migrateAll(List.of(TENANT_A, TENANT_B)); + + // tenant-b has a conflicting object, so V2 can only apply to tenant-a. + createConflictingTable(registry.requireSchema(TENANT_B)); + write(migrations, "V2__add_table.sql", "create table tenant_extra (id bigint primary key);"); + var second = orchestrator(registry, migrations); + + // migrateAll must not stop at the first failure. Throwing here would leave tenant-a's outcome + // unrecorded, which is the state a partial rollout most needs to be able to read back. + second.migrateAll(List.of(TENANT_A, TENANT_B)); + + assertThat(second.status(TENANT_A)) + .get() + .extracting(TenantMigrationStatus::appliedVersion) + .isEqualTo(java.util.Optional.of("2")); + assertThat(second.failed()).extracting(TenantMigrationStatus::tenant).containsExactly(TENANT_B); + assertThat(second.failed().get(0).failed()).isTrue(); + } + + @Test + @DisplayName("an unregistered tenant has no schema at all") + void unregisteredTenantHasNoSchema() { + assertThat(REGISTRY.contains(new TenantId("tenant-z"))).isFalse(); + assertThatThrownBy(() -> REGISTRY.requireSchema(new TenantId("tenant-z"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a released connection is returned to a neutral schema") + void releasedConnectionReturnsToANeutralSchema() throws SQLException { + // current_schema() answers null for a search_path naming a schema that does not exist, so the + // schema has to be real or the assertion would pass for the wrong reason. + createSchema(REGISTRY.requireSchema(TENANT_A)); + + // One physical connection, so the next borrower provably gets the same one back. A pool of + // several would let the assertion pass by picking a connection that was never bound at all. + try (var pool = singleConnectionPool()) { + var provider = new SchemaMultiTenantConnectionProvider(pool, REGISTRY); + + Connection bound = provider.getConnection(TENANT_A); + assertThat(currentSchema(bound)).isEqualTo("tenant_a"); + provider.releaseConnection(bound); + + try (Connection reused = pool.getConnection()) { + assertThat(currentSchema(reused)) + .as("a connection returned to the pool must not carry the last tenant's search_path") + .isEqualTo(SchemaMultiTenantConnectionProvider.NEUTRAL_SCHEMA); + } + } + } + + private static HikariDataSource singleConnectionPool() { + var config = new HikariConfig(); + config.setJdbcUrl(support.jdbcUrl()); + config.setUsername(support.username()); + config.setPassword(support.password()); + config.setMaximumPoolSize(1); + config.setMinimumIdle(1); + return new HikariDataSource(config); + } + + private static SchemaTenantMigrationOrchestrator orchestrator( + SchemaTenantRegistry registry, Path migrations) { + return new SchemaTenantMigrationOrchestrator( + support.dataSource(), registry, "filesystem:" + migrations.toAbsolutePath()); + } + + private static void write(Path directory, String name, String sql) throws Exception { + Files.writeString(directory.resolve(name), sql, StandardCharsets.UTF_8); + } + + private static void createSchema(String schema) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create schema if not exists " + schema); + } + } + + private static void createConflictingTable(String schema) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create table " + schema + ".tenant_extra (other text)"); + } + } + + private static boolean tableExists(String schema, String table) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = + statement.executeQuery( + "select to_regclass('" + schema + "." + table + "') is not null")) { + rows.next(); + return rows.getBoolean(1); + } + } + + private static String currentSchema(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select current_schema()")) { + rows.next(); + return rows.getString(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantColumnIsolationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantColumnIsolationTest.java new file mode 100644 index 00000000..0c85da85 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantColumnIsolationTest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.experimental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Experimental plan Task 2 — shared-schema tenant isolation is fail-closed. */ +@Tag("jpa-contract") +class TenantColumnIsolationTest { + + private static final TenantId TENANT_A = new TenantId("tenant-a"); + private static final TenantId TENANT_B = new TenantId("tenant-b"); + + private static JpaPlatformContractSupport support; + + private final TenantAwareRepositoryGuard guard = new TenantAwareRepositoryGuard(); + + @BeforeAll + static void startServer() throws SQLException { + support = JpaPlatformContractSupport.start(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute( + "create table tenant_item (id bigserial primary key, tenant_id text not null," + + " value text not null)"); + statement.execute("create unique index ux_tenant_item on tenant_item (tenant_id, value)"); + } + insertFor(TENANT_A, "a"); + insertFor(TENANT_B, "b"); + } + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("tenant A cannot read tenant B's rows") + void tenantARepositoryCannotReadTenantBRows() { + assertThat(withTenant(TENANT_A, TenantColumnIsolationTest::findAllForCurrentTenant)) + .containsExactly("a"); + assertThat(withTenant(TENANT_B, TenantColumnIsolationTest::findAllForCurrentTenant)) + .containsExactly("b"); + } + + @Test + @DisplayName("repository access without a tenant is refused") + void repositoryAccessWithoutATenantIsRefused() { + assertThatThrownBy(guard::requireTenantOrAdminScope) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("tenant context is required"); + } + + @Test + @DisplayName("a cross-tenant read requires an audited admin scope") + void crossTenantReadRequiresAnAuditedScope() { + List all = + guard.inAdminScope( + "support ticket 42", + () -> { + guard.requireTenantOrAdminScope(); + return findAll(); + }); + + assertThat(all).containsExactlyInAnyOrder("a", "b"); + assertThat(guard.adminScopeOpen()).isFalse(); + } + + @Test + @DisplayName("an admin scope without a reason is refused") + void adminScopeRequiresAReason() { + assertThatThrownBy(() -> guard.inAdminScope(" ", () -> null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an async job does not inherit the request's tenant") + void asyncJobDoesNotInheritTheTenant() throws Exception { + var seen = new ArrayList(); + var executor = Executors.newSingleThreadExecutor(); + try { + TenantContext.bind(TENANT_A); + executor + .submit(() -> seen.add(TenantContext.current().map(TenantId::value).orElse("unbound"))) + .get(10, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + assertThat(seen).containsExactly("unbound"); + } + + @Test + @DisplayName("the tenant binding is restored, not cleared, after a nested scope") + void nestedScopeRestoresThePreviousTenant() { + TenantContext.bind(TENANT_A); + + String inner = TenantContext.with(TENANT_B, () -> TenantContext.require().value()); + + assertThat(inner).isEqualTo("tenant-b"); + assertThat(TenantContext.require()).isEqualTo(TENANT_A); + } + + private static T withTenant(TenantId tenant, java.util.function.Supplier work) { + return TenantContext.with(tenant, work); + } + + private static List findAllForCurrentTenant() { + TenantId tenant = TenantContext.require(); + List values = new ArrayList<>(); + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "select value from tenant_item where tenant_id = ? order by value")) { + statement.setString(1, tenant.value()); + try (var rows = statement.executeQuery()) { + while (rows.next()) { + values.add(rows.getString(1)); + } + } + } catch (SQLException failure) { + throw new IllegalStateException("tenant query failed", failure); + } + return values; + } + + private static List findAll() { + List values = new ArrayList<>(); + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select value from tenant_item order by value")) { + while (rows.next()) { + values.add(rows.getString(1)); + } + } catch (SQLException failure) { + throw new IllegalStateException("cross-tenant query failed", failure); + } + return values; + } + + private static void insertFor(TenantId tenant, String value) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "insert into tenant_item(tenant_id, value) values (?, ?)")) { + statement.setString(1, tenant.value()); + statement.setString(2, value); + statement.executeUpdate(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java new file mode 100644 index 00000000..1539efb3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java @@ -0,0 +1,158 @@ +package dev.caskeleton.adapter.outbound.persistence.platform.experimental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry; +import dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 8 — database-per-tenant is bounded by connections, not by tenants. + * + *

The number the design cares about is {@code tenants × pool size}, and the server has a hard + * {@code max_connections}. This contract reads that limit from the running server rather than + * assuming it, then shows the budget refusing the tenant that would cross it — which is the whole + * point: exceeding it does not degrade, it fails every tenant at once. + */ +@Tag("jpa-contract") +class TenantPoolCapacityContractTest { + + private static final int POOL_SIZE_PER_TENANT = 2; + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @Test + @DisplayName("the budget refuses the tenant that would exceed the connection ceiling") + void budgetRefusesTheTenantThatWouldExceedTheCeiling() { + var budget = new TenantPoolBudget(4, 8); + + budget.requireCapacity(3, 6); + assertThat(budget.hasCapacity(4, 8)).isFalse(); + assertThatThrownBy(() -> budget.requireCapacity(4, 8)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("the maximum tenant count follows from the server's max_connections") + void maxTenantCountFollowsFromServerMaxConnections() throws SQLException { + int serverMax = serverMaxConnections(); + // Leave headroom for the platform's own admin connections; the budget is what the fleet may + // use, not what the server can technically accept. + int usable = serverMax - 10; + var budget = new TenantPoolBudget(usable / POOL_SIZE_PER_TENANT, usable); + + assertThat(budget.maxTenants()).isEqualTo(usable / POOL_SIZE_PER_TENANT); + assertThat(budget.maxTenants() * POOL_SIZE_PER_TENANT) + .as("the fleet must never be provisioned above what the server accepts") + .isLessThan(serverMax); + } + + @Test + @DisplayName("opening tenants past the budget is refused before a pool is created") + void openingPastTheBudgetIsRefusedBeforeAPoolIsCreated() { + var budget = new TenantPoolBudget(2, 4); + try (var registry = + new TenantDataSourceRegistry( + TenantPoolCapacityContractTest::poolFor, + TenantPoolCapacityContractTest::poolSizeOf, + budget)) { + registry.openTenants(2); + assertThat(registry.openPools()).isEqualTo(2); + assertThat(registry.allocatedConnections()).isEqualTo(4); + + assertThatThrownBy(() -> registry.openTenants(3)).isInstanceOf(IllegalStateException.class); + assertThat(registry.openPools()) + .as("a refused tenant must not leave a half-built pool behind") + .isEqualTo(2); + } + } + + @Test + @DisplayName("every opened tenant pool reaches the real server") + void everyOpenedTenantPoolReachesTheServer() throws SQLException { + var budget = new TenantPoolBudget(3, 6); + try (var registry = + new TenantDataSourceRegistry( + TenantPoolCapacityContractTest::poolFor, + TenantPoolCapacityContractTest::poolSizeOf, + budget)) { + registry.openTenants(3); + + for (TenantId tenant : List.copyOf(registry.openTenantIds())) { + try (Connection connection = registry.require(tenant).getConnection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("select 1")) { + assertThat(rows.next()).isTrue(); + } + } + } + } + + @Test + @DisplayName("evicting a tenant returns its connections to the budget") + void evictingATenantReturnsItsConnectionsToTheBudget() { + var budget = new TenantPoolBudget(2, 4); + try (var registry = + new TenantDataSourceRegistry( + TenantPoolCapacityContractTest::poolFor, + TenantPoolCapacityContractTest::poolSizeOf, + budget)) { + registry.openTenants(2); + TenantId evicted = List.copyOf(registry.openTenantIds()).get(0); + + registry.evict(evicted); + + assertThat(registry.openPools()).isEqualTo(1); + assertThat(registry.allocatedConnections()).isEqualTo(2); + assertThat(registry.openTenantIds()).doesNotContain(evicted); + } + } + + private static javax.sql.DataSource poolFor(TenantId tenant) { + var config = new HikariConfig(); + config.setJdbcUrl(support.jdbcUrl()); + config.setUsername(support.username()); + config.setPassword(support.password()); + config.setMaximumPoolSize(POOL_SIZE_PER_TENANT); + config.setPoolName("tenant-" + tenant.value()); + return new HikariDataSource(config); + } + + private static int poolSizeOf(javax.sql.DataSource pool) { + return ((HikariDataSource) pool).getMaximumPoolSize(); + } + + private static int serverMaxConnections() throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement(); + var rows = statement.executeQuery("show max_connections")) { + rows.next(); + return Integer.parseInt(rows.getString(1)); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java new file mode 100644 index 00000000..274a58a1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.adapter.outbound.persistence; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.Location; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The plan's §3 module dependency map, enforced as package rules. + * + *

The design models the platform as separate Gradle projects, where Gradle refuses an + * unregistered edge at compile time. This repository's fail-closed 19-leaf registry outranks that + * layout, so the modules are packages inside one leaf (see {@code + * docs/jpa/repository-adaptation.md}) — and packages enforce nothing on their own. Without this + * test the module map would be a diagram rather than a constraint, and the first import that + * crossed it would compile. + * + *

{@code verifyCleanArchitectureDependencies} does not cover this. It governs edges *between* + * registered leaves; every edge asserted here is inside a single leaf and therefore invisible to + * it. + */ +class JpaModuleBoundaryTest { + + private static final String ROOT = "dev.caskeleton.adapter.outbound.persistence"; + + /** Every platform package that is downstream of the core contracts. */ + private static final List DOWNSTREAM_OF_API = + List.of( + "transaction", + "springdata", + "querydsl", + "hibernate", + "postgresql", + "migration", + "auditing", + "envers", + "cache", + "observation", + "security", + "experimental", + "testkit"); + + /** + * Capabilities a deployment opts into one at a time. + * + *

The plan makes each of these a separate optional module precisely so that enabling one does + * not drag in another. A dependency between two of them would make that opt-in a fiction. + */ + private static final List INDEPENDENT_CAPABILITIES = + List.of("auditing", "envers", "cache", "observation", "security", "migration", "querydsl"); + + private static final JavaClasses PRODUCTION_CLASSES = + new ClassFileImporter().withImportOption(ProductionOnly.INSTANCE).importPackages(ROOT); + + @Test + @DisplayName("the import actually loaded the production classes these rules govern") + void importActuallyLoadedTheProductionClasses() { + // Every rule below is a `noClasses()` rule, and those pass vacuously when nothing matched. A + // location filter that silently matched no output would report four green module-boundary + // checks for a platform nobody examined — the exact failure mode this suite exists to prevent. + assertThat(PRODUCTION_CLASSES).isNotEmpty(); + for (String platformPackage : DOWNSTREAM_OF_API) { + if (platformPackage.equals("testkit")) { + continue; + } + assertThat(PRODUCTION_CLASSES.containPackage(ROOT + "." + platformPackage)) + .as("no production class imported for platform package %s", platformPackage) + .isTrue(); + } + assertThat(PRODUCTION_CLASSES.containPackage(ROOT + ".api")).isTrue(); + + // The testkit is the one entry that must be absent: it is a separate source set, so its + // non-appearance in the main output is the structural half of the "no production edge" rule. + assertThat(PRODUCTION_CLASSES.containPackage(ROOT + ".testkit")) + .as("the testkit compiles to its own source set and must not reach the main output") + .isFalse(); + } + + @Test + @DisplayName("the core contracts depend on no other platform package") + void coreContractsDependOnNoOtherPlatformPackage() { + noClasses() + .that() + .resideInAPackage(ROOT + ".api..") + .should() + .dependOnClassesThat() + .resideInAnyPackage(packagesOf(DOWNSTREAM_OF_API)) + .as("jpa-core-api depends on nothing else in the platform (plan §3)") + .because( + "every other module depends on the core contracts; an edge back out of them would make" + + " the whole map cyclic and the modules unseparable") + .check(PRODUCTION_CLASSES); + } + + @Test + @DisplayName("no production class depends on the testkit") + void noProductionClassDependsOnTheTestkit() { + noClasses() + .that() + .resideOutsideOfPackage(ROOT + ".testkit..") + .should() + .dependOnClassesThat() + .resideInAPackage(ROOT + ".testkit..") + .as("no production module depends on the testkit (plan §3)") + .because( + "the testkit carries ArchUnit and Testcontainers; a production edge would put both on" + + " every deployment's runtime classpath") + .check(PRODUCTION_CLASSES); + } + + @Test + @DisplayName("the Stable platform does not depend on experimental packages") + void stablePlatformDoesNotDependOnExperimentalPackages() { + noClasses() + .that() + .resideOutsideOfPackage(ROOT + ".experimental..") + .should() + .dependOnClassesThat() + .resideInAPackage(ROOT + ".experimental..") + .as("experimental capabilities are never reachable from Stable code (plan §3)") + .because( + "an experimental capability may change or be withdrawn; a Stable type referencing one" + + " gives it a Stable contract nobody agreed to") + .check(PRODUCTION_CLASSES); + } + + @Test + @DisplayName("opt-in capabilities do not depend on each other") + void optInCapabilitiesDoNotDependOnEachOther() { + for (String capability : INDEPENDENT_CAPABILITIES) { + List others = + INDEPENDENT_CAPABILITIES.stream().filter(other -> !other.equals(capability)).toList(); + noClasses() + .that() + .resideInAPackage(ROOT + "." + capability + "..") + .should() + .dependOnClassesThat() + .resideInAnyPackage(packagesOf(others)) + .as(capability + " depends on no sibling capability (plan §3)") + .because( + "each capability is separately opt-in; a sibling edge would silently enable one" + + " capability by enabling another") + .check(PRODUCTION_CLASSES); + } + } + + private static String[] packagesOf(List names) { + return names.stream().map(name -> ROOT + "." + name + "..").toArray(String[]::new); + } + + /** + * Imports only the {@code main} output. + * + *

ArchUnit's {@code DoNotIncludeTests} keys on {@code /test/} in the path, which does not + * exclude the {@code testkit} source set — and the testkit is exactly what several of these rules + * are about. Matching {@code /classes/java/main/} names the one output that is production. + */ + private enum ProductionOnly implements com.tngtech.archunit.core.importer.ImportOption { + INSTANCE; + + @Override + public boolean includes(Location location) { + return location.contains("/classes/java/main/"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java new file mode 100644 index 00000000..baf30b16 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 2 — operation names stay bounded (design §9.1). */ +class PersistenceOperationNameTest { + + @Test + @DisplayName("a dynamic identifier is not a valid operation name") + void rejectsDynamicIdentifiers() { + assertThatThrownBy(() -> new PersistenceOperationName("order/" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a registered low-cardinality name is accepted verbatim") + void acceptsRegisteredLowCardinalityName() { + assertThat(new PersistenceOperationName("order.place").value()).isEqualTo("order.place"); + } + + @Test + @DisplayName("null, blank, upper case, and over-long names are all rejected") + void rejectsEveryUnboundedShape() { + assertThatThrownBy(() -> new PersistenceOperationName(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new PersistenceOperationName("")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new PersistenceOperationName("Order.Place")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new PersistenceOperationName("a".repeat(97))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java new file mode 100644 index 00000000..afc0058e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 3 — the failure context refuses to represent an unsafe retry (design §17.4, §18.1). */ +class JpaFailureContextTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("payment.commit"); + + @Test + @DisplayName("completion unknown can never be marked retryable") + void completionUnknownCanNeverBeMarkedRetryable() { + var context = + JpaFailureContext.completionUnknown(OPERATION, "40003", 1, Duration.ofMillis(50), "trace"); + + assertThat(context.retryable()).isFalse(); + assertThat(context.completionUnknown()).isTrue(); + } + + @Test + @DisplayName("constructing a retryable completion-unknown context is rejected outright") + void refusesRetryableCompletionUnknown() { + assertThatThrownBy( + () -> + new JpaFailureContext(OPERATION, "40003", null, 1, true, true, Duration.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never retryable"); + } + + @Test + @DisplayName("an unbounded trace id is redacted rather than carried") + void redactsUnboundedIdentifiers() { + var context = + JpaFailureContext.terminal( + OPERATION, "23505", 1, Duration.ZERO, "secret@example.test with spaces"); + + assertThat(context.traceId()).isEqualTo(JpaFailureContext.REDACTED); + } + + @Test + @DisplayName("a malformed SQLSTATE is redacted rather than carried") + void redactsMalformedSqlState() { + var context = JpaFailureContext.terminal(OPERATION, "not-a-state", 1, Duration.ZERO, null); + + assertThat(context.sqlState()).isEqualTo(JpaFailureContext.REDACTED); + } + + @Test + @DisplayName("an absent SQLSTATE becomes the no-state sentinel") + void defaultsAbsentSqlState() { + var context = JpaFailureContext.terminal(OPERATION, null, 1, Duration.ZERO, null); + + assertThat(context.sqlState()).isEqualTo(JpaFailureContext.NO_SQL_STATE); + } + + @Test + @DisplayName("an attempt number below one is rejected") + void requiresPositiveAttempt() { + assertThatThrownBy(() -> JpaFailureContext.terminal(OPERATION, "23505", 0, Duration.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java new file mode 100644 index 00000000..85d62ff2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.persistence.api.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 3 — exception messages carry no row data (design §18.1). */ +class JpaPersistenceExceptionTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("user.create"); + + @Test + @DisplayName("the message is composed only from bounded values") + void messageContainsNoRowData() { + var failure = + new UniqueConstraintViolationException( + JpaFailureContext.terminal(OPERATION, "23505", 1, Duration.ofMillis(3), null), + ConstraintViolationDetails.of(new ConstraintCode("user.active-email.unique")), + new IllegalStateException("duplicate key value violates ... (email)=(a@example.test)")); + + assertThat(failure.getMessage()) + .contains("user.create", "23505", "UNIQUE_CONSTRAINT") + .doesNotContain("a@example.test"); + } + + @Test + @DisplayName("the provider exception is preserved as the cause for server-side diagnosis") + void preservesProviderCause() { + var cause = new IllegalStateException("provider detail"); + var failure = + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 2, Duration.ZERO, null), cause); + + assertThat(failure.getCause()).isSameAs(cause); + assertThat(failure.retryable()).isTrue(); + assertThat(failure.category()).isEqualTo(FailureCategory.SERIALIZATION_FAILURE); + } + + @Test + @DisplayName("a completion-unknown exception forces the safe context whatever it is given") + void completionUnknownForcesSafeContext() { + var failure = + new TransactionCompletionUnknownException( + JpaFailureContext.retryable(OPERATION, "40003", 1, Duration.ZERO, null), + "payment-42", + TransactionCompletionEvidence.UNKNOWN, + null); + + assertThat(failure.completionUnknown()).isTrue(); + assertThat(failure.retryable()).isFalse(); + assertThat(failure.transactionKey()).contains("payment-42"); + assertThat(failure.evidence()).isEqualTo(TransactionCompletionEvidence.UNKNOWN); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java new file mode 100644 index 00000000..d6fc7ddb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 18 — a query name is a registry key, not a description (design §9.5). */ +class QueryNameTest { + + @Test + @DisplayName("raw SQL is not a metric identity") + void rejectsRawSqlAsMetricIdentity() { + assertThatThrownBy(() -> new QueryName("select * from orders where id=42")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a registered dotted name is accepted") + void acceptsRegisteredName() { + assertThat(new QueryName("order.find-recent").value()).isEqualTo("order.find-recent"); + } + + @Test + @DisplayName("the no-op observation opens and closes without a backend") + void noopObservationIsSafeToUse() { + QueryObservation observation = NoopQueryObservation.instance(); + try (QueryScope scope = observation.start(new QueryName("order.find-recent"))) { + scope.rows(3L); + scope.failure(new IllegalStateException("ignored")); + } + assertThat(observation).isSameAs(NoopQueryObservation.instance()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java new file mode 100644 index 00000000..b916a261 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.outbound.persistence.api.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 26 — a cursor is versioned, bounded, and tamper-evident (design §27.3). */ +class SignedJsonCursorCodecTest { + + /** The ordering key and its unique tie-breaker — nothing else belongs in a cursor. */ + private record OrderCursor(Instant createdAt, UUID id) {} + + private static final byte[] KEY = + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + + private final SignedJsonCursorCodec codec = + new SignedJsonCursorCodec<>(new OrderCursorPayloadCodec(), KEY); + + @Test + @DisplayName("a cursor round-trips and tampering is detected") + void detectsTamperingAndRoundTripsTieBreaker() { + var cursor = new OrderCursor(Instant.parse("2026-08-11T00:00:00Z"), UUID.randomUUID()); + String encoded = codec.encode(cursor); + + assertThat(codec.decode(encoded)).isEqualTo(cursor); + assertThatThrownBy(() -> codec.decode(encoded + "x")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a cursor signed with a different key is refused") + void refusesForeignSignature() { + var cursor = new OrderCursor(Instant.EPOCH, UUID.randomUUID()); + var other = + new SignedJsonCursorCodec( + new OrderCursorPayloadCodec(), + "fedcba9876543210fedcba9876543210".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> codec.decode(other.encode(cursor))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("signature"); + } + + @Test + @DisplayName("an unknown cursor version is refused rather than parsed") + void refusesUnknownVersion() { + var cursor = new OrderCursor(Instant.EPOCH, UUID.randomUUID()); + String encoded = codec.encode(cursor); + String downgraded = "v0" + encoded.substring(2); + + assertThatThrownBy(() -> codec.decode(downgraded)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("version"); + } + + @Test + @DisplayName("a short signing key is refused at construction") + void refusesShortKey() { + assertThatThrownBy( + () -> new SignedJsonCursorCodec<>(new OrderCursorPayloadCodec(), new byte[16])) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the page request bounds its size") + void pageRequestIsBounded() { + assertThatThrownBy(() -> KeysetPageRequest.first(0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> KeysetPageRequest.first(KeysetPageRequest.MAX_SIZE + 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(KeysetPageRequest.first(20).fetchSize()).isEqualTo(21); + } + + /** A minimal JSON codec; the application supplies its own in production. */ + private static final class OrderCursorPayloadCodec implements CursorPayloadCodec { + + @Override + public String toJson(OrderCursor cursor) { + return "{\"createdAt\":\"" + cursor.createdAt() + "\",\"id\":\"" + cursor.id() + "\"}"; + } + + @Override + public OrderCursor fromJson(String json) { + String createdAt = between(json, "\"createdAt\":\"", "\""); + String id = between(json, "\"id\":\"", "\""); + return new OrderCursor(Instant.parse(createdAt), UUID.fromString(id)); + } + + private static String between(String json, String prefix, String suffix) { + int start = json.indexOf(prefix); + if (start < 0) { + throw new IllegalArgumentException("malformed cursor payload"); + } + start += prefix.length(); + int end = json.indexOf(suffix, start); + if (end < 0) { + throw new IllegalArgumentException("malformed cursor payload"); + } + return json.substring(start, end); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java new file mode 100644 index 00000000..645d6539 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.outbound.persistence.api.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 5 — transaction and retry profiles refuse unsafe shapes (design §9.2, §19.3). */ +class TransactionProfileTest { + + @Test + @DisplayName("a write profile requires a positive, finite timeout") + void writeProfileRequiresFiniteTimeout() { + assertThatThrownBy( + () -> + new TransactionProfile( + "write", + PropagationMode.REQUIRED, + IsolationLevel.READ_COMMITTED, + Duration.ZERO, + false, + RetryProfile.none())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive timeout"); + } + + @Test + @DisplayName("a read profile may leave the timeout to the connection default") + void readProfileMayOmitTimeout() { + var profile = TransactionProfile.read("order.read", Duration.ZERO); + + assertThat(profile.readOnly()).isTrue(); + assertThat(profile.hasTimeout()).isFalse(); + } + + @Test + @DisplayName("a retry profile can never opt into completion unknown") + void retryProfileRejectsCompletionUnknown() { + assertThatThrownBy( + () -> + new RetryProfile( + "unsafe", + 3, + Duration.ofMillis(10), + Duration.ofMillis(100), + 2.0d, + JitterMode.FULL, + Set.of(FailureCategory.COMPLETION_UNKNOWN))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never a retryable failure category"); + } + + @Test + @DisplayName("a retry profile rejects a category that is not retry eligible") + void retryProfileRejectsIneligibleCategory() { + assertThatThrownBy( + () -> + new RetryProfile( + "unsafe", + 3, + Duration.ofMillis(10), + Duration.ofMillis(100), + 2.0d, + JitterMode.FULL, + Set.of(FailureCategory.UNIQUE_CONSTRAINT))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not retry eligible"); + } + + @Test + @DisplayName("maxAttempts must be at least one") + void retryProfileRequiresAtLeastOneAttempt() { + assertThatThrownBy( + () -> + new RetryProfile( + "none", 0, Duration.ZERO, Duration.ZERO, 1.0d, JitterMode.NONE, Set.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the none profile permits exactly one attempt") + void noneProfileIsSingleAttempt() { + assertThat(RetryProfile.none().enabled()).isFalse(); + assertThat(RetryProfile.none().maxAttempts()).isEqualTo(1); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuardTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuardTest.java new file mode 100644 index 00000000..88cac49a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuardTest.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.persistence.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.SharedCacheMode; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 46 — the L2 cache defaults are refused, not inherited (design §34). */ +class HibernateCacheGuardTest { + + private final HibernateCacheGuard guard = new HibernateCacheGuard(); + private final CacheRegionCatalog catalog = + new CacheRegionCatalog(Map.of("CountryEntity", CacheConcurrencyStrategy.READ_ONLY)); + + @Test + @DisplayName("query cache is off, and only enrolled entities are cacheable") + void queryCacheIsOffAndOnlyRegisteredEntitiesAreCacheable() { + assertThatThrownBy( + () -> + guard.validate( + new HibernateCacheSettings( + true, true, SharedCacheMode.ENABLE_SELECTIVE, Set.of()), + catalog)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Query Cache is disabled by default"); + } + + @Test + @DisplayName("ENABLE_ALL is refused; enrolment must be selective") + void refusesEnableAll() { + assertThatThrownBy( + () -> + guard.validate( + new HibernateCacheSettings(true, false, SharedCacheMode.ALL, Set.of()), + catalog)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ENABLE_SELECTIVE"); + } + + @Test + @DisplayName("a cacheable entity outside the catalog is refused") + void refusesUnenrolledCacheableEntity() { + assertThatThrownBy( + () -> + guard.validate( + new HibernateCacheSettings( + true, false, SharedCacheMode.ENABLE_SELECTIVE, Set.of("OrderEntity")), + catalog)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OrderEntity"); + } + + @Test + @DisplayName("a disabled cache needs no enrolment at all") + void disabledCacheNeedsNoEnrolment() { + assertThatCode(() -> guard.validate(HibernateCacheSettings.disabled(), catalog)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a cached entity targeted by bulk DML is refused without an eviction strategy") + void refusesCachedEntityTargetedByBulkDml() { + assertThatThrownBy(() -> guard.requireBulkEviction(catalog, Set.of("CountryEntity"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bypass the cache"); + } + + @Test + @DisplayName("the policy records the two assumptions that decide cache coherence") + void policyRecordsCoherenceAssumptions() { + var soleWriterClustered = + new HibernateCachePolicy( + true, true, Map.of("CountryEntity", CacheConcurrencyStrategy.READ_ONLY)); + var multiWriter = + new HibernateCachePolicy( + false, true, Map.of("CountryEntity", CacheConcurrencyStrategy.READ_ONLY)); + + assertThat(soleWriterClustered.safeForMultipleInstances()).isTrue(); + assertThat(multiWriter.safeForMultipleInstances()).isFalse(); + assertThat(soleWriterClustered.sharedCacheMode()).isEqualTo(SharedCacheMode.ENABLE_SELECTIVE); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGateTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGateTest.java new file mode 100644 index 00000000..cf62c953 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeatureGateTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget; +import dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext; +import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext; +import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Tasks 1-9 — every experimental capability is off and fail-closed by default. + */ +class ExperimentalFeatureGateTest { + + private final ExperimentalFeatureGate gate = new ExperimentalFeatureGate(); + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + @Test + @DisplayName("a feature is disabled unless its flag is explicitly true") + void featureIsDisabledUnlessExplicitlyEnabled() { + assertThatThrownBy(() -> gate.requireEnabled(ExperimentalFeature.MULTITENANCY_COLUMN, Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("backend.jpa.experimental.multitenancy-column=true"); + + gate.requireEnabled( + ExperimentalFeature.MULTITENANCY_COLUMN, + Map.of("backend.jpa.experimental.multitenancy-column", true)); + } + + @Test + @DisplayName("an absent tenant context is an error, never a default") + void tenantContextIsFailClosed() { + assertThatThrownBy(TenantContext::require) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("tenant context is required"); + + assertThat(TenantContext.with(new TenantId("acme"), () -> TenantContext.require().value())) + .isEqualTo("acme"); + assertThat(TenantContext.current()).isEmpty(); + } + + @Test + @DisplayName("a tenant id that could traverse a schema path is refused") + void tenantIdIsBounded() { + assertThatThrownBy(() -> new TenantId("../public")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("only registered tenant schemas are addressable") + void onlyRegisteredSchemasAreAddressable() { + var registry = new SchemaTenantRegistry(Map.of(new TenantId("acme"), "tenant_acme")); + + assertThat(registry.requireSchema(new TenantId("acme"))).isEqualTo("tenant_acme"); + assertThatThrownBy(() -> registry.requireSchema(new TenantId("unknown"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered tenant schema"); + } + + @Test + @DisplayName("a read after write uses the primary until the replica is proven caught up") + void immediateReadAfterWriteUsesPrimary() { + var token = ConsistencyToken.at(Instant.parse("2026-08-11T00:00:00Z")); + var behind = + new FixedLagMonitor( + Optional.of(Instant.parse("2026-08-10T23:59:00Z")), Optional.of(Duration.ofMinutes(1))); + var router = new ConsistencyAwareDataSourceRouter(behind); + + var decision = router.route(TransactionContext.readOnly(), ReadConsistency.after(token)); + + assertThat(decision.target()).isEqualTo(ReplicaTarget.PRIMARY); + } + + @Test + @DisplayName("a locking read uses the primary even though it is read-only") + void lockingReadUsesPrimary() { + var caughtUp = new FixedLagMonitor(Optional.of(Instant.MAX), Optional.of(Duration.ZERO)); + var router = new ConsistencyAwareDataSourceRouter(caughtUp); + + assertThat(router.route(TransactionContext.lockingRead(), ReadConsistency.eventual()).target()) + .isEqualTo(ReplicaTarget.PRIMARY); + } + + @Test + @DisplayName("unavailable lag evidence routes to the primary") + void unavailableEvidenceUsesPrimary() { + var unknown = new FixedLagMonitor(Optional.empty(), Optional.empty()); + var router = new ConsistencyAwareDataSourceRouter(unknown); + + assertThat( + router + .route( + TransactionContext.readOnly(), + ReadConsistency.boundedStaleness(Duration.ofSeconds(5))) + .target()) + .isEqualTo(ReplicaTarget.PRIMARY); + } + + @Test + @DisplayName("the global tenant pool budget bounds both pools and connections") + void tenantPoolBudgetIsBounded() { + var budget = new TenantPoolBudget(2, 20); + + assertThat(budget.hasCapacity(1, 10)).isTrue(); + assertThatThrownBy(() -> budget.requireCapacity(2, 10)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("tenant pool budget"); + assertThatThrownBy(() -> budget.requireCapacity(1, 20)) + .isInstanceOf(IllegalStateException.class); + } + + /** A lag monitor with a fixed answer, including "I do not know". */ + private record FixedLagMonitor(Optional replayedThrough, Optional lag) + implements ReplicaLagMonitor {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLaneDefinitionTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLaneDefinitionTest.java new file mode 100644 index 00000000..1ba5412c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/CompatibilityLaneDefinitionTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Experimental plan Task 7 — a lane is experimental and publishes nothing. */ +class CompatibilityLaneDefinitionTest { + + @Test + @DisplayName("the JPA 4 lane is experimental and separate from Stable publication") + void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() { + var lane = CompatibilityLane.experimental("jpa4"); + + assertThat(lane.publicationEnabled()).isFalse(); + assertThat(lane.supportLevel()).isEqualTo(SupportLevel.EXPERIMENTAL); + } + + @Test + @DisplayName("all three lanes are defined and none publishes") + void allThreeLanesAreDefinedAndNonePublishes() { + assertThat(CompatibilityLane.defined()) + .extracting(CompatibilityLane::name) + .containsExactly("jpa4", "hibernate8", "postgresql19"); + assertThat(CompatibilityLane.defined()) + .allSatisfy(lane -> assertThat(lane.publicationEnabled()).isFalse()); + } + + @Test + @DisplayName("an experimental lane that tries to publish is refused") + void experimentalLaneMayNotPublish() { + assertThatThrownBy(() -> new CompatibilityLane("jpa4", SupportLevel.EXPERIMENTAL, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stable coordinates"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGateTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGateTest.java new file mode 100644 index 00000000..c1f5eafb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/ExperimentalPromotionGateTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Experimental plan Task 9 — promotion requires evidence and a reviewed decision. */ +class ExperimentalPromotionGateTest { + + private final ExperimentalPromotionGate gate = new ExperimentalPromotionGate(); + + @Test + @DisplayName("promotion requires all evidence and a reviewed ADR") + void promotionRequiresAllEvidenceAndReviewedAdr() { + var evidence = + PromotionEvidence.none() + .withCompatibility(true) + .withSecurity(true) + .withFailure(true) + .withMigration(true) + .withPerformance(true) + .withReviewedAdr(false); + + assertThat(gate.evaluate(evidence)).isEqualTo(PromotionDecision.BLOCKED_MISSING_ADR); + } + + @Test + @DisplayName("complete evidence with a reviewed ADR is eligible for Stable review") + void completeEvidenceIsEligible() { + var evidence = + PromotionEvidence.none() + .withCompatibility(true) + .withSecurity(true) + .withFailure(true) + .withMigration(true) + .withPerformance(true) + .withReviewedAdr(true); + + assertThat(gate.evaluate(evidence)).isEqualTo(PromotionDecision.ELIGIBLE_FOR_STABLE_REVIEW); + } + + @Test + @DisplayName("a reviewed ADR alone does not promote anything") + void reviewedAdrAloneIsBlockedTechnically() { + assertThat(gate.evaluate(PromotionEvidence.none().withReviewedAdr(true))) + .isEqualTo(PromotionDecision.BLOCKED_TECHNICAL); + } + + @Test + @DisplayName("each missing suite blocks technically on its own") + void eachMissingSuiteBlocksOnItsOwn() { + var complete = + PromotionEvidence.none() + .withCompatibility(true) + .withSecurity(true) + .withFailure(true) + .withMigration(true) + .withPerformance(true) + .withReviewedAdr(true); + + assertThat(gate.evaluate(complete.withSecurity(false))) + .isEqualTo(PromotionDecision.BLOCKED_TECHNICAL); + assertThat(gate.evaluate(complete.withFailure(false))) + .isEqualTo(PromotionDecision.BLOCKED_TECHNICAL); + assertThat(gate.evaluate(complete.withMigration(false))) + .isEqualTo(PromotionDecision.BLOCKED_TECHNICAL); + assertThat(gate.evaluate(complete.withPerformance(false))) + .isEqualTo(PromotionDecision.BLOCKED_TECHNICAL); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Hibernate8CompatibilityTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Hibernate8CompatibilityTest.java new file mode 100644 index 00000000..65eb84cc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Hibernate8CompatibilityTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import org.hibernate.Version; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 8 — the Hibernate 8 lane. + * + *

The provider version is read from Hibernate itself rather than from the build file, because + * the build file states an intent and the classpath states the fact. Dependency resolution, + * platform BOMs, and transitive upgrades all move the second without touching the first. + */ +class Hibernate8CompatibilityTest { + + private static final String STABLE_MAJOR = "7."; + + @Test + @DisplayName("the lane is defined, experimental, and never publishes") + void laneIsDefinedExperimentalAndNonPublishing() { + CompatibilityLane lane = + CompatibilityLane.defined().stream() + .filter(candidate -> candidate.name().equals("hibernate8")) + .findFirst() + .orElseThrow(); + + assertThat(lane.supportLevel()).isEqualTo(SupportLevel.EXPERIMENTAL); + assertThat(lane.publicationEnabled()).isFalse(); + } + + @Test + @DisplayName("the resolved provider is the Stable major, not an experimental one") + void resolvedProviderIsTheStableMajor() { + assertThat(Version.getVersionString()) + .as("the platform's Stable answer is only valid for the provider it was measured against") + .startsWith(STABLE_MAJOR); + } + + @Test + @DisplayName("Hibernate 8 may not silently replace the Stable provider") + void hibernate8MayNotSilentlyReplaceTheStableProvider() { + var policy = HibernateCompatibilityPolicy.fromClasspath(); + + assertThat(policy.stableProvider()).startsWith(STABLE_MAJOR); + assertThat(policy.mayReplaceStableProvider("8.0.0")) + .as("a provider major that no contract has run against is not a drop-in") + .isFalse(); + // The policy enumerates majors, not point releases: what a lane covers is a provider + // generation, and 8.0.1 is no more measured than 8.0.0 was. + assertThat(policy.experimentalProviders()).contains("8"); + } + + @Test + @DisplayName("the Hibernate 8 lane is not executable until the provider is on the classpath") + void laneIsNotExecutableUntilTheProviderExists() { + assertThat(Version.getVersionString().startsWith("8.")) + .as( + "when this fails, Hibernate 8 is resolved and the lane must actually be run before the" + + " platform claims anything about it") + .isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicyTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicyTest.java new file mode 100644 index 00000000..e7b5e082 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicyTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Experimental plan Task 8 — a lane result never replaces the Stable provider. */ +class HibernateCompatibilityPolicyTest { + + private final HibernateCompatibilityPolicy policy = + new HibernateCompatibilityPolicy(new HibernateProviderPolicy("7.1.8.Final")); + + @Test + @DisplayName("Hibernate 8 cannot replace the Stable provider without a promotion") + void hibernateEightCannotReplaceStableProviderWithoutPromotion() { + assertThat(policy.stableProvider()).isEqualTo("7.4"); + assertThat(policy.experimentalProviders()).contains("8"); + assertThat(policy.mayReplaceStableProvider("8.0.0.Final")).isFalse(); + } + + @Test + @DisplayName("a 7.x provider is not experimental") + void sevenIsNotExperimental() { + assertThat(policy.mayReplaceStableProvider("7.1.8.Final")).isTrue(); + } + + @Test + @DisplayName("the declared baseline and the resolved runtime are held apart") + void declaredBaselineAndRuntimeAreHeldApart() { + var provider = new HibernateProviderPolicy("7.1.8.Final"); + + assertThat(provider.stableProvider()).isEqualTo("7.4"); + assertThat(provider.runtimeMinorVersion()).isEqualTo("7.1"); + assertThat(provider.driftsFromDeclaredBaseline()).isTrue(); + assertThat(provider.report()).contains("declaredStable=7.4").contains("runtime=7.1.8.Final"); + } + + @Test + @DisplayName("a runtime matching the declared baseline reports no drift") + void matchingRuntimeReportsNoDrift() { + assertThat(new HibernateProviderPolicy("7.4.1.Final").driftsFromDeclaredBaseline()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Jpa4CompatibilityTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Jpa4CompatibilityTest.java new file mode 100644 index 00000000..8d281730 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/Jpa4CompatibilityTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 7 — the Jakarta Persistence 4 lane. + * + *

JPA 4 is not released, so this lane cannot run against it. That is exactly why the test is + * here: a lane whose target does not exist must report "not executable", never "passed". The + * detection reads the specification version off the API actually on the classpath, so the day JPA 4 + * lands the assertion inverts and forces someone to run the lane rather than letting the platform + * quietly claim compatibility it never measured. + */ +class Jpa4CompatibilityTest { + + private static final String STABLE_SPECIFICATION = "3.2"; + + @Test + @DisplayName("the lane is defined, experimental, and never publishes") + void laneIsDefinedExperimentalAndNonPublishing() { + CompatibilityLane lane = laneNamed("jpa4"); + + assertThat(lane.supportLevel()).isEqualTo(SupportLevel.EXPERIMENTAL); + assertThat(lane.publicationEnabled()) + .as("publishing from a lane would let consumers depend on an unmeasured answer") + .isFalse(); + } + + @Test + @DisplayName("an experimental lane cannot be configured to publish") + void experimentalLaneCannotBeConfiguredToPublish() { + assertThatThrownBy(() -> new CompatibilityLane("jpa4", SupportLevel.EXPERIMENTAL, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the platform still compiles against the Stable specification") + void platformStillCompilesAgainstTheStableSpecification() { + Optional specification = specificationVersionOnClasspath(); + + assertThat(specification) + .as("the Jakarta Persistence API jar must declare the specification it implements") + .isPresent(); + assertThat(specification).contains(STABLE_SPECIFICATION); + } + + @Test + @DisplayName("the JPA 4 lane is not executable until the specification is on the classpath") + void laneIsNotExecutableUntilTheSpecificationExists() { + boolean targetPresent = + specificationVersionOnClasspath().filter(version -> version.startsWith("4.")).isPresent(); + + assertThat(targetPresent) + .as( + "when this fails, Jakarta Persistence 4 is present and the lane must actually be run" + + " before the platform claims anything about it") + .isFalse(); + } + + private static CompatibilityLane laneNamed(String name) { + return CompatibilityLane.defined().stream() + .filter(lane -> lane.name().equals(name)) + .findFirst() + .orElseThrow(() -> new AssertionError("no compatibility lane named " + name)); + } + + /** The specification version declared by the Jakarta Persistence API actually resolved. */ + private static Optional specificationVersionOnClasspath() { + Package apiPackage = jakarta.persistence.EntityManager.class.getPackage(); + return Optional.ofNullable(apiPackage.getSpecificationVersion()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PostgreSql19CompatibilityTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PostgreSql19CompatibilityTest.java new file mode 100644 index 00000000..beb5f7ee --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/PostgreSql19CompatibilityTest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.experimental.next; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Experimental plan Task 9 — the PostgreSQL 19 lane. + * + *

Unlike the specification and provider lanes, this one has no artifact to detect: a server + * version is only observable by starting one. So the assertion is about the support matrix instead + * — 19 must not appear in it, and the runtime policy must refuse a server reporting it, because a + * major nobody ran the contracts against is not a supported major regardless of how well it happens + * to work. + */ +class PostgreSql19CompatibilityTest { + + private static final int UNMEASURED_MAJOR = 19; + + @Test + @DisplayName("the lane is defined, experimental, and never publishes") + void laneIsDefinedExperimentalAndNonPublishing() { + CompatibilityLane lane = + CompatibilityLane.defined().stream() + .filter(candidate -> candidate.name().equals("postgresql19")) + .findFirst() + .orElseThrow(); + + assertThat(lane.supportLevel()).isEqualTo(SupportLevel.EXPERIMENTAL); + assertThat(lane.publicationEnabled()).isFalse(); + } + + @Test + @DisplayName("the three defined lanes are exactly the plan's three, all non-publishing") + void definedLanesAreExactlyThePlansThree() { + assertThat(CompatibilityLane.defined()) + .extracting(CompatibilityLane::name) + .containsExactly("jpa4", "hibernate8", "postgresql19"); + assertThat(CompatibilityLane.defined()) + .allSatisfy(lane -> assertThat(lane.publicationEnabled()).isFalse()); + } + + @Test + @DisplayName("an unmeasured server major is not in the Stable support matrix") + void unmeasuredServerMajorIsNotInTheStableMatrix() { + assertThat(SUPPORT_MATRIX).doesNotContain(UNMEASURED_MAJOR); + assertThat(SUPPORT_MATRIX) + .as("the matrix is the set of majors the contracts actually ran against") + .containsExactly(16, 17, 18); + } + + @Test + @DisplayName("a lane may not be promoted to Stable while it is still a lane") + void laneMayNotBePromotedWhileStillALane() { + assertThatThrownBy(() -> new CompatibilityLane("postgresql19", SupportLevel.EXPERIMENTAL, true)) + .isInstanceOf(IllegalArgumentException.class); + + // A Stable lane is representable — that is what promotion produces — but only once the + // contracts have been run and the matrix updated, which is the promotion gate's job. + var promoted = new CompatibilityLane("postgresql19", SupportLevel.STABLE, true); + assertThat(promoted.publicationEnabled()).isTrue(); + } + + /** + * The majors the Stable contracts actually run against. + * + *

Read from the testkit's matrix rather than restated here: a literal copy would keep + * asserting 16-18 after someone added 19 to the matrix without running its contracts, which is + * the precise mistake this test exists to catch. + */ + private static final java.util.List SUPPORT_MATRIX = + dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion.stable() + .stream() + .map( + dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion + ::majorVersion) + .toList(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java index a18dd287..8ada120f 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java @@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate; * USING}, and only an execution proves that the substitution kept the three outcomes intact. * *

In-memory and process-local, so this stays an ordinary unit test: no container, no network, - * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the - * {@code postgresqlIntegrationTest} source set. + * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the {@code + * postgresqlIntegrationTest} source set. */ class H2ClaimSqlTest { @@ -142,8 +142,9 @@ class H2ClaimSqlTest { List claimed = claimEligible(now, 10); - assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", - "evt-other"); + assertThat(claimed) + .extracting(OutboxEventEntity::getEventId) + .containsExactly("evt-old", "evt-other"); } @Test diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollectorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollectorTest.java new file mode 100644 index 00000000..f0af0c1d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollectorTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 22 — loads and fetches are separate counters (design §25.3, §28.3). */ +class HibernateStatisticsCollectorTest { + + @Test + @DisplayName("entity load is separated from entity fetch") + void separatesEntityLoadFromEntityFetch() { + var collector = + new HibernateStatisticsCollector( + statistics(10, 100, 3, 5, 2, 1, 0), JdbcBatchCounter.uninstrumented()); + var before = collector.snapshot(); + + var delta = collector.snapshot().minus(before); + + assertThat(delta.entityLoads()).isZero(); + assertThat(before.entityLoads()).isEqualTo(100L); + assertThat(before.entityFetches()).isEqualTo(3L); + } + + @Test + @DisplayName("a delta subtracts every dimension") + void deltaSubtractsEveryDimension() { + var before = new HibernateStatisticsSnapshot(1, 2, 3, 4, 5, 6, 7); + var after = new HibernateStatisticsSnapshot(11, 22, 33, 44, 55, 66, 77); + + var delta = after.minus(before); + + assertThat(delta.preparedStatements()).isEqualTo(10L); + assertThat(delta.entityLoads()).isEqualTo(20L); + assertThat(delta.entityFetches()).isEqualTo(30L); + assertThat(delta.collectionLoads()).isEqualTo(40L); + assertThat(delta.collectionFetches()).isEqualTo(50L); + assertThat(delta.flushes()).isEqualTo(60L); + assertThat(delta.jdbcBatches()).isEqualTo(70L); + } + + @Test + @DisplayName("disabled statistics fail loudly rather than reporting zeros") + void disabledStatisticsFailLoudly() { + Statistics statistics = mock(Statistics.class); + when(statistics.isStatisticsEnabled()).thenReturn(false); + var collector = new HibernateStatisticsCollector(statistics, JdbcBatchCounter.uninstrumented()); + + assertThatThrownBy(collector::snapshot) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("generate_statistics is disabled"); + } + + @Test + @DisplayName("an uninstrumented JDBC layer says so rather than reporting zero batches") + void uninstrumentedBatchCounterSaysSo() { + var collector = + new HibernateStatisticsCollector( + statistics(1, 1, 1, 1, 1, 1, 0), JdbcBatchCounter.uninstrumented()); + + assertThat(collector.batchInstrumented()).isFalse(); + assertThat(JdbcBatchCounter.uninstrumented().batchExecutions()).isZero(); + } + + @Test + @DisplayName("the summary names every measured dimension") + void summaryNamesEveryDimension() { + assertThat(new HibernateStatisticsSnapshot(1, 2, 3, 4, 5, 6, 7).summary()) + .contains("statements=1", "entityLoads=2", "entityFetches=3", "jdbcBatches=7"); + } + + private static Statistics statistics( + long statements, + long entityLoads, + long entityFetches, + long collectionLoads, + long collectionFetches, + long flushes, + long unusedBatches) { + Statistics statistics = mock(Statistics.class); + when(statistics.isStatisticsEnabled()).thenReturn(true); + when(statistics.getPrepareStatementCount()).thenReturn(statements); + when(statistics.getEntityLoadCount()).thenReturn(entityLoads); + when(statistics.getEntityFetchCount()).thenReturn(entityFetches); + when(statistics.getCollectionLoadCount()).thenReturn(collectionLoads); + when(statistics.getCollectionFetchCount()).thenReturn(collectionFetches); + when(statistics.getFlushCount()).thenReturn(flushes); + return statistics; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuardTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuardTest.java new file mode 100644 index 00000000..86279bfd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuardTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.persistence.hibernate.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.testkit.id.IdentityEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.id.SequenceEntity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 33 — a profile that cannot batch is a startup failure (design §28.2). */ +class HibernateBatchConfigurationGuardTest { + + private final HibernateBatchConfigurationGuard guard = new HibernateBatchConfigurationGuard(); + + @Test + @DisplayName("an IDENTITY entity cannot satisfy a profile that requires batching") + void rejectsIdentityEntityInRequiredBatchProfile() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, true); + + assertThatThrownBy(() -> guard.validate(profile, IdentityEntity.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("IDENTITY disables insert batching"); + } + + @Test + @DisplayName("a sequence entity satisfies the same profile") + void acceptsSequenceEntity() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, true); + + guard.validate(profile, SequenceEntity.class); + + assertThat(guard.usesIdentityGeneration(SequenceEntity.class)).isFalse(); + assertThat(guard.usesIdentityGeneration(IdentityEntity.class)).isTrue(); + } + + @Test + @DisplayName("a profile that does not require batching does not fail on IDENTITY") + void permissiveProfileDoesNotFail() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, false); + + guard.validate(profile, IdentityEntity.class); + } + + @Test + @DisplayName("batch sizes must be positive and clear must not precede flush") + void rejectsIncoherentSizes() { + assertThatThrownBy(() -> new JpaBatchProfile("import", 0, 50, 50, true, true, true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new JpaBatchProfile("import", 50, 50, 10, true, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("clearSize"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspectorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspectorTest.java new file mode 100644 index 00000000..58c139be --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/ConcurrentIndexMigrationInspectorTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 43 — concurrent index builds must be non-transactional (design §32). */ +class ConcurrentIndexMigrationInspectorTest { + + private final ConcurrentIndexMigrationInspector inspector = + new ConcurrentIndexMigrationInspector(); + + @Test + @DisplayName("a transactional concurrent index migration is refused") + void concurrentIndexMustBeMarkedNonTransactional() { + var migration = + new MigrationResource( + "V42__order_index.sql", "create index concurrently ix_order on orders(created_at);"); + + assertThatThrownBy(() -> inspector.validate(migration, true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("executeInTransaction=false"); + } + + @Test + @DisplayName("the same migration is accepted once marked non-transactional") + void nonTransactionalMigrationIsAccepted() { + var migration = + new MigrationResource( + "V42__order_index.sql", "create index concurrently ix_order on orders(created_at);"); + + inspector.validate(migration, false); + + assertThat(inspector.containsConcurrentIndexStatement(migration.sql())).isTrue(); + } + + @Test + @DisplayName("drop and reindex concurrently carry the same restriction") + void recognisesEveryConcurrentStatement() { + assertThat(inspector.containsConcurrentIndexStatement("drop index concurrently ix_order")) + .isTrue(); + assertThat(inspector.containsConcurrentIndexStatement("reindex table concurrently orders")) + .isTrue(); + assertThat(inspector.containsConcurrentIndexStatement("create index ix_order on orders(id)")) + .isFalse(); + } + + @Test + @DisplayName("a concurrent index migration must contain nothing else") + void requiresIsolatedStatement() { + var mixed = + new MigrationResource( + "V43__mixed.sql", + "alter table orders add column note text;\n" + + "create index concurrently ix_order on orders(created_at);"); + + assertThatThrownBy(() -> inspector.requireIsolatedStatement(mixed)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mixes a concurrent index statement"); + } + + @Test + @DisplayName("ddl-auto values that mutate a deployed schema are refused") + void refusesSchemaMutatingDdlAuto() { + var policy = FlywaySchemaPolicy.standard(); + + assertThatThrownBy(() -> policy.requirePermittedDdlAuto("update")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Flyway owns schema change"); + policy.requirePermittedDdlAuto("validate"); + policy.requirePermittedDdlAuto("none"); + } + + @Test + @DisplayName("production validates rather than migrating at startup") + void productionValidatesOnly() { + var policy = FlywaySchemaPolicy.standard(); + + assertThat(policy.modeFor("prod")).isEqualTo(SchemaManagementMode.VALIDATE_ONLY); + assertThat(policy.modeFor("local")).isEqualTo(SchemaManagementMode.MIGRATE_ON_STARTUP); + assertThat(policy.modeFor("unknown-profile")).isEqualTo(SchemaManagementMode.VALIDATE_ONLY); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGateTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGateTest.java new file mode 100644 index 00000000..9947d43f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/migration/FlywayValidationGateTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.persistence.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException; +import java.util.List; +import org.flywaydb.core.api.CoreErrorCode; +import org.flywaydb.core.api.ErrorDetails; +import org.flywaydb.core.api.output.ValidateOutput; +import org.flywaydb.core.api.output.ValidateResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 41 — validation fails closed and never repairs (design §31). + * + *

The absence of a repair call is asserted structurally: the gate holds no Flyway instance and + * has no path that could invoke one. That is stronger than counting invocations on a probe, because + * it cannot regress into "repair is called but the probe was not wired". + */ +class FlywayValidationGateTest { + + private final FlywayValidationGate gate = new FlywayValidationGate(); + + @Test + @DisplayName("a checksum mismatch fails and nothing is repaired automatically") + void checksumMismatchFailsAndNeverRepairsAutomatically() { + ValidateResult result = validationFailure(CoreErrorCode.VALIDATE_ERROR, "CHECKSUM_MISMATCH"); + + assertThatThrownBy(() -> gate.requireValid(result)).isInstanceOf(SchemaMismatchException.class); + + // Structural, not behavioural: the gate holds no Flyway object at all, so no code path in it + // can reach repair(). That cannot regress into "repair is called but the probe was not wired". + assertThat(FlywayValidationGate.class.getDeclaredFields()) + .allSatisfy( + field -> + assertThat(field.getType().getName()).doesNotStartWith("org.flywaydb.core.Flyway")); + assertThat(FlywayValidationGate.class.getDeclaredMethods()) + .extracting(java.lang.reflect.Method::getName) + .doesNotContain("repair"); + } + + @Test + @DisplayName("only bounded error codes reach the exception, never the message text") + void onlyErrorCodesAreSurfaced() { + ValidateResult result = + validationFailure( + CoreErrorCode.VALIDATE_ERROR, "/db/migration/V42__secret_path.sql failed"); + + assertThat(gate.sanitizedErrorCodes(result)) + .containsExactly(CoreErrorCode.VALIDATE_ERROR.toString()); + assertThat(gate.sanitizedErrorCodes(result).toString()).doesNotContain("secret_path"); + } + + @Test + @DisplayName("a successful validation passes through") + void successfulValidationPasses() { + assertThatCode(() -> gate.requireValid(successfulValidation())).doesNotThrowAnyException(); + } + + @Test + @DisplayName("a failure with no invalid-migration detail still fails closed") + void failureWithoutDetailStillFailsClosed() { + ValidateResult result = + new ValidateResult("11", "postgres", null, false, 0, List.of(), List.of()); + + assertThatThrownBy(() -> gate.requireValid(result)).isInstanceOf(SchemaMismatchException.class); + assertThat(gate.sanitizedErrorCodes(result)).isEmpty(); + } + + private static ValidateResult successfulValidation() { + return new ValidateResult("11", "postgres", null, true, 1, List.of(), List.of()); + } + + private static ValidateResult validationFailure(CoreErrorCode code, String description) { + ValidateOutput invalid = + new ValidateOutput("42", "order index", "SQL", new ErrorDetails(code, description)); + return new ValidateResult( + "11", + "postgres", + new ErrorDetails(code, description), + false, + 1, + List.of(invalid), + List.of()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTagsTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTagsTest.java new file mode 100644 index 00000000..4e3de88e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTagsTest.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.micrometer.core.instrument.Tag; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 48 — metric tags stay bounded and PII-free (design §37). */ +class JpaMetricTagsTest { + + @Test + @DisplayName("an unbounded tag value is refused at construction") + void metricsNeverUseEntityIdOrSqlParameterAsTag() { + assertThatThrownBy(() -> JpaMetricTags.success("orders", "order.place", "secret@example.test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("low-cardinality"); + + assertThatThrownBy(() -> JpaMetricTags.success("orders", "entity 42", JpaMetricTags.NONE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a registered tag set produces exactly the five bounded dimensions") + void producesBoundedDimensions() { + var tags = JpaMetricTags.failure("orders", "order.place", "order.find", "DEADLOCK"); + + assertThat(tags.toTags()) + .extracting(Tag::getKey) + .containsExactlyInAnyOrder( + "persistence.unit", + "persistence.operation", + "persistence.query", + "outcome", + "failure.category"); + assertThat(tags.toTags()) + .extracting(Tag::getValue) + .noneMatch(value -> value.contains("@") || value.contains(" ")); + } + + @Test + @DisplayName("an absent dimension becomes the bounded none sentinel") + void absentDimensionIsBounded() { + var tags = JpaMetricTags.success("orders", null, null); + + assertThat(tags.operationName()).isEqualTo(JpaMetricTags.NONE); + assertThat(tags.queryName()).isEqualTo(JpaMetricTags.NONE); + } + + @Test + @DisplayName("SQL literals are removed before any diagnostic use") + void redactsSqlLiterals() { + String redacted = + SqlDiagnosticRedactor.redact("insert into users(email, age) values ('a@example.test', 42)"); + + assertThat(redacted).doesNotContain("a@example.test").doesNotContain("42"); + assertThat(redacted).contains("insert into users"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaObservabilityContractTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaObservabilityContractTest.java new file mode 100644 index 00000000..59d8f72e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaObservabilityContractTest.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.outbound.persistence.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException; +import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 48 — no metric tag ever carries row data (design §37). */ +class JpaObservabilityContractTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("user.create"); + private static final QueryName QUERY = new QueryName("user.find-by-email"); + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @Test + @DisplayName("metrics never use an entity id or a SQL parameter as a tag") + void metricsNeverUseEntityIdOrSqlParameterAsTag() { + var observation = new MicrometerQueryObservation(registry, "orders"); + + try (QueryScope scope = observation.start(QUERY)) { + scope.rows(1L); + scope.failure(uniqueViolation()); + } + + assertThat(registry.getMeters()) + .flatExtracting(meter -> meter.getId().getTags()) + .extracting(Tag::getValue) + .noneMatch(value -> value.contains("secret@example.test") || value.contains("entity-42")); + } + + @Test + @DisplayName("a failed query is tagged with the platform's failure category") + void failureIsTaggedWithTheCategory() { + var observation = new MicrometerQueryObservation(registry, "orders"); + + try (QueryScope scope = observation.start(QUERY)) { + scope.failure(uniqueViolation()); + } + + assertThat(registry.getMeters()) + .flatExtracting(meter -> meter.getId().getTags()) + .extracting(Tag::getValue) + .contains("UNIQUE_CONSTRAINT"); + } + + @Test + @DisplayName("query duration and row count are both recorded") + void durationAndRowsAreBothRecorded() { + var observation = new MicrometerQueryObservation(registry, "orders"); + + try (QueryScope scope = observation.start(QUERY)) { + scope.rows(2_000L); + } + + assertThat(registry.find(MicrometerQueryObservation.DURATION_METER).timer()).isNotNull(); + assertThat(registry.find(MicrometerQueryObservation.ROWS_METER).summary().max()) + .isEqualTo(2_000.0d); + } + + @Test + @DisplayName("completion unknown gets its own counter, not the general failure counter") + void completionUnknownHasItsOwnCounter() { + var observation = new JpaTransactionObservation(registry, "orders"); + + observation.recordCompletionUnknown(OPERATION); + + assertThat(registry.find(JpaTransactionObservation.COMPLETION_UNKNOWN_METER).counter().count()) + .isEqualTo(1.0d); + assertThat(registry.find(JpaTransactionObservation.ROLLBACK_METER).counter()).isNull(); + } + + @Test + @DisplayName("a rollback records both duration and the rollback counter") + void rollbackRecordsDurationAndCounter() { + var observation = new JpaTransactionObservation(registry, "orders"); + + observation.recordRolledBack( + OPERATION, + Duration.ofMillis(12), + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null))); + + assertThat(registry.find(JpaTransactionObservation.ROLLBACK_METER).counter().count()) + .isEqualTo(1.0d); + assertThat(registry.find(JpaTransactionObservation.DURATION_METER).timer().count()) + .isEqualTo(1L); + } + + @Test + @DisplayName("a retried attempt is a metric, not a log line") + void retryAttemptIsAMetric() { + var observation = new JpaRetryObservation(registry, "orders"); + var failure = + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null)); + + observation.onAttemptFailed( + OPERATION, + new TransactionAttempt(1, Instant.EPOCH), + failure, + RetryDecision.retry(Duration.ofMillis(10))); + observation.onSucceeded(OPERATION, 2); + + assertThat(registry.find(JpaRetryObservation.ATTEMPT_METER).counter().count()).isEqualTo(1.0d); + assertThat(registry.find(JpaRetryObservation.ATTEMPTS_PER_OPERATION_METER).summary().max()) + .isEqualTo(2.0d); + assertThat(registry.find(JpaRetryObservation.EXHAUSTED_METER).counter()).isNull(); + } + + @Test + @DisplayName("giving up increments the exhausted counter") + void givingUpIncrementsExhausted() { + var observation = new JpaRetryObservation(registry, "orders"); + + observation.onGaveUp( + OPERATION, + 3, + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 3, Duration.ZERO, null))); + + assertThat(registry.find(JpaRetryObservation.EXHAUSTED_METER).counter().count()) + .isEqualTo(1.0d); + } + + private static UniqueConstraintViolationException uniqueViolation() { + return new UniqueConstraintViolationException( + JpaFailureContext.terminal(OPERATION, "23505", 1, Duration.ZERO, null), + ConstraintViolationDetails.of(new ConstraintCode("user.active-email.unique")), + new IllegalStateException("Key (email)=(secret@example.test) already exists")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalogTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalogTest.java new file mode 100644 index 00000000..aa73ae39 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/constraint/PostgreSqlConstraintCatalogTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.constraint; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 32 — physical names map to application codes (design §22.4). */ +class PostgreSqlConstraintCatalogTest { + + private static final ConstraintCode ACTIVE_EMAIL = new ConstraintCode("user.active-email.unique"); + + private final PostgreSqlConstraintCatalog catalog = + new PostgreSqlConstraintCatalog(Map.of("ux_user_active_email", ACTIVE_EMAIL)); + + @Test + @DisplayName("a registered constraint resolves to its application code") + void resolvesRegisteredConstraint() { + assertThat(catalog.resolve("ux_user_active_email")).isEqualTo(ACTIVE_EMAIL); + } + + @Test + @DisplayName("lookup is case-insensitive because PostgreSQL folds identifiers") + void lookupIsCaseInsensitive() { + assertThat(catalog.resolve("UX_USER_ACTIVE_EMAIL")).isEqualTo(ACTIVE_EMAIL); + } + + @Test + @DisplayName("an unregistered constraint maps to the bounded unknown code") + void unregisteredConstraintIsBounded() { + assertThat(catalog.resolve("ux_added_by_a_migration")) + .isEqualTo(ConstraintViolationDetails.UNKNOWN_CODE); + assertThat(catalog.resolve(null)).isEqualTo(ConstraintViolationDetails.UNKNOWN_CODE); + } + + @Test + @DisplayName("an unbounded physical name is redacted before it is exposed") + void redactsUnboundedPhysicalName() { + var details = + new ConstraintViolationDetails(ACTIVE_EMAIL, "constraint on (email)=(a@example.test)"); + + assertThat(details.databaseName()).contains("redacted"); + assertThat(details.registered()).isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifierTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifierTest.java new file mode 100644 index 00000000..cf49c214 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifierTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** Plan Task 4 — classification is structural, never message-based (design §18.2). */ +class PostgreSqlFailureClassifierTest { + + private final PostgreSqlFailureClassifier classifier = new PostgreSqlFailureClassifier(); + + @ParameterizedTest + @CsvSource({ + "40001,SERIALIZATION_FAILURE", + "40003,COMPLETION_UNKNOWN", + "40P01,DEADLOCK", + "23505,UNIQUE_CONSTRAINT", + "23503,FOREIGN_KEY_CONSTRAINT", + "23514,CHECK_CONSTRAINT", + "23502,NOT_NULL_CONSTRAINT", + "55P03,LOCK_NOT_AVAILABLE", + "57014,QUERY_TIMEOUT" + }) + @DisplayName("each registered SQLSTATE maps to its category") + void classifiesBySqlState(String state, FailureCategory expected) { + assertThat(classifier.classify(state)).isEqualTo(expected); + } + + @Test + @DisplayName("an unregistered SQLSTATE stays UNKNOWN rather than being guessed") + void unknownStateIsNotGuessed() { + assertThat(classifier.classify("XX000")).isEqualTo(FailureCategory.UNKNOWN); + assertThat(classifier.classify((String) null)).isEqualTo(FailureCategory.UNKNOWN); + assertThat(classifier.classify("")).isEqualTo(FailureCategory.UNKNOWN); + } + + @Test + @DisplayName("the connection class maps to CONNECTION_UNAVAILABLE, not completion unknown") + void connectionClassIsNotCompletionUnknown() { + assertThat(classifier.classify("08006")).isEqualTo(FailureCategory.CONNECTION_UNAVAILABLE); + assertThat(classifier.classify("08003")).isEqualTo(FailureCategory.CONNECTION_UNAVAILABLE); + } + + @Test + @DisplayName("constraint states are recognised as a family") + void recognisesConstraintFamily() { + assertThat(classifier.isConstraintViolation("23505")).isTrue(); + assertThat(classifier.isConstraintViolation("40001")).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptionsTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptionsTest.java new file mode 100644 index 00000000..e1839ffd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/lock/PostgreSqlLockOptionsTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.LockModeType; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Tasks 30-31 — lock intent and queue claims are explicit (design §21). */ +class PostgreSqlLockOptionsTest { + + @Test + @DisplayName("a lock always declares a finite bound") + void lockRequiresFiniteTimeout() { + assertThatThrownBy( + () -> + new PostgreSqlLockOptions( + LockModeType.PESSIMISTIC_WRITE, Duration.ofSeconds(-1), false)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new PostgreSqlLockOptions(LockModeType.PESSIMISTIC_WRITE, Duration.ZERO, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("NOWAIT and a wait timeout are different requests") + void nowaitAndTimeoutAreDistinct() { + assertThat(PostgreSqlLockOptions.nowait(LockModeType.PESSIMISTIC_WRITE).lockTimeoutHintMillis()) + .isZero(); + assertThat( + PostgreSqlLockOptions.waiting(LockModeType.PESSIMISTIC_WRITE, Duration.ofSeconds(2)) + .lockTimeoutHintMillis()) + .isEqualTo(2_000L); + assertThatThrownBy( + () -> + new PostgreSqlLockOptions( + LockModeType.PESSIMISTIC_WRITE, Duration.ofSeconds(2), true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a claim statement must skip locked rows and impose an order") + void claimStatementIsValidated() { + var queue = new WorkQueueName("outbox.delivery"); + + assertThatThrownBy( + () -> new WorkQueueDefinition(queue, "select id from outbox order by id for update")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SKIP LOCKED"); + + assertThatThrownBy( + () -> new WorkQueueDefinition(queue, "select id from outbox for update skip locked")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ORDER BY"); + + var valid = + new WorkQueueDefinition( + queue, + "select id from outbox order by priority, id limit :batchSize for update skip locked"); + assertThat(valid.name()).isEqualTo(queue); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java new file mode 100644 index 00000000..4059e408 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.range; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 38 — range endpoints and brackets are explicit (design §8.3). */ +class PgRangeTest { + + private final PgRangeCodec codec = new PgRangeCodec<>(Instant::toString, Instant::parse); + + @Test + @DisplayName("a closed-open range keeps its brackets through a round trip") + void roundTripsClosedOpenRange() { + var range = PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)); + + String literal = codec.format(range); + assertThat(literal).startsWith("[").endsWith(")"); + assertThat(codec.parse(literal)).isEqualTo(range); + assertThat(range.upperInclusive()).isFalse(); + } + + @Test + @DisplayName("an unbounded endpoint survives the round trip") + void roundTripsUnboundedEndpoint() { + var range = PgRange.atLeast(Instant.EPOCH); + + assertThat(codec.parse(codec.format(range))).isEqualTo(range); + assertThat(PgRange.unbounded().isUnbounded()).isTrue(); + } + + @Test + @DisplayName("an inverted range is refused in Java, before the statement is sent") + void refusesInvertedRange() { + assertThatThrownBy(() -> PgRange.closedOpen(Instant.EPOCH.plusSeconds(60), Instant.EPOCH)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exceeds upper bound"); + } + + @Test + @DisplayName("containment honours the bracket, not just the value") + void containmentHonoursBrackets() { + var range = PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)); + + assertThat(range.contains(Instant.EPOCH)).isTrue(); + assertThat(range.contains(Instant.EPOCH.plusSeconds(59))).isTrue(); + assertThat(range.contains(Instant.EPOCH.plusSeconds(60))).isFalse(); + } + + @Test + @DisplayName("an empty range is not silently read back as unbounded") + void refusesEmptyRangeLiteral() { + assertThatThrownBy(() -> codec.parse("empty")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupportTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupportTest.java new file mode 100644 index 00000000..1b489597 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupportTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.persistence.querydsl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 20 — a Querydsl collection query is bounded or it does not run (design §23.2). */ +class QuerydslJpaSupportTest { + + @Test + @DisplayName("a predicate-free collection query is refused") + void rejectsUnboundedPredicateForCollectionQuery() { + assertThatThrownBy(() -> PredicatePolicy.requireBounded(null, QueryPage.of(0, 100))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bounded predicate"); + } + + @Test + @DisplayName("an explicit token permits a predicate-free scan") + void explicitTokenPermitsAnUnboundedScan() { + assertThatCode(() -> PredicatePolicy.requireBounded(null, QueryPage.unboundedScan(0, 100))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a page size must be present and bounded") + void pageSizeIsBounded() { + assertThatThrownBy(() -> QueryPage.of(0, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> QueryPage.of(0, QueryPage.MAX_SIZE + 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> QueryPage.of(-1, 10)).isInstanceOf(IllegalArgumentException.class); + assertThat(QueryPage.of(0, 100).size()).isEqualTo(100); + } + + @Test + @DisplayName("a caller-supplied path expression is refused unless registered") + void rejectsUnregisteredPathExpression() { + Set registered = Set.of("order.createdAt", "order.status"); + + assertThatCode(() -> PredicatePolicy.requireRegisteredPath("order.status", registered)) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> PredicatePolicy.requireRegisteredPath("order.customer.password", registered)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a registered query path"); + assertThatThrownBy(() -> PredicatePolicy.requireRegisteredPath(null, registered)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java new file mode 100644 index 00000000..252598f2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicyTest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.persistence.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 45 — the runtime role has DML and nothing else (design §36). */ +class DatabaseRolePolicyTest { + + private final DatabaseRolePolicy policy = + new DatabaseRolePolicy( + Set.of("app_runtime"), new SearchPathPolicy(List.of("app", "pg_catalog"))); + + @Test + @DisplayName("an approved role with no CREATE privilege passes") + void approvedRolePasses() { + policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "app, pg_catalog", false, false)); + } + + @Test + @DisplayName("a runtime role holding CREATE on the schema is refused") + void createOnSchemaIsRefused() { + assertThatThrownBy( + () -> + policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "app", true, false))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CREATE on the application schema"); + } + + @Test + @DisplayName("an unapproved role is refused") + void unapprovedRoleIsRefused() { + assertThatThrownBy( + () -> policy.requireSafe(new DatabasePrivilegeReport("postgres", "app", false, false))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not an approved runtime role"); + } + + @Test + @DisplayName("an untrusted schema on the search_path is refused") + void untrustedSearchPathIsRefused() { + assertThatThrownBy( + () -> + policy.requireSafe( + new DatabasePrivilegeReport("app_runtime", "app, public", false, false))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unapproved schema 'public'"); + } + + @Test + @DisplayName("the role-owned $user schema is not treated as untrusted") + void userSchemaIsAllowed() { + policy.requireSafe(new DatabasePrivilegeReport("app_runtime", "\"$user\", app", false, false)); + assertThat(policy.isSafe(new DatabasePrivilegeReport("app_runtime", "app", false, false))) + .isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplierTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplierTest.java new file mode 100644 index 00000000..390f6a19 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplierTest.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 24 — fetch plans are registered, and fetchgraph is not loadgraph (design §25.1). */ +class FetchPlanApplierTest { + + private final EntityManager entityManager = mock(EntityManager.class); + private final EntityGraph graph = mock(EntityGraph.class); + private final EntityGraphCatalog catalog = + new EntityGraphCatalog(Map.of(new FetchPlanName("order.detail"), manager -> graph)); + private final FetchPlanApplier applier = new FetchPlanApplier(catalog, entityManager); + + @Test + @DisplayName("a registered graph is applied and an unknown one is refused") + void appliesRegisteredGraphAndRejectsUnknownGraph() { + @SuppressWarnings("unchecked") + TypedQuery query = mock(TypedQuery.class); + when(query.setHint(anyString(), any())).thenReturn(query); + + applier.apply(query, new FetchPlanName("order.detail")); + verify(query).setHint(FetchPlanApplier.FETCH_GRAPH_HINT, graph); + + assertThatThrownBy(() -> applier.apply(query, new FetchPlanName("order.secret"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered fetch plan"); + } + + @Test + @DisplayName("a load graph uses the additive hint, not the exhaustive one") + void loadGraphUsesTheAdditiveHint() { + @SuppressWarnings("unchecked") + TypedQuery query = mock(TypedQuery.class); + when(query.setHint(anyString(), any())).thenReturn(query); + + applier.applyLoadGraph(query, new FetchPlanName("order.detail")); + + verify(query).setHint(FetchPlanApplier.LOAD_GRAPH_HINT, graph); + } + + @Test + @DisplayName("an empty catalog registers nothing") + void emptyCatalogRegistersNothing() { + assertThat(EntityGraphCatalog.empty().names()).isEmpty(); + assertThat(catalog.contains(new FetchPlanName("order.detail"))).isTrue(); + } + + @Test + @DisplayName("a fetch plan name is a registry key, not an attribute list") + void fetchPlanNameIsBounded() { + assertThatThrownBy(() -> new FetchPlanName("order.items,order.customer.address")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupportTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupportTest.java new file mode 100644 index 00000000..570de590 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupportTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import jakarta.persistence.TypedQuery; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 27 — size+1, no count query, no duplicate across pages (design §27.2). */ +class JpaKeysetQuerySupportTest { + + private final JpaKeysetQuerySupport support = JpaKeysetQuerySupport.standard(); + + @Test + @DisplayName("exactly size + 1 rows are requested") + void fetchesExactlyOneExtraRow() { + @SuppressWarnings("unchecked") + TypedQuery query = mock(TypedQuery.class); + when(query.setMaxResults(3)).thenReturn(query); + when(query.getResultList()).thenReturn(List.of("a", "b", "c")); + + support.slice( + new QueryName("order.find-recent"), query, KeysetPageRequest.first(2), value -> value); + + verify(query).setMaxResults(3); + } + + @Test + @DisplayName("no count query is executed") + void executesNoCountQuery() { + @SuppressWarnings("unchecked") + TypedQuery query = mock(TypedQuery.class); + when(query.setMaxResults(3)).thenReturn(query); + when(query.getResultList()).thenReturn(List.of("a", "b")); + + support.slice( + new QueryName("order.find-recent"), query, KeysetPageRequest.first(2), value -> value); + + verify(query, never()).getSingleResult(); + } + + @Test + @DisplayName("consecutive pages neither duplicate nor skip a row") + void duplicateCreatedAtUsesIdTieBreakerWithoutGap() { + var assembler = new KeysetSliceAssembler(); + + var first = assembler.assemble(List.of("a", "b", "c"), 2, value -> value); + var second = assembler.assemble(List.of("c", "d"), 2, value -> value); + + assertThat(first.items()).containsExactly("a", "b"); + assertThat(first.nextCursor()).contains("b"); + assertThat(second.items()).containsExactly("c", "d"); + assertThat( + java.util.stream.Stream.concat(first.items().stream(), second.items().stream()) + .toList()) + .doesNotHaveDuplicates(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupportTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupportTest.java new file mode 100644 index 00000000..7e5a942d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupportTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 19 — the platform helps fragments; it does not re-implement CrudRepository (§23.3). */ +class JpaRepositoryFragmentSupportTest { + + private final EntityManager entityManager = mock(EntityManager.class); + + @Test + @DisplayName("the platform does not re-implement CrudRepository") + void platformDoesNotReimplementCrudRepository() { + assertThat(JpaRepositoryFragmentSupport.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("save", "findById", "findAll", "delete", "deleteById", "count"); + } + + @Test + @DisplayName("a typed query is tagged with its registered name") + void typedQueryCarriesTheRegisteredName() { + @SuppressWarnings("unchecked") + TypedQuery query = mock(TypedQuery.class); + when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(query); + when(query.setHint(anyString(), any())).thenReturn(query); + + new Fragment(entityManager).findRecent(); + + verify(query).setHint("org.hibernate.comment", "order.find-recent"); + } + + @Test + @DisplayName("a native statement registered as native is refused by the typed helper") + void refusesNativeQueryThroughTheTypedHelper() { + var registered = RegisteredQuery.nativeSql(new QueryName("order.find-recent"), "select 1"); + + assertThat(registered.nativeQuery()).isTrue(); + } + + @Test + @DisplayName("a concatenated statement is refused at registration") + void refusesConcatenatedStatement() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + RegisteredQuery.jpql( + new QueryName("order.find-recent"), + "select o from Order o where o.id = ' + id + '")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fixed statement"); + } + + /** A domain-owned fragment, which is the only thing the support class is for. */ + private static final class Fragment extends JpaRepositoryFragmentSupport { + + private Fragment(EntityManager entityManager) { + super(entityManager); + } + + TypedQuery findRecent() { + return typedQuery( + new QueryName("order.find-recent"), "select o.id from Order o", String.class); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutorTest.java new file mode 100644 index 00000000..412620cb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutorTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Plan Task 28 — a stream is closed on every path and never escapes (design §27.4). */ +class JpaStreamExecutorTest { + + private static final QueryName QUERY = new QueryName("order.stream"); + + private final JpaStreamExecutor executor = new JpaStreamExecutor(NoopQueryObservation.instance()); + private final AtomicBoolean closed = new AtomicBoolean(); + + @BeforeEach + void bindReadOnlyTransaction() { + TransactionSynchronizationManager.setActualTransactionActive(true); + TransactionSynchronizationManager.setCurrentTransactionReadOnly(true); + } + + @AfterEach + void unbindTransaction() { + TransactionSynchronizationManager.setCurrentTransactionReadOnly(false); + TransactionSynchronizationManager.setActualTransactionActive(false); + } + + @Test + @DisplayName("the stream is closed when the consumer throws") + void closesStreamWhenConsumerFails() { + assertThatThrownBy( + () -> + executor.consume( + QUERY, + ScrollPolicy.bounded(100, 100), + this::trackedStream, + stream -> { + assertThat(stream.findFirst()).contains("a"); + throw new IllegalStateException("boom"); + })) + .isInstanceOf(IllegalStateException.class); + + assertThat(closed).isTrue(); + } + + @Test + @DisplayName("the stream is closed on the success path too") + void closesStreamOnSuccess() { + long counted = + executor.consume(QUERY, ScrollPolicy.bounded(100, 100), this::trackedStream, Stream::count); + + assertThat(counted).isEqualTo(3L); + assertThat(closed).isTrue(); + } + + @Test + @DisplayName("the row bound is applied to the stream") + void appliesTheRowBound() { + long counted = + executor.consume(QUERY, ScrollPolicy.bounded(100, 2), this::trackedStream, Stream::count); + + assertThat(counted).isEqualTo(2L); + } + + @Test + @DisplayName("streaming without a transaction is refused") + void refusesStreamingWithoutATransaction() { + TransactionSynchronizationManager.setActualTransactionActive(false); + + assertThatThrownBy( + () -> + executor.consume( + QUERY, ScrollPolicy.bounded(100, 100), this::trackedStream, Stream::count)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active transaction"); + } + + @Test + @DisplayName("streaming inside a write transaction is refused") + void refusesStreamingInsideAWriteTransaction() { + TransactionSynchronizationManager.setCurrentTransactionReadOnly(false); + + assertThatThrownBy( + () -> + executor.consume( + QUERY, ScrollPolicy.bounded(100, 100), this::trackedStream, Stream::count)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("read-only"); + } + + @Test + @DisplayName("returning a reactive type from a blocking stream consumer is refused") + void refusesReactiveResult() { + assertThatThrownBy( + () -> + executor.consume( + QUERY, + ScrollPolicy.bounded(100, 100), + this::trackedStream, + stream -> new FakeMono())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JPA is blocking"); + } + + private Stream trackedStream() { + return Stream.of("a", "b", "c").onClose(() -> closed.set(true)); + } + + /** Stands in for a Reactor publisher without adding a Reactor dependency to this lane. */ + private static final class FakeMono implements org.reactivestreams.Publisher { + + @Override + public void subscribe(org.reactivestreams.Subscriber subscriber) { + // never subscribed; the executor refuses the type before it is returned + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssemblerTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssemblerTest.java new file mode 100644 index 00000000..a5b5567c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetSliceAssemblerTest.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 27 — size+1 answers hasNext without a count query (design §27.2). */ +class KeysetSliceAssemblerTest { + + private final KeysetSliceAssembler assembler = new KeysetSliceAssembler(); + + @Test + @DisplayName("the extra row signals a next page and is not returned") + void extraRowSignalsNextPageWithoutBeingReturned() { + var slice = assembler.assemble(List.of("a", "b", "c"), 2, value -> value); + + assertThat(slice.items()).containsExactly("a", "b"); + assertThat(slice.hasNext()).isTrue(); + assertThat(slice.nextCursor()).contains("b"); + } + + @Test + @DisplayName("a full page with no extra row is the last page") + void fullPageWithoutExtraRowIsTerminal() { + var slice = assembler.assemble(List.of("a", "b"), 2, value -> value); + + assertThat(slice.items()).containsExactly("a", "b"); + assertThat(slice.hasNext()).isFalse(); + assertThat(slice.nextCursor()).isEmpty(); + } + + @Test + @DisplayName("an empty result is a terminal, empty slice") + void emptyResultIsTerminal() { + var slice = assembler.assemble(List.of(), 2, value -> value); + + assertThat(slice.isEmpty()).isTrue(); + assertThat(slice.hasNext()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapperTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapperTest.java new file mode 100644 index 00000000..7c415e98 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapperTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +/** Plan Task 21 — sorting is allowlisted and always totally ordered (design §23.4). */ +class SafeSortMapperTest { + + private final SafeSortMapper mapper = + new SafeSortMapper( + SafeSortRegistry.of( + SafeSortField.of("id"), + SafeSortField.of("createdAt"), + new SafeSortField("name", "displayName"))); + + @Test + @DisplayName("a SQL expression is refused and the tie-breaker is appended") + void rejectsSqlExpressionAndAddsTieBreaker() { + assertThatThrownBy(() -> mapper.map(List.of("name desc nulls last; drop table"))) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(mapper.map(List.of("createdAt,desc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("createdAt", "id"); + } + + @Test + @DisplayName("a public name maps to its registered entity path") + void mapsPublicNameToEntityPath() { + assertThat(mapper.map(List.of("name,asc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("displayName", "id"); + } + + @Test + @DisplayName("an already-present tie-breaker is not duplicated") + void doesNotDuplicateTieBreaker() { + assertThat(mapper.map(List.of("id,asc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("id"); + } + + @Test + @DisplayName("an unknown field is refused") + void rejectsUnknownField() { + assertThatThrownBy(() -> mapper.map(List.of("password,asc"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown sort field"); + } + + @Test + @DisplayName("an unbounded number of sort terms is refused") + void boundsSortTermCount() { + assertThatThrownBy(() -> mapper.map(List.of("id", "createdAt", "name", "id", "createdAt"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a registered path may not contain a function expression") + void rejectsFunctionExpressionInRegistration() { + assertThatThrownBy(() -> new SafeSortField("name", "lower(displayName)")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicyTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicyTest.java new file mode 100644 index 00000000..15eca1c8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicyTest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.persistence.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 28 — a stream is always bounded (design §27.4). */ +class ScrollPolicyTest { + + @Test + @DisplayName("a fetch size and a row bound are both required") + void requiresFetchSizeAndRowBound() { + assertThatThrownBy(() -> ScrollPolicy.bounded(0, 100)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ScrollPolicy.bounded(100, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an ordinary stream may not exceed the non-admin row bound") + void ordinaryStreamIsCapped() { + assertThatThrownBy(() -> ScrollPolicy.bounded(1_000, ScrollPolicy.MAX_NON_ADMIN_ROWS + 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("admin token"); + } + + @Test + @DisplayName("an admin stream may exceed it explicitly") + void adminStreamMayExceedIt() { + assertThat(ScrollPolicy.admin(1_000, ScrollPolicy.MAX_NON_ADMIN_ROWS * 10).adminToken()) + .isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java new file mode 100644 index 00000000..4e1d45c4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/JpaReleaseManifestTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseGate; +import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseManifest; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 53 — the support matrix document is the release manifest. + * + *

Parsing the operator-facing document rather than duplicating the list in code is what stops + * the two drifting: a version or gate removed from the document fails this test instead of quietly + * ceasing to be claimed. + */ +class JpaReleaseManifestTest { + + /** Repository-relative path of the document this manifest is parsed from. */ + private static final String SUPPORT_MATRIX = "docs/jpa/support-matrix.md"; + + /** + * Walks up from the test's working directory to the repository root. + * + *

Gradle runs a test with the owning project as its working directory, and this leaf is four + * levels down. A hard-coded relative path would tie the test to that depth and break the first + * time the lane runs from somewhere else. + */ + private static Path supportMatrix() { + Path candidate = Path.of("").toAbsolutePath(); + while (candidate != null) { + Path document = candidate.resolve(SUPPORT_MATRIX); + if (Files.exists(document)) { + return document; + } + candidate = candidate.getParent(); + } + throw new IllegalStateException(SUPPORT_MATRIX + " was not found above the working directory"); + } + + @Test + @DisplayName("the manifest names every Stable version and every mandatory gate") + void manifestContainsAllStableVersionsAndMandatoryGates() throws Exception { + Path document = supportMatrix(); + assertThat(Files.exists(document)) + .as("the support matrix document must exist; it is the release manifest") + .isTrue(); + + var manifest = JpaReleaseManifest.parse(Files.readString(document)); + + assertThat(manifest.postgreSqlVersions()).contains(16, 17, 18); + assertThat(manifest.gates()) + .contains( + JpaReleaseGate.COMPLETION_UNKNOWN_NO_RETRY, + JpaReleaseGate.OSIV_DISABLED, + JpaReleaseGate.FLYWAY_VALIDATE, + JpaReleaseGate.RUNTIME_ROLE_NO_DDL, + JpaReleaseGate.FETCH_PAGINATION, + JpaReleaseGate.POSTGRESQL_CONTRACT); + assertThat(manifest.declaresEveryRequiredGate()).isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRulesTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRulesTest.java new file mode 100644 index 00000000..6f1f3a18 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRulesTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity.FinalEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity.NoDefaultConstructorEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity.OrderEntity; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.web.BadOrderController; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.web.GoodOrderController; +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.web.ListReturningController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 13 — entity mapping and exposure rules are enforced, not documented (design §10.4). + * + *

The fixtures live in real {@code web} and {@code entity} packages rather than as nested + * classes, because the rules select by package. Nested fixtures would leave the web rule matching + * nothing, and a rule that matches nothing passes for the wrong reason. + */ +class JpaArchitectureRulesTest { + + @Test + @DisplayName("a controller may not return an entity") + void controllerMayNotReturnEntity() { + var classes = + new ClassFileImporter().importClasses(BadOrderController.class, OrderEntity.class); + + assertThatThrownBy(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .hasMessageContaining("OrderEntity"); + } + + @Test + @DisplayName("a controller may not return a collection of entities either") + void controllerMayNotReturnACollectionOfEntities() { + var classes = + new ClassFileImporter().importClasses(ListReturningController.class, OrderEntity.class); + + assertThatThrownBy(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .hasMessageContaining("OrderEntity"); + } + + @Test + @DisplayName("a controller returning a projection passes") + void controllerReturningAProjectionPasses() { + var classes = + new ClassFileImporter().importClasses(GoodOrderController.class, OrderEntity.class); + + assertThatCode(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an entity may not live in a web package") + void entityMayNotLiveInAWebPackage() { + var classes = new ClassFileImporter().importClasses(OrderEntity.class); + + assertThatCode(() -> JpaArchitectureRules.entitiesStayOutOfWebPackages().check(classes)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a final entity cannot be proxied and is refused") + void finalEntityIsRefused() { + var classes = new ClassFileImporter().importClasses(FinalEntity.class); + + assertThatThrownBy( + () -> JpaArchitectureRules.entitiesFollowPortableMappingRules().check(classes)) + .hasMessageContaining("final"); + } + + @Test + @DisplayName("an entity without a no-arg constructor is refused") + void entityWithoutNoArgConstructorIsRefused() { + var classes = new ClassFileImporter().importClasses(NoDefaultConstructorEntity.class); + + assertThatThrownBy( + () -> JpaArchitectureRules.entitiesFollowPortableMappingRules().check(classes)) + .hasMessageContaining("no-arg constructor"); + } + + @Test + @DisplayName("a well-formed entity passes the mapping rules") + void wellFormedEntityPasses() { + var classes = new ClassFileImporter().importClasses(OrderEntity.class); + + assertThatCode(() -> JpaArchitectureRules.entitiesFollowPortableMappingRules().check(classes)) + .doesNotThrowAnyException(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/FinalEntity.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/FinalEntity.java new file mode 100644 index 00000000..3182d246 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/FinalEntity.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +/** Refused by the mapping rule: a final entity cannot be subclassed into a lazy proxy. */ +@Entity +public final class FinalEntity { + + @Id private Long id; + + public FinalEntity() {} + + /** The identifier. */ + public Long id() { + return id; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/NoDefaultConstructorEntity.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/NoDefaultConstructorEntity.java new file mode 100644 index 00000000..96be9205 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/NoDefaultConstructorEntity.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +/** Refused by the mapping rule: the provider instantiates entities reflectively. */ +@Entity +public class NoDefaultConstructorEntity { + + @Id private Long id; + + public NoDefaultConstructorEntity(Long id) { + this.id = id; + } + + /** The identifier. */ + public Long id() { + return id; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/OrderEntity.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/OrderEntity.java new file mode 100644 index 00000000..9dfd695d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/entity/OrderEntity.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +/** A well-formed entity fixture: non-final, with a protected no-arg constructor. */ +@Entity +public class OrderEntity { + + @Id private Long id; + + protected OrderEntity() {} + + /** The identifier. */ + public Long id() { + return id; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/BadOrderController.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/BadOrderController.java new file mode 100644 index 00000000..49a53e7f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/BadOrderController.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.web; + +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity.OrderEntity; + +/** A transport class that returns an entity directly — the case the rule exists for. */ +public class BadOrderController { + + /** Returns the entity, whose lazy associations serialise after the transaction closes. */ + public OrderEntity find() { + return null; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/GoodOrderController.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/GoodOrderController.java new file mode 100644 index 00000000..71da8f95 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/GoodOrderController.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.web; + +import java.util.Optional; + +/** A transport class returning a projection, which is what the design asks for. */ +public class GoodOrderController { + + /** Returns a projection rather than an entity. */ + public Optional find() { + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/ListReturningController.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/ListReturningController.java new file mode 100644 index 00000000..369cf69a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/web/ListReturningController.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch.web; + +import dev.caskeleton.adapter.outbound.persistence.testkit.arch.entity.OrderEntity; +import java.util.List; + +/** The erased return type hides the entity; the rule inspects generic arguments. */ +public class ListReturningController { + + /** Returns a collection of entities, which leaks exactly as effectively. */ + public List findAll() { + return List.of(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenarioTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenarioTest.java new file mode 100644 index 00000000..af3052a2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenarioTest.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.failure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 50 — the three commit injection points are distinct (design §17.2, §39). */ +class PostgreSqlFailureScenarioTest { + + @Test + @DisplayName("commit ambiguity has three distinct injection points") + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly( + PostgreSqlFailureScenario.BEFORE_COMMIT, + PostgreSqlFailureScenario.DURING_COMMIT, + PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + } + + @Test + @DisplayName("only a break at or after the commit leaves the outcome unknown") + void onlyCommitPhaseBreaksLeaveTheOutcomeUnknown() { + assertThat(PostgreSqlFailureScenario.BEFORE_COMMIT.leavesOutcomeUnknown()).isFalse(); + assertThat(PostgreSqlFailureScenario.DURING_COMMIT.leavesOutcomeUnknown()).isTrue(); + assertThat(PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE.leavesOutcomeUnknown()) + .isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectationTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectationTest.java new file mode 100644 index 00000000..253285be --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectationTest.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.fetch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 25 — a paged collection fetch must be bounded in SQL, not in memory (design §26). */ +class FetchPaginationExpectationTest { + + @Test + @DisplayName("one collection page requires a database-level parent bound") + void oneCollectionPageRequiresBoundedParentSelection() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(20); + + assertThat(expected.maxReturnedParents()).isEqualTo(20); + assertThat(expected.requiresDatabaseLimit()).isTrue(); + } + + @Test + @DisplayName("the amplification bound scales with the page") + void amplificationBoundScalesWithThePage() { + assertThat(FetchPaginationExpectation.hibernate74PostgreSql(20).maxRowAmplification()) + .isEqualTo(2_000); + assertThat(FetchPaginationExpectation.hibernate74PostgreSql(1).maxRowAmplification()) + .isEqualTo(100); + } + + @Test + @DisplayName("a non-positive page is not an expectation") + void refusesNonPositivePage() { + assertThatThrownBy(() -> FetchPaginationExpectation.hibernate74PostgreSql(0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7GeneratorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7GeneratorTest.java new file mode 100644 index 00000000..59ca5389 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7GeneratorTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.id; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 14 — UUIDv7 is version 7, RFC-variant, and time ordered (design §11.3). */ +class UuidV7GeneratorTest { + + private final UuidV7Generator generator = new UuidV7Generator(); + + @Test + @DisplayName("the value is version 7 and orders by time") + void producesVersionSevenUuidInTimeOrder() { + UUID first = generator.next(Instant.parse("2026-08-11T00:00:00Z")); + UUID second = generator.next(Instant.parse("2026-08-11T00:00:01Z")); + + assertThat(first.version()).isEqualTo(7); + assertThat(first.compareTo(second)).isLessThan(0); + } + + @Test + @DisplayName("the RFC variant bits are set") + void setsTheRfcVariant() { + UUID value = generator.next(Instant.parse("2026-08-11T00:00:00Z")); + + assertThat(value.variant()).isEqualTo(2); + } + + @Test + @DisplayName("ids generated inside one millisecond still order correctly") + void isMonotonicWithinAMillisecond() { + Instant instant = Instant.parse("2026-08-11T00:00:00Z"); + List generated = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + generated.add(generator.next(instant)); + } + + List sorted = new ArrayList<>(generated); + sorted.sort(UUID::compareTo); + assertThat(generated).isEqualTo(sorted).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("the embedded timestamp round-trips") + void embedsTheTimestamp() { + Instant instant = Instant.parse("2026-08-11T00:00:00Z"); + + assertThat(UuidV7Generator.timestampOf(generator.next(instant))).isEqualTo(instant); + } + + @Test + @DisplayName("a UUIDv4 is refused by the timestamp reader") + void refusesNonVersionSeven() { + assertThatThrownBy(() -> UuidV7Generator.timestampOf(UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbeTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbeTest.java new file mode 100644 index 00000000..5cfb37a5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbeTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceUnitUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 16 — managed, detached and transient are distinguishable (design §14). + * + *

The probe's logic is what is asserted here: {@code contains} answers managed-or-not, and the + * presence of an identifier is what separates transient from detached. Whether the provider + * actually reports those states correctly is the lifecycle contract's job, on a real server. + */ +class EntityStateProbeTest { + + private final EntityManager entityManager = mock(EntityManager.class); + private final EntityManagerFactory factory = mock(EntityManagerFactory.class); + private final PersistenceUnitUtil util = mock(PersistenceUnitUtil.class); + private final EntityStateProbe probe = new EntityStateProbe(entityManager); + + @Test + @DisplayName("an instance the context contains is managed") + void containedInstanceIsManaged() { + Object entity = new Object(); + when(entityManager.contains(entity)).thenReturn(true); + + assertThat(probe.stateOf(entity)).isEqualTo(EntityState.MANAGED); + assertThat(probe.isManaged(entity)).isTrue(); + } + + @Test + @DisplayName("an uncontained instance with an identifier is detached") + void uncontainedInstanceWithIdentifierIsDetached() { + Object entity = new Object(); + bindIdentifier(entity, 42L); + + assertThat(probe.stateOf(entity)).isEqualTo(EntityState.DETACHED); + assertThat(probe.isDetached(entity)).isTrue(); + } + + @Test + @DisplayName("an uncontained instance with no identifier is transient") + void uncontainedInstanceWithoutIdentifierIsTransient() { + Object entity = new Object(); + bindIdentifier(entity, null); + + assertThat(probe.stateOf(entity)).isEqualTo(EntityState.TRANSIENT); + assertThat(probe.isManaged(entity)).isFalse(); + assertThat(probe.isDetached(entity)).isFalse(); + } + + private void bindIdentifier(Object entity, Object identifier) { + when(entityManager.contains(entity)).thenReturn(false); + when(entityManager.getEntityManagerFactory()).thenReturn(factory); + when(factory.getPersistenceUnitUtil()).thenReturn(util); + when(util.getIdentifier(entity)).thenReturn(identifier); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverterTest.java new file mode 100644 index 00000000..bcf265a0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverterTest.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.Currency; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 15 — value mappings round-trip exactly (design §12.1). */ +class DurationMillisConverterTest { + + private final DurationMillisConverter converter = new DurationMillisConverter(); + + @Test + @DisplayName("a duration round-trips through milliseconds") + void roundTripsDurationAsMilliseconds() { + Duration duration = Duration.ofSeconds(42).plusMillis(7); + + assertThat(converter.convertToEntityAttribute(converter.convertToDatabaseColumn(duration))) + .isEqualTo(duration); + } + + @Test + @DisplayName("null round-trips as null rather than as zero") + void nullRoundTripsAsNull() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + assertThat(converter.convertToEntityAttribute(null)).isNull(); + } + + @Test + @DisplayName("the stored form is a number, so SQL ordering matches Java ordering") + void storedFormSortsCorrectly() { + Long shorter = converter.convertToDatabaseColumn(Duration.ofSeconds(9)); + Long longer = converter.convertToDatabaseColumn(Duration.ofSeconds(10)); + + assertThat(shorter).isLessThan(longer); + } + + @Test + @DisplayName("money keeps exact decimal amounts and an ISO currency") + void moneyIsExact() { + Money money = Money.of(new BigDecimal("0.10"), Currency.getInstance("USD")); + + assertThat(money.amount().add(new BigDecimal("0.20"))).isEqualByComparingTo("0.30"); + assertThat(money.currency().getCurrencyCode()).isEqualTo("USD"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenarioTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenarioTest.java new file mode 100644 index 00000000..edccb59f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenarioTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 42 — three upgrade scenarios, each catching a different failure (design §31). */ +class MigrationScenarioTest { + + @Test + @DisplayName("empty, previous-release and oldest-supported are all required") + void requiresEmptyPreviousAndOldestSupportedScenarios() { + assertThat(MigrationScenario.required()) + .extracting(MigrationScenario::name) + .containsExactlyInAnyOrder("empty", "previous-release", "oldest-supported"); + } + + @Test + @DisplayName("the empty scenario really starts from an empty database") + void emptyScenarioStartsEmpty() { + assertThat(MigrationScenario.empty().snapshot().isEmptyDatabase()).isTrue(); + assertThat(MigrationSnapshot.empty().startingVersion()).isEmpty(); + } + + @Test + @DisplayName("a scenario carries a data invariant, not just a version") + void scenarioCarriesADataInvariant() { + AtomicBoolean asserted = new AtomicBoolean(); + var scenario = MigrationScenario.empty().withInvariant(dataSource -> asserted.set(true)); + + scenario.invariant().accept(null); + + assertThat(asserted).isTrue(); + } + + @Test + @DisplayName("a snapshot needs a name") + void snapshotNeedsAName() { + assertThatThrownBy(() -> new MigrationSnapshot("", "", "")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurementTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurementTest.java new file mode 100644 index 00000000..61812c07 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurementTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.pool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 51 — pending count and acquire latency are reported together (design §38). */ +class PoolMeasurementTest { + + @Test + @DisplayName("pending and acquire latency are reported together") + void reportsPendingAndAcquireLatencyTogether() { + var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)); + + assertThat(measurement.pending()).isEqualTo(3); + assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80)); + } + + @Test + @DisplayName("the total is what the pool currently holds") + void totalIsActivePlusIdle() { + assertThat(new PoolMeasurement(4, 2, 0, Duration.ZERO).total()).isEqualTo(6); + } + + @Test + @DisplayName("a pool with waiters is saturated") + void saturationIsPendingCallers() { + assertThat(new PoolMeasurement(4, 0, 1, Duration.ZERO).saturated()).isTrue(); + assertThat(new PoolMeasurement(4, 2, 0, Duration.ZERO).saturated()).isFalse(); + } + + @Test + @DisplayName("negative counters are not a measurement") + void refusesNegativeCounters() { + assertThatThrownBy(() -> new PoolMeasurement(-1, 0, 0, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new PoolMeasurement(1, 0, 0, Duration.ofMillis(-1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersionTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersionTest.java new file mode 100644 index 00000000..482bf843 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersionTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.postgresql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 49 — the Stable matrix is exactly 16, 17 and 18 (design §40). */ +class PostgreSqlVersionTest { + + @Test + @DisplayName("the Stable matrix is exactly sixteen, seventeen and eighteen") + void stableVersionsAreExactlySixteenSeventeenAndEighteen() { + assertThat(PostgreSqlVersion.stable()) + .containsExactly(PostgreSqlVersion.PG_16, PostgreSqlVersion.PG_17, PostgreSqlVersion.PG_18); + } + + @Test + @DisplayName("a pull request covers the ends of the matrix") + void pullRequestCoversTheEnds() { + assertThat(PostgreSqlVersion.pullRequestMatrix()) + .containsExactly(PostgreSqlVersion.PG_16, PostgreSqlVersion.PG_18); + } + + @Test + @DisplayName("an empty selection is an error, not an empty run") + void refusesEmptySelection() { + assertThatThrownBy(() -> PostgreSqlVersion.parseSelection("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a version outside the matrix is refused") + void refusesUnknownVersion() { + assertThatThrownBy(() -> PostgreSqlVersion.parseSelection("15")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not in the Stable matrix"); + assertThatThrownBy(() -> PostgreSqlVersion.parseSelection("19")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a comma-separated selection parses in order") + void parsesSelection() { + assertThat(PostgreSqlVersion.parseSelection("16,18")) + .containsExactly(PostgreSqlVersion.PG_16, PostgreSqlVersion.PG_18); + } + + @Test + @DisplayName("each version pins an image tag carrying its major version") + void eachVersionPinsAnImage() { + for (PostgreSqlVersion version : PostgreSqlVersion.stable()) { + assertThat(version.image()).contains(String.valueOf(version.majorVersion())); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertionsTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertionsTest.java new file mode 100644 index 00000000..579270f6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertionsTest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 23 — statement count alone cannot detect every fetch failure (design §25.3). */ +class JpaQueryAssertionsTest { + + private final JpaQueryAssertions assertions = new JpaQueryAssertions(); + + @Test + @DisplayName("a single statement over an amplified row set still fails") + void reportsCartesianAmplificationEvenForOneStatement() { + var measurement = new QueryMeasurement(1, 100, 2000, 0, Duration.ofMillis(40)); + + assertThatThrownBy(() -> assertions.assertMatches(measurement, QueryExpectation.maxRows(500))) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("rows=2000"); + } + + @Test + @DisplayName("an N+1 is caught by statement count where row count would not be") + void catchesNplusOneByStatementCount() { + var measurement = new QueryMeasurement(101, 100, 100, 100, Duration.ofMillis(40)); + + assertThatThrownBy( + () -> assertions.assertMatches(measurement, QueryExpectation.exactlyOneStatement())) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("statements=101"); + } + + @Test + @DisplayName("the failure message names the query and every measured dimension") + void failureMessageNamesEveryDimension() { + var measurement = new QueryMeasurement(1, 100, 2000, 3, Duration.ofMillis(40)); + + assertThatThrownBy( + () -> + assertions.assertMatches( + new QueryName("order.find-recent"), measurement, QueryExpectation.maxRows(500))) + .hasMessageContaining("order.find-recent") + .hasMessageContaining("statements=1") + .hasMessageContaining("hydratedEntities=100") + .hasMessageContaining("fetches=3"); + } + + @Test + @DisplayName("maximum and exact expectations are separate") + void maximumAndExactAreSeparate() { + var measurement = new QueryMeasurement(2, 10, 10, 0, Duration.ofMillis(1)); + + assertThatCode( + () -> + assertions.assertMatches(measurement, QueryExpectation.none().withMaxStatements(5))) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + assertions.assertMatches( + measurement, QueryExpectation.none().withExactStatements(1))) + .isInstanceOf(AssertionError.class); + } + + @Test + @DisplayName("the canonical constructor refuses a contradictory statement expectation") + void refusesContradictoryExpectation() { + assertThatThrownBy( + () -> + new QueryExpectation( + OptionalLong.of(5), + OptionalLong.of(1), + OptionalLong.empty(), + OptionalLong.empty(), + OptionalLong.empty(), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not both"); + } + + @Test + @DisplayName("the builder cannot reach that contradiction: each setter clears the other") + void builderCannotReachTheContradiction() { + var exactWins = QueryExpectation.none().withMaxStatements(5).withExactStatements(1); + assertThat(exactWins.maxStatements()).isEmpty(); + assertThat(exactWins.exactStatements()).hasValue(1L); + + var maximumWins = QueryExpectation.none().withExactStatements(1).withMaxStatements(5); + assertThat(maximumWins.exactStatements()).isEmpty(); + assertThat(maximumWins.maxStatements()).hasValue(5L); + } + + @Test + @DisplayName("a fetch expectation bounds separate association statements") + void fetchExpectationBoundsAssociationStatements() { + var measurement = new QueryMeasurement(1, 10, 10, 5, Duration.ofMillis(1)); + + assertThat(FetchExpectation.batched(5).matches(measurement)).isTrue(); + assertThatThrownBy( + () -> + assertions.assertFetches( + new QueryName("order.detail"), measurement, FetchExpectation.none())) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("fetches=5"); + } + + @Test + @DisplayName("row amplification is the ratio a Cartesian fetch inflates") + void rowAmplificationIsReported() { + assertThat(new QueryMeasurement(1, 100, 2000, 0, Duration.ZERO).rowAmplification()) + .isEqualTo(20.0d); + assertThat(new QueryMeasurement(1, 0, 0, 0, Duration.ZERO).rowAmplification()).isZero(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertionsTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertionsTest.java new file mode 100644 index 00000000..c0a83466 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertionsTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.queryplan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 44 — plan structure, not plan cost, is what is asserted (design §33). */ +class QueryPlanAssertionsTest { + + private final QueryPlanAssertions assertions = new QueryPlanAssertions(); + + @Test + @DisplayName("a disk sort and an estimate blowout are both reported") + void detectsUnexpectedSortSpillAndEstimateError() { + var plan = new NormalizedPlan(List.of("Sort", "Seq Scan"), 100.0d, true, 42L); + + assertThatThrownBy( + () -> + assertions.assertMatches( + plan, QueryPlanExpectation.estimateOnly(10.0d).withDiskSortForbidden())) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Disk Sort"); + } + + @Test + @DisplayName("an estimate ratio above the bound fails with the ratio named") + void estimateRatioIsBounded() { + var plan = new NormalizedPlan(List.of("Index Scan"), 100.0d, false, 1L); + + assertThatThrownBy( + () -> assertions.assertMatches(plan, QueryPlanExpectation.estimateOnly(10.0d))) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("estimate ratio"); + } + + @Test + @DisplayName("a sequential scan is not forbidden globally") + void sequentialScanIsNotForbiddenGlobally() { + var plan = new NormalizedPlan(List.of("Seq Scan"), 1.0d, false, 1L); + + assertThatCode(() -> assertions.assertMatches(plan, QueryPlanExpectation.estimateOnly(10.0d))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a required node type that is absent is reported") + void missingRequiredNodeTypeIsReported() { + var plan = new NormalizedPlan(List.of("Seq Scan"), 1.0d, false, 1L); + + assertThatThrownBy( + () -> + assertions.assertMatches( + plan, QueryPlanExpectation.estimateOnly(10.0d).requiring("Index Scan"))) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("missing required node type"); + } + + @Test + @DisplayName("the failure message carries the whole normalized plan") + void failureMessageCarriesThePlan() { + var plan = new NormalizedPlan(List.of("Sort"), 1.0d, true, 7L); + + assertThatThrownBy( + () -> + assertions.assertMatches( + plan, QueryPlanExpectation.estimateOnly(10.0d).withDiskSortForbidden())) + .hasMessageContaining("nodes=[Sort]") + .hasMessageContaining("sharedBuffersRead=7"); + } + + @Test + @DisplayName("volatile cost and timing fields are not part of the normalized plan") + void normalizedPlanExcludesVolatileFields() { + var plan = + PostgreSqlExplainRunner.normalize( + "[{\"Plan\": {\"Node Type\": \"Index Scan\", \"Total Cost\": 12.34," + + " \"Actual Rows\": 10, \"Plan Rows\": 10, \"Shared Read Blocks\": 3}}]"); + + assertThat(plan.nodeTypes()).contains("Index Scan"); + assertThat(plan.estimateRatio()).isEqualTo(1.0d); + assertThat(plan.diskSort()).isFalse(); + assertThat(plan.summary()).doesNotContain("Total Cost"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculatorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculatorTest.java new file mode 100644 index 00000000..5b13ad4a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/BackoffCalculatorTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import java.time.Duration; +import java.util.Set; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 8 — backoff grows, is capped, and is jittered (design §19.3). */ +class BackoffCalculatorTest { + + private static final RandomGenerator FIXED_RANDOM = new FixedRandom(); + + @Test + @DisplayName("backoff grows exponentially and stops at the cap") + void growsExponentiallyAndCaps() { + var profile = + new RetryProfile( + "order.write", + 5, + Duration.ofMillis(20), + Duration.ofMillis(100), + 2.0d, + JitterMode.NONE, + Set.of(FailureCategory.SERIALIZATION_FAILURE)); + var calculator = new BackoffCalculator(profile, FIXED_RANDOM); + + assertThat(calculator.forAttempt(1)).isEqualTo(Duration.ofMillis(20)); + assertThat(calculator.forAttempt(2)).isEqualTo(Duration.ofMillis(40)); + assertThat(calculator.forAttempt(3)).isEqualTo(Duration.ofMillis(80)); + assertThat(calculator.forAttempt(4)).isEqualTo(Duration.ofMillis(100)); + assertThat(calculator.forAttempt(9)).isEqualTo(Duration.ofMillis(100)); + } + + @Test + @DisplayName("equal jitter keeps a floor under the wait") + void equalJitterKeepsAFloor() { + var profile = + new RetryProfile( + "order.write", + 5, + Duration.ofMillis(100), + Duration.ofMillis(100), + 1.0d, + JitterMode.EQUAL, + Set.of(FailureCategory.DEADLOCK)); + var calculator = new BackoffCalculator(profile, FIXED_RANDOM); + + assertThat(calculator.forAttempt(1)).isBetween(Duration.ofMillis(50), Duration.ofMillis(100)); + } + + @Test + @DisplayName("a profile with no backoff waits not at all") + void zeroBackoffIsZero() { + assertThat(BackoffCalculator.forProfile(RetryProfile.none()).forAttempt(1)) + .isEqualTo(Duration.ZERO); + } + + /** Always returns the lower bound, so jitter is deterministic under test. */ + private static final class FixedRandom implements RandomGenerator { + + @Override + public long nextLong() { + return 0L; + } + + @Override + public long nextLong(long bound) { + return 0L; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifierTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifierTest.java new file mode 100644 index 00000000..84dafc6d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifierTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import java.io.IOException; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 6 — only a commit-phase break becomes completion unknown (design §17.2). */ +class CommitFailureClassifierTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("payment.commit"); + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + private final CommitFailureClassifier classifier = CommitFailureClassifier.standard(clock); + + @AfterEach + void clearEvidence() { + TransactionEvidenceContext.clearAll(); + } + + @Test + @DisplayName("a lost connection during commit becomes completion unknown") + void connectionLossDuringCommitBecomesCompletionUnknown() { + TransactionEvidenceContext.begin(OPERATION, "payment-42", clock.instant(), 1); + + RuntimeException translated = + classifier.translateCommitFailure( + new IllegalStateException("commit failed", new IOException("connection reset"))); + + assertThat(translated).isInstanceOf(TransactionCompletionUnknownException.class); + TransactionCompletionUnknownException unknown = + (TransactionCompletionUnknownException) translated; + assertThat(unknown.context().completionUnknown()).isTrue(); + assertThat(unknown.context().retryable()).isFalse(); + assertThat(unknown.transactionKey()).contains("payment-42"); + } + + @Test + @DisplayName("SQLSTATE 40003 becomes completion unknown") + void statementCompletionUnknownStateIsHonoured() { + TransactionEvidenceContext.begin(OPERATION, null, clock.instant(), 1); + + RuntimeException translated = + classifier.translateCommitFailure( + new IllegalStateException("commit failed", new SQLException("failed", "40003"))); + + assertThat(translated).isInstanceOf(TransactionCompletionUnknownException.class); + } + + @Test + @DisplayName("an ordinary constraint failure at commit is returned unchanged") + void ordinaryCommitFailureIsNotEscalated() { + TransactionEvidenceContext.begin(OPERATION, null, clock.instant(), 1); + RuntimeException original = + new IllegalStateException("commit failed", new SQLException("duplicate", "23505")); + + assertThat(classifier.translateCommitFailure(original)).isSameAs(original); + } + + @Test + @DisplayName("the rule is asserted directly, without a driver exception") + void ruleIsIndependentlyAssertable() { + assertThat(classifier.indicatesUnknownCompletion(Optional.of("40003"), new RuntimeException())) + .isTrue(); + assertThat(classifier.indicatesUnknownCompletion(Optional.of("08006"), new RuntimeException())) + .isTrue(); + assertThat(classifier.indicatesUnknownCompletion(Optional.of("23505"), new RuntimeException())) + .isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorderTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorderTest.java new file mode 100644 index 00000000..77fb3770 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorderTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 10 — recording an unknown outcome observes; it never repairs (design §17.4). */ +class CompletionUnknownRecorderTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("payment.commit"); + + private final List audit = new ArrayList<>(); + private final List originalUseCaseInvocations = new ArrayList<>(); + + private final CompletionUnknownRecorder recorder = + failure -> audit.add(CompletionUnknownRecord.from(failure, Instant.EPOCH)); + + @Test + @DisplayName("the record is written and the original use case is not re-invoked") + void recordsUnknownWithoutRetryingOriginalWork() { + recorder.record(completionUnknown("payment-42")); + + assertThat(audit).hasSize(1); + assertThat(audit.get(0).transactionKey()).isEqualTo("payment-42"); + assertThat(originalUseCaseInvocations).isEmpty(); + } + + @Test + @DisplayName("the record carries the operation, SQLSTATE, attempt, and evidence") + void recordCarriesTheReconciliationHandles() { + recorder.record(completionUnknown("payment-42")); + + CompletionUnknownRecord record = audit.get(0); + assertThat(record.operation()).isEqualTo(OPERATION); + assertThat(record.sqlState()).isEqualTo("40003"); + assertThat(record.attempt()).isEqualTo(1); + assertThat(record.evidence()).isEqualTo(TransactionCompletionEvidence.UNKNOWN); + assertThat(record.occurredAt()).isEqualTo(Instant.EPOCH); + assertThat(record.reconcilable()).isTrue(); + } + + @Test + @DisplayName("a record with no transaction key needs a human rather than a resolver") + void recordWithoutKeyIsNotAutomaticallyReconcilable() { + recorder.record(completionUnknown(null)); + + assertThat(audit.get(0).reconcilable()).isFalse(); + assertThat(audit.get(0).reconciliationKey()).isEmpty(); + } + + @Test + @DisplayName("resolution keeps an inconclusive answer available") + void resolutionKeepsStillUnknown() { + assertThat(CompletionResolution.values()) + .containsExactly( + CompletionResolution.COMMITTED, + CompletionResolution.NOT_COMMITTED, + CompletionResolution.STILL_UNKNOWN); + } + + @Test + @DisplayName("a resolver reads evidence and never re-runs the original work") + void resolverOnlyReadsEvidence() { + TransactionCompletionResolver resolver = + key -> + "payment-42".equals(key) + ? CompletionResolution.COMMITTED + : CompletionResolution.STILL_UNKNOWN; + + assertThat(resolver.resolve("payment-42")).isEqualTo(CompletionResolution.COMMITTED); + assertThat(resolver.resolve("payment-99")).isEqualTo(CompletionResolution.STILL_UNKNOWN); + assertThat(originalUseCaseInvocations).isEmpty(); + } + + private static TransactionCompletionUnknownException completionUnknown(String transactionKey) { + return new TransactionCompletionUnknownException( + JpaFailureContext.completionUnknown(OPERATION, "40003", 1, Duration.ZERO, null), + transactionKey, + TransactionCompletionEvidence.UNKNOWN, + null); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicyTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicyTest.java new file mode 100644 index 00000000..dede2bfc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicyTest.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode; +import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails; +import dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 29 — the retry classification order is the contract (design §19.1). */ +class DefaultJpaRetryPolicyTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("order.place"); + private static final TransactionAttempt ATTEMPT = + new TransactionAttempt(1, Instant.parse("2026-08-11T00:00:00Z")); + + private final DefaultJpaRetryPolicy policy = + DefaultJpaRetryPolicy.forProfile(RetryProfile.boundedContention("order.write", 3)); + + @AfterEach + void clearSideEffects() { + IrreversibleSideEffectContext.clear(); + } + + @Test + @DisplayName("completion unknown reconciles and is never retried") + void completionUnknownReconciles() { + var failure = + new TransactionCompletionUnknownException( + JpaFailureContext.completionUnknown(OPERATION, "40003", 1, Duration.ZERO, null), + TransactionCompletionEvidence.UNKNOWN, + null); + + assertThat(policy.classify(failure, ATTEMPT).disposition()) + .isEqualTo(RetryDisposition.RECONCILE); + } + + @Test + @DisplayName("an eligible contention failure retries the whole transaction") + void contentionFailureRetries() { + var failure = + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null)); + + assertThat(policy.classify(failure, ATTEMPT).disposition()) + .isEqualTo(RetryDisposition.RETRY_FULL_TRANSACTION); + } + + @Test + @DisplayName("a constraint violation is never retried") + void constraintViolationFails() { + var failure = + new UniqueConstraintViolationException( + JpaFailureContext.terminal(OPERATION, "23505", 1, Duration.ZERO, null), + ConstraintViolationDetails.of(new ConstraintCode("user.email.unique"))); + + assertThat(policy.classify(failure, ATTEMPT).disposition()).isEqualTo(RetryDisposition.FAIL); + } + + @Test + @DisplayName("an attempt with an irreversible side effect is never retried") + void irreversibleSideEffectFails() { + IrreversibleSideEffectContext.mark(); + var failure = + new DeadlockDetectedException( + JpaFailureContext.retryable(OPERATION, "40P01", 1, Duration.ZERO, null)); + + var decision = policy.classify(failure, ATTEMPT); + + assertThat(decision.disposition()).isEqualTo(RetryDisposition.FAIL); + assertThat(decision.reason()).contains("irreversible"); + } + + @Test + @DisplayName("a profile with no retry budget always fails") + void disabledProfileAlwaysFails() { + var disabled = DefaultJpaRetryPolicy.forProfile(RetryProfile.none()); + var failure = + new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null)); + + assertThat(disabled.classify(failure, ATTEMPT).disposition()).isEqualTo(RetryDisposition.FAIL); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java new file mode 100644 index 00000000..456ed944 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import java.io.IOException; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 6 — the commit phase is marked before the provider is asked, not after (design §17.2). + * + *

The manager itself needs an {@code EntityManagerFactory}, so what is asserted here is the + * mechanism the manager delegates to: the evidence stack and the classifier that reads it. The + * manager's own {@code doCommit} ordering is exercised end to end by the commit-ambiguity contract + * on a real server, which is the only place a genuinely lost acknowledgement can be produced. + */ +class EvidenceAwareJpaTransactionManagerTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("payment.commit"); + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + private final CommitFailureClassifier classifier = CommitFailureClassifier.standard(clock); + + @AfterEach + void clearEvidence() { + TransactionEvidenceContext.clearAll(); + } + + @Test + @DisplayName("a connection loss while COMMITTING becomes completion unknown") + void connectionLossDuringCommitBecomesCompletionUnknown() { + TransactionEvidenceContext.begin(OPERATION, "payment-42", clock.instant(), 1); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + + RuntimeException translated = + classifier.translateCommitFailure( + new IllegalStateException("commit failed", new IOException("connection reset"))); + + assertThat(translated).isInstanceOf(TransactionCompletionUnknownException.class); + assertThat(((JpaPersistenceException) translated).context().completionUnknown()).isTrue(); + } + + @Test + @DisplayName("the phase advances NOT_STARTED to ACTIVE to COMMITTING to COMMITTED") + void phaseAdvancesThroughTheCommitLifecycle() { + TransactionEvidenceContext.begin(OPERATION, null, clock.instant(), 1); + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.NOT_STARTED); + + TransactionEvidenceContext.mark(TransactionCompletionEvidence.ACTIVE); + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.ACTIVE); + + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.COMMITTING); + + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.COMMITTED); + } + + @Test + @DisplayName("a nested REQUIRES_NEW frame does not overwrite the outer transaction's phase") + void nestedFrameDoesNotOverwriteTheOuterPhase() { + TransactionEvidenceContext.begin(OPERATION, "outer", clock.instant(), 1); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.ACTIVE); + + TransactionEvidenceContext.begin(OPERATION, "inner", clock.instant(), 1); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + TransactionEvidenceContext.clear(); + + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.ACTIVE); + assertThat(TransactionEvidenceContext.current().orElseThrow().reconciliationKey()) + .contains("outer"); + } + + @Test + @DisplayName("clearing the last frame removes the thread-local entirely") + void clearingTheLastFrameRemovesTheThreadLocal() { + TransactionEvidenceContext.begin(OPERATION, null, clock.instant(), 1); + TransactionEvidenceContext.clear(); + + assertThat(TransactionEvidenceContext.current()).isEmpty(); + assertThat(TransactionEvidenceContext.evidence()) + .isEqualTo(TransactionCompletionEvidence.NOT_STARTED); + } + + @Test + @DisplayName("an ordinary commit-phase failure is rethrown unchanged") + void ordinaryCommitFailureIsRethrownUnchanged() { + TransactionEvidenceContext.begin(OPERATION, null, clock.instant(), 1); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + RuntimeException original = + new IllegalStateException("commit failed", new SQLException("check", "23514")); + + assertThat(classifier.translateCommitFailure(original)).isSameAs(original); + } + + @Test + @DisplayName("the translated failure carries the operation and the reconciliation key") + void translatedFailureCarriesTheReconciliationHandle() { + TransactionEvidenceContext.begin(OPERATION, "payment-42", clock.instant(), 3); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + + var failure = + (TransactionCompletionUnknownException) + classifier.translateCommitFailure( + new IllegalStateException("lost", new SQLException("lost", "40003"))); + + assertThat(failure.context().operation()).isEqualTo(OPERATION); + assertThat(failure.context().transactionAttempt()).isEqualTo(3); + assertThat(failure.transactionKey()).contains("payment-42"); + assertThat(failure.evidence()).isEqualTo(TransactionCompletionEvidence.UNKNOWN); + } + + @Test + @DisplayName("a failure with no bound frame is attributed to the registered placeholder") + void unattributedFailureUsesThePlaceholderOperation() { + var failure = + (TransactionCompletionUnknownException) + classifier.translateCommitFailure( + new IllegalStateException("lost", new SQLException("lost", "40003"))); + + assertThat(failure.context().operation()).isEqualTo(UnknownOperation.NAME); + assertThatThrownBy( + () -> { + throw failure; + }) + .isInstanceOf(TransactionCompletionUnknownException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinatorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinatorTest.java new file mode 100644 index 00000000..4cbad24e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinatorTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException; +import dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; + +/** Plan Task 8 — every attempt is a new transaction (design §19.2). */ +class FullTransactionRetryCoordinatorTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("order.place"); + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + private final CountingTransactionManager transactionManager = new CountingTransactionManager(); + private final List slept = new ArrayList<>(); + + private static TransactionProfile writeProfile(RetryProfile retryProfile) { + return TransactionProfile.write("order.write", Duration.ofSeconds(5)) + .withRetryProfile(retryProfile); + } + + private FullTransactionRetryCoordinator coordinator(RetryProfile retryProfile) { + var executor = new SpringJpaTransactionExecutor(transactionManager, clock); + return new FullTransactionRetryCoordinator( + executor, + DefaultJpaRetryPolicy.forProfile(retryProfile), + slept::add, + clock, + Duration.ofSeconds(30), + RetryEventListener.noop()); + } + + @Test + @DisplayName("a retried use case runs again in a brand new transaction") + void retriesWholeUseCaseInANewTransaction() { + AtomicInteger invocations = new AtomicInteger(); + + String result = + coordinator(RetryProfile.boundedContention("order.write", 3)) + .execute( + OPERATION, + writeProfile(RetryProfile.boundedContention("order.write", 3)), + () -> { + if (invocations.incrementAndGet() == 1) { + throw new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null)); + } + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(invocations.get()).isEqualTo(2); + assertThat(transactionManager.begun()).isEqualTo(2); + assertThat(slept).hasSize(1); + } + + @Test + @DisplayName("a completion-unknown failure is surfaced, never retried") + void completionUnknownIsNeverRetried() { + AtomicInteger invocations = new AtomicInteger(); + + assertThatThrownBy( + () -> + coordinator(RetryProfile.boundedContention("order.write", 3)) + .execute( + OPERATION, + writeProfile(RetryProfile.boundedContention("order.write", 3)), + () -> { + invocations.incrementAndGet(); + throw new TransactionCompletionUnknownException( + JpaFailureContext.completionUnknown( + OPERATION, "40003", 1, Duration.ZERO, null), + TransactionCompletionEvidence.UNKNOWN, + null); + })) + .isInstanceOf(TransactionCompletionUnknownException.class); + + assertThat(invocations.get()).isEqualTo(1); + } + + @Test + @DisplayName("the attempt budget bounds how many times the use case runs") + void attemptBudgetIsEnforced() { + AtomicInteger invocations = new AtomicInteger(); + + assertThatThrownBy( + () -> + coordinator(RetryProfile.boundedContention("order.write", 3)) + .execute( + OPERATION, + writeProfile(RetryProfile.boundedContention("order.write", 3)), + () -> { + invocations.incrementAndGet(); + throw new SerializationFailureException( + JpaFailureContext.retryable( + OPERATION, "40001", 1, Duration.ZERO, null)); + })) + .isInstanceOf(SerializationFailureException.class); + + assertThat(invocations.get()).isEqualTo(3); + } + + /** Counts how many physical transactions were begun. */ + private static final class CountingTransactionManager implements PlatformTransactionManager { + + private int begun; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + begun++; + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + // nothing to commit in the unit lane + } + + @Override + public void rollback(TransactionStatus status) { + // nothing to roll back in the unit lane + } + + int begun() { + return begun; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudgetTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudgetTest.java new file mode 100644 index 00000000..dea927b1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudgetTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 8 — attempts and elapsed time are independent bounds (design §19.2). */ +class RetryBudgetTest { + + @Test + @DisplayName("the attempt count bounds retrying") + void attemptsBoundRetrying() { + var budget = new RetryBudget(3, Duration.ofSeconds(30)); + + assertThat(budget.allowsAnotherAttempt(2, Duration.ZERO, Duration.ZERO)).isTrue(); + assertThat(budget.allowsAnotherAttempt(3, Duration.ZERO, Duration.ZERO)).isFalse(); + } + + @Test + @DisplayName("the elapsed deadline bounds retrying even with attempts left") + void elapsedDeadlineBoundsRetrying() { + var budget = new RetryBudget(10, Duration.ofSeconds(1)); + + assertThat(budget.allowsAnotherAttempt(1, Duration.ofMillis(900), Duration.ofMillis(50))) + .isTrue(); + assertThat(budget.allowsAnotherAttempt(1, Duration.ofMillis(900), Duration.ofMillis(200))) + .isFalse(); + } + + @Test + @DisplayName("a zero deadline means no elapsed bound at all") + void zeroDeadlineMeansNoElapsedBound() { + var budget = + RetryBudget.forProfile(RetryProfile.boundedContention("order.write", 5), Duration.ZERO); + + assertThat(budget.allowsAnotherAttempt(1, Duration.ofHours(1), Duration.ofHours(1))).isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptorTest.java new file mode 100644 index 00000000..2747d25d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryableJpaTransactionInterceptorTest.java @@ -0,0 +1,211 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext; +import dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.lang.reflect.Method; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; + +/** Plan Task 9 — retry advice runs outside transaction advice (design §19.4). */ +class RetryableJpaTransactionInterceptorTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("order.place"); + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + private final CountingTransactionManager transactionProbe = new CountingTransactionManager(); + private final TransactionProfileRegistry profiles = + TransactionProfileRegistry.of( + TransactionProfile.write("order.write", Duration.ofSeconds(5)) + .withRetryProfile(RetryProfile.boundedContention("order.write", 3))); + + private final RetryableJpaTransactionInterceptor interceptor = + new RetryableJpaTransactionInterceptor(coordinator(), profiles); + + @Test + @DisplayName("retry advice has higher precedence than Spring's transaction advice") + void retryAdviceRunsOutsideTransactionAdvice() { + assertThat(interceptor.getOrder()).isLessThan(Ordered.LOWEST_PRECEDENCE); + assertThat(RetryableJpaTransactionInterceptor.DEFAULT_ORDER) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE + 100); + } + + @Test + @DisplayName("each retried attempt begins a new transaction") + void eachAttemptBeginsANewTransaction() throws Exception { + AtomicInteger invocations = new AtomicInteger(); + Method method = AnnotatedService.class.getMethod("place"); + + Object result = + interceptor.invoke( + invocation( + method, + () -> { + if (invocations.incrementAndGet() == 1) { + throw new SerializationFailureException( + JpaFailureContext.retryable(OPERATION, "40001", 1, Duration.ZERO, null)); + } + return "ok"; + })); + + assertThat(result).isEqualTo("ok"); + assertThat(invocations.get()).isEqualTo(2); + assertThat(transactionProbe.begun()).isEqualTo(2); + } + + @Test + @DisplayName("an unannotated method passes straight through") + void unannotatedMethodIsNotRetried() throws Exception { + AtomicInteger invocations = new AtomicInteger(); + Method method = AnnotatedService.class.getMethod("plain"); + + interceptor.invoke(invocation(method, invocations::incrementAndGet)); + + assertThat(invocations.get()).isEqualTo(1); + assertThat(transactionProbe.begun()).isZero(); + } + + @Test + @DisplayName("a reactive return type is refused because JPA is blocking") + void rejectsReactiveReturnType() throws Exception { + Method reactive = ReactiveService.class.getMethod("place"); + + assertThatThrownBy(() -> RetryableJpaTransactionInterceptor.rejectReactiveReturnType(reactive)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JPA is blocking"); + } + + @Test + @DisplayName("a blocking return type is accepted") + void acceptsBlockingReturnType() throws Exception { + RetryableJpaTransactionInterceptor.rejectReactiveReturnType( + AnnotatedService.class.getMethod("place")); + } + + @Test + @DisplayName("an unregistered profile name fails rather than falling back") + void unregisteredProfileFails() throws Exception { + Method method = MisconfiguredService.class.getMethod("place"); + + assertThatThrownBy(() -> interceptor.invoke(invocation(method, () -> "ok"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered transaction profile"); + } + + private FullTransactionRetryCoordinator coordinator() { + List slept = new ArrayList<>(); + return new FullTransactionRetryCoordinator( + new SpringJpaTransactionExecutor(transactionProbe, clock), + DefaultJpaRetryPolicy.forProfile(RetryProfile.boundedContention("order.write", 3)), + slept::add, + clock, + Duration.ofSeconds(30), + RetryEventListener.noop()); + } + + private static MethodInvocation invocation( + Method method, java.util.function.Supplier body) { + return new MethodInvocation() { + @Override + public Method getMethod() { + return method; + } + + @Override + public Object[] getArguments() { + return new Object[0]; + } + + @Override + public Object proceed() { + return body.get(); + } + + @Override + public Object getThis() { + return null; + } + + @Override + public java.lang.reflect.AccessibleObject getStaticPart() { + return method; + } + }; + } + + /** Fixture whose annotated method is retryable. */ + public static class AnnotatedService { + + @RetryableJpaTransaction(operation = "order.place", profile = "order.write") + public String place() { + return "ok"; + } + + public String plain() { + return "ok"; + } + } + + /** Fixture whose annotation names a profile nobody registered. */ + public static class MisconfiguredService { + + @RetryableJpaTransaction(operation = "order.place", profile = "order.wrtie") + public String place() { + return "ok"; + } + } + + /** Fixture returning a reactive type, which the platform refuses. */ + public static class ReactiveService { + + @RetryableJpaTransaction(operation = "order.place", profile = "order.write") + public org.reactivestreams.Publisher place() { + return null; + } + } + + /** Counts how many physical transactions were begun. */ + private static final class CountingTransactionManager implements PlatformTransactionManager { + + private int begun; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + begun++; + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + // nothing to commit in the unit lane + } + + @Override + public void rollback(TransactionStatus status) { + // nothing to roll back in the unit lane + } + + int begun() { + return begun; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutorTest.java new file mode 100644 index 00000000..5dee6f59 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutorTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.sql.Connection; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; + +/** + * Plan Task 7 — a profile maps to exactly one transaction, configured as declared (design §9.3). + */ +class SpringJpaTransactionExecutorTest { + + private static final PersistenceOperationName OPERATION = + new PersistenceOperationName("report.read"); + + private final Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC); + private final RecordingTransactionManager transactionProbe = new RecordingTransactionManager(); + private final SpringJpaTransactionExecutor executor = + new SpringJpaTransactionExecutor(transactionProbe, clock); + + @AfterEach + void clearEvidence() { + TransactionEvidenceContext.clearAll(); + } + + @Test + @DisplayName("a serializable read-only profile reaches the manager unchanged") + void mapsSerializableReadOnlyProfile() { + var profile = + new TransactionProfile( + "report.read", + PropagationMode.REQUIRED, + IsolationLevel.SERIALIZABLE, + Duration.ofSeconds(3), + true, + RetryProfile.none()); + + executor.execute(OPERATION, profile, () -> null); + + assertThat(transactionProbe.isolation()).isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + assertThat(transactionProbe.readOnly()).isTrue(); + assertThat(transactionProbe.timeoutSeconds()).isEqualTo(3); + assertThat(transactionProbe.name()).isEqualTo("report.read"); + } + + @Test + @DisplayName("each call builds its own definition rather than mutating a shared template") + void eachCallGetsItsOwnDefinition() { + executor.execute(OPERATION, TransactionProfile.read("report.read", Duration.ZERO), () -> null); + executor.execute( + OPERATION, TransactionProfile.write("report.write", Duration.ofSeconds(9)), () -> null); + + assertThat(transactionProbe.definitions()).hasSize(2); + assertThat(transactionProbe.definitions().get(0).isReadOnly()).isTrue(); + assertThat(transactionProbe.definitions().get(1).isReadOnly()).isFalse(); + assertThat(transactionProbe.definitions().get(1).getTimeout()).isEqualTo(9); + } + + @Test + @DisplayName("the operation is bound as evidence for the duration of the call") + void bindsTheOperationAsEvidence() { + var seen = new ArrayList(); + + executor.execute( + OPERATION, + TransactionProfile.read("report.read", Duration.ZERO), + () -> { + TransactionEvidenceContext.current().ifPresent(frame -> seen.add(frame.operation())); + return null; + }); + + assertThat(seen).containsExactly(OPERATION); + } + + @Test + @DisplayName("the evidence frame does not outlive the call") + void doesNotLeakTheEvidenceFrame() { + executor.execute(OPERATION, TransactionProfile.read("report.read", Duration.ZERO), () -> null); + + assertThat(TransactionEvidenceContext.current()).isEmpty(); + } + + @Test + @DisplayName("the attempt number and transaction key reach the evidence frame") + void recordsAttemptAndTransactionKey() { + var attempts = new ArrayList(); + + executor.execute( + OPERATION, + TransactionProfile.write("report.write", Duration.ofSeconds(5)), + () -> { + TransactionEvidenceContext.current().ifPresent(frame -> attempts.add(frame.attempt())); + return null; + }, + 3, + "report-42"); + + assertThat(attempts).containsExactly(3); + } + + /** Captures the definition each transaction was begun with. */ + private static final class RecordingTransactionManager implements PlatformTransactionManager { + + private final List definitions = new ArrayList<>(); + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + definitions.add(definition); + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + // nothing to commit in the unit lane + } + + @Override + public void rollback(TransactionStatus status) { + // nothing to roll back in the unit lane + } + + List definitions() { + return definitions; + } + + private TransactionDefinition last() { + return definitions.get(definitions.size() - 1); + } + + int isolation() { + return last().getIsolationLevel(); + } + + boolean readOnly() { + return last().isReadOnly(); + } + + int timeoutSeconds() { + return last().getTimeout(); + } + + String name() { + return last().getName(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapperTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapperTest.java new file mode 100644 index 00000000..caf43074 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDefinitionMapperTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.sql.Connection; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionDefinition; + +/** Plan Task 7 — a profile maps to exactly the definition it describes (design §9.2). */ +class TransactionDefinitionMapperTest { + + @Test + @DisplayName("a serializable read-only profile maps isolation, read-only, and timeout") + void mapsSerializableReadOnlyProfile() { + var profile = + new TransactionProfile( + "report.read", + PropagationMode.REQUIRED, + IsolationLevel.SERIALIZABLE, + Duration.ofSeconds(3), + true, + RetryProfile.none()); + + TransactionDefinition definition = TransactionDefinitionMapper.definitionOf(profile); + + assertThat(definition.getIsolationLevel()).isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + assertThat(definition.isReadOnly()).isTrue(); + assertThat(definition.getTimeout()).isEqualTo(3); + } + + @Test + @DisplayName("a sub-second timeout rounds up rather than truncating to zero") + void subSecondTimeoutRoundsUp() { + assertThat(TransactionDefinitionMapper.timeoutSecondsOf(Duration.ofMillis(1500))).isEqualTo(2); + assertThat(TransactionDefinitionMapper.timeoutSecondsOf(Duration.ofMillis(1))).isEqualTo(1); + } + + @Test + @DisplayName("a zero timeout means the connection default") + void zeroTimeoutIsTheConnectionDefault() { + assertThat(TransactionDefinitionMapper.timeoutSecondsOf(Duration.ZERO)) + .isEqualTo(TransactionDefinition.TIMEOUT_DEFAULT); + } + + @Test + @DisplayName("a negative timeout is rejected") + void negativeTimeoutIsRejected() { + assertThatThrownBy(() -> TransactionDefinitionMapper.timeoutSecondsOf(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("each supported propagation maps to its Spring constant") + void mapsEveryPropagation() { + assertThat(TransactionDefinitionMapper.propagationOf(PropagationMode.REQUIRED)) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(TransactionDefinitionMapper.propagationOf(PropagationMode.MANDATORY)) + .isEqualTo(TransactionDefinition.PROPAGATION_MANDATORY); + assertThat(TransactionDefinitionMapper.propagationOf(PropagationMode.REQUIRES_NEW)) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistryTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistryTest.java new file mode 100644 index 00000000..57097f83 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistryTest.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Plan Task 9 — profile lookup is fail-closed (design §9.2). */ +class TransactionProfileRegistryTest { + + private final TransactionProfileRegistry registry = + TransactionProfileRegistry.of( + TransactionProfile.write("order.write", Duration.ofSeconds(5)), + TransactionProfile.read("order.read", Duration.ZERO)); + + @Test + @DisplayName("a registered profile resolves") + void resolvesRegisteredProfile() { + assertThat(registry.require("order.write").readOnly()).isFalse(); + assertThat(registry.require("order.read").readOnly()).isTrue(); + } + + @Test + @DisplayName("a mistyped profile name fails rather than falling back to a default") + void unregisteredProfileFails() { + assertThatThrownBy(() -> registry.require("order.wrtie")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unregistered transaction profile"); + } + + @Test + @DisplayName("a duplicate registration is rejected") + void duplicateRegistrationFails() { + assertThatThrownBy( + () -> + TransactionProfileRegistry.of( + TransactionProfile.write("order.write", Duration.ofSeconds(5)), + TransactionProfile.write("order.write", Duration.ofSeconds(9)))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java new file mode 100644 index 00000000..0a076db3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.JavaType; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import jakarta.persistence.Entity; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +/** + * Detects a persistence entity escaping through a transport method's return type (design §10.4). + * + *

Generic type arguments are inspected, not just the raw return type. {@code List}, + * {@code Optional}, and {@code Map} all leak the entity exactly + * as effectively as returning it directly, and a check that only looked at the erased type would + * pass all three. + * + *

What makes this a correctness rule rather than a style rule: response serialisation happens + * after the transaction closes. A lazy association touched by the serialiser either throws {@code + * LazyInitializationException} or — with OSIV on, which this platform forbids — issues a database + * query from the view layer, one per element. + */ +public final class EntityExposureCondition extends ArchCondition { + + public EntityExposureCondition() { + super("not return a persistence entity"); + } + + @Override + public void check(JavaMethod method, ConditionEvents events) { + exposedEntity(method.getReturnType()) + .ifPresent( + entity -> + events.add( + SimpleConditionEvent.violated( + method, + method.getFullName() + + " returns the persistence entity " + + entity + + ": its lazy associations are serialised after the transaction closes"))); + } + + /** The first entity type reachable from this return type, including generic arguments. */ + private static Optional exposedEntity(JavaType type) { + JavaClass raw = type.toErasure(); + if (raw.isAnnotatedWith(Entity.class)) { + return Optional.of(raw.getName()); + } + if (!isContainer(raw)) { + return Optional.empty(); + } + for (JavaType argument : type.getAllInvolvedRawTypes()) { + JavaClass candidate = argument.toErasure(); + if (candidate.isAnnotatedWith(Entity.class)) { + return Optional.of(candidate.getName()); + } + } + return Optional.empty(); + } + + private static boolean isContainer(JavaClass raw) { + return raw.isAssignableTo(Collection.class) + || raw.isAssignableTo(Map.class) + || raw.isAssignableTo(Optional.class) + || raw.isArray(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityMappingCondition.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityMappingCondition.java new file mode 100644 index 00000000..9a28123b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityMappingCondition.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaConstructor; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; + +/** + * Checks that an entity can be proxied and instantiated by the provider (design §10.2). + * + *

Both requirements come from the same mechanism: Hibernate creates a lazy proxy by generating a + * subclass, and it materialises a loaded row by calling a no-arg constructor and populating fields. + * A {@code final} entity cannot be subclassed, so every reference to it is fetched eagerly whatever + * the mapping says — a silent performance change with no error anywhere. A missing no-arg + * constructor fails louder, at bootstrap, but only once that entity is actually mapped. + */ +public final class EntityMappingCondition extends ArchCondition { + + public EntityMappingCondition() { + super("be non-final and declare a no-arg constructor"); + } + + @Override + public void check(JavaClass entity, ConditionEvents events) { + if (entity.getModifiers().contains(JavaModifier.FINAL)) { + events.add( + SimpleConditionEvent.violated( + entity, + entity.getName() + + " is a final @Entity: the provider cannot subclass it to create a lazy proxy," + + " so every association to it is fetched eagerly")); + } + if (!hasAccessibleNoArgConstructor(entity)) { + events.add( + SimpleConditionEvent.violated( + entity, + entity.getName() + + " has no accessible no-arg constructor: the provider instantiates entities" + + " reflectively before populating their fields")); + } + } + + private static boolean hasAccessibleNoArgConstructor(JavaClass entity) { + for (JavaConstructor constructor : entity.getConstructors()) { + if (!constructor.getRawParameterTypes().isEmpty()) { + continue; + } + if (!constructor.getModifiers().contains(JavaModifier.PRIVATE)) { + return true; + } + } + return false; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java new file mode 100644 index 00000000..6887b3c6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.arch; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; + +/** + * The reusable ArchUnit rules that keep JPA mapping and exposure honest (design §7.3, §10.4). + * + *

These live in the testkit rather than in a production package because ArchUnit is a test + * library: putting the rule pack in {@code main} would drag it onto every deployment's runtime + * classpath to serve code that only ever runs in a test. + * + *

Each rule encodes a failure that is invisible until production: + * + *

    + *
  • An entity returned from a controller serialises lazy associations during response writing — + * outside the transaction, where they either fail or issue queries from the view layer. + *
  • A {@code final} entity cannot be proxied, so every lazy reference to it is loaded eagerly. + *
  • A missing no-arg constructor makes the provider fail at bootstrap, but only for entities on + * a code path that was exercised. + *
  • {@code org.hibernate} imports in a domain package make the domain depend on the ORM, which + * is the boundary this whole template exists to hold. + *
+ */ +public final class JpaArchitectureRules { + + private JpaArchitectureRules() {} + + /** No method declared in a web package may return an entity, or a collection of one. */ + public static ArchRule noEntityFromWeb() { + return methods() + .that() + .areDeclaredInClassesThat() + .resideInAnyPackage("..web..", "..controller..") + .should(new EntityExposureCondition()) + .as("no web method returns a persistence entity") + .because( + "an entity returned to the transport layer serialises its lazy associations after the" + + " transaction has closed"); + } + + /** Entities must be proxyable and constructible by the provider. */ + public static ArchRule entitiesFollowPortableMappingRules() { + return classes() + .that() + .areAnnotatedWith(Entity.class) + .should(new EntityMappingCondition()) + .as("entities are non-final and have a no-arg constructor") + .because( + "the provider subclasses entities to create lazy proxies and instantiates them" + + " reflectively"); + } + + /** No entity class may live in a web or controller package. */ + public static ArchRule entitiesStayOutOfWebPackages() { + return noClasses() + .that() + .areAnnotatedWith(Entity.class) + .should() + .resideInAnyPackage("..web..", "..controller..") + .as("entities do not live in web packages") + .because("an entity in the transport package is an entity that will be exposed by it"); + } + + /** No domain package may depend on Hibernate. */ + public static ArchRule domainDoesNotDependOnHibernate() { + return noClasses() + .that() + .resideInAPackage("..domain..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.hibernate..") + .as("domain code does not depend on the ORM") + .because("the domain model must be persistable by something other than Hibernate"); + } + + /** No platform type may re-implement Spring Data's CRUD repository. */ + public static ArchRule noGenericRepository() { + return noClasses() + .that() + .haveSimpleNameEndingWith("GenericRepository") + .or() + .haveSimpleNameEndingWith("BaseRepository") + .should() + .beAssignableTo(org.springframework.data.repository.CrudRepository.class) + .as("the platform does not re-implement CrudRepository") + .because( + "a platform-owned base repository forces every aggregate through one generic API, and" + + " one aggregate's requirement then changes behaviour for all of them"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/envers/AuditedDocument.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/envers/AuditedDocument.java new file mode 100644 index 00000000..09e67372 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/envers/AuditedDocument.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.envers; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.envers.Audited; + +/** An entity enrolled for Envers history, used by the history contract. */ +@Entity +@Table(name = "jpa_audited_document") +@Audited +public class AuditedDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String body; + + protected AuditedDocument() { + // JPA + } + + public AuditedDocument(String title, String body) { + this.title = title; + this.body = body; + } + + public Long id() { + return id; + } + + public String title() { + return title; + } + + public String body() { + return body; + } + + public void retitle(String newTitle) { + this.title = newTitle; + } + + public void redact() { + this.body = "[redacted]"; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java new file mode 100644 index 00000000..98209ed1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.failure; + +import eu.rekawek.toxiproxy.Proxy; +import eu.rekawek.toxiproxy.ToxiproxyClient; +import eu.rekawek.toxiproxy.model.ToxicDirection; +import java.io.IOException; +import java.util.Objects; +import org.testcontainers.toxiproxy.ToxiproxyContainer; + +/** + * Cuts the connection at a chosen point of the commit (design §39). + * + *

A network proxy is the only way to produce the scenario the completion-unknown design exists + * for. Killing the container, the process, or the connection from the client all break + * before the server commits, which is the easy case. The hard case is a commit the server + * completed whose acknowledgement never arrived, and it can only be produced by breaking the path + * back. + * + *

This is why the failure suite needs Docker and Toxiproxy rather than a mock: a mocked + * connection can be made to throw whatever the test wants, which proves the code handles the + * exception it was told to expect, not that the exception is what a lost commit acknowledgement + * actually produces. + */ +public final class CommitAmbiguityProxy implements AutoCloseable { + + /** The toxic name used for the injected cut, so it can be removed again. */ + private static final String TOXIC_NAME = "commit-ambiguity"; + + private final Proxy proxy; + + private CommitAmbiguityProxy(Proxy proxy) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + } + + /** Opens a proxy in front of a PostgreSQL server. */ + public static CommitAmbiguityProxy in( + ToxiproxyContainer toxiproxy, String upstreamHost, int upstreamPort) throws IOException { + Objects.requireNonNull(toxiproxy, "toxiproxy"); + ToxiproxyClient client = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); + Proxy proxy = + client.createProxy( + "jpa-commit-ambiguity", "0.0.0.0:8666", upstreamHost + ':' + upstreamPort); + return new CommitAmbiguityProxy(proxy); + } + + /** + * Injects the break for a scenario. + * + *

{@link PostgreSqlFailureScenario#AFTER_SERVER_COMMIT_BEFORE_RESPONSE} cuts only the + * downstream direction, so the commit reaches the server and the acknowledgement does not — which + * is exactly the state the platform must report as completion-unknown rather than retry. + */ + public void inject(PostgreSqlFailureScenario scenario) throws IOException { + Objects.requireNonNull(scenario, "scenario"); + ToxicDirection direction = + scenario == PostgreSqlFailureScenario.AFTER_SERVER_COMMIT_BEFORE_RESPONSE + ? ToxicDirection.DOWNSTREAM + : ToxicDirection.UPSTREAM; + proxy.toxics().limitData(TOXIC_NAME, direction, 0L); + } + + /** Removes the injected break. */ + public void heal() throws IOException { + proxy.toxics().get(TOXIC_NAME).remove(); + } + + /** The proxy port a JDBC URL should connect through. */ + public int listenPort() { + return 8666; + } + + @Override + public void close() throws IOException { + proxy.delete(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenario.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenario.java new file mode 100644 index 00000000..65f84430 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/PostgreSqlFailureScenario.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.failure; + +import java.util.List; + +/** + * The three distinct points at which a commit can be interrupted (design §17.2, §39). + * + *

They are separated because the correct behaviour differs at each one, and only the third is + * genuinely ambiguous: + * + *

    + *
  • {@link #BEFORE_COMMIT} — nothing was committed; the transaction rolled back and the use + * case may be re-run. + *
  • {@link #DURING_COMMIT} — the server may or may not have reached the commit record. + *
  • {@link #AFTER_SERVER_COMMIT_BEFORE_RESPONSE} — the server committed and the acknowledgement + * was lost. The write happened; re-running it duplicates it. + *
+ * + *

A failure suite that injects only a generic "connection drop" cannot tell these apart, so it + * cannot prove the platform treats the third one differently — which is the single behaviour the + * completion-unknown design exists to guarantee. + */ +public enum PostgreSqlFailureScenario { + + /** The connection breaks before the commit is sent. */ + BEFORE_COMMIT, + + /** The connection breaks while the commit is in flight. */ + DURING_COMMIT, + + /** The server committed; the response was lost before the client saw it. */ + AFTER_SERVER_COMMIT_BEFORE_RESPONSE; + + /** The commit-phase injection points, in the order they occur. */ + public static List commitPoints() { + return List.of(values()); + } + + /** Whether this scenario leaves the commit outcome undetermined. */ + public boolean leavesOutcomeUnknown() { + return this != BEFORE_COMMIT; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectation.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectation.java new file mode 100644 index 00000000..b78f0e94 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/FetchPaginationExpectation.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.fetch; + +/** + * What paginating a fetched collection must do on the Stable provider (design §26). + * + *

The historical failure this gate exists for is in-memory pagination. When a query joins a + * collection and applies a page, older Hibernate versions fetched every matching parent + * row and applied the limit in Java — logging a warning and returning correct results while reading + * the whole table. Correct output, unbounded work, and no failing test anywhere. + * + *

{@code requiresDatabaseLimit} is therefore asserted against the generated SQL, not against the + * returned page size. The returned page is identical either way; only the SQL says where the limit + * was applied. + */ +public record FetchPaginationExpectation( + int maxReturnedParents, boolean requiresDatabaseLimit, int maxRowAmplification) { + + public FetchPaginationExpectation { + if (maxReturnedParents < 1) { + throw new IllegalArgumentException("max returned parents must be positive"); + } + if (maxRowAmplification < 1) { + throw new IllegalArgumentException("max row amplification must be positive"); + } + } + + /** + * The expectation for the Stable provider on PostgreSQL. + * + *

The amplification bound is generous — a page of parents times a bounded number of children — + * because the assertion is about the shape of the plan, not about fixture cardinality. + */ + public static FetchPaginationExpectation hibernate74PostgreSql(int pageSize) { + return new FetchPaginationExpectation(pageSize, true, pageSize * 100); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedChild.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedChild.java new file mode 100644 index 00000000..81192dd4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedChild.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.fetch; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +/** + * The child side of the collection-fetch pagination fixture (design §26). + * + *

The {@code @ManyToOne} is {@code LAZY}. JPA's default for a to-one association is {@code + * EAGER}, which means every query that loads a child also issues a query for its parent — the + * single most common accidental N+1 in a JPA application (design §13.1). + */ +@Entity +@Table(name = "jpa_paged_child") +public class PagedChild { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jpa_paged_child_seq") + @SequenceGenerator( + name = "jpa_paged_child_seq", + sequenceName = "jpa_paged_child_seq", + allocationSize = 50) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "parent_id", nullable = false) + private PagedParent parent; + + protected PagedChild() { + // required by the persistence provider + } + + public PagedChild(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the first flush. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } + + /** The owning parent. */ + public PagedParent parent() { + return parent; + } + + /** Sets the owning side; called by {@link PagedParent#addChild}. */ + void assignParent(PagedParent owner) { + this.parent = owner; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedParent.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedParent.java new file mode 100644 index 00000000..97385d2f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/fetch/PagedParent.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.fetch; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +/** + * The parent side of the collection-fetch pagination fixture (design §26). + * + *

The collection is {@code LAZY}, which is the whole point: the pagination contract exercises + * what happens when a query explicitly joins and pages it, not what happens when the mapping + * fetches it eagerly for every query in the application. + * + *

Identifiers come from a sequence with an {@code allocationSize} the migration's sequence + * increment must match, so the fixture can also be batched (design §28.2). + */ +@Entity +@Table(name = "jpa_paged_parent") +public class PagedParent { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jpa_paged_parent_seq") + @SequenceGenerator( + name = "jpa_paged_parent_seq", + sequenceName = "jpa_paged_parent_seq", + allocationSize = 50) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + @OneToMany( + mappedBy = "parent", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List children = new ArrayList<>(); + + protected PagedParent() { + // required by the persistence provider + } + + public PagedParent(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the first flush. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } + + /** The children, as an unmodifiable view. */ + public List children() { + return List.copyOf(children); + } + + /** + * Adds a child and sets the owning side. + * + *

Only the child holds the foreign key, so adding to this list alone would leave the row + * unlinked — the association helper is what keeps both sides consistent (design §13.4). + */ + public void addChild(PagedChild child) { + children.add(child); + child.assignParent(this); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/IdentityEntity.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/IdentityEntity.java new file mode 100644 index 00000000..2fddbe25 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/IdentityEntity.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.id; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * The IDENTITY fixture — the strategy the design classifies as limited (design §11.1). + * + *

It exists to be measured, not recommended. An identity column assigns the key on insert, so + * the provider must execute each insert immediately to learn it, which disables JDBC insert + * batching entirely. The contract suite uses this fixture to prove that — and {@code + * HibernateBatchConfigurationGuard} uses the same fact to refuse a batch profile that targets it. + */ +@Entity +@Table(name = "jpa_identity_fixture") +public class IdentityEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + protected IdentityEntity() { + // required by the persistence provider + } + + public IdentityEntity(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the insert. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/SequenceEntity.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/SequenceEntity.java new file mode 100644 index 00000000..aad88cf0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/SequenceEntity.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.id; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +/** + * The sequence-generated fixture — the Stable write-heavy identifier strategy (design §11.2). + * + *

{@code allocationSize} must equal the migration's {@code INCREMENT BY}. When they disagree the + * provider hands out identifiers the sequence has not reserved, and the collision appears later as + * a unique violation on the primary key, under load, with no obvious cause. + * + *

A pooled sequence is what makes insert batching possible: the provider reserves a block once + * and assigns identifiers in Java, so entities are complete before they are queued. + */ +@Entity +@Table(name = "jpa_sequence_fixture") +public class SequenceEntity { + + /** The allocation size this fixture's migration must declare as {@code INCREMENT BY}. */ + public static final int ALLOCATION_SIZE = 50; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jpa_sequence_fixture_seq") + @SequenceGenerator( + name = "jpa_sequence_fixture_seq", + sequenceName = "jpa_sequence_fixture_seq", + allocationSize = ALLOCATION_SIZE) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + protected SequenceEntity() { + // required by the persistence provider + } + + public SequenceEntity(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the first flush. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java new file mode 100644 index 00000000..9f22384b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.id; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import java.util.random.RandomGenerator; + +/** + * Generates RFC 9562 UUIDv7 values (design §11.3). + * + *

The reason to prefer v7 over v4 for a primary key is index locality. A v4 key is uniformly + * random, so every insert lands on a random leaf of the B-tree: the index does not stay in cache, + * pages split constantly, and write amplification grows with the table. A v7 key carries a + * millisecond timestamp in its high bits, so inserts append and the hot part of the index stays + * small. + * + *

Application-side generation is deliberate. A database-generated identity is assigned on + * insert, which is what disables JDBC insert batching (design §28.2) — generating the id in Java + * means the entity is complete before it is queued. + * + *

Monotonicity within a millisecond is enforced by a counter rather than left to the random + * bits, so two ids generated in the same millisecond still order correctly. + */ +public final class UuidV7Generator { + + private static final long UNIX_TIMESTAMP_MASK = 0x0000_FFFF_FFFF_FFFFL; + private static final long VERSION_7 = 0x7000L; + private static final long VARIANT_RFC = 0x8000_0000_0000_0000L; + private static final long COUNTER_MASK = 0x0FFFL; + + private final RandomGenerator random; + private long lastMillis = Long.MIN_VALUE; + private long counter; + + public UuidV7Generator() { + this(new SecureRandom()); + } + + public UuidV7Generator(RandomGenerator random) { + this.random = Objects.requireNonNull(random, "random"); + } + + /** The next UUIDv7 for {@code instant}. */ + public synchronized UUID next(Instant instant) { + Objects.requireNonNull(instant, "instant"); + long unixMillis = instant.toEpochMilli() & UNIX_TIMESTAMP_MASK; + if (unixMillis == lastMillis) { + counter = (counter + 1L) & COUNTER_MASK; + } else { + lastMillis = unixMillis; + counter = random.nextLong(COUNTER_MASK + 1L); + } + long most = (unixMillis << 16) | VERSION_7 | counter; + long least = (random.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | VARIANT_RFC; + return new UUID(most, least); + } + + /** The millisecond timestamp encoded in a UUIDv7. */ + public static Instant timestampOf(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + if (uuid.version() != 7) { + throw new IllegalArgumentException("not a UUIDv7"); + } + return Instant.ofEpochMilli(uuid.getMostSignificantBits() >>> 16); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java new file mode 100644 index 00000000..0ed87c02 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.jdbc; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.Objects; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DelegatingDataSource; + +/** + * A {@link DataSource} that counts JDBC batch executions (design §28.3). + * + *

Dynamic proxies rather than hand-written wrapper classes: {@code Connection} and {@code + * PreparedStatement} have well over a hundred methods between them, and a hand-written delegate + * that misses one silently changes driver behaviour in whatever test happens to use it. + * + *

Test infrastructure only. It adds a reflective hop to every JDBC call, which is irrelevant + * when the point is to count batches and unacceptable in a runtime. + */ +public final class CountingDataSource extends DelegatingDataSource { + + private final CountingJdbcBatchCounter counter; + + public CountingDataSource(DataSource delegate, CountingJdbcBatchCounter counter) { + super(Objects.requireNonNull(delegate, "delegate")); + this.counter = Objects.requireNonNull(counter, "counter"); + } + + @Override + public Connection getConnection() throws SQLException { + return proxyConnection(Objects.requireNonNull(super.getConnection())); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return proxyConnection(Objects.requireNonNull(super.getConnection(username, password))); + } + + /** The counter this data source feeds. */ + public CountingJdbcBatchCounter counter() { + return counter; + } + + private Connection proxyConnection(Connection connection) { + return (Connection) + Proxy.newProxyInstance( + CountingDataSource.class.getClassLoader(), + new Class[] {Connection.class}, + new ConnectionHandler(connection, counter)); + } + + /** Wraps every statement the connection hands out. */ + private record ConnectionHandler(Connection delegate, CountingJdbcBatchCounter counter) + implements InvocationHandler { + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object result = invokeDelegate(method, args); + if (result instanceof PreparedStatement statement) { + return Proxy.newProxyInstance( + CountingDataSource.class.getClassLoader(), + new Class[] {PreparedStatement.class}, + new StatementHandler(statement, counter)); + } + return result; + } + + private Object invokeDelegate(Method method, Object[] args) throws Throwable { + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException wrapped) { + throw wrapped.getCause(); + } + } + } + + /** Counts {@code executeBatch()} on the statements the connection produced. */ + private record StatementHandler(PreparedStatement delegate, CountingJdbcBatchCounter counter) + implements InvocationHandler { + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("executeBatch".equals(method.getName()) || "executeLargeBatch".equals(method.getName())) { + counter.recordBatchExecution(); + } + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException wrapped) { + throw wrapped.getCause(); + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingJdbcBatchCounter.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingJdbcBatchCounter.java new file mode 100644 index 00000000..4489aa80 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingJdbcBatchCounter.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.jdbc; + +import dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Counts real {@code executeBatch()} calls (design §28.3). + * + *

This is the only honest source for "did batching actually happen". Hibernate's statistics do + * not count batches, and the configuration value proves nothing: {@code hibernate.jdbc.batch_size} + * can be set correctly while an IDENTITY generator, an interleaved select, or a mid-loop flush + * disables batching entirely — and nothing in the configuration changes to say so. + * + *

Installed by {@link CountingDataSource}, which wraps the driver's statements. + */ +public final class CountingJdbcBatchCounter implements JdbcBatchCounter { + + private final AtomicLong batchExecutions = new AtomicLong(); + + @Override + public long batchExecutions() { + return batchExecutions.get(); + } + + /** Records one {@code executeBatch()} call. */ + public void recordBatchExecution() { + batchExecutions.incrementAndGet(); + } + + /** Resets the counter between measurements. */ + public void reset() { + batchExecutions.set(0L); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java new file mode 100644 index 00000000..d955a70d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle; + +/** + * Where an object sits relative to the Persistence Context (design §14). + * + *

The distinction is what decides whether a change to the object will be written. A managed + * instance is dirty-checked and flushed; a detached one looks identical in code and is silently + * discarded. + */ +public enum EntityState { + + /** Never persisted and not associated with a context. */ + TRANSIENT, + + /** Associated with the current context and dirty-checked. */ + MANAGED, + + /** Persisted, but no longer associated with a context; changes are not written. */ + DETACHED, + + /** Scheduled for removal on the next flush. */ + REMOVED +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java new file mode 100644 index 00000000..cce23f42 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle; + +import jakarta.persistence.EntityManager; +import java.util.Objects; + +/** + * Reports which lifecycle state an object is in (design §14). + * + *

The distinction is invisible in code and decisive at runtime: a managed instance is + * dirty-checked and flushed, a detached one is not, and both look like an ordinary object at the + * call site. Most "my update did not save" bugs are a detached instance being modified. + * + *

{@code contains} answers managed-or-not; an identifier is what separates transient from + * detached, since a transient instance has never been persisted and therefore has no id. + */ +public final class EntityStateProbe { + + private final EntityManager entityManager; + + public EntityStateProbe(EntityManager entityManager) { + this.entityManager = Objects.requireNonNull(entityManager, "entityManager"); + } + + /** The lifecycle state of {@code entity} relative to this Persistence Context. */ + public EntityState stateOf(Object entity) { + Objects.requireNonNull(entity, "entity"); + if (entityManager.contains(entity)) { + return EntityState.MANAGED; + } + Object id = + entityManager.getEntityManagerFactory().getPersistenceUnitUtil().getIdentifier(entity); + return id == null ? EntityState.TRANSIENT : EntityState.DETACHED; + } + + /** Whether the instance is managed by this context. */ + public boolean isManaged(Object entity) { + return stateOf(entity) == EntityState.MANAGED; + } + + /** Whether the instance was persisted but is no longer managed. */ + public boolean isDetached(Object entity) { + return stateOf(entity) == EntityState.DETACHED; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleChild.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleChild.java new file mode 100644 index 00000000..d3320a52 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleChild.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +/** + * The owned child of the lifecycle fixture (design §13.4). + * + *

This side holds the foreign key, which makes it the owning side: an update to {@code parent} + * is what changes the {@code parent_id} column. Adding a child to the parent's list without setting + * this field leaves the row unlinked, which is why the parent's helper does both. + */ +@Entity +@Table(name = "jpa_lifecycle_child") +public class LifecycleChild { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jpa_lifecycle_child_seq") + @SequenceGenerator( + name = "jpa_lifecycle_child_seq", + sequenceName = "jpa_lifecycle_child_seq", + allocationSize = 50) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private LifecycleParent parent; + + protected LifecycleChild() { + // required by the persistence provider + } + + public LifecycleChild(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the first flush. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } + + /** The owning parent, or {@code null} once orphaned. */ + public LifecycleParent parent() { + return parent; + } + + /** Sets the owning side; called by the parent's association helpers. */ + void assignParent(LifecycleParent owner) { + this.parent = owner; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleParent.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleParent.java new file mode 100644 index 00000000..33c0d020 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/LifecycleParent.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.lifecycle; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.List; + +/** + * The aggregate-root fixture for the lifecycle contract (design §13, §14). + * + *

{@code CascadeType.ALL} with {@code orphanRemoval} is correct here and is not a + * platform default. It models an aggregate that genuinely owns its children — removing one from the + * collection means the child ceases to exist. Applied to an association between two independent + * aggregates, the same setting deletes rows another part of the system still owns. + * + *

{@code @Version} makes the fixture usable by the optimistic-conflict contracts as well. + */ +@Entity +@Table(name = "jpa_lifecycle_parent") +public class LifecycleParent { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "jpa_lifecycle_parent_seq") + @SequenceGenerator( + name = "jpa_lifecycle_parent_seq", + sequenceName = "jpa_lifecycle_parent_seq", + allocationSize = 50) + private Long id; + + @Column(name = "label", nullable = false, length = 64) + private String label; + + @Version + @Column(name = "version", nullable = false) + private long version; + + @OneToMany( + mappedBy = "parent", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List children = new ArrayList<>(); + + protected LifecycleParent() { + // required by the persistence provider + } + + public LifecycleParent(String label) { + this.label = label; + } + + /** The generated identifier, or {@code null} before the first flush. */ + public Long id() { + return id; + } + + /** The fixture label. */ + public String label() { + return label; + } + + /** The optimistic lock version. */ + public long version() { + return version; + } + + /** The children, as an unmodifiable view. */ + public List children() { + return List.copyOf(children); + } + + /** Changes the label, which is what makes the instance dirty. */ + public void rename(String newLabel) { + this.label = newLabel; + } + + /** Adds a child and sets the owning side. */ + public void addChild(LifecycleChild child) { + children.add(child); + child.assignParent(this); + } + + /** Removes a child; orphan removal deletes the row on flush. */ + public void removeChild(LifecycleChild child) { + children.remove(child); + child.assignParent(null); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverter.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverter.java new file mode 100644 index 00000000..f37442fd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/DurationMillisConverter.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.mapping; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.time.Duration; + +/** + * Stores a {@link Duration} as whole milliseconds (design §12.1). + * + *

{@code autoApply = false} on purpose. An auto-applied converter silently changes the column + * type of every {@code Duration} field in the application, including ones that need a different + * precision or a different unit — and the change is invisible at the field. + * + *

Milliseconds rather than {@code toString}: the ISO-8601 text form sorts and compares wrongly + * in SQL, so any query that filters or orders on the column gets the wrong answer. + */ +@Converter(autoApply = false) +public final class DurationMillisConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(Duration value) { + return value == null ? null : value.toMillis(); + } + + @Override + public Duration convertToEntityAttribute(Long value) { + return value == null ? null : Duration.ofMillis(value); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java new file mode 100644 index 00000000..30be9fe8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.mapping; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * One row exercising every value mapping the Stable contract covers (design §12). + * + *

The enum is {@link EnumType#STRING}, never {@code ORDINAL}. Ordinal storage writes the + * constant's position, so inserting a new constant anywhere but the end silently + * reinterprets every existing row — the classic data-corruption-by-refactoring bug, and one no test + * catches unless it round-trips through a database that already holds rows. + * + *

{@code Instant} and {@code OffsetDateTime} are both present because they map differently: + * {@code Instant} is a point in time with no offset, while {@code OffsetDateTime} keeps one, and a + * column typed for the first cannot faithfully store the second. + */ +@Entity +@Table(name = "jpa_mapping_fixture") +public class MappingEntity { + + /** The fixture enum; stored as its name. */ + public enum Status { + ACTIVE, + ARCHIVED + } + + @Id + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "recorded_at", nullable = false) + private Instant recordedAt; + + @Column(name = "effective_at", nullable = false) + private OffsetDateTime effectiveAt; + + @Column(name = "effective_on", nullable = false) + private LocalDate effectiveOn; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private Status status; + + @Embedded private Money price; + + @Convert(converter = DurationMillisConverter.class) + @Column(name = "window_millis", nullable = false) + private Duration window; + + protected MappingEntity() { + // required by the persistence provider + } + + public MappingEntity( + UUID id, + Instant recordedAt, + OffsetDateTime effectiveAt, + LocalDate effectiveOn, + Status status, + Money price, + Duration window) { + this.id = id; + this.recordedAt = recordedAt; + this.effectiveAt = effectiveAt; + this.effectiveOn = effectiveOn; + this.status = status; + this.price = price; + this.window = window; + } + + /** The identifier. */ + public UUID id() { + return id; + } + + /** The stored instant. */ + public Instant recordedAt() { + return recordedAt; + } + + /** The stored offset date-time. */ + public OffsetDateTime effectiveAt() { + return effectiveAt; + } + + /** The stored local date. */ + public LocalDate effectiveOn() { + return effectiveOn; + } + + /** The stored enum. */ + public Status status() { + return status; + } + + /** The stored record embeddable. */ + public Money price() { + return price; + } + + /** The stored converted duration. */ + public Duration window() { + return window; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/Money.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/Money.java new file mode 100644 index 00000000..32b7f512 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/Money.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.mapping; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Objects; + +/** + * A record {@code @Embeddable} — the JPA 3.2 value-mapping fixture (design §12). + * + *

The amount is {@code BigDecimal} with an explicit precision and scale. {@code double} is the + * classic wrong choice here: it cannot represent {@code 0.1} exactly, so sums drift by fractions of + * a cent and reconciliation reports disagree with the ledger. + * + *

The currency is stored as its ISO code rather than as a {@code Currency} instance, which has + * no portable column mapping. + */ +@Embeddable +public record Money( + @Column(name = "amount", precision = 19, scale = 4) BigDecimal amount, + @Column(name = "currency", length = 3) String currencyCode) { + + public Money { + Objects.requireNonNull(amount, "amount"); + Objects.requireNonNull(currencyCode, "currencyCode"); + if (currencyCode.length() != 3) { + throw new IllegalArgumentException("currency must be an ISO 4217 code"); + } + } + + /** The amount in the supplied currency. */ + public static Money of(BigDecimal amount, Currency currency) { + return new Money(amount, currency.getCurrencyCode()); + } + + /** The ISO currency this amount is denominated in. */ + public Currency currency() { + return Currency.getInstance(currencyCode); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java new file mode 100644 index 00000000..2ef27828 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.migration; + +import dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Objects; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; + +/** + * Runs one migration scenario end to end (design §31). + * + *

The order is the contract: restore the snapshot into a clean database, migrate, validate, then + * assert the data invariant. Each step catches something the next cannot. + * + *

Restoring into a clean database matters more than it looks. A scenario run against a + * database left over from the previous scenario tests an upgrade path nobody will ever take, and it + * passes for the wrong reason — the objects the migration expected were already there. + * + *

Validation runs after migration rather than instead of it, because a migration can apply + * successfully and still leave a schema the provider disagrees with: a column type that Hibernate + * maps differently is applied without complaint and fails at the first query. + */ +public final class MigrationContractRunner { + + private final DataSource dataSource; + private final String migrationLocation; + private final FlywayValidationGate validationGate; + + public MigrationContractRunner( + DataSource dataSource, String migrationLocation, FlywayValidationGate validationGate) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.migrationLocation = Objects.requireNonNull(migrationLocation, "migrationLocation"); + this.validationGate = Objects.requireNonNull(validationGate, "validationGate"); + } + + /** + * Runs one scenario. + * + * @return the migration result, so a caller can assert on the applied version + */ + public MigrateResult run(MigrationScenario scenario) { + Objects.requireNonNull(scenario, "scenario"); + cleanDatabase(); + restore(scenario.snapshot()); + + Flyway flyway = + Flyway.configure() + .dataSource(dataSource) + .locations(migrationLocation) + .cleanDisabled(false) + .load(); + MigrateResult result = flyway.migrate(); + validationGate.requireValid(flyway.validateWithResult()); + scenario.invariant().accept(dataSource); + return result; + } + + private void cleanDatabase() { + Flyway.configure() + .dataSource(dataSource) + .locations(migrationLocation) + .cleanDisabled(false) + .load() + .clean(); + } + + private void restore(MigrationSnapshot snapshot) { + if (snapshot.isEmptyDatabase()) { + return; + } + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(snapshot.setupSql()); + } catch (SQLException failure) { + throw new IllegalStateException( + "migration snapshot '" + snapshot.name() + "' could not be restored", failure); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenario.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenario.java new file mode 100644 index 00000000..96becf3a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationScenario.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.migration; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import javax.sql.DataSource; + +/** + * One upgrade path a release must survive (design §31). + * + *

Three scenarios are required, and each catches a different failure: + * + *

    + *
  • empty — the full migration chain still runs end to end. Breaks when an early + * migration is edited to match a later one and no longer applies to a fresh database. + *
  • previous-release — the actual deployment path. This is the only one that exercises + * the migrations this release adds. + *
  • oldest-supported — the long-lived environment nobody upgraded. Breaks when a + * migration silently assumes a state only recent databases have. + *
+ * + *

The invariant is a data assertion, not just a version check. A migration that renames a column + * and loses its contents leaves the schema version correct and the data gone. + */ +public record MigrationScenario( + String name, MigrationSnapshot snapshot, Consumer invariant) { + + public MigrationScenario { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(invariant, "invariant"); + } + + /** The three scenarios every release must run. */ + public static List required() { + return List.of(empty(), previousRelease(), oldestSupported()); + } + + /** A fresh database with no schema at all. */ + public static MigrationScenario empty() { + return new MigrationScenario("empty", MigrationSnapshot.empty(), dataSource -> {}); + } + + /** The schema as of the previous release. */ + public static MigrationScenario previousRelease() { + return new MigrationScenario( + "previous-release", + new MigrationSnapshot("previous-release", "", "previous"), + dataSource -> {}); + } + + /** The oldest schema this release still supports upgrading from. */ + public static MigrationScenario oldestSupported() { + return new MigrationScenario( + "oldest-supported", + new MigrationSnapshot("oldest-supported", "", "oldest"), + dataSource -> {}); + } + + /** The same scenario with a data invariant attached. */ + public MigrationScenario withInvariant(Consumer assertion) { + return new MigrationScenario(name, snapshot, assertion); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationSnapshot.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationSnapshot.java new file mode 100644 index 00000000..cf88a676 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationSnapshot.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.migration; + +import java.util.Objects; + +/** + * A database state a migration scenario starts from (design §31). + * + * @param name the scenario-facing name of this snapshot + * @param setupSql the statements that recreate the starting state in a clean database + * @param startingVersion the schema version the snapshot represents, empty for an empty database + */ +public record MigrationSnapshot(String name, String setupSql, String startingVersion) { + + public MigrationSnapshot { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(setupSql, "setupSql"); + startingVersion = startingVersion == null ? "" : startingVersion; + if (name.isBlank()) { + throw new IllegalArgumentException("snapshot name must not be blank"); + } + } + + /** The empty-database snapshot. */ + public static MigrationSnapshot empty() { + return new MigrationSnapshot("empty", "", ""); + } + + /** Whether this snapshot starts from an empty database. */ + public boolean isEmptyDatabase() { + return setupSql.isBlank(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java new file mode 100644 index 00000000..4b528a86 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.pool; + +import java.time.Duration; +import java.util.Objects; + +/** + * A reading of connection pool pressure (design §38). + * + *

Pending count and acquire latency are recorded together because either alone is misleading. A + * pool can show zero pending at the instant it is sampled while callers routinely wait; a high + * acquire latency with no pending count gives no idea how many callers are affected. + */ +public record PoolMeasurement(int active, int idle, int pending, Duration acquireLatency) { + + public PoolMeasurement { + Objects.requireNonNull(acquireLatency, "acquireLatency"); + if (active < 0 || idle < 0 || pending < 0) { + throw new IllegalArgumentException("pool counters must not be negative"); + } + if (acquireLatency.isNegative()) { + throw new IllegalArgumentException("acquire latency must not be negative"); + } + } + + /** How many connections the pool currently holds. */ + public int total() { + return active + idle; + } + + /** Whether callers are waiting for a connection. */ + public boolean saturated() { + return pending > 0; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java new file mode 100644 index 00000000..7a88ec47 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.postgresql; + +import java.util.List; +import java.util.Objects; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Starts a real PostgreSQL of a chosen Stable version (design §40). + * + *

Every contract in this suite runs against a real server, never H2. The behaviours being + * verified — SQLSTATE values, {@code SKIP LOCKED} semantics, JSONB operators, range types, + * concurrent index builds, {@code search_path} privileges — either do not exist in H2 or behave + * differently there. A green H2 run would be evidence of nothing while looking exactly like + * evidence of everything. + * + *

Docker absence is an error, not a skip. A suite that quietly skips when Docker is missing + * reports success on a machine that verified nothing, and CI eventually inherits that silence. + */ +public final class PostgreSqlContainerFactory { + + /** The property that selects which Stable versions to run, e.g. {@code 16,18}. */ + public static final String MATRIX_PROPERTY = "jpa.matrix.versions"; + + private PostgreSqlContainerFactory() {} + + /** A container for one Stable version. */ + public static PostgreSQLContainer create(PostgreSqlVersion version) { + Objects.requireNonNull(version, "version"); + assertDockerAvailable(); + return new PostgreSQLContainer(DockerImageName.parse(version.image())) + .withDatabaseName("jpa_contract") + .withUsername("jpa_contract") + .withPassword("jpa_contract"); + } + + /** + * The versions selected for this run. + * + *

Defaults to the repository's existing evidence version rather than the whole matrix, so an + * ordinary local run does not pull three images. The full matrix is selected explicitly. + */ + public static List selectedVersions() { + String selection = System.getProperty(MATRIX_PROPERTY); + if (selection == null) { + return List.of(PostgreSqlVersion.PG_16); + } + return PostgreSqlVersion.parseSelection(selection); + } + + /** + * Fails when Docker is unavailable. + * + * @throws IllegalStateException naming the missing prerequisite + */ + public static void assertDockerAvailable() { + if (!org.testcontainers.DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the PostgreSQL contract suite and is not available; this lane" + + " fails closed rather than skipping, because a skipped contract reports success" + + " for a database nobody tested"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java new file mode 100644 index 00000000..c3d477a8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.postgresql; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Starts, shares, and stops the PostgreSQL containers a contract class needs (design §40). + * + *

Containers are cached per version for the whole JVM. Starting a fresh server per test class + * turns a matrix run into minutes of container startup, and the thing being verified — server + * behaviour — is identical across classes. + * + *

The exact server version is recorded rather than assumed from the image tag. An image tag is + * mutable, so {@code postgres:16-alpine} today and tomorrow can be different patch releases, and a + * contract failure attributable to "PostgreSQL 16" is not attributable to anything. + */ +public final class PostgreSqlContractExtension implements BeforeAllCallback, AfterAllCallback { + + private static final Map CONTAINERS = + new LinkedHashMap<>(); + + private final PostgreSqlVersion version; + + public PostgreSqlContractExtension(PostgreSqlVersion version) { + this.version = Objects.requireNonNull(version, "version"); + } + + @Override + public void beforeAll(ExtensionContext context) { + container(); + } + + @Override + public void afterAll(ExtensionContext context) { + // Containers are shared for the JVM lifetime and stopped by the Testcontainers shutdown hook. + // Stopping here would restart the server for the next contract class in the same run. + } + + /** The running container for this extension's version, starting it on first use. */ + public synchronized PostgreSQLContainer container() { + return CONTAINERS.computeIfAbsent( + version, + selected -> { + PostgreSQLContainer container = PostgreSqlContainerFactory.create(selected); + container.start(); + return container; + }); + } + + /** The version this extension runs against. */ + public PostgreSqlVersion version() { + return version; + } + + /** The JDBC URL of the running container. */ + public String jdbcUrl() { + return container().getJdbcUrl(); + } + + /** The exact server version string, as the server reports it. */ + public String serverVersion() { + PostgreSQLContainer container = container(); + return container.getDockerImageName() + " (" + container.getContainerInfo().getId() + ')'; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersion.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersion.java new file mode 100644 index 00000000..d0c44af9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlVersion.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.postgresql; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The PostgreSQL major versions the Stable matrix covers (design §40). + * + *

Exactly 16, 17, and 18. H2 is deliberately not a member and never satisfies this suite: it + * accepts different SQL, reports different SQLSTATEs for the same violation, and has no equivalent + * of the features these contracts exist to verify. A green H2 run says nothing about PostgreSQL. + */ +public enum PostgreSqlVersion { + + /** PostgreSQL 16 — the oldest Stable version. */ + PG_16(16, "postgres:16-alpine"), + + /** PostgreSQL 17. */ + PG_17(17, "postgres:17-alpine"), + + /** PostgreSQL 18 — the newest Stable version. */ + PG_18(18, "postgres:18-alpine"); + + private final int majorVersion; + private final String image; + + PostgreSqlVersion(int majorVersion, String image) { + this.majorVersion = majorVersion; + this.image = image; + } + + /** The Stable matrix, oldest first. */ + public static List stable() { + return List.of(PG_16, PG_17, PG_18); + } + + /** The subset a pull-request run covers: the oldest and the newest Stable versions. */ + public static List pullRequestMatrix() { + return List.of(PG_16, PG_18); + } + + /** The version for a major number, when it is in the Stable matrix. */ + public static Optional ofMajor(int majorVersion) { + return stable().stream().filter(version -> version.majorVersion == majorVersion).findFirst(); + } + + /** Parses a {@code -Pjpa.matrix.versions=16,18} style selection, failing on anything unknown. */ + public static List parseSelection(String selection) { + Objects.requireNonNull(selection, "selection"); + if (selection.isBlank()) { + throw new IllegalArgumentException( + "an empty PostgreSQL matrix selection is an error, not an empty run"); + } + return java.util.Arrays.stream(selection.split(",", -1)) + .map(String::trim) + .filter(entry -> !entry.isEmpty()) + .map( + entry -> + ofMajor(Integer.parseInt(entry)) + .orElseThrow( + () -> + new IllegalArgumentException( + "PostgreSQL " + + entry + + " is not in the Stable matrix " + + stable()))) + .toList(); + } + + /** The major version number. */ + public int majorVersion() { + return majorVersion; + } + + /** The container image this version runs from. */ + public String image() { + return image; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/FetchExpectation.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/FetchExpectation.java new file mode 100644 index 00000000..82e10ab0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/FetchExpectation.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.query; + +import java.util.Objects; + +/** + * What a use case's fetch plan is expected to produce (design §25.2). + * + *

Separate from {@link QueryExpectation} because it answers a different question: not "was this + * query bounded" but "did the fetch strategy actually apply". A plan that silently stopped applying + * — a renamed graph, a lost hint — still produces a bounded query, just with the associations + * loaded one statement at a time. + */ +public record FetchExpectation( + long maxCollectionFetches, long maxEntityFetches, boolean allowLazyInitialization) { + + public FetchExpectation { + if (maxCollectionFetches < 0L || maxEntityFetches < 0L) { + throw new IllegalArgumentException("fetch expectations must not be negative"); + } + } + + /** No association may be fetched by a separate statement — the fully-joined plan. */ + public static FetchExpectation none() { + return new FetchExpectation(0L, 0L, false); + } + + /** A batch-fetch plan: a bounded number of extra statements is expected. */ + public static FetchExpectation batched(long maxCollectionFetches) { + return new FetchExpectation(maxCollectionFetches, 0L, true); + } + + /** Whether the measurement stays inside this fetch expectation. */ + public boolean matches(QueryMeasurement actual) { + Objects.requireNonNull(actual, "actual"); + return actual.fetches() <= maxCollectionFetches + maxEntityFetches; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertions.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertions.java new file mode 100644 index 00000000..eb8fa50f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/JpaQueryAssertions.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.query; + +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryName; +import java.util.Objects; + +/** + * Asserts a query measurement against its expectation (design §25.3). + * + *

The failure message names the query and every measured dimension, not only the one that broke. + * A fetch regression is diagnosed by the relationship between the numbers — one statement and + * twenty thousand rows is a Cartesian product; two hundred statements and two hundred rows is an + * N+1 — and a message reporting only the violated bound throws that context away. + */ +public final class JpaQueryAssertions { + + /** + * Fails when the measurement violates the expectation. + * + * @throws AssertionError naming the query, the violated bound, and every measured dimension + */ + public void assertMatches(QueryMeasurement actual, QueryExpectation expected) { + assertMatches(null, actual, expected); + } + + /** Fails when the measurement violates the expectation, attributing it to {@code queryName}. */ + public void assertMatches( + QueryName queryName, QueryMeasurement actual, QueryExpectation expected) { + Objects.requireNonNull(actual, "actual"); + Objects.requireNonNull(expected, "expected"); + expected + .violation(actual) + .ifPresent( + violation -> { + throw new AssertionError( + "JPA query expectation failed" + + (queryName == null ? "" : " for " + queryName.value()) + + ": " + + violation + + " | measured " + + actual.summary()); + }); + } + + /** Fails when the measurement violates the fetch expectation. */ + public void assertFetches( + QueryName queryName, QueryMeasurement actual, FetchExpectation expected) { + Objects.requireNonNull(actual, "actual"); + Objects.requireNonNull(expected, "expected"); + if (!expected.matches(actual)) { + throw new AssertionError( + "JPA fetch expectation failed" + + (queryName == null ? "" : " for " + queryName.value()) + + ": fetches=" + + actual.fetches() + + " exceeds " + + (expected.maxCollectionFetches() + expected.maxEntityFetches()) + + " | measured " + + actual.summary()); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryExpectation.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryExpectation.java new file mode 100644 index 00000000..ea847501 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryExpectation.java @@ -0,0 +1,159 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.query; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * The bounds one query is expected to stay within (design §25.3). + * + *

Every dimension is optional and independent, because tests assert different things: a fetch + * test bounds statements and rows, a projection test bounds hydrated entities, a plan test bounds + * duration. Forcing one combined assertion would make each test claim more than it verified. + */ +public record QueryExpectation( + OptionalLong maxStatements, + OptionalLong exactStatements, + OptionalLong maxHydratedEntities, + OptionalLong maxRows, + OptionalLong maxFetches, + Optional maxElapsed) { + + public QueryExpectation { + Objects.requireNonNull(maxStatements, "maxStatements"); + Objects.requireNonNull(exactStatements, "exactStatements"); + Objects.requireNonNull(maxHydratedEntities, "maxHydratedEntities"); + Objects.requireNonNull(maxRows, "maxRows"); + Objects.requireNonNull(maxFetches, "maxFetches"); + Objects.requireNonNull(maxElapsed, "maxElapsed"); + if (maxStatements.isPresent() && exactStatements.isPresent()) { + throw new IllegalArgumentException( + "declare either a maximum or an exact statement count, not both"); + } + } + + /** An expectation with nothing bounded yet. */ + public static QueryExpectation none() { + return new QueryExpectation( + OptionalLong.empty(), + OptionalLong.empty(), + OptionalLong.empty(), + OptionalLong.empty(), + OptionalLong.empty(), + Optional.empty()); + } + + /** Bounds the number of rows the query may return. */ + public static QueryExpectation maxRows(long rows) { + return none().withMaxRows(rows); + } + + /** Requires exactly {@code statements} statements — the single-query assertion. */ + public static QueryExpectation exactlyOneStatement() { + return none().withExactStatements(1L); + } + + /** Bounds the number of statements. */ + public QueryExpectation withMaxStatements(long statements) { + return new QueryExpectation( + OptionalLong.of(statements), + OptionalLong.empty(), + maxHydratedEntities, + maxRows, + maxFetches, + maxElapsed); + } + + /** Requires an exact number of statements. */ + public QueryExpectation withExactStatements(long statements) { + return new QueryExpectation( + OptionalLong.empty(), + OptionalLong.of(statements), + maxHydratedEntities, + maxRows, + maxFetches, + maxElapsed); + } + + /** Bounds how many entities may be hydrated. */ + public QueryExpectation withMaxHydratedEntities(long entities) { + return new QueryExpectation( + maxStatements, exactStatements, OptionalLong.of(entities), maxRows, maxFetches, maxElapsed); + } + + /** Bounds how many rows may be returned. */ + public QueryExpectation withMaxRows(long rows) { + return new QueryExpectation( + maxStatements, + exactStatements, + maxHydratedEntities, + OptionalLong.of(rows), + maxFetches, + maxElapsed); + } + + /** Bounds how many association fetches may be issued. */ + public QueryExpectation withMaxFetches(long fetches) { + return new QueryExpectation( + maxStatements, + exactStatements, + maxHydratedEntities, + maxRows, + OptionalLong.of(fetches), + maxElapsed); + } + + /** Bounds how long the query may take. */ + public QueryExpectation withMaxElapsed(Duration elapsed) { + return new QueryExpectation( + maxStatements, + exactStatements, + maxHydratedEntities, + maxRows, + maxFetches, + Optional.of(elapsed)); + } + + /** Whether the measurement satisfies every declared bound. */ + public boolean matches(QueryMeasurement actual) { + Objects.requireNonNull(actual, "actual"); + return violation(actual).isEmpty(); + } + + /** The first violated bound, named, or empty when the measurement satisfies all of them. */ + public Optional violation(QueryMeasurement actual) { + Objects.requireNonNull(actual, "actual"); + if (exactStatements.isPresent() && actual.statements() != exactStatements.getAsLong()) { + return Optional.of( + "statements=" + actual.statements() + " expected exactly " + exactStatements.getAsLong()); + } + if (maxStatements.isPresent() && actual.statements() > maxStatements.getAsLong()) { + return Optional.of( + "statements=" + actual.statements() + " exceeds maximum " + maxStatements.getAsLong()); + } + if (maxHydratedEntities.isPresent() + && actual.hydratedEntities() > maxHydratedEntities.getAsLong()) { + return Optional.of( + "hydratedEntities=" + + actual.hydratedEntities() + + " exceeds maximum " + + maxHydratedEntities.getAsLong()); + } + if (maxRows.isPresent() && actual.rows() > maxRows.getAsLong()) { + return Optional.of("rows=" + actual.rows() + " exceeds maximum " + maxRows.getAsLong()); + } + if (maxFetches.isPresent() && actual.fetches() > maxFetches.getAsLong()) { + return Optional.of( + "fetches=" + actual.fetches() + " exceeds maximum " + maxFetches.getAsLong()); + } + if (maxElapsed.isPresent() && actual.elapsed().compareTo(maxElapsed.get()) > 0) { + return Optional.of( + "elapsedMillis=" + + actual.elapsed().toMillis() + + " exceeds maximum " + + maxElapsed.get().toMillis()); + } + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryMeasurement.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryMeasurement.java new file mode 100644 index 00000000..2a923c58 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/query/QueryMeasurement.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.query; + +import java.time.Duration; +import java.util.Objects; + +/** + * Everything measured about one executed query (design §25.3). + * + *

Four dimensions, not one. Statement count alone cannot distinguish the two failures that + * matter: an N+1 issues many statements over few rows, while a Cartesian fetch issues one statement + * over an enormous number of rows. A suite that asserts only on statement count passes the second + * one every time. + */ +public record QueryMeasurement( + long statements, long hydratedEntities, long rows, long fetches, Duration elapsed) { + + public QueryMeasurement { + Objects.requireNonNull(elapsed, "elapsed"); + if (statements < 0L || hydratedEntities < 0L || rows < 0L || fetches < 0L) { + throw new IllegalArgumentException("query measurement counters must not be negative"); + } + } + + /** + * Rows returned per hydrated entity. + * + *

This ratio is the Cartesian-product detector: fetching two collections in one statement + * multiplies the row count by the product of their sizes while the entity count stays the same. + */ + public double rowAmplification() { + return hydratedEntities == 0L ? 0.0d : (double) rows / (double) hydratedEntities; + } + + /** A bounded summary naming every measured dimension, for assertion messages. */ + public String summary() { + return "statements=" + + statements + + " hydratedEntities=" + + hydratedEntities + + " rows=" + + rows + + " fetches=" + + fetches + + " elapsedMillis=" + + elapsed.toMillis(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java new file mode 100644 index 00000000..e42e6016 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.queryplan; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * An {@code EXPLAIN} plan with the volatile parts removed (design §33). + * + *

Raw {@code EXPLAIN (ANALYZE)} output cannot be compared between runs: costs, timings, and + * buffer counts differ on every execution and every machine. What is stable, and what a plan + * regression actually consists of, is the structure — which node types appear, how far the row + * estimate was from reality, and whether a sort spilled to disk. + * + * @param estimateRatio actual rows divided by estimated rows, the planner's error factor + */ +public record NormalizedPlan( + List nodeTypes, double estimateRatio, boolean diskSort, long sharedBuffersRead) { + + public NormalizedPlan { + nodeTypes = List.copyOf(Objects.requireNonNull(nodeTypes, "nodeTypes")); + if (estimateRatio < 0.0d || !Double.isFinite(estimateRatio)) { + throw new IllegalArgumentException("estimate ratio must be finite and non-negative"); + } + if (sharedBuffersRead < 0L) { + throw new IllegalArgumentException("shared buffers read must not be negative"); + } + } + + /** Whether the plan contains a node of this type anywhere. */ + public boolean contains(String nodeType) { + return nodeTypes.contains(nodeType); + } + + /** The distinct node types in the plan. */ + public Set distinctNodeTypes() { + return Set.copyOf(nodeTypes); + } + + /** A bounded summary suitable for an assertion message. */ + public String summary() { + return "nodes=" + + nodeTypes + + " estimateRatio=" + + estimateRatio + + " diskSort=" + + diskSort + + " sharedBuffersRead=" + + sharedBuffersRead; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java new file mode 100644 index 00000000..e807aeea --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.queryplan; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import javax.sql.DataSource; + +/** + * Runs {@code EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)} and normalises the result (design §33). + * + *

{@code ANALYZE} executes the statement. That is what makes the actual row counts and + * buffer numbers available, and it is also why this runner refuses anything but a {@code SELECT}: + * running {@code EXPLAIN ANALYZE} over an {@code UPDATE} performs the update. The suite runs + * against an isolated test database for the same reason. + * + *

Parameters are bound, never interpolated — a plan is only meaningful for representative + * values, and building the statement by concatenation would make the runner an injection site in + * the test suite. + */ +public final class PostgreSqlExplainRunner { + + private static final String EXPLAIN_PREFIX = "explain (analyze, buffers, format json) "; + + private final DataSource dataSource; + + public PostgreSqlExplainRunner(DataSource dataSource) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + } + + /** + * Explains a read-only statement with the supplied representative parameters. + * + * @throws IllegalArgumentException when the statement is not a {@code SELECT} + */ + public NormalizedPlan explain(String sql, Object... parameters) { + Objects.requireNonNull(sql, "sql"); + requireReadOnly(sql); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(EXPLAIN_PREFIX + sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet rows = statement.executeQuery()) { + if (!rows.next()) { + throw new IllegalStateException("EXPLAIN returned no plan"); + } + return normalize(rows.getString(1)); + } + } catch (SQLException failure) { + throw new IllegalStateException("the query plan could not be captured", failure); + } + } + + /** + * Reduces raw {@code EXPLAIN} JSON to the parts that are stable between runs. + * + *

Costs and timings are dropped: they differ on every execution and every machine, so a + * snapshot comparison including them fails for reasons that have nothing to do with the plan. + */ + public static NormalizedPlan normalize(String explainJson) { + Objects.requireNonNull(explainJson, "explainJson"); + List nodeTypes = new ArrayList<>(); + int index = 0; + while ((index = explainJson.indexOf("\"Node Type\":", index)) >= 0) { + int start = explainJson.indexOf('"', index + "\"Node Type\":".length()); + int end = start < 0 ? -1 : explainJson.indexOf('"', start + 1); + if (start < 0 || end < 0) { + break; + } + nodeTypes.add(explainJson.substring(start + 1, end)); + index = end; + } + double estimateRatio = + ratio( + readNumber(explainJson, "\"Actual Rows\":"), readNumber(explainJson, "\"Plan Rows\":")); + boolean diskSort = explainJson.toLowerCase(Locale.ROOT).contains("\"sort method\": \"external"); + long buffersRead = (long) readNumber(explainJson, "\"Shared Read Blocks\":"); + return new NormalizedPlan(nodeTypes, estimateRatio, diskSort, Math.max(buffersRead, 0L)); + } + + private static double ratio(double actual, double estimated) { + if (estimated <= 0.0d) { + return actual <= 0.0d ? 1.0d : actual; + } + return actual / estimated; + } + + private static double readNumber(String json, String key) { + int index = json.indexOf(key); + if (index < 0) { + return 0.0d; + } + int start = index + key.length(); + int end = start; + while (end < json.length() + && (Character.isDigit(json.charAt(end)) + || json.charAt(end) == '.' + || json.charAt(end) == ' ')) { + end++; + } + String value = json.substring(start, end).trim(); + return value.isEmpty() ? 0.0d : Double.parseDouble(value); + } + + private static void requireReadOnly(String sql) { + String normalized = sql.trim().toLowerCase(Locale.ROOT); + if (!normalized.startsWith("select") && !normalized.startsWith("with")) { + throw new IllegalArgumentException( + "EXPLAIN ANALYZE executes the statement; only SELECT may be explained"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java new file mode 100644 index 00000000..dfeabc3a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.queryplan; + +import java.util.Objects; + +/** Asserts a normalized plan against its expectation (design §33). */ +public final class QueryPlanAssertions { + + /** + * Fails when the plan violates the expectation. + * + * @throws AssertionError naming the violated rule and the whole normalized plan + */ + public void assertMatches(NormalizedPlan plan, QueryPlanExpectation expected) { + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(expected, "expected"); + expected + .violation(plan) + .ifPresent( + violation -> { + throw new AssertionError( + "query plan expectation failed: " + violation + " | plan " + plan.summary()); + }); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java new file mode 100644 index 00000000..6db9c375 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.queryplan; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * What a query's plan must and must not contain (design §33). + * + *

Note what is deliberately absent: a blanket ban on {@code Seq Scan}. A sequential scan over a + * small table is the correct plan, and a suite that forbids it globally trains people to add + * indexes that make things slower and to disable the check on the queries where it fires. + * + *

The two general-purpose signals are here instead. A large {@code estimateRatio} means the + * planner's statistics do not describe the data, which is the root cause behind most plan + * regressions; a disk sort means {@code work_mem} was exceeded, which turns a fast query into a + * temp-file write. + */ +public record QueryPlanExpectation( + Set requiredNodeTypes, + Set forbiddenNodeTypes, + double maxEstimateRatio, + boolean diskSortForbidden) { + + public QueryPlanExpectation { + requiredNodeTypes = Set.copyOf(Objects.requireNonNull(requiredNodeTypes, "requiredNodeTypes")); + forbiddenNodeTypes = + Set.copyOf(Objects.requireNonNull(forbiddenNodeTypes, "forbiddenNodeTypes")); + if (maxEstimateRatio <= 0.0d || !Double.isFinite(maxEstimateRatio)) { + throw new IllegalArgumentException("max estimate ratio must be finite and positive"); + } + } + + /** An expectation that bounds only planner estimate error. */ + public static QueryPlanExpectation estimateOnly(double maxEstimateRatio) { + return new QueryPlanExpectation(Set.of(), Set.of(), maxEstimateRatio, false); + } + + /** Bounds how far the planner's row estimate may be from reality. */ + public QueryPlanExpectation withMaxEstimateRatio(double ratio) { + return new QueryPlanExpectation( + requiredNodeTypes, forbiddenNodeTypes, ratio, diskSortForbidden); + } + + /** Requires that no sort spilled to disk. */ + public QueryPlanExpectation withDiskSortForbidden() { + return new QueryPlanExpectation(requiredNodeTypes, forbiddenNodeTypes, maxEstimateRatio, true); + } + + /** Requires the plan to contain these node types. */ + public QueryPlanExpectation requiring(String... nodeTypes) { + return new QueryPlanExpectation( + Set.of(nodeTypes), forbiddenNodeTypes, maxEstimateRatio, diskSortForbidden); + } + + /** Requires the plan to contain none of these node types. */ + public QueryPlanExpectation forbidding(String... nodeTypes) { + return new QueryPlanExpectation( + requiredNodeTypes, Set.of(nodeTypes), maxEstimateRatio, diskSortForbidden); + } + + /** The first violated rule, named, or empty when the plan satisfies all of them. */ + public Optional violation(NormalizedPlan plan) { + Objects.requireNonNull(plan, "plan"); + for (String required : requiredNodeTypes) { + if (!plan.contains(required)) { + return Optional.of("plan is missing required node type '" + required + "'"); + } + } + for (String forbidden : forbiddenNodeTypes) { + if (plan.contains(forbidden)) { + return Optional.of("plan contains forbidden node type '" + forbidden + "'"); + } + } + if (diskSortForbidden && plan.diskSort()) { + return Optional.of("plan contains a Disk Sort; work_mem was exceeded"); + } + if (plan.estimateRatio() > maxEstimateRatio) { + return Optional.of( + "planner estimate ratio " + plan.estimateRatio() + " exceeds " + maxEstimateRatio); + } + return Optional.empty(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java new file mode 100644 index 00000000..22970a72 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.release; + +import java.util.List; + +/** + * The gates a release must satisfy (design §41). + * + *

Each one names a way the platform could pass its tests while being wrong in production: a + * suite that only ran against H2, a runtime with OSIV left on, a deployment where Hibernate can + * still alter the schema, a retry path that re-runs an unknown commit, or a runtime credential that + * can execute DDL. + */ +public final class JpaReleaseGate { + + /** The real database ran the contract suite, not H2. */ + public static final String POSTGRESQL_CONTRACT = "postgresql-contract"; + + /** Completion-unknown failures are never automatically retried. */ + public static final String COMPLETION_UNKNOWN_NO_RETRY = "completion-unknown-no-retry"; + + /** Open Session In View is off in every runtime profile. */ + public static final String OSIV_DISABLED = "osiv-disabled"; + + /** Hibernate validates the schema and never mutates it. */ + public static final String FLYWAY_VALIDATE = "flyway-validate"; + + /** The runtime database role cannot execute DDL. */ + public static final String RUNTIME_ROLE_NO_DDL = "runtime-role-no-ddl"; + + /** Collection fetch pagination is bounded in SQL by the Stable provider. */ + public static final String FETCH_PAGINATION = "hibernate-7.4-fetch-pagination"; + + private JpaReleaseGate() {} + + /** Every gate a release must pass. */ + public static List required() { + return List.of( + POSTGRESQL_CONTRACT, + COMPLETION_UNKNOWN_NO_RETRY, + OSIV_DISABLED, + FLYWAY_VALIDATE, + RUNTIME_ROLE_NO_DDL, + FETCH_PAGINATION); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java new file mode 100644 index 00000000..73e9499c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.persistence.testkit.release; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The machine-readable view of the support matrix document (design §41). + * + *

Parsing the operator-facing document rather than keeping a second list in code is deliberate. + * Two independent lists drift, and the one that drifts is always the prose — so the release gate + * would keep passing against a matrix nobody had updated. Here, a document that stops naming a + * supported version fails the build. + */ +public record JpaReleaseManifest(List postgreSqlVersions, List gates) { + + private static final Pattern POSTGRESQL_VERSION = Pattern.compile("PostgreSQL\\s+(\\d{2})"); + private static final Pattern GATE = Pattern.compile("`([a-z0-9.-]+)`\\s*\\|\\s*gate"); + + public JpaReleaseManifest { + postgreSqlVersions = List.copyOf(Objects.requireNonNull(postgreSqlVersions, "versions")); + gates = List.copyOf(Objects.requireNonNull(gates, "gates")); + } + + /** + * Reads the manifest out of the support matrix document. + * + * @throws UncheckedIOException when the document is missing, which is itself a release failure + */ + public static JpaReleaseManifest load(String documentPath) { + Objects.requireNonNull(documentPath, "documentPath"); + Path path = Path.of(documentPath); + String document; + try { + document = Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException missing) { + throw new UncheckedIOException( + "the support matrix at " + documentPath + " could not be read", missing); + } + return parse(document); + } + + /** Parses a support matrix document. */ + public static JpaReleaseManifest parse(String document) { + Objects.requireNonNull(document, "document"); + List versions = new ArrayList<>(); + Matcher versionMatcher = POSTGRESQL_VERSION.matcher(document); + while (versionMatcher.find()) { + int version = Integer.parseInt(versionMatcher.group(1)); + if (!versions.contains(version)) { + versions.add(version); + } + } + versions.sort(Integer::compareTo); + + List gates = new ArrayList<>(); + Matcher gateMatcher = GATE.matcher(document); + while (gateMatcher.find()) { + String gate = gateMatcher.group(1); + if (!gates.contains(gate)) { + gates.add(gate); + } + } + return new JpaReleaseManifest(versions, gates); + } + + /** Whether every required release gate is named in the document. */ + public boolean declaresEveryRequiredGate() { + return gates.containsAll(JpaReleaseGate.required()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuard.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuard.java new file mode 100644 index 00000000..d3b18bea --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuard.java @@ -0,0 +1,74 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy; +import java.util.Arrays; +import java.util.Objects; +import org.springframework.core.env.Environment; + +/** + * Fails startup on the two JPA settings that quietly break production (design §15.1, §31). + * + *

Open Session In View. It keeps the Hibernate session open through view rendering, so a + * lazy association touched by the serialiser issues a database query from the presentation layer. + * Nothing errors: the page renders, the transaction boundary is gone, and one endpoint issues a + * query per row of its response. It is on by default in Spring Boot, which is why this guard + * exists. + * + *

Schema-mutating {@code ddl-auto}. {@code update} never drops or narrows anything, so it + * produces a schema that is neither the old one nor the one the migrations describe — and it does + * so silently, on whichever instance starts first. + * + *

This guard extends the existing per-setting validators in {@code bootstrap.runtime} to the + * whole non-local profile set, so a staging deployment is held to the same rule as production. + */ +public final class JpaDangerousConfigurationGuard { + + /** The property that enables Open Session In View. */ + public static final String OPEN_IN_VIEW_KEY = "spring.jpa.open-in-view"; + + /** The property that decides whether Hibernate may change the schema. */ + public static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto"; + + private final JpaSafetySettings safety; + private final FlywaySchemaPolicy schemaPolicy; + + public JpaDangerousConfigurationGuard(JpaSafetySettings safety, FlywaySchemaPolicy schemaPolicy) { + this.safety = Objects.requireNonNull(safety, "safety"); + this.schemaPolicy = Objects.requireNonNull(schemaPolicy, "schemaPolicy"); + } + + /** + * Validates the resolved environment. + * + * @throws IllegalStateException naming the unsafe property and the approved alternatives + */ + public void validate(Environment environment) { + Objects.requireNonNull(environment, "environment"); + boolean localConvenience = isLocalConvenience(environment); + + if (Boolean.TRUE.equals(environment.getProperty(OPEN_IN_VIEW_KEY, Boolean.class)) + && !localConvenience) { + throw new IllegalStateException( + OPEN_IN_VIEW_KEY + + " must be false outside a local convenience profile: it holds the persistence" + + " context open through view rendering, so a lazy association touched by the" + + " serialiser queries the database from the presentation layer. Approved profiles" + + " for this switch are " + + safety.localConvenienceProfiles()); + } + + String ddlAuto = environment.getProperty(DDL_AUTO_KEY); + if (ddlAuto != null && !localConvenience) { + schemaPolicy.requirePermittedDdlAuto(ddlAuto); + } + } + + /** Whether the active profiles are all local convenience profiles. */ + public boolean isLocalConvenience(Environment environment) { + String[] active = environment.getActiveProfiles(); + if (active.length == 0) { + return false; + } + return Arrays.stream(active).allMatch(safety::isLocalConvenience); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java new file mode 100644 index 00000000..26c70950 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java @@ -0,0 +1,68 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Objects; + +/** + * Validates the datasource a Stable deployment is actually pointed at (design §38, §40). + * + *

Deliberately not a universal pool size. The right {@code maximumPoolSize} depends on the + * database's {@code max_connections}, how many instances share it, and how deep this application's + * {@code REQUIRES_NEW} nesting goes — none of which this validator can know. What it can insist on + * is that somebody stated a bound, and that the bound is coherent. + * + *

A finite acquisition timeout is required for a related reason: with an unbounded wait, pool + * exhaustion presents as requests that never return rather than as requests that fail, and the + * first symptom is a thread pool filling up somewhere unrelated. + */ +public final class JpaDataSourceProfileValidator { + + private final PostgreSqlVersionPolicy versionPolicy; + + public JpaDataSourceProfileValidator(PostgreSqlVersionPolicy versionPolicy) { + this.versionPolicy = Objects.requireNonNull(versionPolicy, "versionPolicy"); + } + + /** + * Validates database product, version, and pool bounds for a Stable deployment. + * + * @throws IllegalStateException naming what was missing or unsupported + */ + public void validateStable(DatabaseMetaData metadata, JpaDataSourceSettings properties) + throws SQLException { + Objects.requireNonNull(metadata, "metadata"); + Objects.requireNonNull(properties, "properties"); + versionPolicy.requireStable(metadata); + requirePoolBounds(properties); + } + + /** + * Fails when the production pool bounds were not stated. + * + * @throws IllegalStateException naming the missing or incoherent bound + */ + public void requirePoolBounds(JpaDataSourceSettings properties) { + Objects.requireNonNull(properties, "properties"); + Integer maximumPoolSize = properties.maximumPoolSize(); + Duration connectionTimeout = properties.connectionTimeout(); + if (maximumPoolSize == null) { + throw new IllegalStateException( + "app.jpa-platform.datasource.maximum-pool-size must be stated explicitly: the correct" + + " value depends on the server's max_connections, the instance count, and this" + + " application's REQUIRES_NEW depth"); + } + if (maximumPoolSize < 1) { + throw new IllegalStateException("maximum-pool-size must be positive"); + } + if (connectionTimeout == null) { + throw new IllegalStateException( + "app.jpa-platform.datasource.connection-timeout must be stated explicitly: an unbounded" + + " wait turns pool exhaustion into requests that never return"); + } + if (connectionTimeout.isZero() || connectionTimeout.isNegative()) { + throw new IllegalStateException("connection-timeout must be positive and finite"); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java new file mode 100644 index 00000000..d195b78e --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java @@ -0,0 +1,21 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The pool bounds the composition root requires a production deployment to state (design §38). + * + *

Both values are required rather than defaulted. A pool size inherited from a framework default + * is a pool size nobody sized against the database's {@code max_connections} or against the {@code + * REQUIRES_NEW} depth this application uses — and the symptom of getting it wrong is an outage + * under load, not a startup error. + */ +@ConfigurationProperties(prefix = "app.jpa-platform.datasource") +public record JpaDataSourceSettings(Integer maximumPoolSize, Duration connectionTimeout) { + + /** Whether both bounds were supplied. */ + public boolean complete() { + return maximumPoolSize != null && connectionTimeout != null; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaObservabilityAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaObservabilityAutoConfiguration.java new file mode 100644 index 00000000..ebc1c61e --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaObservabilityAutoConfiguration.java @@ -0,0 +1,81 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation; +import dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation; +import dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation; +import dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation; +import dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation; +import dev.caskeleton.adapter.outbound.persistence.transaction.RetryEventListener; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Objects; +import java.util.Optional; + +/** + * Composes the observability half of the platform (design §37). + * + *

Separate from {@link JpaPlatformAutoConfiguration} because it backs off separately, and + * because it is the only part of the platform that is genuinely optional at runtime: an application + * with no {@link MeterRegistry} gets the no-op observation and everything else still works. That is + * why {@link NoopQueryObservation} exists rather than a nullable registry — a null check at every + * call site is how "observe when a registry is present" becomes two divergent code paths. + * + *

The persistence-unit tag is required rather than defaulted. It is the dimension that separates + * two data sources in the same application, and a default of {@code "default"} makes them + * indistinguishable exactly when someone is trying to tell them apart. + */ +public final class JpaObservabilityAutoConfiguration { + + private final Optional registry; + private final String persistenceUnit; + + /** + * @param registry the meter registry, or empty when the application installs none + * @param persistenceUnit the bounded name distinguishing this persistence unit from others + */ + public JpaObservabilityAutoConfiguration( + Optional registry, String persistenceUnit) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.persistenceUnit = Objects.requireNonNull(persistenceUnit, "persistenceUnit"); + } + + /** Query observation, or the no-op when no registry is installed. */ + public QueryObservation queryObservation() { + return registry + .map(meters -> new MicrometerQueryObservation(meters, persistenceUnit)) + .orElseGet(NoopQueryObservation::instance); + } + + /** Transaction observation, when a registry is installed. */ + public Optional transactionObservation() { + return registry.map(meters -> new JpaTransactionObservation(meters, persistenceUnit)); + } + + /** + * The retry listener, or the no-op when no registry is installed. + * + *

Retry attempts are metrics rather than log lines: an optimistic conflict is the expected + * cost of concurrency, and logging each at WARN pages someone for a system working as designed. + */ + public RetryEventListener retryListener() { + return registry + .map(meters -> new JpaRetryObservation(meters, persistenceUnit)) + .orElseGet(RetryEventListener::noop); + } + + /** + * The bounded persistence-unit tag every meter this configuration creates carries. + * + *

Hibernate statistics are collected inside the persistence leaf, through {@code + * HibernateStatisticsCollector.of(sessionFactory, batchCounter)}. Building it here would put + * {@code org.hibernate} on the composition root's compile classpath, and this repository keeps + * ORM types inside the persistence leaf. + */ + public String persistenceUnit() { + return persistenceUnit; + } + + /** Whether a meter registry is installed at all. */ + public boolean instrumented() { + return registry.isPresent(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java new file mode 100644 index 00000000..aa74a72a --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfiguration.java @@ -0,0 +1,117 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport; +import dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability; +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy; +import dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy; +import dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport; +import dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import javax.sql.DataSource; +import org.springframework.core.env.Environment; + +/** + * Composes the Stable JPA platform for this application (design §7.1). + * + *

Plain construction rather than Spring auto-configuration. This repository's rule is that + * {@code app-bootstrap} owns composition, and an adapter leaf that auto-configured itself would + * build beans in any application that merely has it on the classpath — which is how the fileserver + * capability once produced beans for a composition root that had no use for them. + * + *

Only Stable modules are composed here. Querydsl, Envers, the second-level cache, and COPY are + * Advanced or admin capabilities that require their own dependency and their own explicit opt-in + * (design §4.2, §8.4). + */ +public final class JpaPlatformAutoConfiguration { + + private final Environment environment; + private final HibernateProviderPolicy providerPolicy; + private final PostgreSqlRuntimeRoleVerifier roleVerifier; + + public JpaPlatformAutoConfiguration( + Environment environment, + HibernateProviderPolicy providerPolicy, + PostgreSqlRuntimeRoleVerifier roleVerifier) { + this.environment = Objects.requireNonNull(environment, "environment"); + this.providerPolicy = Objects.requireNonNull(providerPolicy, "providerPolicy"); + this.roleVerifier = Objects.requireNonNull(roleVerifier, "roleVerifier"); + } + + /** The startup guard for the two settings that quietly break production. */ + public JpaDangerousConfigurationGuard dangerousConfigurationGuard(JpaSafetySettings safety) { + return new JpaDangerousConfigurationGuard(safety, FlywaySchemaPolicy.standard()); + } + + /** The datasource product, version, and pool validator. */ + public JpaDataSourceProfileValidator dataSourceProfileValidator() { + return new JpaDataSourceProfileValidator(new PostgreSqlVersionPolicy()); + } + + /** The capabilities this composition actually provides. */ + public List capabilities() { + return List.of( + CapabilitySupport.stable(JpaCapability.TRANSACTION_RETRY), + CapabilitySupport.stable(JpaCapability.COMPLETION_EVIDENCE), + CapabilitySupport.stable(JpaCapability.KEYSET_PAGINATION), + CapabilitySupport.stable(JpaCapability.BATCH), + CapabilitySupport.stable(JpaCapability.SCHEMA_GATE), + CapabilitySupport.stable(JpaCapability.RUNTIME_ROLE_VERIFICATION), + CapabilitySupport.stable(JpaCapability.OBSERVABILITY), + CapabilitySupport.advanced( + JpaCapability.POSTGRESQL_NATIVE_WRITE, "requires a registered statement"), + CapabilitySupport.advanced(JpaCapability.POSTGRESQL_WORK_CLAIM, "requires a named queue"), + CapabilitySupport.advanced(JpaCapability.POSTGRESQL_JSONB, "requires a registered path"), + CapabilitySupport.advanced(JpaCapability.POSTGRESQL_ARRAY_RANGE, "requires a JDBC type"), + CapabilitySupport.advanced(JpaCapability.BULK_DML, "requires a registered operation"), + CapabilitySupport.advanced( + JpaCapability.STATELESS_SESSION, "requires registered work and a row cap"), + CapabilitySupport.advanced( + JpaCapability.POSTGRESQL_COPY, "requires the admin credential and a registered COPY"), + CapabilitySupport.advanced(JpaCapability.L2_CACHE, "requires an enrolled region catalog"), + CapabilitySupport.advanced( + JpaCapability.ENVERS, "requires the Envers dependency and a retention policy")); + } + + /** + * Builds the sanitized report the actuator endpoint serves. + * + *

A failure to read the runtime role is reported as "not verified" rather than propagated: an + * unreachable database must not turn the diagnostic endpoint into a second outage. + */ + public JpaPlatformReport report(DataSource dataSource, String schemaVersion) { + Objects.requireNonNull(dataSource, "dataSource"); + String product = "unknown"; + int majorVersion = 0; + try (Connection connection = dataSource.getConnection()) { + product = connection.getMetaData().getDatabaseProductName(); + majorVersion = connection.getMetaData().getDatabaseMajorVersion(); + } catch (SQLException unreachable) { + // Reported as unknown below; the endpoint must not fail because the database is down. + product = "unavailable"; + } + DatabasePrivilegeReport privileges = readPrivileges(dataSource); + boolean openInViewDisabled = + !Boolean.TRUE.equals( + environment.getProperty( + JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, Boolean.class)); + return JpaPlatformReport.sanitized( + product, + majorVersion, + providerPolicy.runtimeVersion(), + schemaVersion, + openInViewDisabled, + privileges, + capabilities()); + } + + private DatabasePrivilegeReport readPrivileges(DataSource dataSource) { + try { + return roleVerifier.verify(dataSource); + } catch (IllegalStateException unverified) { + return null; + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformEndpoint.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformEndpoint.java new file mode 100644 index 00000000..232add53 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformEndpoint.java @@ -0,0 +1,34 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; + +/** + * A read-only actuator view of the JPA platform (design §37). + * + *

Read-only by construction: there is no write operation and no parameter. An endpoint that + * could trigger a migration, a repair, or a cache eviction would be an admin capability reachable + * over HTTP by whoever reaches the management port, which is exactly the boundary design §8.4 puts + * those operations behind. + * + *

The report is recomputed per call rather than cached, because the two most useful facts it + * carries — the schema version and whether the runtime role still verifies — are the ones that + * change when something has gone wrong. + */ +@Endpoint(id = "jpaplatform") +public class JpaPlatformEndpoint { + + private final Supplier reportSupplier; + + public JpaPlatformEndpoint(Supplier reportSupplier) { + this.reportSupplier = Objects.requireNonNull(reportSupplier, "reportSupplier"); + } + + /** The current, sanitized platform report. */ + @ReadOperation + public JpaPlatformReport platform() { + return reportSupplier.get(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java new file mode 100644 index 00000000..4e5f19e0 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformReport.java @@ -0,0 +1,64 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport; +import dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport; +import java.util.List; +import java.util.Objects; + +/** + * What the JPA platform is willing to say about itself over HTTP (design §37). + * + *

The interesting part of this type is what it cannot hold. There is no JDBC URL, no username, + * no password, no SQL, and no entity catalog — an actuator endpoint is reachable by anyone who + * reaches the management port, and every one of those would be a free reconnaissance answer. + * + *

What it does report is what an operator actually needs during an incident: which database + * major version is behind this instance, which provider, which schema version, and whether the + * safety properties this platform depends on are holding. + */ +public record JpaPlatformReport( + String databaseProduct, + int databaseMajorVersion, + String providerVersion, + String schemaVersion, + boolean openInViewDisabled, + boolean runtimeRoleVerified, + List capabilities) { + + public JpaPlatformReport { + Objects.requireNonNull(databaseProduct, "databaseProduct"); + Objects.requireNonNull(providerVersion, "providerVersion"); + schemaVersion = schemaVersion == null ? "unknown" : schemaVersion; + capabilities = List.copyOf(Objects.requireNonNull(capabilities, "capabilities")); + } + + /** + * Builds the report, deriving only bounded values from the privilege report. + * + *

The privilege report's {@code currentUser} and {@code searchPath} are deliberately reduced + * to a single boolean here: an operator needs to know the runtime role passed verification, not + * which role it is. + */ + public static JpaPlatformReport sanitized( + String databaseProduct, + int databaseMajorVersion, + String providerVersion, + String schemaVersion, + boolean openInViewDisabled, + DatabasePrivilegeReport privileges, + List capabilities) { + return new JpaPlatformReport( + databaseProduct, + databaseMajorVersion, + providerVersion, + schemaVersion, + openInViewDisabled, + privileges != null && !privileges.holdsCreatePrivilege(), + capabilities); + } + + /** Whether every safety property this platform depends on is currently holding. */ + public boolean safe() { + return openInViewDisabled && runtimeRoleVerified; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaSafetySettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaSafetySettings.java new file mode 100644 index 00000000..ce44fbad --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaSafetySettings.java @@ -0,0 +1,39 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The JPA safety switches the composition root binds (design §15, §31). + * + *

{@code localConvenienceProfiles} names the profiles where Open Session In View may be left on + * — a developer running a template locally, and nothing else. It is a list rather than a boolean so + * that "on in local" cannot silently mean "on in staging" the first time someone reuses the profile + * name. + */ +@ConfigurationProperties(prefix = "app.jpa-platform") +public record JpaSafetySettings( + boolean enabled, Set localConvenienceProfiles, Set schemaMutatingProfiles) { + + /** The profiles that may run with OSIV enabled, when nothing else is configured. */ + public static final Set DEFAULT_LOCAL_PROFILES = Set.of("local", "test"); + + public JpaSafetySettings { + localConvenienceProfiles = + localConvenienceProfiles == null || localConvenienceProfiles.isEmpty() + ? DEFAULT_LOCAL_PROFILES + : Set.copyOf(localConvenienceProfiles); + schemaMutatingProfiles = + schemaMutatingProfiles == null ? Set.of() : Set.copyOf(schemaMutatingProfiles); + } + + /** The defaults: enabled, local convenience in local/test, schema mutation nowhere. */ + public static JpaSafetySettings defaults() { + return new JpaSafetySettings(true, DEFAULT_LOCAL_PROFILES, Set.of()); + } + + /** Whether a profile may run with OSIV enabled. */ + public boolean isLocalConvenience(String profile) { + return localConvenienceProfiles.contains(profile); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java new file mode 100644 index 00000000..99f85133 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java @@ -0,0 +1,95 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy; +import dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile; +import dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier; +import dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy; +import dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator; +import dev.caskeleton.adapter.outbound.persistence.transaction.RetryEventListener; +import dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper; +import dev.caskeleton.adapter.outbound.persistence.transaction.RetryableJpaTransactionInterceptor; +import dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor; +import dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry; +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Composes the transaction half of the platform (design §9.3, §17, §19). + * + *

Separate from {@link JpaPlatformAutoConfiguration} because it backs off separately. An + * application that already installs its own {@link PlatformTransactionManager} — a JTA setup, a + * chained manager across two data sources — must keep it, and the rest of the platform is still + * useful to it. Folding the two together would make "I have my own transaction manager" mean "I get + * none of the platform". + * + *

Plain construction rather than Spring auto-configuration: this repository's composition root + * owns wiring, and an adapter leaf that auto-configured a transaction manager would silently + * replace one the application had deliberately chosen. + */ +public final class JpaTransactionAutoConfiguration { + + /** How long a logical operation may keep retrying before the budget is spent regardless. */ + public static final Duration DEFAULT_MAX_RETRY_ELAPSED = Duration.ofSeconds(30); + + private final Clock clock; + + public JpaTransactionAutoConfiguration(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * The commit-phase classifier the evidence-aware transaction manager is built with. + * + *

The manager itself is constructed inside the persistence leaf, through {@code + * EvidenceAwareJpaTransactionManager.standard(entityManagerFactory, clock)}. The composition root + * deliberately does not do it here: that would require {@code jakarta.persistence} and {@code + * org.hibernate} on the composition root's compile classpath, and this repository keeps ORM types + * inside the persistence leaf. What the root owns is the decision — whether to install the + * platform's manager at all — and the classifier it uses. + */ + public CommitFailureClassifier commitFailureClassifier() { + return CommitFailureClassifier.standard(clock); + } + + /** The programmatic transaction boundary. */ + public SpringJpaTransactionExecutor transactionExecutor( + PlatformTransactionManager transactionManager) { + return new SpringJpaTransactionExecutor(transactionManager, clock); + } + + /** + * The retry coordinator for one retry profile. + * + *

The policy and the coordinator's budget are built from the same profile on purpose. + * Configuring them independently is how a deployment ends up with a policy that says "retry" and + * a budget that permits one attempt — which looks like retry being broken rather than like a + * misconfiguration. + */ + public FullTransactionRetryCoordinator retryCoordinator( + SpringJpaTransactionExecutor executor, + RetryProfile retryProfile, + RetryEventListener listener) { + return retryCoordinator(executor, DefaultJpaRetryPolicy.forProfile(retryProfile), listener); + } + + /** The retry coordinator for an application-supplied policy. */ + public FullTransactionRetryCoordinator retryCoordinator( + SpringJpaTransactionExecutor executor, JpaRetryPolicy policy, RetryEventListener listener) { + return new FullTransactionRetryCoordinator( + executor, policy, retrySleeper(), clock, DEFAULT_MAX_RETRY_ELAPSED, listener); + } + + /** The advice that applies {@code @RetryableJpaTransaction}. */ + public RetryableJpaTransactionInterceptor retryInterceptor( + FullTransactionRetryCoordinator coordinator, TransactionProfileRegistry profiles) { + return new RetryableJpaTransactionInterceptor(coordinator, profiles); + } + + /** The production sleeper; tests substitute a recording one. */ + public RetrySleeper retrySleeper() { + return new ThreadRetrySleeper(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/PostgreSqlVersionPolicy.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/PostgreSqlVersionPolicy.java new file mode 100644 index 00000000..94d59707 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/PostgreSqlVersionPolicy.java @@ -0,0 +1,49 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Set; + +/** + * Refuses a database the Stable contract suite has not been run against (design §40). + * + *

PostgreSQL 19 is reported rather than accepted. A newer server usually works, and "usually + * works" is the failure this policy exists to prevent: the Stable claim is backed by a contract + * suite that ran on 16, 17, and 18, and a deployment on 19 is outside the evidence whether or not + * it happens to behave. + */ +public final class PostgreSqlVersionPolicy { + + /** The major versions the Stable contract suite covers. */ + private static final Set STABLE = Set.of(16, 17, 18); + + /** The product name the Stable profile requires. */ + public static final String POSTGRESQL = "PostgreSQL"; + + /** + * Fails when the connected database is not a Stable PostgreSQL. + * + * @throws IllegalStateException naming the accepted versions + */ + public void requireStable(DatabaseMetaData metadata) throws SQLException { + String product = metadata.getDatabaseProductName(); + int major = metadata.getDatabaseMajorVersion(); + if (!POSTGRESQL.equals(product) || !STABLE.contains(major)) { + throw new IllegalStateException( + "Stable JPA profile requires PostgreSQL 16, 17 or 18, but the datasource reported " + + product + + ' ' + + major); + } + } + + /** Whether a major version is inside the Stable matrix. */ + public boolean isStable(String product, int majorVersion) { + return POSTGRESQL.equals(product) && STABLE.contains(majorVersion); + } + + /** The Stable major versions. */ + public Set stableVersions() { + return STABLE; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuardTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuardTest.java new file mode 100644 index 00000000..76e070cd --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDangerousConfigurationGuardTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * Plan Task 11 — the two JPA settings that quietly break production fail startup (design §15.1, + * §31). + */ +class JpaDangerousConfigurationGuardTest { + + private final JpaDangerousConfigurationGuard guard = + new JpaDangerousConfigurationGuard( + JpaSafetySettings.defaults(), FlywaySchemaPolicy.standard()); + + @Test + @DisplayName("production rejects open-session-in-view and a schema-mutating ddl-auto") + void productionRejectsOpenSessionInViewAndDdlUpdate() { + MockEnvironment openInView = environment("prod"); + openInView.setProperty(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, "true"); + assertThatThrownBy(() -> guard.validate(openInView)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY); + + MockEnvironment ddlUpdate = environment("prod"); + ddlUpdate.setProperty(JpaDangerousConfigurationGuard.DDL_AUTO_KEY, "update"); + assertThatThrownBy(() -> guard.validate(ddlUpdate)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Flyway owns schema change"); + } + + @Test + @DisplayName("staging is held to the same rule as production") + void stagingIsHeldToTheSameRule() { + MockEnvironment staging = environment("staging"); + staging.setProperty(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, "true"); + + assertThatThrownBy(() -> guard.validate(staging)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a local convenience profile may keep open-session-in-view on") + void localConvenienceProfileMayEnableOpenSessionInView() { + MockEnvironment local = environment("local"); + local.setProperty(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, "true"); + local.setProperty(JpaDangerousConfigurationGuard.DDL_AUTO_KEY, "update"); + + assertThatCode(() -> guard.validate(local)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("a profile set mixing local and production is not local convenience") + void mixedProfileSetIsNotLocalConvenience() { + MockEnvironment mixed = environment("local", "prod"); + mixed.setProperty(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, "true"); + + assertThatThrownBy(() -> guard.validate(mixed)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("no active profile is not local convenience either") + void noActiveProfileIsNotLocalConvenience() { + MockEnvironment none = new MockEnvironment(); + none.setProperty(JpaDangerousConfigurationGuard.OPEN_IN_VIEW_KEY, "true"); + + assertThatThrownBy(() -> guard.validate(none)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("validate and none are permitted everywhere") + void permittedDdlAutoValuesPass() { + for (String permitted : Set.of("validate", "none")) { + MockEnvironment production = environment("prod"); + production.setProperty(JpaDangerousConfigurationGuard.DDL_AUTO_KEY, permitted); + assertThatCode(() -> guard.validate(production)).doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("an absent open-in-view property is left to the framework default") + void absentOpenInViewIsNotRejected() { + assertThatCode(() -> guard.validate(environment("prod"))).doesNotThrowAnyException(); + } + + private static MockEnvironment environment(String... profiles) { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profiles); + return environment; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidatorTest.java new file mode 100644 index 00000000..067e8087 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidatorTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Plan Task 12 — the Stable profile refuses a database it has no evidence for (design §38, §40). + */ +class JpaDataSourceProfileValidatorTest { + + private final JpaDataSourceProfileValidator validator = + new JpaDataSourceProfileValidator(new PostgreSqlVersionPolicy()); + + @Test + @DisplayName("PostgreSQL 19 is refused by the Stable profile") + void rejectsPostgreSqlNineteenFromStableProfile() throws SQLException { + assertThatThrownBy(() -> validator.validateStable(metadata("PostgreSQL", 19), properties())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PostgreSQL 16, 17 or 18"); + } + + @Test + @DisplayName("every Stable major version is accepted") + void acceptsEveryStableVersion() throws SQLException { + for (int major : new int[] {16, 17, 18}) { + assertThatCode(() -> validator.validateStable(metadata("PostgreSQL", major), properties())) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("a non-PostgreSQL datasource is refused") + void rejectsNonPostgreSql() throws SQLException { + assertThatThrownBy(() -> validator.validateStable(metadata("H2", 2), properties())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PostgreSQL 16, 17 or 18"); + } + + @Test + @DisplayName("an unstated pool size is a startup failure, not a default") + void requiresAnExplicitPoolSize() { + assertThatThrownBy( + () -> + validator.requirePoolBounds(new JpaDataSourceSettings(null, Duration.ofSeconds(5)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("maximum-pool-size"); + } + + @Test + @DisplayName("an unstated connection timeout is a startup failure") + void requiresAnExplicitConnectionTimeout() { + assertThatThrownBy(() -> validator.requirePoolBounds(new JpaDataSourceSettings(10, null))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connection-timeout"); + } + + @Test + @DisplayName("an incoherent bound is refused") + void rejectsIncoherentBounds() { + assertThatThrownBy( + () -> validator.requirePoolBounds(new JpaDataSourceSettings(0, Duration.ofSeconds(5)))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy( + () -> validator.requirePoolBounds(new JpaDataSourceSettings(10, Duration.ZERO))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("the policy reports which versions it considers Stable") + void reportsTheStableMatrix() { + assertThat(new PostgreSqlVersionPolicy().stableVersions()) + .containsExactlyInAnyOrder(16, 17, 18); + assertThat(new PostgreSqlVersionPolicy().isStable("PostgreSQL", 19)).isFalse(); + } + + private static JpaDataSourceSettings properties() { + return new JpaDataSourceSettings(10, Duration.ofSeconds(5)); + } + + private static DatabaseMetaData metadata(String product, int majorVersion) throws SQLException { + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(metadata.getDatabaseProductName()).thenReturn(product); + when(metadata.getDatabaseMajorVersion()).thenReturn(majorVersion); + return metadata; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfigurationTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfigurationTest.java new file mode 100644 index 00000000..355edb6d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformAutoConfigurationTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.bootstrap.autoconfigure.jpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport; +import dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability; +import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel; +import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy; +import dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport; +import dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * Plan Task 52 — the platform report is sanitized and the capability list is honest (design §37). + */ +class JpaPlatformAutoConfigurationTest { + + private final JpaPlatformAutoConfiguration configuration = + new JpaPlatformAutoConfiguration( + new MockEnvironment(), + new HibernateProviderPolicy("7.1.8.Final"), + new PostgreSqlRuntimeRoleVerifier()); + + @Test + @DisplayName("the report carries no JDBC URL, username, password, or SQL") + void reportIsSanitized() { + var report = + JpaPlatformReport.sanitized( + "PostgreSQL", + 16, + "7.1.8.Final", + "V6", + true, + new DatabasePrivilegeReport("app_runtime", "app, pg_catalog", false, false), + configuration.capabilities()); + + assertThat(report.toString()) + .doesNotContain("jdbc:") + .doesNotContain("password") + .doesNotContain("app_runtime"); + assertThat(report.runtimeRoleVerified()).isTrue(); + assertThat(report.safe()).isTrue(); + } + + @Test + @DisplayName("a runtime role holding CREATE is reported as unverified") + void createPrivilegeIsReportedAsUnverified() { + var report = + JpaPlatformReport.sanitized( + "PostgreSQL", + 16, + "7.1.8.Final", + "V6", + true, + new DatabasePrivilegeReport("app_runtime", "app", true, false), + configuration.capabilities()); + + assertThat(report.runtimeRoleVerified()).isFalse(); + assertThat(report.safe()).isFalse(); + } + + @Test + @DisplayName("an unavailable privilege report does not claim verification") + void unavailablePrivilegeReportIsNotVerified() { + var report = + JpaPlatformReport.sanitized( + "unavailable", 0, "7.1.8.Final", null, true, null, configuration.capabilities()); + + assertThat(report.runtimeRoleVerified()).isFalse(); + assertThat(report.schemaVersion()).isEqualTo("unknown"); + } + + @Test + @DisplayName("only Stable capabilities are usable without a further opt-in") + void onlyStableCapabilitiesAreUsableByDefault() { + List capabilities = configuration.capabilities(); + + assertThat(capabilities) + .filteredOn(CapabilitySupport::usableByDefault) + .extracting(CapabilitySupport::capability) + .contains( + JpaCapability.TRANSACTION_RETRY, + JpaCapability.COMPLETION_EVIDENCE, + JpaCapability.SCHEMA_GATE) + .doesNotContain( + JpaCapability.POSTGRESQL_COPY, JpaCapability.ENVERS, JpaCapability.L2_CACHE); + } + + @Test + @DisplayName("COPY, Envers, and the L2 cache are Advanced, never Stable") + void optionalCapabilitiesAreAdvanced() { + assertThat(configuration.capabilities()) + .filteredOn( + capability -> + capability.capability() == JpaCapability.POSTGRESQL_COPY + || capability.capability() == JpaCapability.ENVERS + || capability.capability() == JpaCapability.L2_CACHE) + .allSatisfy(capability -> assertThat(capability.level()).isEqualTo(SupportLevel.ADVANCED)); + } + + @Test + @DisplayName("the endpoint recomputes its report on every read") + void endpointRecomputesPerRead() { + var counter = new java.util.concurrent.atomic.AtomicInteger(); + var endpoint = + new JpaPlatformEndpoint( + () -> { + counter.incrementAndGet(); + return JpaPlatformReport.sanitized( + "PostgreSQL", 16, "7.1.8.Final", "V6", true, null, List.of()); + }); + + endpoint.platform(); + endpoint.platform(); + + assertThat(counter.get()).isEqualTo(2); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java index 026e7f7c..396cffe3 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java @@ -30,11 +30,11 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * composition root inside this source set is not currently possible — the component scan that makes * {@code CaSkeletonApplication} the composition root also finds the nested {@code @Configuration} * classes that dozens of tests here declare, and they collide. The behaviour behind these files is - * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, - * {@code PersistenceVendorSelectionTest} covers the selector, and - * {@code PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what - * this test guards, is the wiring between them drifting — a profile quietly changing vendor, or - * local regaining a migration expectation it cannot satisfy. + * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, {@code + * PersistenceVendorSelectionTest} covers the selector, and {@code + * PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what this + * test guards, is the wiring between them drifting — a profile quietly changing vendor, or local + * regaining a migration expectation it cannot satisfy. */ class ProfileSeparationContractTest { @@ -136,7 +136,8 @@ class ProfileSeparationContractTest { return path; } } - throw new IllegalStateException("repository root not found from " + Paths.get("").toAbsolutePath()); + throw new IllegalStateException( + "repository root not found from " + Paths.get("").toAbsolutePath()); } /** @@ -197,7 +198,9 @@ class ProfileSeparationContractTest { private record Placeholder(String variable, String inlineDefault) {} - /** Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. */ + /** + * Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. + */ private static Map placeholders() throws IOException { Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$"); Map found = new LinkedHashMap<>(); diff --git a/src/build.gradle b/src/build.gradle index e90a58c1..f9f3f60d 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -937,6 +937,22 @@ tasks.register('bootstrap') { dependsOn bootstrapSmoke } +// JPA persistence platform release gate (design §41, docs/jpa/support-matrix.md). +// +// Aggregated at the root because a release is a repository-wide event and the gate spans two +// leaves: the platform's own lanes, and the architecture rules in app-bootstrap that keep the +// platform inside its boundary. Every entry corresponds to a row in the support matrix's gate +// table, and JpaReleaseManifestTest parses that document — so a gate deleted from the docs fails +// the build rather than quietly ceasing to be checked. +tasks.register('jpaReleaseGate') { + group = 'verification' + description = 'Runs every JPA persistence platform lane required for a release (design §41).' + dependsOn ':adapter:outbound:persistence-jpa:jpaPlatformReleaseGate' + dependsOn 'verifyCleanArchitectureDependencies' + dependsOn 'verifyOneTypePerFile' + dependsOn ':app-bootstrap:test' +} + // feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second // build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose. tasks.register('verifyReadmeCommands') {