From 7eb6af5d5f674894c5bdc514584105dfc32b51af Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Fri, 31 Jul 2026 23:48:51 +0900 Subject: [PATCH] feat: add JPA production capability --- .github/ci-gate-matrix.yml | 14 + .github/scripts/verify-gate-matrix.sh | 2 +- .github/workflows/ci-quality-gates.yml | 37 +- .github/workflows/jpa-r2-evidence.yml | 53 + docs/runbooks/migration-failed.md | 77 +- .../2026-07-28-jpa-production-capability.md | 484 +++++++++ src/adapter/inbound/web/build.gradle | 16 + .../web/error/GlobalExceptionHandler.java | 14 +- .../web/error/GlobalExceptionHandlerTest.java | 44 + .../web/error/SpanErrorRecorderHookTest.java | 8 +- .../outbound/persistence-jpa/README.md | 116 +++ .../outbound/persistence-jpa/build.gradle | 134 +++ .../outbound/persistence-jpa/gradle.lockfile | 314 +++--- .../PersistenceExceptionTranslator.java | 47 +- .../PostgreSqlLocalTimeoutConfigurer.java | 36 + .../PostgreSqlPersistenceConfig.java | 8 + .../PostgreSqlOwnerSafeIdempotencyStore.java | 882 +++++++++++++++++ .../PostgreSqlSameStoreInboxAdapter.java | 588 +++++++++++ ...ostgreSqlImmutableOutboxAppendAdapter.java | 380 +++++++ .../PostgreSqlPollingDeliveryAdapter.java | 531 ++++++++++ .../EffectiveTransactionTimeouts.java | 25 + .../transaction/JpaTransactionSettings.java | 103 ++ .../SpringPolicyTransactionPort.java | 270 +++++ .../transaction/SpringTransactionPort.java | 145 ++- .../TransactionDeadlineCalculator.java | 114 +++ .../TransactionLocalTimeoutConfigurer.java | 8 + .../transaction/TransactionRetryBackoff.java | 97 ++ .../TransactionRetryClassifier.java | 25 + .../transaction/TransactionStartBudget.java | 11 + .../jpa/core/V1__initialize_or_adopt.sql | 41 + .../idempotency/V1__expand_owner_safe_v2.sql | 141 +++ .../inbox/V1__initialize_same_store_inbox.sql | 70 ++ .../V1__initialize_polling_delivery_v2.sql | 152 +++ .../V1__initialize_or_adopt.sql | 307 ++++++ ...6__capability_schema_registry_adoption.sql | 43 + .../PostgreSqlAggregateIntegrationTest.java | 93 ++ .../PostgreSqlIdempotencyIntegrationTest.java | 298 ++++++ .../PostgreSqlInboxIntegrationTest.java | 280 ++++++ .../PostgreSqlLifecycleIntegrationTest.java | 120 +++ .../PostgreSqlMigrationIntegrationTest.java | 224 +++++ .../PostgreSqlOptionalStreamLifecycle.java | 220 +++++ ...ostgreSqlOutboxPollingIntegrationTest.java | 267 +++++ ...ostgreSqlOutboxStorageIntegrationTest.java | 291 ++++++ .../PostgreSqlQueryIntegrationTest.java | 83 ++ .../readiness/PostgreSqlReadinessSupport.java | 154 +++ ...greSqlSecurityBaselineIntegrationTest.java | 169 ++++ .../readiness/PostgreSqlTlsMaterial.java | 128 +++ .../PostgreSqlTransactionIntegrationTest.java | 396 ++++++++ .../interrupted/failing/V1__interrupted.sql | 6 + .../interrupted/recovery/V1__recovered.sql | 7 + .../db/readiness/rolling/V1__legacy_shape.sql | 4 + .../db/readiness/rolling/V2__expand_shape.sql | 2 + .../PersistenceExceptionTranslatorTest.java | 46 + .../PostgreSqlLocalTimeoutConfigurerTest.java | 47 + .../JpaTransactionSettingsTest.java | 69 ++ .../SpringPolicyTransactionPortTest.java | 370 +++++++ .../TransactionDeadlineCalculatorTest.java | 84 ++ .../TransactionRetryBackoffTest.java | 100 ++ .../TransactionRetryClassifierTest.java | 38 + src/app-bootstrap/README.md | 19 +- .../HikariPoolConstraintValidator.java | 35 +- .../runtime/JpaSchemaSafetyValidator.java | 59 ++ .../PostgreSqlTransportSecurityValidator.java | 94 ++ .../runtime/RuntimeSafetyConfig.java | 11 + .../src/main/resources/application.yml | 25 +- .../DistributedLockProviderContractTest.java | 2 +- .../IdempotencyUniqueScopeContractTest.java | 2 +- .../outbox/OutboxContainerTestSupport.java | 6 +- .../HikariPoolConstraintValidatorTest.java | 54 +- .../runtime/JpaSchemaSafetyValidatorTest.java | 91 ++ ...tgreSqlTransportSecurityValidatorTest.java | 103 ++ src/application-core/README.md | 35 + .../v2/IdempotencyClaimAttempt.java | 20 + .../v2/IdempotencyClaimOutcome.java | 73 ++ .../v2/IdempotencyClaimRequest.java | 41 + .../v2/IdempotencyCompleteOutcome.java | 14 + .../v2/IdempotencyFailOutcome.java | 14 + .../v2/IdempotencyFailureDisposition.java | 7 + .../idempotency/v2/IdempotencyInspection.java | 28 + .../v2/IdempotencyInspectionOutcome.java | 15 + .../v2/IdempotencyInspectionRequest.java | 17 + .../v2/IdempotencyMutationResult.java | 35 + .../idempotency/v2/IdempotencyOwner.java | 37 + .../v2/IdempotencyReleaseOutcome.java | 13 + .../v2/IdempotencyRenewOutcome.java | 23 + .../v2/IdempotencyScopeDigest.java | 32 + .../v2/IdempotencyStartOutcome.java | 23 + .../idempotency/v2/IdempotencyState.java | 10 + .../v2/IdempotencyStorePortV2.java | 37 + .../application/inbox/InboxClaimAttempt.java | 20 + .../application/inbox/InboxClaimOutcome.java | 40 + .../application/inbox/InboxClaimRequest.java | 35 + .../application/inbox/InboxOwner.java | 32 + .../inbox/InboxOwnerTransition.java | 20 + .../application/inbox/InboxScopeDigest.java | 18 + .../application/inbox/InboxState.java | 10 + .../application/inbox/InboxStorePort.java | 23 + .../inbox/InboxTransitionOutcome.java | 15 + .../application/outbound/CallBudget.java | 7 +- .../outbox/v2/ClaimedOutboxDelivery.java | 35 + .../outbox/v2/NewOutboxEventV2.java | 66 ++ .../outbox/v2/OutboxAppendOutcome.java | 9 + .../outbox/v2/OutboxAppendPortV2.java | 12 + .../outbox/v2/OutboxAppendReceipt.java | 23 + .../outbox/v2/OutboxDeliveryClaimRequest.java | 26 + .../outbox/v2/OutboxDeliveryOwner.java | 45 + .../outbox/v2/OutboxDeliveryTransition.java | 13 + .../v2/OutboxDeliveryTransitionOutcome.java | 15 + .../outbox/v2/OutboxDispatchAuthority.java | 8 + .../v2/OutboxPollingDeliveryPortV2.java | 17 + .../outbox/v2/OutboxPublicationAuthority.java | 8 + .../application/transaction/OperationId.java | 26 + .../transaction/PolicyTransactionPort.java | 13 + .../transaction/ReadConsistency.java | 9 + .../transaction/ReconciliationReference.java | 24 + .../TransactionAdmissionException.java | 16 + .../transaction/TransactionOutcome.java | 10 + .../transaction/TransactionPhase.java | 12 + .../transaction/TransactionPolicyId.java | 45 + .../transaction/TransactionRequest.java | 38 + .../transaction/TransactionResult.java | 83 ++ .../v2/IdempotencyV2ContractTest.java | 91 ++ .../application/inbox/InboxContractTest.java | 39 + .../application/outbound/CallBudgetTest.java | 7 + .../v2/OutboxDeliveryV2ContractTest.java | 61 ++ .../outbox/v2/OutboxV2ContractTest.java | 76 ++ .../transaction/OperationIdTest.java | 25 + .../transaction/TransactionRequestTest.java | 114 +++ .../transaction/TransactionResultTest.java | 58 ++ src/build.gradle | 610 ++++++++++++ src/config/jpa/readiness-cards.yaml | 589 +++++++++++ src/gradle/jpa-evidence.gradle | 930 ++++++++++++++++++ src/gradlew.bat | 186 ++-- .../SamplePostgreSqlPersistenceConfig.java | 9 + .../src/main/resources/application.yml | 4 +- .../db/sample-migration/V2__work_log.sql | 4 +- .../{V6__poster.sql => V7__poster.sql} | 3 +- ...osterRepositoryAdapterIntegrationTest.java | 4 +- ...rkLogRepositoryAdapterIntegrationTest.java | 11 +- .../src/test/resources/application-test.yml | 2 +- .../error/PersistenceFailureException.java | 9 +- 141 files changed, 13094 insertions(+), 319 deletions(-) create mode 100644 .github/workflows/jpa-r2-evidence.yml create mode 100644 docs/superpowers/plans/2026-07-28-jpa-production-capability.md create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EffectiveTransactionTimeouts.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionLocalTimeoutConfigurer.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionStartBudget.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/core/V1__initialize_or_adopt.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/idempotency/V1__expand_owner_safe_v2.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/inbox/V1__initialize_same_store_inbox.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-polling/V1__initialize_polling_delivery_v2.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-storage/V1__initialize_or_adopt.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlIdempotencyIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxPollingIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/failing/V1__interrupted.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/recovery/V1__recovered.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V1__legacy_shape.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V2__expand_shape.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurerTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettingsTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPortTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculatorTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifierTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidator.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidatorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidatorTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimAttempt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyCompleteOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailureDisposition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspection.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyMutationResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyOwner.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyReleaseOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyRenewOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStartOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyState.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimAttempt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwner.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwnerTransition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxScopeDigest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxState.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxStorePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxTransitionOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/ClaimedOutboxDelivery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/NewOutboxEventV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendPortV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendReceipt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryClaimRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryOwner.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransitionOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDispatchAuthority.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPublicationAuthority.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/OperationId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/ReadConsistency.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionAdmissionException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPhase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionResult.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/inbox/InboxContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/transaction/OperationIdTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionRequestTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionResultTest.java create mode 100644 src/config/jpa/readiness-cards.yaml create mode 100644 src/gradle/jpa-evidence.gradle rename src/sample-portfolio/src/main/resources/db/sample-migration/{V6__poster.sql => V7__poster.sql} (93%) diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index e68b3a6f..034c6095 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -101,6 +101,20 @@ gates: workflow: ci-quality-gates.yml job: gate-matrix-lint execution: job + - id: jpa-candidate-evidence + release_blocking: true + mechanism: workflow-job + ref: jpa-candidate-evidence + workflow: ci-quality-gates.yml + job: jpa-candidate-evidence + execution: job + - id: jpa-r2-evidence + release_blocking: conditional + mechanism: workflow-job + ref: jpa-r2-evidence + workflow: jpa-r2-evidence.yml + job: jpa-r2-evidence + execution: job - id: quality-release-gate release_blocking: true mechanism: workflow-job diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index 8a5fc443..4b0a881d 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -5,7 +5,7 @@ readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" -readonly EXPECTED_GATE_COUNT=19 +readonly EXPECTED_GATE_COUNT=21 if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 diff --git a/.github/workflows/ci-quality-gates.yml b/.github/workflows/ci-quality-gates.yml index 172db402..0bacfb2a 100644 --- a/.github/workflows/ci-quality-gates.yml +++ b/.github/workflows/ci-quality-gates.yml @@ -70,6 +70,35 @@ jobs: - name: Verify the gate matrix against the repository run: bash .github/scripts/verify-gate-matrix.sh + jpa-candidate-evidence: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - 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: Produce zero-skip JPA candidate manifests + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence + --no-daemon + --stacktrace + - name: Retain content-addressed JPA candidate manifests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: jpa-candidate-evidence-${{ github.sha }} + path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests + if-no-files-found: error + retention-days: 14 + # Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check. quarantine: runs-on: ubuntu-latest @@ -94,6 +123,7 @@ jobs: - quality-gates - sample-off - gate-matrix-lint + - jpa-candidate-evidence if: always() runs-on: ubuntu-latest steps: @@ -102,9 +132,14 @@ jobs: QUALITY_RESULT: ${{ needs.quality-gates.result }} SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }} MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }} + JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }} run: | set -euo pipefail - for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}"; do + for result in \ + "${QUALITY_RESULT}" \ + "${SAMPLE_OFF_RESULT}" \ + "${MATRIX_RESULT}" \ + "${JPA_CANDIDATE_RESULT}"; do if [[ "${result}" != "success" ]]; then echo "::error::release-gate: required job result was ${result}" exit 1 diff --git a/.github/workflows/jpa-r2-evidence.yml b/.github/workflows/jpa-r2-evidence.yml new file mode 100644 index 00000000..222bee8e --- /dev/null +++ b/.github/workflows/jpa-r2-evidence.yml @@ -0,0 +1,53 @@ +name: jpa-r2-evidence + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + TESTCONTAINERS_REUSE_ENABLE: "false" + +jobs: + jpa-r2-evidence: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + JPA_EVIDENCE_PROFILE: r2 + JPA_EVIDENCE_CI_JOB: >- + actions:${{ github.workflow }}:${{ github.run_id }}:${{ github.job }} + JPA_EVIDENCE_ARTIFACT_LOCATION: >- + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - 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 the production-profile JPA R2 manifest DAG + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence + -PjpaEvidenceProfile=r2 + --no-daemon + --stacktrace + - name: Retain JPA R2 attempt manifests + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: jpa-r2-evidence-${{ github.sha }}-${{ github.run_id }} + path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests + if-no-files-found: error + retention-days: 30 diff --git a/docs/runbooks/migration-failed.md b/docs/runbooks/migration-failed.md index d33b6f9e..f03875fc 100644 --- a/docs/runbooks/migration-failed.md +++ b/docs/runbooks/migration-failed.md @@ -4,8 +4,8 @@ category: INTERNAL error_codes: [MIGRATION_FAILED] severity: P1 owner: oncall -last_updated: 2026-06-15 -status: stub +last_updated: 2026-07-29 +status: active --- # Runbook: MIGRATION_FAILED (`runbook://migration/failed`) @@ -15,21 +15,82 @@ status: stub - Container exits with code 70 (migration failure exit) - Structured log with `error.code=MIGRATION_FAILED`, `startup.phase=migration` - App refuses to start (fail-fast) +- JPA capability adapter refuses activation because its + `capability_schema_registry.lifecycle_state` is not `ACTIVE` ## Diagnosis -- Check Flyway migration log for which script failed and why -- Review latest migration script for SQL errors +1. Stop rollout and keep the failed revision out of readiness. Do not route traffic to a partially + migrated instance. +2. Identify the exact stream from `src/config/jpa/readiness-cards.yaml`. Each stream has an + independent `location` and `history-table`; do not infer ownership from a broad + `classpath:db/migration` scan. +3. From a privileged migration session, capture the stream state before changing anything: + + ```sql + select installed_rank, version, description, success + from + order by installed_rank; + + select capability_id, installation_origin, core_epoch, feature_revision, lifecycle_state + from capability_schema_registry + where capability_id = ''; + ``` + +4. Check whether any owned relation was created without a successful history entry. Compare only + against the owned tables in the reviewed migration; do not drop unrelated relations. +5. Classify the failure: + - lock/statement timeout: remove the blocker or reduce rollout concurrency, then rerun; + - SQL/data precondition: create a new forward migration that makes the precondition explicit; + - checksum mismatch: compare the deployed artifact with the already applied script before + considering repair; + - connection/TLS failure: fix transport or credentials without changing Flyway history. ## Action -- Fix migration script or roll back to previous migration version -- Run migration manually in repair mode if checksum mismatch +1. Prefer forward recovery. Fix the environmental blocker or add a new immutable migration, then + rerun the same owned stream with its exact history table. +2. For an optional stream that never installed successfully, keep the capability marker absent and + the runtime adapter disabled until migration succeeds. +3. After a successful migration, validate: + - the history contains only successful expected versions; + - `core_epoch` and `feature_revision` match the readiness registry; + - the marker is `INSTALLED_INACTIVE`; + - owned objects and constraints exist. +4. Change the marker to `ACTIVE` only after the compatible application revision is deployed and its + readiness check succeeds. Disabling or rolling back application code changes the marker to + `INSTALLED_INACTIVE`; it does not drop history or owned data. +5. Re-run the candidate evidence task before promoting: + + ```bash + cd src + ./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain + ``` + +6. Record the failed revision, stream/history table, root cause, recovery migration, elapsed time + and verification artifact in the incident. + +Do not: + +- edit an already applied migration; +- delete or rewrite Flyway history to make validation green; +- run `flyway repair` before checksum provenance is proven and reviewed; +- use `clean`, destructive rollback, or schema-wide restore as the first response; +- mark a capability `ACTIVE` before its migration and adapter readiness succeed. + +If commit outcome was indeterminate during the failure, reconcile by the application +`OperationId`/idempotency reference before retrying business work. Never blind-retry a commit whose +result is unknown. ## Escalation -- P1 immediate: app cannot start until migration is resolved +- P1 immediate: the required application revision cannot become ready. +- Escalate to the database owner before Flyway history repair, destructive DDL, point-in-time + recovery, or primary failover. +- R3 restore/PITR and failover rehearsal requires a target-like backup topology; local + Testcontainers evidence is not a substitute. --- -> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) +This runbook is forward-only. The reviewed migration artifact and the per-card evidence manifest +are the audit sources. diff --git a/docs/superpowers/plans/2026-07-28-jpa-production-capability.md b/docs/superpowers/plans/2026-07-28-jpa-production-capability.md new file mode 100644 index 00000000..79a73a11 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-jpa-production-capability.md @@ -0,0 +1,484 @@ +# JPA/PostgreSQL Production Capability Implementation Plan + +> 상태: Phase 0~3 기반과 Phase 4의 idempotency/outbox polling/inbox 후보 구현 및 전체 +> local/real PostgreSQL 검증을 마쳤다. 검증을 통과한 항목은 `implemented-candidate`이며 +> immutable 운영 evidence가 없는 항목을 R2로 승격하지 않는다. Phase 5~7은 외부 topology와 +> policy prerequisite가 없어 `not-implemented`를 유지한다. + +- 작성일: 2026-07-28 +- 구현 branch: `codex/jpa-production-capability` +- worktree: + `/home/donghyeon/workspace/clean-architecture-backend-template-jpa` +- 시작 revision: `b3add0162df8d4a0a11e749e514901defe0a62a3` +- 설계 원본: + `/home/donghyeon/workspace/clean-architecture-backend-template/docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md` +- 설계 SHA-256: + `c02eaef2a193a6ca66f4814087cc4d6bce723509aec251f40ea7b029046fd234` + +설계 문서는 `main` worktree의 untracked 사용자 변경이므로 stage/commit/copy하지 않는다. 구현 +중에는 위 절대 경로와 hash를 승인된 정본 snapshot으로 사용한다. 정본이 바뀌면 hash drift를 +먼저 보고하고 해당 task의 설계를 재검토한다. + +## 1. 목표와 완료 경계 + +목표는 JPA/PostgreSQL leaf의 각 capability를 독립적으로 구현·검증하는 것이다. + +```text +truthful baseline + -> transaction/failure/deadline + -> entity/query discipline + -> migration/lifecycle/security + -> owner-safe reliability + -> optional replica + -> optional tenant/coordination + -> R3 rehearsal +``` + +한 phase의 unit test 통과를 전체 JPA R2로 확대하지 않는다. card가 R2가 되려면 설계 §31.3의 +prerequisite, real PostgreSQL task, zero-skip sentinel과 immutable evidence manifest를 모두 +충족해야 한다. + +현재 구현 작업의 완료 경계는 다음과 같다. + +1. 독립 worktree와 계획이 존재한다. +2. Phase 0의 SQLState, Duration, OSIV/DDL, machine-readable readiness baseline이 + fail-closed한다. +3. named transaction policy, absolute deadline, PostgreSQL local timeout, phase-aware outcome, + bounded serialization/deadlock retry가 구현된다. +4. PostgreSQL 16 real test source set에서 lifecycle/security/migration/transaction/ + aggregate/query가 무-skip로 실행된다. +5. owner-safe idempotency V2, immutable outbox storage V2, polling delivery V2, same-store + inbox가 독립 migration stream과 real PostgreSQL concurrency test를 가진다. +6. 외부 CDC, replica, tenant/RLS, R3는 토폴로지/evidence 없이 선택하거나 R2로 광고하지 않는다. +7. 전체 test/check와 Wiki capture 결과를 기록한다. + +## 2. 공통 구현 규칙 + +- `src/config/architecture/modules.json`의 19개 leaf와 edge를 유지한다. +- `domain-core`에는 Spring/JPA/JDBC/PostgreSQL type을 추가하지 않는다. +- application contract에는 framework-neutral Java type만 둔다. +- transaction boundary는 application use case가 `TransactionPort`로 소유한다. +- controller/repository/mapper/configuration에 business policy를 두지 않는다. +- PostgreSQL 전용 code/import는 persistence-jpa leaf의 `.postgresql` package에 둔다. +- 동작 변경은 failing test를 먼저 확인한 뒤 최소 production code를 작성한다. +- applied Flyway V1/V3/V4/V5는 수정하지 않는다. +- agent는 stage/commit/amend/push하지 않는다. +- 다른 worktree의 dirty/untracked 변경을 복사하거나 되돌리지 않는다. + +worktree 생성 직후 `src/gradlew.bat`는 CRLF blob과 checkout/attribute line-ending +normalization 차이 때문에 dirty로 표시된다. 비교 결과 의미 있는 텍스트 변경은 없지만 raw +worktree hash와 HEAD blob hash는 EOL 표현 때문에 다르다. targeted restore로도 사라지지 않는 +known baseline drift이므로 구현 diff와 완료 판정에서 분리하고 stage하지 않는다. + +## 3. Phase 0 — Truthful baseline과 contract freeze + +### Task 0.1 SQLState mapping duplicate fail-fast + +상태: 2026-07-28 구현 및 focused/architecture 검증 완료. + +소유 leaf: `adapter-outbound-persistence-jpa` + +파일: + +- 수정: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java` +- 수정: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java` +- 필요 시 수정: + `src/adapter/outbound/persistence-jpa/README.md` + +TDD: + +1. 서로 다른 두 `SqlStateErrorMapping`이 같은 exact SQLState에 같은 + `OperationalError`를 등록해도 constructor가 실패하는 test를 작성한다. +2. 같은 SQLState에 서로 다른 `OperationalError`를 등록하면 실패하는 test를 작성한다. +3. error message가 raw SQL, credential, endpoint 없이 duplicate SQLState와 mapping + contributor type을 식별하는지 검증한다. +4. focused test를 실행해 RED를 확인한다. +5. `putAll`을 explicit merge로 바꾸고 first/duplicate provenance를 보존한다. +6. null mapping/map/key/value와 `08*` pseudo-entry를 fail-fast할지 현재 SPI 계약에 맞춰 + validation test를 추가한다. 이 세부 계약은 범위를 키우지 않고 constructor invariant로 + 한정한다. +7. focused test를 GREEN으로 만든다. + +검증: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests 'dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslatorTest' \ + --console=plain +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +### Task 0.2 Duration/OSIV/DDL production safety + +상태: 2026-07-28 strict Duration와 prod DDL guard 구현 완료. OSIV guard는 기존 구현을 +재사용하고 함께 회귀 검증했다. + +소유 leaf: + +- `app-bootstrap`: runtime settings/startup validator +- `adapter-outbound-persistence-jpa`: typed provider settings가 필요할 때만 + +선행 조사 파일: + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/CLAUDE.md` + +TDD: + +1. `5s`, `PT5S`, millisecond number의 canonical/legacy 허용 matrix를 test로 고정한다. +2. invalid/unknown Duration을 skip하지 않고 startup failure로 만드는 RED를 확인한다. +3. `spring.jpa.open-in-view=true`를 거절한다. +4. production profile의 `ddl-auto=update|create|create-drop`을 거절한다. +5. local/sample compatibility를 별도 test로 유지한다. + +검증: + +```bash +cd src +./gradlew :app-bootstrap:test \ + --tests 'dev.caskeleton.bootstrap.runtime.HikariPoolConstraintValidatorTest' \ + --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyEnvKeys --console=plain +``` + +### Task 0.3 Machine-readable readiness baseline + +상태: 2026-07-28 구현 및 mutation/registry 검증 완료. + +파일: + +- 추가: `src/config/jpa/readiness-cards.yaml` +- 수정: `src/build.gradle` +- 추가: persistence-jpa readiness registry parser/validation tests + +구현: + +1. 설계 §31.3의 15 card와 7 owned migration stream을 exact key로 옮긴다. +2. unknown/missing card, duplicate task, cycle, missing prerequisite, duplicate + location/history를 fail-closed한다. +3. 현재 구현되지 않은 task/card는 `not-implemented`로 유지한다. +4. 존재하지 않는 target task를 통과 증거로 만들지 않는다. +5. registry structural verification task를 `check`의 architecture policy chain에 연결하되 + real PostgreSQL readiness를 거짓으로 통과시키지 않는다. + +## 4. Phase 1 — Transaction/failure/deadline foundation + +상태: 2026-07-28 application contract, Spring executor, local timeout, phase-aware outcome, +bounded retry/backoff 후보 구현 완료. commit fault injection과 immutable R2 manifest는 남아 있다. + +### Task 1.1 Additive application transaction contract + +소유 leaf: `application-core` + +예상 파일: + +- 추가: `transaction/TransactionPolicy.java` +- 추가: `transaction/CallBudget.java` +- 추가: `transaction/OperationId.java` +- 추가: `transaction/TransactionOutcome.java` +- 추가: `transaction/PolicyTransactionPort.java` +- 수정: `transaction/TransactionPort.java` +- tests: 같은 package의 pure unit tests + +계약: + +- 기존 `inWrite`, `inRead`, `inNew` source compatibility 유지 +- named write policy는 stable operation ID 요구 +- legacy facade는 non-replayable/uncorrelated policy로 격리 +- absolute deadline과 finite timeout intersection +- core에는 Spring `TransactionDefinition`/`DurationStyle`을 노출하지 않음 + +### Task 1.2 Spring policy executor와 propagation ownership + +소유 leaf: `adapter-outbound-persistence-jpa` + +예상 파일: + +- 수정: `transaction/SpringTransactionPort.java` +- 추가: `transaction/SpringPolicyTransactionPort.java` +- 추가: transaction phase/outcome collaborator +- tests: unit + real PostgreSQL task + +검증: + +- REQUIRED physical owner와 participant 구분 +- REQUIRES_NEW depth/capacity admission +- read/write route mismatch fail-fast +- commit callback ordering +- locale 없는 `toLowerCase()` 제거 + +### Task 1.3 Deadline와 PostgreSQL local timeout + +- Hikari acquisition은 fixed pool timeout으로 유지 +- action 시작 전 remaining budget pre-gate +- first statement 전 `SET LOCAL statement_timeout`, `lock_timeout` +- transaction/statement/lock rounding boundary test +- pool wait 뒤 total budget overshoot negative test + +### Task 1.4 Phase-aware failure/retry + +- operation/query executor를 모든 production persistence path에 연결 +- constraint name allowlist +- begin/action/flush/commit/after-completion phase 분류 +- `COMMIT_INDETERMINATE`는 blind retry 금지 +- pre-commit + replay-safe + budget 조건에서만 whole-transaction retry + +## 5. Phase 2 — Entity/query discipline + +상태: production template에 임의 business aggregate를 추가하지 않고 sample의 기존 entity/ +mapper/query discipline을 실제 PostgreSQL aggregate CAS와 query-plan fixture로 검증했다. + +### Task 2.1 Aggregate persistence baseline + +- domain aggregate와 persistence entity 분리 +- mapper round-trip과 invariant failure test +- optimistic version/expected-version conflict +- audit creation carry-forward와 bulk DML guard +- bounded persistence-context batch + +### Task 2.2 Purpose-built query model + +- application projection `*QueryPort` +- allowlisted query ID +- max page/IN bound와 signed/versioned keyset cursor +- N+1 statement budget +- native/JDBC query는 `.postgresql` package +- representative `EXPLAIN` invariant task + +## 6. Phase 3 — Migration/lifecycle/security + +상태: legacy V1/V3/V4/V5/V6 adoption, independent core stream, PostgreSQL 16 lifecycle/security/ +migration/transaction/aggregate/query candidate task와 content-addressed manifest producer 구현 +완료. TLS verify-full/role/redaction, pool lifecycle, fresh/interrupted/rolling migration, +transaction concurrency/fault dimension을 실제 PostgreSQL과 transport test로 채웠다. clean CI +provenance와 외부 restore rehearsal이 없으면 R2/R3 aggregation은 계속 fail-closed한다. + +### Task 3.1 Legacy adoption과 independent streams + +- legacy V1/V3/V4/V5 checksum/object fingerprint +- `capability_schema_registry` +- explicit target stream version-0 adoption command +- core/optional history table ownership +- fresh/LEGACY_ADOPTED/interrupted paths +- old/target dual authority rejection + +### Task 3.2 Real PostgreSQL qualification source set + +canonical tasks: + +```text +postgresqlLifecycleIntegrationTest +postgresqlSecurityBaselineIntegrationTest +postgresqlMigrationIntegrationTest +postgresqlTransactionIntegrationTest +postgresqlAggregateIntegrationTest +postgresqlQueryIntegrationTest +verifyJpaPrimaryFoundationEvidence +``` + +Docker/Testcontainers가 없으면 R2 lane은 skip이 아니라 fail이다. local optional task와 evidence +producer를 분리한다. + +구현된 evidence task: + +```text +verifyJpaEvidenceHarnessContract +generateJpaEvidenceManifests +verifyJpaCandidateEvidence +verifyJpaPrimaryFoundationEvidence +``` + +candidate task는 11개 active card의 exact JUnit selector, zero-skip count, source/이미지/의존성 +version과 prerequisite manifest ID를 SHA-256 filename manifest로 남긴다. primary task는 +`-PjpaEvidenceProfile=r2`, clean revision, CI job/artifact metadata, 모든 base dimension과 +prerequisite R2를 추가로 요구한다. + +### Task 3.3 Lifecycle/security + +- migration/runtime role 분리 +- trusted schema/search_path, `PUBLIC CREATE`/`TEMP` revoke +- TLS verify-full profile +- startup/readiness/shutdown/quiesce +- bounded/redacted metric/trace/log +- restore/forward-recovery runbook + +## 7. Phase 4 — Owner-safe same-store reliability + +상태: idempotency V2, outbox storage V2, polling delivery V2, inbox V1은 각각 +`implemented-candidate`. 네 stream 모두 fresh-disabled/first-enable/disable/re-enable/ +interrupted-recovery의 non-destructive lifecycle을 실제 PostgreSQL에서 검증한다. CDC는 external +messaging prerequisite가 없어 `not-implemented`다. + +독립 implementation slice: + +1. `jpa-idempotency-owner-safe-v2` +2. `jpa-outbox-storage-v2` +3. `jpa-outbox-polling-delivery-v2` 또는 `jpa-outbox-cdc-retention-v1` +4. `jpa-inbox-same-store-v1` + +각 slice는 자기 migration stream/task/manifest를 가진다. + +outbox storage 구현은: + +- V3 `outbox_event`를 수정하지 않음 +- `outbox_publication_control_v2` +- `outbox_publication_cutover_v2` +- `outbox_event_identity_v2` +- `outbox_event_log_v2` +- polling 선택 시에만 `outbox_delivery_v2` +- fresh/legacy genesis sentinel +- legacy mutation trigger/ACL fence +- paused old writer와 cutover barrier test + +를 포함한다. + +## 8. Phase 5–7 + +상태: 선택된 replica topology, tenant mode/RLS policy, target-like backup/failover environment가 +없으므로 registry에서 `not-implemented`를 유지한다. 로컬 단일 PostgreSQL 테스트를 해당 +운영 보장의 대체 evidence로 사용하지 않는다. + +### Phase 5 — Primary/replica + +- 별도 pool/route context +- explicit `ReadConsistency` +- endpoint-bound lag evidence +- strong/RYW primary default +- failover authority reconciliation + +### Phase 6 — Tenant/RLS와 JDBC coordination + +- tenant-prefixed unique/FK/query +- missing context fail-closed +- optional FORCE RLS +- runtime role bypass negative test +- JDBC coordination은 `EFFICIENCY_ONLY` + +### Phase 7 — R3 + +- target-like load/capacity +- failover, rolling migration, certificate rotation +- backup/PITR restore +- outbox/idempotency/inbox reconciliation +- measured RPO/RTO와 operator game day + +## 9. 공통 verification ladder + +변경 leaf focused test부터 실행한다. + +```bash +cd src +./gradlew :application-core:test --console=plain +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew verifyPublicPathSnapshot --console=plain +./gradlew verifyEnvKeys --console=plain +``` + +전체 `test`/`check`와 real PostgreSQL task는 해당 phase가 경계를 실제로 변경하거나 required +task를 추가한 시점에 실행한다. 실행하지 못한 명령은 이유와 남은 위험을 branch-note와 최종 +응답에 기록한다. + +## 10. Wiki capture + +각 의미 있는 slice가 끝날 때 실제 vault의 branch-note: + +```text +raw/branch-notes/codex-jpa-production-capability.md +``` + +에 다음을 누적한다. + +- design hash와 plan path +- 변경 파일/decision ID +- RED/GREEN/architecture command와 결과 +- 실패/차단/known baseline drift +- evidence grade와 아직 R2가 아닌 이유 +- 실제 파생 raw interview/blog/error 판단 + +canonical 문서는 별도 요청 전 생성하지 않는다. + +## 11. 최종 실행 결과 + +2026-07-28: + +- `./gradlew :sample-portfolio:test --console=plain` + → 성공, 176 tests. +- `./gradlew test --console=plain` + → 성공, 1m 59s. +- PostgreSQL readiness task 10개 + (`lifecycle`, `security`, `migration`, `transaction`, `aggregate`, `query`, `idempotency`, + `outbox-storage`, `outbox-polling`, `inbox`) + → 성공, 49s. XML 합계 23 tests, `skipped=0`, `failures=0`, `errors=0`. +- `./gradlew check --console=plain` + → 성공, 2m 9s, 209 actionable tasks. 같은 실행에서 root architecture policy, + Checkstyle, Spotless, SpotBugs와 custom PostgreSQL source set 검증을 통과했다. +- `./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys + verifyJpaReadinessRegistry --console=plain` + → 성공. 19개 leaf edge, 1개 public path, 113 env keys, exact 15 cards/7 streams 검증. +- `git diff --check` + → 진단 없음. +- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence --console=plain` + → 기존 unconditional sentinel을 제거했다. content-addressed candidate manifest를 검증한 뒤 + candidate profile과 observability, TLS/role/redaction, fresh/interrupted/rolling migration, + transaction concurrency 누락을 card별 blocker로 보고 R2를 차단한다. +- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain` + → 성공, active card 11개 manifest 생성. PostgreSQL 23 tests와 primary base aggregation + 7 tests 모두 zero-skip이고 content hash/prerequisite link를 검증했다. +- `bash .github/scripts/verify-gate-matrix.sh` + → 성공, 21 gates verified. PR candidate evidence job과 conditional R2 workflow가 registry에 + 반영됐다. +- CI metadata를 주입한 + `verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2` + → PostgreSQL 23 tests와 r2-profile manifest 11개 생성 뒤 의도된 실패, 1m 21s. + `worktree-is-dirty`, observability, TLS/roles/redaction, migration + fresh/interrupted/rolling, transaction concurrency를 실제 blocker로 보고했다. +- `./gradlew test --console=plain` + → 성공, 15s, 78 tasks up-to-date. 직전 evidence lane에서 persistence/app test는 강제 + 재실행했다. +- `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --console=plain` + → 성공, 9s, 230 actionable tasks(37 executed, 193 up-to-date). + +전체 test에서 발견한 sample Flyway 회귀는 independent `V1` stream을 broad +`classpath:db/migration`으로 합친 문제와 production/sample `V6` 충돌이었다. sample slice를 +legacy PostgreSQL location으로 한정하고 disposable poster migration을 `V7`로 이동했다. 세부 +재현·해결 기록은 Wiki +`raw/errors/flyway-independent-stream-broad-root-collision-2026-07-28.md`에 남겼다. + +2026-07-29 completion pass: + +- primary foundation의 pool lifecycle/observability, TLS verify-full/role/redaction, + fresh/interrupted/rolling migration, transaction concurrency/fault evidence를 추가했다. +- idempotency/outbox storage/outbox polling/inbox 네 독립 stream에 + fresh-disabled/first-enable/disable/re-enable/interrupted-recovery 실제 PostgreSQL + lifecycle test를 추가했다. +- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain` + → **BUILD SUCCESSFUL in 1m 55s**. 11개 manifest 모두 `missing=none`, zero-skip. + PostgreSQL producer 38 tests와 web redaction support 2 tests가 실행됐으며 primary + aggregation은 20 tests다. +- `./gradlew test --console=plain` + → **BUILD SUCCESSFUL in 55s**, 78 actionable tasks. +- `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --console=plain` + → 포맷과 test fixture SQL construction을 수정한 뒤 **BUILD SUCCESSFUL in 12s**, + 231 actionable tasks. 19 leaf architecture, Checkstyle, Spotless, SpotBugs, dependency lock, + env/readiness/public-path gate를 통과했다. +- CI 메타데이터 형식만 주입한 + `verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2` + → **의도된 BUILD FAILED in 2m 6s**. missing evidence는 없고 root blocker는 + `worktree-is-dirty`; 다른 blocker는 prerequisite R2 전파뿐이다. +- `bash .github/scripts/verify-gate-matrix.sh` + → **OK**, 21 gates/21 verified. +- `git diff --check` + → 진단 없음. + +현재 환경에서 선택된 Phase 0~4 후보의 로컬 구현·검증은 완료됐다. R2 승격은 사람의 +commit/push, clean revision에서의 retained CI artifact가 필요하고, R3는 target-like +backup/failover/load/operator rehearsal 환경이 필요하다. diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 2380c624..09bfd675 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -16,3 +16,19 @@ dependencies { // owned by feature-contract-verification-test-suite (planned). implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' } + +tasks.register('jpaPersistenceRedactionContractTest', Test) { + group = 'verification' + description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching( + 'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails') + includeTestsMatching( + 'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode') + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java index 61ed69a9..32380121 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java @@ -218,8 +218,8 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { /** * Handles a pre-classified {@link PersistenceFailureException}: its {@link * PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a - * category-derived safe string and the raw cause is logged server-side. See README for the design - * rationale. + * category-derived safe string. Logs and traces receive only the stable classification because a + * JDBC cause can contain SQL values, constraints, credentials, and endpoints. */ @ExceptionHandler(PersistenceFailureException.class) public ResponseEntity> handlePersistenceFailure(PersistenceFailureException ex) { @@ -228,13 +228,17 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { "persistence failure classified as {} (category={}, retryable={})", code.code(), code.category(), - code.retryable(), - ex); - spanErrorRecorder.recordException(ex, code.code()); + code.retryable()); + spanErrorRecorder.recordException(sanitizedPersistenceFailure(code), code.code()); return ErrorResponseFactory.envelope( code, ClientSafeErrorMessages.forPersistence(code.category()), null); } + private static PersistenceFailureException sanitizedPersistenceFailure(ApiErrorCode code) { + return new PersistenceFailureException( + code, "persistence failure classified as " + code.code(), null); + } + /** * Handles a pre-classified {@link DependencyFailureException}: its {@link * DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java index ba65dad2..8b9a19b8 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java @@ -2,6 +2,8 @@ package dev.caskeleton.adapter.inbound.web.error; import static org.assertj.core.api.Assertions.assertThat; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; import dev.caskeleton.shared.error.AdapterDisabledException; import dev.caskeleton.shared.error.DependencyFailureException; @@ -12,6 +14,7 @@ import dev.caskeleton.shared.response.Envelope; import dev.caskeleton.shared.tracing.SpanErrorRecorder; import java.sql.SQLException; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -139,6 +142,47 @@ class GlobalExceptionHandlerTest { assertThat(body.error().details()).isNull(); } + @Test + void persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails() { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + String raw = + "jdbc:postgresql://db.internal:5432/customer?user=runtime&password=secret " + + "constraint=uq_customer_email detail=(alice@example.test)"; + try { + PersistenceFailureException carrier = + new PersistenceFailureException( + OperationalError.DB_UNIQUE_VIOLATION, + "persistence failure classified from SQLState=23505", + new SQLException(raw, "23505")); + + handler.handlePersistenceFailure(carrier); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.getFirst(); + assertThat(event.getFormattedMessage()) + .isEqualTo( + "persistence failure classified as DB_UNIQUE_VIOLATION " + + "(category=CONFLICT, retryable=false)"); + assertThat(event.getThrowableProxy()).isNull(); + assertThat(event.getFormattedMessage()) + .doesNotContain( + "db.internal", + "customer", + "runtime", + "secret", + "uq_customer_email", + "alice@example.test", + "23505"); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + @Test void persistenceFailureTransientMapsTo503RetryableWithSafeMessage() { PersistenceFailureException carrier = diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java index 3c64fc83..40616be1 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java @@ -46,7 +46,7 @@ class SpanErrorRecorderHookTest { } @Test - void persistenceFailureHandlerRecordsExceptionWithClassifiedCode() { + void persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode() { CapturingRecorder recorder = new CapturingRecorder(); GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder); @@ -59,7 +59,11 @@ class SpanErrorRecorderHookTest { handler.handlePersistenceFailure(ex); assertThat(recorder.calls).hasSize(1); - assertThat(recorder.calls.get(0).error()).isSameAs(ex); + assertThat(recorder.calls.get(0).error()) + .isInstanceOf(PersistenceFailureException.class) + .isNotSameAs(ex) + .hasMessage("persistence failure classified as DB_UNIQUE_VIOLATION") + .hasNoCause(); assertThat(recorder.calls.get(0).errorCode()) .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION.code()); } diff --git a/src/adapter/outbound/persistence-jpa/README.md b/src/adapter/outbound/persistence-jpa/README.md index 84182a90..74de5e77 100644 --- a/src/adapter/outbound/persistence-jpa/README.md +++ b/src/adapter/outbound/persistence-jpa/README.md @@ -85,6 +85,8 @@ failure translation contract 다. 여기서는 SPI 구조와 fallback 근거만 기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql` 가 추가 `SqlStateErrorMapping` 빈으로 기여한다. +- 서로 다른 contributor가 같은 exact SQLState를 등록하면 code가 같더라도 startup construction을 + 실패시킨다. last-writer-wins merge는 mapping ownership drift를 숨기므로 허용하지 않는다. - `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접 처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다. - **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에 @@ -192,6 +194,120 @@ retention 보다 오래된 PUBLISHED row 를 주기적으로 비워 테이블 cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property 문서는 app-bootstrap `OutboxSettings` / `application.yml` 에 있다. +## JPA production capability candidate + +`src/config/jpa/readiness-cards.yaml`이 15개 capability와 7개 독립 schema stream의 +machine-readable SSOT다. `selected` base card와 `implemented-candidate` reliability card를 +구분하며, 실제 PostgreSQL 테스트 통과만으로 immutable 운영 evidence가 필요한 R2를 주장하지 +않는다. + +독립 Flyway stream은 broad `classpath:db/migration`으로 함께 실행하지 않는다. 각 stream은 +자기 location/history table을 사용하고 non-empty schema adoption 때 version 0 baseline을 명시한 +뒤 V1부터 실행한다. + +| Capability | Location | History table | 상태 | +|---|---|---|---| +| core/adoption | `db/migration/jpa/core` | `flyway_jpa_core_history` | selected candidate | +| idempotency V2 | `db/migration/jpa/idempotency` | `flyway_jpa_idempotency_history` | implemented-candidate | +| outbox storage V2 | `db/migration/jpa/outbox-storage` | `flyway_jpa_outbox_storage_history` | implemented-candidate | +| polling delivery V2 | `db/migration/jpa/outbox-polling` | `flyway_jpa_outbox_polling_history` | implemented-candidate | +| inbox V1 | `db/migration/jpa/inbox` | `flyway_jpa_inbox_history` | implemented-candidate | + +### owner-safe idempotency V2 + +`PostgreSqlOwnerSafeIdempotencyStore`는 row lock을 얻은 뒤 `clock_timestamp()`를 평가한다. +claim takeover와 start/renew/complete/fail/release는 scope/state/owner/attempt/claim operation/ +state revision을 SQL predicate로 다시 검증한다. expired `CLAIMED`만 takeover하며 expired +`EXECUTING`은 `ABANDONED`로 닫고 reconciliation을 요구한다. raw client key는 저장하지 않고 +versioned HMAC scope digest만 쓴다. + +### immutable outbox storage와 polling delivery V2 + +`PostgreSqlImmutableOutboxAppendAdapter`는 publication control을 `FOR SHARE`로 잠근 상태에서 +compact global identity guard와 partitioned immutable envelope를 같은 business transaction에 +기록한다. cutover는 control `FOR UPDATE`와 충돌하므로 시작된 append를 추월하지 못하며, target +authority 활성화 뒤 legacy V1 writer trigger가 실패한다. + +polling mode일 때 database trigger가 initial `outbox_delivery_v2` row를 같은 transaction에 +생성한다. `PostgreSqlPollingDeliveryAdapter`는 bounded `FOR UPDATE SKIP LOCKED` claim, +aggregate version/ordinal strict-order head gate, owner/token/attempt/version/epoch completion +CAS를 사용한다. broker 호출은 transaction 밖이고 duplicate publish 가능성은 stable event ID로 +consumer inbox에서 처리한다. + +### same-store inbox + +`PostgreSqlSameStoreInboxAdapter`의 transactional claim은 business mutation/outgoing outbox/ +completion과 caller의 한 primary write transaction에 참여한다. received lease expiry는 takeover할 +수 있지만 processing lease expiry는 blind retry하지 않고 recovery-required terminal state로 +보낸다. broker ACK는 commit 이후에만 실행한다. + +### 실제 PostgreSQL task + +base 6개 task 외에 다음 candidate task가 Docker 부재 시 skip이 아니라 실패하도록 등록돼 있다. + +```text +postgresqlIdempotencyIntegrationTest +postgresqlOutboxStorageIntegrationTest +postgresqlOutboxPollingIntegrationTest +postgresqlInboxIntegrationTest +``` + +### evidence manifest와 R2 gate + +`readiness-cards.yaml`의 `evidence.scenarios`와 `evidence.task-claims`가 required evidence를 실제 +JUnit selector/Gradle task에 연결한다. `verifyJpaReadinessRegistryContract`는 unknown claim, +duplicate selector와 다른 card task 차용을 mutation test로 거절한다. + +```bash +./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain +``` + +위 task는 active card 11개의 producer를 실행하고 JUnit XML에서 exact selector와 +executed/skipped/failure/error 수를 읽는다. 각 manifest는 source revision/dirty digest, +prerequisite manifest ID, PostgreSQL image digest, pgjdbc/Hibernate/Flyway version, topology와 +migration/dispatch metadata를 담고 다음 위치에 canonical JSON SHA-256 이름으로 생성된다. + +```text +build/jpa-evidence/manifests//.json +``` + +후보 검증은 zero-skip, schema, content hash와 prerequisite link가 맞으면 성공하지만 +`attainedReadiness=R1`을 유지한다. 로컬 후보 lane은 다음 E2/E3 동작을 실제 PostgreSQL에서 +검증한다. + +- bounded pool saturation과 shutdown 뒤 connection 거부 +- runtime/migration role 분리, trusted namespace, TLS `verify-full`의 정상·hostname mismatch· + untrusted CA·expired certificate 경로 +- persistence failure의 HTTP/log/span redaction +- fresh/legacy adoption, interrupted migration forward recovery, N/N-1 additive rolling shape +- serialization/deadlock/lock/statement timeout, pool exhaustion, commit transport 단절 +- idempotency/outbox/inbox 독립 stream의 disabled/first-enable/disable/re-enable/interrupted + lifecycle + +각 manifest는 그래도 candidate profile, dirty source와 아직 R2가 아닌 prerequisite를 +`readinessBlockers`에 보존하므로 후보 통과를 R2로 오인할 수 없다. + +실제 aggregation gate는 별도 명령이다. + +```bash +./gradlew \ + :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \ + -PjpaEvidenceProfile=r2 \ + --console=plain +``` + +이 task는 clean revision, `JPA_EVIDENCE_CI_JOB`, +`JPA_EVIDENCE_ARTIFACT_LOCATION`, immutable PostgreSQL image digest, 모든 required evidence와 +R2 prerequisite DAG가 있어야만 성공한다. `.github/workflows/ci-quality-gates.yml`의 candidate +job은 PR에서 zero-skip manifest를 보존하고, `.github/workflows/jpa-r2-evidence.yml`은 명시적으로 +실행하는 production-profile lane이다. 로컬 dirty worktree 또는 unpublished 실행은 +`worktree-is-dirty`/CI provenance blocker를 보고 실패하는 것이 정식 동작이다. R2 승격은 clean +revision에서 workflow를 실행하고 보존된 manifest artifact를 검토한 뒤에만 가능하다. + +stream migration이 중단되면 history/registry/object 상태를 먼저 확인하고 기존 migration을 +임의 수정하거나 history를 바로 `repair`하지 않는다. 장애를 수정한 forward migration으로 +복구하는 운영 절차는 `docs/runbooks/migration-failed.md`를 따른다. + ## lock — 분산 락 provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다. diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index e4bf00bf..d3bcd151 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -3,6 +3,24 @@ // 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). +sourceSets { + postgresqlIntegrationTest { + java.setSrcDirs(['src/postgresqlIntegrationTest/java']) + resources.setSrcDirs(['src/postgresqlIntegrationTest/resources']) + compileClasspath += sourceSets.main.output + runtimeClasspath += output + compileClasspath + } +} + +configurations { + postgresqlIntegrationTestImplementation.extendsFrom testImplementation + postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly + postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly + postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor +} + +ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine' + dependencies { implementation project(':application-core') implementation project(':shared-contract') @@ -18,6 +36,122 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.flywaydb:flyway-database-postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql' + postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } + +def registerPostgreSqlReadinessTest = { String taskName, String testClass -> + tasks.register(taskName, Test) { + group = 'verification' + description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}." + testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs + classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching testClass + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs( + '-Duser.timezone=UTC', + "-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}") + } +} + +def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlLifecycleIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest') +def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlSecurityBaselineIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest') +def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlMigrationIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest') +def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTransactionIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest') +def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlAggregateIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest') +def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlQueryIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest') +def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlIdempotencyIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest') +def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlOutboxStorageIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest') +def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlOutboxPollingIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest') +def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlInboxIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest') + +def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { + group = 'verification' + description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.' + File vendorSource = file('src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql') + inputs.dir(vendorSource) + doLast { + List violations = [] + vendorSource.eachFileRecurse { File source -> + if (!source.name.endsWith('.java')) { + return + } + String text = source.getText('UTF-8') + def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/ + if (concatenatedSql.find()) { + violations << "${source}: concatenated SQL construction" + } + source.readLines().eachWithIndex { String line, int index -> + if (line.contains("set_config('") && !line.contains('?')) { + violations << "${source}:${index + 1}: set_config value is not parameterized" + } + } + } + if (!violations.isEmpty()) { + throw new GradleException( + "verifyJpaSqlConstructionSafety: ${violations.size()} violation(s):\n " + + violations.join('\n ')) + } + logger.lifecycle( + 'verifyJpaSqlConstructionSafety: OK — no concatenated SQL construction and all set_config values are parameterized.') + } +} + +def verifyJpaSecurityFixtures = tasks.register('verifyJpaSecurityFixtures') { + group = 'verification' + description = 'Verifies the no-skip PostgreSQL security fixture covers runtime-role namespace denial.' + File fixture = file( + 'src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java') + inputs.file(fixture) + doLast { + if (!fixture.isFile()) { + throw new GradleException("verifyJpaSecurityFixtures: missing ${fixture}") + } + String text = fixture.getText('UTF-8') + ['runtimeRoleCannotCreateInApplicationSchema', 'assertDockerAvailable', '42501'].each { + String required -> + if (!text.contains(required)) { + throw new GradleException( + "verifyJpaSecurityFixtures: ${fixture} is missing '${required}'") + } + } + logger.lifecycle( + 'verifyJpaSecurityFixtures: OK — no-skip Docker and runtime-role namespace denial fixtures are present.') + } +} + +postgresqlSecurityBaselineIntegrationTest.configure { + dependsOn verifyJpaSqlConstructionSafety + dependsOn verifyJpaSecurityFixtures + dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest') +} + +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 14cf6ab1..afff9730 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -1,196 +1,210 @@ # 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,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +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 com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath 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,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +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,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +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.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +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.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +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.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +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 commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=runtimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +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 jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +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.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,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-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,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath 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=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +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.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=runtimeClasspath,testRuntimeClasspath +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.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=runtimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.14.1=runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=runtimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +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.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +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.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.mockito:mockito-core:5.20.0=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.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=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +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.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +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.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +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.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +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 empty= diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java index 8f11644e..5442aab8 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java @@ -5,8 +5,9 @@ import dev.caskeleton.shared.error.OperationalError; import dev.caskeleton.shared.error.PersistenceFailureException; import java.sql.SQLException; import java.util.Collection; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Component; @@ -27,15 +28,53 @@ public class PersistenceExceptionTranslator { private final Map byExactSqlState; public PersistenceExceptionTranslator(Collection mappings) { - Map merged = new HashMap<>(); - for (SqlStateErrorMapping m : mappings) { - merged.putAll(m.exactMappings()); + Objects.requireNonNull(mappings, "mappings"); + Map merged = new LinkedHashMap<>(); + Map contributors = new LinkedHashMap<>(); + for (SqlStateErrorMapping mapping : mappings) { + Objects.requireNonNull(mapping, "mapping"); + String contributor = mapping.getClass().getName(); + Map exactMappings = + Objects.requireNonNull(mapping.exactMappings(), contributor + ".exactMappings()"); + for (Map.Entry entry : exactMappings.entrySet()) { + String sqlState = Objects.requireNonNull(entry.getKey(), contributor + " SQLState"); + OperationalError candidate = + Objects.requireNonNull(entry.getValue(), contributor + " mapping for " + sqlState); + if (sqlState.isBlank()) { + throw new IllegalArgumentException(contributor + " contributed a blank SQLState"); + } + + OperationalError previous = merged.putIfAbsent(sqlState, candidate); + if (previous != null) { + throw new IllegalStateException( + "Duplicate exact SQLState mapping " + + sqlState + + ": " + + contributors.get(sqlState) + + " -> " + + previous.name() + + " conflicts with " + + contributor + + " -> " + + candidate.name()); + } + contributors.put(sqlState, contributor); + } } this.byExactSqlState = Map.copyOf(merged); } /** Classify {@code ex}, or {@link Optional#empty()} when its SQLState is unmapped or absent. */ public Optional translate(DataAccessException ex) { + return translate((Throwable) ex); + } + + /** + * Classify a transaction or persistence wrapper by walking its cause chain for the first + * SQLState. + */ + public Optional translate(Throwable ex) { + Objects.requireNonNull(ex, "ex"); String sqlState = extractSqlState(ex); if (sqlState == null) { return Optional.empty(); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java new file mode 100644 index 00000000..31a15cea --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; +import java.time.Duration; +import java.util.Objects; +import org.springframework.jdbc.core.JdbcOperations; + +/** Applies finite PostgreSQL timeout guards to the current transaction only. */ +public final class PostgreSqlLocalTimeoutConfigurer implements TransactionLocalTimeoutConfigurer { + + private static final String STATEMENT_TIMEOUT_SQL = + "select set_config('statement_timeout', ?, true)"; + private static final String LOCK_TIMEOUT_SQL = "select set_config('lock_timeout', ?, true)"; + private static final String IDLE_TIMEOUT_SQL = + "select set_config('idle_in_transaction_session_timeout', ?, true)"; + + private final JdbcOperations jdbcOperations; + + public PostgreSqlLocalTimeoutConfigurer(JdbcOperations jdbcOperations) { + this.jdbcOperations = Objects.requireNonNull(jdbcOperations, "jdbcOperations must be non-null"); + } + + @Override + public void apply(EffectiveTransactionTimeouts timeouts) { + Objects.requireNonNull(timeouts, "timeouts must be non-null"); + apply(STATEMENT_TIMEOUT_SQL, timeouts.statementTimeout()); + apply(LOCK_TIMEOUT_SQL, timeouts.lockTimeout()); + apply(IDLE_TIMEOUT_SQL, timeouts.idleGuardTimeout()); + } + + private void apply(String sql, Duration timeout) { + String value = timeout.toMillis() + "ms"; + jdbcOperations.queryForObject(sql, String.class, value); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java index 787fa620..78248b09 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java @@ -3,11 +3,13 @@ package dev.caskeleton.adapter.outbound.persistence.postgresql; import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; import jakarta.persistence.EntityManager; import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcOperations; /** * PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor @@ -27,6 +29,12 @@ public class PostgreSqlPersistenceConfig { return new PostgreSqlSqlStateErrorMapping(); } + @Bean + public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer( + JdbcOperations jdbcOperations) { + return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations); + } + @Bean public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() { return configuration -> configuration.locations("classpath:db/migration/postgresql"); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java new file mode 100644 index 00000000..4db8dd4f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java @@ -0,0 +1,882 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency; + +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyFailOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyFailureDisposition; +import dev.caskeleton.application.idempotency.v2.IdempotencyInspection; +import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult; +import dev.caskeleton.application.idempotency.v2.IdempotencyOwner; +import dev.caskeleton.application.idempotency.v2.IdempotencyReleaseOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyState; +import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2; +import dev.caskeleton.application.transaction.OperationId; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * PostgreSQL owner-safe idempotency V2 implementation. + * + *

Mutations require an application-owned primary read-write transaction. The row is locked + * before {@code clock_timestamp()} is evaluated, and every state change repeats the complete owner + * CAS tuple in SQL. Raw client idempotency keys never reach this adapter. + */ +@Repository +public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePortV2 { + + static final int INLINE_RESPONSE_MAX_BYTES = 8 * 1024; + + private static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(1); + private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30); + private static final String V2_PRINCIPAL_SENTINEL = "__v2_scope_digest__"; + private static final String DB_NOW_SQL = "select clock_timestamp()"; + + private static final String ACTIVE_CAPABILITY_SQL = + """ + select count(*) + from capability_schema_registry + where capability_id = 'jpa-idempotency-owner-safe-v2' + and core_epoch = 1 + and feature_revision = 2 + and lifecycle_state = 'ACTIVE' + """; + + private static final String INSERT_CLAIM_SQL = + """ + insert into idempotency_record ( + id, tenant, principal, idempotency_key, use_case_name, + request_hash, status, response_payload, response_ref, created_at, expires_at, + scope_hash, key_digest_version, operation_code, record_version, state_revision, + owner_token, attempt, claim_operation_id, processing_lease_until, replay_until, + policy_revision, response_codec_id, response_codec_version, response_digest, updated_at + ) + select + ?, '', ?, ?, ?, + ?, 'CLAIMED', null, null, db_now, + db_now + (? * interval '1 millisecond'), + ?, ?, ?, 2, 0, + ?, 1, ?, db_now + (? * interval '1 millisecond'), null, + ?, ?, 1, null, db_now + from (select clock_timestamp() as db_now) authority + on conflict (scope_hash) where record_version = 2 do nothing + """; + + private static final String SELECT_ROW_SQL = + """ + select scope_hash, key_digest_version, operation_code, request_hash, status, + state_revision, owner_token, attempt, claim_operation_id, + last_transition_operation_id, last_transition_kind, + last_transition_result_digest, processing_lease_until, replay_until, + response_payload, response_digest, response_codec_id, policy_revision, expires_at + from idempotency_record + where scope_hash = ? + and record_version = 2 + """; + + private static final String SELECT_ROW_FOR_UPDATE_SQL = SELECT_ROW_SQL + " for update"; + + private static final String RESET_CLAIM_SQL = + """ + update idempotency_record + set idempotency_key = ?, + use_case_name = ?, + key_digest_version = ?, + operation_code = ?, + request_hash = ?, + status = 'CLAIMED', + state_revision = state_revision + 1, + owner_token = ?, + attempt = attempt + 1, + claim_operation_id = ?, + last_transition_operation_id = null, + last_transition_kind = null, + last_transition_result_digest = null, + reconciliation_evidence_digest = null, + processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'), + replay_until = null, + policy_revision = ?, + response_codec_id = ?, + response_codec_version = 1, + response_digest = null, + response_payload = null, + response_ref = null, + failure_disposition = null, + updated_at = clock_timestamp(), + completed_at = null, + expires_at = clock_timestamp() + (? * interval '1 millisecond') + where scope_hash = ? + and record_version = 2 + and status = ? + and state_revision = ? + """; + + private static final String ABANDON_EXPIRED_EXECUTION_SQL = + """ + update idempotency_record + set status = 'ABANDONED', + state_revision = state_revision + 1, + last_transition_operation_id = claim_operation_id, + last_transition_kind = 'EXPIRED_EXECUTION', + last_transition_result_digest = ?, + failure_disposition = 'EFFECT_UNKNOWN_ABANDONED', + updated_at = clock_timestamp() + where scope_hash = ? + and record_version = 2 + and status = 'EXECUTING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private static final String START_SQL = + """ + update idempotency_record + set status = 'EXECUTING', + state_revision = state_revision + 1, + last_transition_operation_id = ?, + last_transition_kind = 'START', + last_transition_result_digest = ?, + updated_at = clock_timestamp() + where scope_hash = ? + and record_version = 2 + and status = 'CLAIMED' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + and processing_lease_until > clock_timestamp() + """; + + private static final String RENEW_SQL = + """ + update idempotency_record + set state_revision = state_revision + 1, + processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'), + last_transition_operation_id = ?, + last_transition_kind = 'RENEW', + last_transition_result_digest = ?, + updated_at = clock_timestamp() + where scope_hash = ? + and record_version = 2 + and status in ('CLAIMED', 'EXECUTING') + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + and processing_lease_until > clock_timestamp() + """; + + private static final String COMPLETE_SQL = + """ + update idempotency_record + set status = 'COMPLETED', + state_revision = state_revision + 1, + last_transition_operation_id = ?, + last_transition_kind = 'COMPLETE', + last_transition_result_digest = ?, + response_payload = ?, + response_ref = null, + response_digest = ?, + replay_until = clock_timestamp() + (? * interval '1 millisecond'), + completed_at = clock_timestamp(), + updated_at = clock_timestamp(), + expires_at = clock_timestamp() + (? * interval '1 millisecond') + where scope_hash = ? + and record_version = 2 + and status = 'EXECUTING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private static final String FAIL_SQL = + """ + update idempotency_record + set status = ?, + state_revision = state_revision + 1, + last_transition_operation_id = ?, + last_transition_kind = ?, + last_transition_result_digest = ?, + failure_disposition = ?, + processing_lease_until = clock_timestamp(), + updated_at = clock_timestamp(), + expires_at = clock_timestamp() + (? * interval '1 millisecond') + where scope_hash = ? + and record_version = 2 + and status = 'EXECUTING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private static final String RELEASE_SQL = + """ + update idempotency_record + set status = 'FAILED_RETRYABLE', + state_revision = state_revision + 1, + last_transition_operation_id = ?, + last_transition_kind = 'RELEASE', + last_transition_result_digest = ?, + failure_disposition = 'NO_EFFECT_RETRYABLE', + processing_lease_until = clock_timestamp(), + updated_at = clock_timestamp() + where scope_hash = ? + and record_version = 2 + and status = 'CLAIMED' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private final JdbcOperations jdbc; + private final SecureRandom secureRandom; + + @Autowired + public PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc) { + this(jdbc, new SecureRandom()); + } + + PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc, SecureRandom secureRandom) { + this.jdbc = Objects.requireNonNull(jdbc, "jdbc"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom"); + } + + @Override + public IdempotencyClaimAttempt newClaimAttempt(OperationId operationId) { + Objects.requireNonNull(operationId, "operationId"); + byte[] token = new byte[32]; + secureRandom.nextBytes(token); + return new IdempotencyClaimAttempt(HexFormat.of().formatHex(token), operationId); + } + + @Override + public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) { + Objects.requireNonNull(request, "request"); + requirePrimaryWriteTransaction(); + requireActiveCapability(); + + int inserted = + jdbc.update( + INSERT_CLAIM_SQL, + UUID.randomUUID(), + V2_PRINCIPAL_SENTINEL, + request.scope().digest(), + request.scope().operationCode(), + request.requestFingerprint().hex(), + request.replayTtl().toMillis(), + request.scope().digest(), + request.scope().keyDigestVersion(), + request.scope().operationCode(), + request.claimAttempt().ownerToken(), + request.claimAttempt().operationId().value(), + request.processingLeaseTtl().toMillis(), + request.policyRevision(), + request.responseCodecId()); + + Row row = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim); + Instant dbNow = databaseNowAfterLock(); + if (inserted == 1) { + return acquired(row); + } + + if (isExpiredCompleted(row, dbNow)) { + return resetClaim(request, row); + } + if (!row.requestHash().equals(request.requestFingerprint().hex())) { + return new IdempotencyClaimOutcome.FingerprintMismatch(); + } + if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) { + return new IdempotencyClaimOutcome.CompletedReplay( + new StoredResponse(row.responsePayload()), row.replayUntil()); + } + if (sameClaimAttempt(row, request.claimAttempt())) { + return new IdempotencyClaimOutcome.ReplayedAcquire(owner(row), row.processingLeaseUntil()); + } + if (row.ownerToken().equals(request.claimAttempt().ownerToken())) { + return new IdempotencyClaimOutcome.OwnerOperationConflict(); + } + if (row.state() == IdempotencyState.CLAIMED && !dbNow.isBefore(row.processingLeaseUntil())) { + return resetClaim(request, row); + } + if (row.state() == IdempotencyState.FAILED_RETRYABLE) { + return resetClaim(request, row); + } + if (row.state() == IdempotencyState.EXECUTING && !dbNow.isBefore(row.processingLeaseUntil())) { + abandonExpiredExecution(row); + return new IdempotencyClaimOutcome.RecoveryRequired(row.attempt()); + } + if (row.state() == IdempotencyState.ABANDONED) { + return new IdempotencyClaimOutcome.RecoveryRequired(row.attempt()); + } + + Duration retryAfter = + row.processingLeaseUntil().isAfter(dbNow) + ? Duration.between(dbNow, row.processingLeaseUntil()) + : Duration.ZERO; + return new IdempotencyClaimOutcome.InProgress(retryAfter, row.attempt()); + } + + @Override + public IdempotencyMutationResult markExecutionStarted( + IdempotencyOwner owner, OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requirePrimaryWriteTransaction(); + Row row = findForUpdate(owner.scope().digest()).orElse(null); + if (row == null) { + return startResult(IdempotencyStartOutcome.ABSENT, null); + } + if (isDuplicate(row, "START", operationId)) { + return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row)); + } + IdempotencyStartOutcome mismatch = classifyStartMismatch(row, owner); + if (mismatch != null) { + return startResult(mismatch, null); + } + String resultDigest = transitionDigest("START", operationId, owner); + int updated = + jdbc.update( + START_SQL, + operationId.value(), + resultDigest, + owner.scope().digest(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + if (updated != 1) { + return startResult(IdempotencyStartOutcome.NOT_OWNER, null); + } + return startResult( + IdempotencyStartOutcome.STARTED, owner.withStateRevision(owner.stateRevision() + 1)); + } + + @Override + public IdempotencyMutationResult renew( + IdempotencyOwner owner, Duration processingLeaseTtl, OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requirePositiveBounded("processing lease TTL", processingLeaseTtl, MAXIMUM_PROCESSING_LEASE); + requirePrimaryWriteTransaction(); + Row row = findForUpdate(owner.scope().digest()).orElse(null); + if (row == null) { + return renewResult(IdempotencyRenewOutcome.ABSENT, null); + } + if (isDuplicate(row, "RENEW", operationId)) { + return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row)); + } + IdempotencyRenewOutcome mismatch = classifyRenewMismatch(row, owner); + if (mismatch != null) { + return renewResult(mismatch, null); + } + int updated = + jdbc.update( + RENEW_SQL, + processingLeaseTtl.toMillis(), + operationId.value(), + transitionDigest("RENEW", operationId, owner), + owner.scope().digest(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + if (updated != 1) { + return renewResult(IdempotencyRenewOutcome.NOT_OWNER, null); + } + return renewResult( + IdempotencyRenewOutcome.RENEWED, owner.withStateRevision(owner.stateRevision() + 1)); + } + + @Override + public IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, + StoredResponse response, + Duration replayTtl, + OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(response, "response"); + Objects.requireNonNull(operationId, "operationId"); + requirePositiveBounded("replay TTL", replayTtl, MAXIMUM_RETENTION); + requireInlineResponse(response); + requirePrimaryWriteTransaction(); + Row row = findForUpdate(owner.scope().digest()).orElse(null); + if (row == null) { + return IdempotencyCompleteOutcome.ABSENT; + } + String responseDigest = sha256(response.payload()); + if (row.state() == IdempotencyState.COMPLETED + && "COMPLETE".equals(row.lastTransitionKind()) + && operationId.value().equals(row.lastTransitionOperationId())) { + return responseDigest.equals(row.responseDigest()) + ? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT + : IdempotencyCompleteOutcome.RESPONSE_CONFLICT; + } + IdempotencyCompleteOutcome mismatch = classifyCompleteMismatch(row, owner); + if (mismatch != null) { + return mismatch; + } + int updated = + jdbc.update( + COMPLETE_SQL, + operationId.value(), + responseDigest, + response.payload(), + responseDigest, + replayTtl.toMillis(), + replayTtl.toMillis(), + owner.scope().digest(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + return updated == 1 + ? IdempotencyCompleteOutcome.COMPLETED + : IdempotencyCompleteOutcome.INDETERMINATE; + } + + @Override + public IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(disposition, "disposition"); + Objects.requireNonNull(operationId, "operationId"); + requirePositiveBounded("failure retention", retention, MAXIMUM_RETENTION); + requirePrimaryWriteTransaction(); + Row row = findForUpdate(owner.scope().digest()).orElse(null); + if (row == null) { + return IdempotencyFailOutcome.ABSENT; + } + String transitionKind = + disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE + ? "FAIL_RETRYABLE" + : "FAIL_ABANDONED"; + if (isDuplicate(row, transitionKind, operationId)) { + return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION; + } + IdempotencyFailOutcome mismatch = classifyFailMismatch(row, owner); + if (mismatch != null) { + return mismatch; + } + String targetState = + disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE + ? IdempotencyState.FAILED_RETRYABLE.name() + : IdempotencyState.ABANDONED.name(); + int updated = + jdbc.update( + FAIL_SQL, + targetState, + operationId.value(), + transitionKind, + transitionDigest(transitionKind, operationId, owner), + disposition.name(), + retention.toMillis(), + owner.scope().digest(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + if (updated != 1) { + return IdempotencyFailOutcome.INDETERMINATE; + } + return disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE + ? IdempotencyFailOutcome.MARKED_RETRYABLE + : IdempotencyFailOutcome.MARKED_ABANDONED; + } + + @Override + public IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requirePrimaryWriteTransaction(); + Row row = findForUpdate(owner.scope().digest()).orElse(null); + if (row == null) { + return IdempotencyReleaseOutcome.ABSENT; + } + if (isDuplicate(row, "RELEASE", operationId)) { + return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION; + } + if (!sameOwnerTuple(row, owner)) { + return IdempotencyReleaseOutcome.NOT_OWNER; + } + if (row.state() == IdempotencyState.EXECUTING) { + return IdempotencyReleaseOutcome.EXECUTION_ALREADY_STARTED; + } + if (row.state() != IdempotencyState.CLAIMED) { + return IdempotencyReleaseOutcome.OPERATION_CONFLICT; + } + int updated = + jdbc.update( + RELEASE_SQL, + operationId.value(), + transitionDigest("RELEASE", operationId, owner), + owner.scope().digest(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + return updated == 1 + ? IdempotencyReleaseOutcome.RELEASED_BEFORE_EXECUTION + : IdempotencyReleaseOutcome.INDETERMINATE; + } + + @Override + public IdempotencyInspection inspect(IdempotencyInspectionRequest request) { + Objects.requireNonNull(request, "request"); + Optional found = find(request.scope().digest()); + if (found.isEmpty()) { + return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT); + } + Row row = found.get(); + if (!row.requestHash().equals(request.requestFingerprint().hex())) { + return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH); + } + boolean sameAttempt = sameClaimAttempt(row, request.claimAttempt()); + if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) { + return new IdempotencyInspection( + IdempotencyInspectionOutcome.COMPLETED_REPLAY, + Optional.empty(), + Optional.empty(), + Optional.of(new StoredResponse(row.responsePayload())), + Optional.ofNullable(row.replayUntil())); + } + if (sameAttempt && row.state() == IdempotencyState.CLAIMED) { + return inspectionWithOwner(IdempotencyInspectionOutcome.CLAIMED_SAME_OPERATION, row); + } + if (sameAttempt && row.state() == IdempotencyState.EXECUTING) { + return inspectionWithOwner(IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION, row); + } + if (row.ownerToken().equals(request.claimAttempt().ownerToken()) && !sameAttempt) { + return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.OPERATION_CONFLICT); + } + return switch (row.state()) { + case FAILED_RETRYABLE -> + IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FAILED_RETRYABLE); + case ABANDONED -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABANDONED); + default -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.IN_PROGRESS_OTHER); + }; + } + + private IdempotencyClaimOutcome resetClaim(IdempotencyClaimRequest request, Row row) { + int updated = + jdbc.update( + RESET_CLAIM_SQL, + request.scope().digest(), + request.scope().operationCode(), + request.scope().keyDigestVersion(), + request.scope().operationCode(), + request.requestFingerprint().hex(), + request.claimAttempt().ownerToken(), + request.claimAttempt().operationId().value(), + request.processingLeaseTtl().toMillis(), + request.policyRevision(), + request.responseCodecId(), + request.replayTtl().toMillis(), + request.scope().digest(), + row.state().name(), + row.stateRevision()); + if (updated != 1) { + return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId()); + } + Row reset = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim); + return new IdempotencyClaimOutcome.TakenOverClaimed(owner(reset), reset.processingLeaseUntil()); + } + + private void abandonExpiredExecution(Row row) { + String resultDigest = sha256("EXPIRED_EXECUTION|" + row.claimOperationId()); + int updated = + jdbc.update( + ABANDON_EXPIRED_EXECUTION_SQL, + resultDigest, + row.scopeHash(), + row.ownerToken(), + row.attempt(), + row.claimOperationId(), + row.stateRevision()); + if (updated != 1) { + throw indeterminateClaim(); + } + } + + private IdempotencyStartOutcome classifyStartMismatch(Row row, IdempotencyOwner owner) { + if (!sameOwnerIdentity(row, owner)) { + return IdempotencyStartOutcome.NOT_OWNER; + } + if (row.stateRevision() != owner.stateRevision()) { + return IdempotencyStartOutcome.OPERATION_CONFLICT; + } + if (row.state() != IdempotencyState.CLAIMED) { + return IdempotencyStartOutcome.NOT_CLAIMED; + } + return null; + } + + private IdempotencyRenewOutcome classifyRenewMismatch(Row row, IdempotencyOwner owner) { + if (!sameOwnerIdentity(row, owner)) { + return IdempotencyRenewOutcome.NOT_OWNER; + } + if (row.stateRevision() != owner.stateRevision()) { + return IdempotencyRenewOutcome.OPERATION_CONFLICT; + } + if (row.state() != IdempotencyState.CLAIMED && row.state() != IdempotencyState.EXECUTING) { + return IdempotencyRenewOutcome.NOT_IN_PROGRESS; + } + return null; + } + + private IdempotencyCompleteOutcome classifyCompleteMismatch(Row row, IdempotencyOwner owner) { + if (!sameOwnerIdentity(row, owner)) { + return IdempotencyCompleteOutcome.NOT_OWNER; + } + if (row.stateRevision() != owner.stateRevision()) { + return IdempotencyCompleteOutcome.OPERATION_CONFLICT; + } + if (row.state() != IdempotencyState.EXECUTING) { + return IdempotencyCompleteOutcome.NOT_IN_PROGRESS; + } + return null; + } + + private IdempotencyFailOutcome classifyFailMismatch(Row row, IdempotencyOwner owner) { + if (!sameOwnerIdentity(row, owner)) { + return IdempotencyFailOutcome.NOT_OWNER; + } + if (row.stateRevision() != owner.stateRevision()) { + return IdempotencyFailOutcome.OPERATION_CONFLICT; + } + if (row.state() != IdempotencyState.EXECUTING) { + return IdempotencyFailOutcome.NOT_IN_PROGRESS; + } + return null; + } + + private Optional findForUpdate(String scopeHash) { + return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash); + } + + private Optional find(String scopeHash) { + return queryOne(SELECT_ROW_SQL, scopeHash); + } + + private Optional queryOne(String sql, String scopeHash) { + List rows = jdbc.query(sql, this::mapRow, scopeHash); + if (rows.size() > 1) { + throw new IllegalStateException("multiple idempotency V2 rows for one scope digest"); + } + return rows.stream().findFirst(); + } + + private Row mapRow(ResultSet resultSet, int rowNumber) throws SQLException { + return new Row( + resultSet.getString("scope_hash"), + resultSet.getInt("key_digest_version"), + resultSet.getString("operation_code"), + resultSet.getString("request_hash"), + IdempotencyState.valueOf(resultSet.getString("status")), + resultSet.getLong("state_revision"), + resultSet.getString("owner_token"), + resultSet.getLong("attempt"), + resultSet.getString("claim_operation_id"), + resultSet.getString("last_transition_operation_id"), + resultSet.getString("last_transition_kind"), + resultSet.getString("last_transition_result_digest"), + instant(resultSet, "processing_lease_until"), + nullableInstant(resultSet, "replay_until"), + resultSet.getString("response_payload"), + resultSet.getString("response_digest"), + resultSet.getString("response_codec_id"), + resultSet.getInt("policy_revision"), + instant(resultSet, "expires_at")); + } + + private Instant databaseNowAfterLock() { + OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class); + if (value == null) { + throw new IllegalStateException("PostgreSQL returned no authoritative database time"); + } + return value.toInstant(); + } + + private void requireActiveCapability() { + Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class); + if (active == null || active != 1) { + throw new IllegalStateException( + "jpa-idempotency-owner-safe-v2 is not active at core epoch 1/revision 2"); + } + } + + private static void requirePrimaryWriteTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "owner-safe idempotency mutation requires an active primary transaction"); + } + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + throw new IllegalStateException( + "owner-safe idempotency mutation requires a read-write transaction"); + } + } + + private static void requirePositiveBounded(String name, Duration value, Duration maximum) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(name + " must be positive and at most " + maximum); + } + } + + private static void requireInlineResponse(StoredResponse response) { + int size = response.payload().getBytes(StandardCharsets.UTF_8).length; + if (size > INLINE_RESPONSE_MAX_BYTES) { + throw new IllegalArgumentException( + "SAME_STORE_TRANSACTIONAL response exceeds the bounded inline response limit"); + } + } + + private static boolean sameClaimAttempt(Row row, IdempotencyClaimAttempt attempt) { + return row.ownerToken().equals(attempt.ownerToken()) + && row.claimOperationId().equals(attempt.operationId().value()); + } + + private static boolean sameOwnerIdentity(Row row, IdempotencyOwner owner) { + return row.scopeHash().equals(owner.scope().digest()) + && row.ownerToken().equals(owner.ownerToken()) + && row.attempt() == owner.attempt() + && row.claimOperationId().equals(owner.claimOperationId().value()); + } + + private static boolean sameOwnerTuple(Row row, IdempotencyOwner owner) { + return sameOwnerIdentity(row, owner) && row.stateRevision() == owner.stateRevision(); + } + + private static boolean isDuplicate(Row row, String transitionKind, OperationId operationId) { + return transitionKind.equals(row.lastTransitionKind()) + && operationId.value().equals(row.lastTransitionOperationId()); + } + + private static boolean isExpiredCompleted(Row row, Instant dbNow) { + return row.state() == IdempotencyState.COMPLETED + && row.replayUntil() != null + && !dbNow.isBefore(row.replayUntil()); + } + + private static IdempotencyClaimOutcome.Acquired acquired(Row row) { + return new IdempotencyClaimOutcome.Acquired(owner(row), row.processingLeaseUntil()); + } + + private static IdempotencyOwner owner(Row row) { + return new IdempotencyOwner( + new dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest( + row.scopeHash(), row.keyDigestVersion(), row.operationCode()), + row.ownerToken(), + row.attempt(), + row.stateRevision(), + new OperationId(row.claimOperationId())); + } + + private static IdempotencyMutationResult startResult( + IdempotencyStartOutcome outcome, IdempotencyOwner owner) { + return new IdempotencyMutationResult<>(outcome, owner, IdempotencyStartOutcome::carriesOwner); + } + + private static IdempotencyMutationResult renewResult( + IdempotencyRenewOutcome outcome, IdempotencyOwner owner) { + return new IdempotencyMutationResult<>(outcome, owner, IdempotencyRenewOutcome::carriesOwner); + } + + private static IdempotencyInspection inspectionWithOwner( + IdempotencyInspectionOutcome outcome, Row row) { + return new IdempotencyInspection( + outcome, + Optional.of(owner(row)), + Optional.of(row.processingLeaseUntil()), + Optional.empty(), + Optional.empty()); + } + + private static String transitionDigest( + String transition, OperationId operationId, IdempotencyOwner owner) { + return sha256( + transition + + '|' + + operationId.value() + + '|' + + owner.ownerToken() + + '|' + + owner.attempt() + + '|' + + owner.stateRevision()); + } + + private static String sha256(String value) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static Instant instant(ResultSet resultSet, String column) throws SQLException { + return resultSet.getObject(column, OffsetDateTime.class).toInstant(); + } + + private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException { + OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class); + return value == null ? null : value.toInstant(); + } + + private IllegalStateException indeterminateClaim() { + return new IllegalStateException("owner-safe idempotency claim outcome is indeterminate"); + } + + private record Row( + String scopeHash, + int keyDigestVersion, + String operationCode, + String requestHash, + IdempotencyState state, + long stateRevision, + String ownerToken, + long attempt, + String claimOperationId, + String lastTransitionOperationId, + String lastTransitionKind, + String lastTransitionResultDigest, + Instant processingLeaseUntil, + Instant replayUntil, + String responsePayload, + String responseDigest, + String responseCodecId, + int policyRevision, + Instant expiresAt) {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java new file mode 100644 index 00000000..810c4f88 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java @@ -0,0 +1,588 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.inbox; + +import dev.caskeleton.application.inbox.InboxClaimAttempt; +import dev.caskeleton.application.inbox.InboxClaimOutcome; +import dev.caskeleton.application.inbox.InboxClaimRequest; +import dev.caskeleton.application.inbox.InboxOwner; +import dev.caskeleton.application.inbox.InboxOwnerTransition; +import dev.caskeleton.application.inbox.InboxScopeDigest; +import dev.caskeleton.application.inbox.InboxState; +import dev.caskeleton.application.inbox.InboxStorePort; +import dev.caskeleton.application.inbox.InboxTransitionOutcome; +import dev.caskeleton.application.transaction.OperationId; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * PostgreSQL same-store inbox. + * + *

Claim, business mutation, optional outgoing outbox append, and completion are intended to run + * inside one caller-owned transaction. Broker acknowledgement is deliberately outside this port. + */ +@Repository +public class PostgreSqlSameStoreInboxAdapter implements InboxStorePort { + + private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30); + private static final String DB_NOW_SQL = "select clock_timestamp()"; + + private static final String ACTIVE_CAPABILITY_SQL = + """ + select count(*) + from capability_schema_registry + where capability_id = 'jpa-inbox-same-store-v1' + and core_epoch = 1 + and feature_revision = 1 + and lifecycle_state = 'ACTIVE' + """; + + private static final String INSERT_SQL = + """ + insert into inbox_record_v1 ( + scope_hash, + message_intent_digest, + state, + state_revision, + owner_token, + attempt, + claim_operation_id, + processing_lease_until, + last_operation_id, + last_transition_kind, + last_result_digest, + terminal_at, + retention_until, + created_at, + updated_at + ) + select + ?, ?, 'RECEIVED', 0, ?, 1, ?, + db_now + (? * interval '1 millisecond'), + null, null, null, null, + db_now + (? * interval '1 millisecond'), + db_now, db_now + from (select clock_timestamp() as db_now) authority + on conflict (scope_hash) do nothing + """; + + private static final String SELECT_SQL = + """ + select scope_hash, message_intent_digest, state, state_revision, owner_token, attempt, + claim_operation_id, processing_lease_until, last_operation_id, + last_transition_kind, last_result_digest, terminal_at, retention_until + from inbox_record_v1 + where scope_hash = ? + """; + + private static final String SELECT_FOR_UPDATE_SQL = SELECT_SQL + " for update"; + + private static final String RESET_SQL = + """ + update inbox_record_v1 + set message_intent_digest = ?, + state = 'RECEIVED', + state_revision = state_revision + 1, + owner_token = ?, + attempt = attempt + 1, + claim_operation_id = ?, + processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'), + last_operation_id = null, + last_transition_kind = null, + last_result_digest = null, + terminal_at = null, + retention_until = clock_timestamp() + (? * interval '1 millisecond'), + updated_at = clock_timestamp() + where scope_hash = ? + and state = ? + and state_revision = ? + """; + + private static final String EXPIRE_PROCESSING_SQL = + """ + update inbox_record_v1 + set state = 'DEAD', + state_revision = state_revision + 1, + last_operation_id = claim_operation_id, + last_transition_kind = 'EXPIRED_PROCESSING', + last_result_digest = ?, + terminal_at = clock_timestamp(), + updated_at = clock_timestamp() + where scope_hash = ? + and state = 'PROCESSING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private static final String START_SQL = + """ + update inbox_record_v1 + set state = 'PROCESSING', + state_revision = state_revision + 1, + last_operation_id = ?, + last_transition_kind = 'START', + last_result_digest = ?, + updated_at = clock_timestamp() + where scope_hash = ? + and state = 'RECEIVED' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + and processing_lease_until > clock_timestamp() + """; + + private static final String COMPLETE_SQL = + """ + update inbox_record_v1 + set state = 'COMPLETED', + state_revision = state_revision + 1, + last_operation_id = ?, + last_transition_kind = 'COMPLETE', + last_result_digest = ?, + terminal_at = clock_timestamp(), + updated_at = clock_timestamp() + where scope_hash = ? + and state = 'PROCESSING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private static final String FAIL_SQL = + """ + update inbox_record_v1 + set state = ?, + state_revision = state_revision + 1, + last_operation_id = ?, + last_transition_kind = ?, + last_result_digest = ?, + terminal_at = case when ? = 'DEAD' then clock_timestamp() else null end, + retention_until = clock_timestamp() + (? * interval '1 millisecond'), + processing_lease_until = clock_timestamp(), + updated_at = clock_timestamp() + where scope_hash = ? + and state = 'PROCESSING' + and owner_token = ? + and attempt = ? + and claim_operation_id = ? + and state_revision = ? + """; + + private final DataSource dataSource; + private final JdbcOperations jdbc; + private final SecureRandom secureRandom; + + @Autowired + public PostgreSqlSameStoreInboxAdapter(DataSource dataSource) { + this(dataSource, new JdbcTemplate(dataSource), new SecureRandom()); + } + + PostgreSqlSameStoreInboxAdapter( + DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.jdbc = Objects.requireNonNull(jdbc, "jdbc"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom"); + } + + @Override + public InboxClaimAttempt newClaimAttempt(OperationId operationId) { + Objects.requireNonNull(operationId, "operationId"); + byte[] token = new byte[32]; + secureRandom.nextBytes(token); + return new InboxClaimAttempt(HexFormat.of().formatHex(token), operationId); + } + + @Override + public InboxClaimOutcome claim(InboxClaimRequest request) { + Objects.requireNonNull(request, "request"); + requireSameResourcePrimaryWriteTransaction(); + requireActiveCapability(); + int inserted = + jdbc.update( + INSERT_SQL, + request.scope().value(), + request.messageIntentDigest(), + request.claimAttempt().ownerToken(), + request.claimAttempt().operationId().value(), + request.processingLease().toMillis(), + request.terminalRetention().toMillis()); + InboxRow row = findForUpdate(request.scope()).orElseThrow(this::indeterminate); + Instant dbNow = databaseNowAfterLock(); + if (inserted == 1) { + return new InboxClaimOutcome.Acquired(owner(row), row.processingLeaseUntil()); + } + if ((row.state() == InboxState.COMPLETED || row.state() == InboxState.DEAD) + && !dbNow.isBefore(row.retentionUntil())) { + return resetClaim(request, row); + } + if (!row.messageIntentDigest().equals(request.messageIntentDigest())) { + return new InboxClaimOutcome.IntentMismatch(); + } + if (sameClaimAttempt(row, request.claimAttempt())) { + if (row.state() == InboxState.COMPLETED) { + return new InboxClaimOutcome.Completed(); + } + return new InboxClaimOutcome.ReplayedAcquire(owner(row), row.processingLeaseUntil()); + } + if (row.ownerToken().equals(request.claimAttempt().ownerToken())) { + return new InboxClaimOutcome.OwnerOperationConflict(); + } + if (row.state() == InboxState.COMPLETED) { + return new InboxClaimOutcome.Completed(); + } + if ((row.state() == InboxState.RECEIVED && !dbNow.isBefore(row.processingLeaseUntil())) + || row.state() == InboxState.RETRYABLE) { + return resetClaim(request, row); + } + if (row.state() == InboxState.PROCESSING && !dbNow.isBefore(row.processingLeaseUntil())) { + expireProcessing(row); + return new InboxClaimOutcome.RecoveryRequired(row.attempt()); + } + if (row.state() == InboxState.DEAD) { + return new InboxClaimOutcome.RecoveryRequired(row.attempt()); + } + Duration retryAfter = + row.processingLeaseUntil().isAfter(dbNow) + ? Duration.between(dbNow, row.processingLeaseUntil()) + : Duration.ZERO; + return new InboxClaimOutcome.InProgress(retryAfter, row.attempt()); + } + + @Override + public InboxOwnerTransition markProcessing(InboxOwner owner, OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requireSameResourcePrimaryWriteTransaction(); + InboxRow row = findForUpdate(owner.scope()).orElse(null); + if (row == null) { + return transition(InboxTransitionOutcome.ABSENT, null); + } + if (isDuplicate(row, "START", operationId)) { + return transition(InboxTransitionOutcome.PROCESSING_STARTED, owner(row)); + } + InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.RECEIVED); + if (mismatch != null) { + return transition(mismatch, null); + } + int updated = + jdbc.update( + START_SQL, + operationId.value(), + transitionDigest("START", operationId, owner), + owner.scope().value(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + return updated == 1 + ? transition( + InboxTransitionOutcome.PROCESSING_STARTED, + owner.withStateRevision(owner.stateRevision() + 1)) + : transition(InboxTransitionOutcome.NOT_OWNER, null); + } + + @Override + public InboxTransitionOutcome complete(InboxOwner owner, OperationId operationId) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requireSameResourcePrimaryWriteTransaction(); + InboxRow row = findForUpdate(owner.scope()).orElse(null); + if (row == null) { + return InboxTransitionOutcome.ABSENT; + } + String digest = transitionDigest("COMPLETE", operationId, owner); + InboxTransitionOutcome duplicate = classifyDuplicate(row, "COMPLETE", operationId, digest); + if (duplicate != null) { + return duplicate; + } + InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING); + if (mismatch != null) { + return mismatch; + } + int updated = + jdbc.update( + COMPLETE_SQL, + operationId.value(), + digest, + owner.scope().value(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + return updated == 1 ? InboxTransitionOutcome.COMPLETED : InboxTransitionOutcome.RESULT_CONFLICT; + } + + @Override + public InboxTransitionOutcome markRetryable( + InboxOwner owner, Duration retention, OperationId operationId) { + return fail(owner, retention, operationId, InboxState.RETRYABLE); + } + + @Override + public InboxTransitionOutcome markDead( + InboxOwner owner, Duration retention, OperationId operationId) { + return fail(owner, retention, operationId, InboxState.DEAD); + } + + private InboxTransitionOutcome fail( + InboxOwner owner, Duration retention, OperationId operationId, InboxState target) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + requirePositiveRetention(retention); + requireSameResourcePrimaryWriteTransaction(); + InboxRow row = findForUpdate(owner.scope()).orElse(null); + if (row == null) { + return InboxTransitionOutcome.ABSENT; + } + String kind = target == InboxState.RETRYABLE ? "RETRYABLE" : "DEAD"; + String digest = transitionDigest(kind, operationId, owner); + InboxTransitionOutcome duplicate = classifyDuplicate(row, kind, operationId, digest); + if (duplicate != null) { + return duplicate; + } + InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING); + if (mismatch != null) { + return mismatch; + } + int updated = + jdbc.update( + FAIL_SQL, + target.name(), + operationId.value(), + kind, + digest, + target.name(), + retention.toMillis(), + owner.scope().value(), + owner.ownerToken(), + owner.attempt(), + owner.claimOperationId().value(), + owner.stateRevision()); + if (updated != 1) { + return InboxTransitionOutcome.RESULT_CONFLICT; + } + return target == InboxState.RETRYABLE + ? InboxTransitionOutcome.RETRYABLE + : InboxTransitionOutcome.DEAD; + } + + private InboxClaimOutcome resetClaim(InboxClaimRequest request, InboxRow row) { + int updated = + jdbc.update( + RESET_SQL, + request.messageIntentDigest(), + request.claimAttempt().ownerToken(), + request.claimAttempt().operationId().value(), + request.processingLease().toMillis(), + request.terminalRetention().toMillis(), + request.scope().value(), + row.state().name(), + row.stateRevision()); + if (updated != 1) { + throw indeterminate(); + } + InboxRow reset = findForUpdate(request.scope()).orElseThrow(this::indeterminate); + return new InboxClaimOutcome.TakenOver(owner(reset), reset.processingLeaseUntil()); + } + + private void expireProcessing(InboxRow row) { + String resultDigest = sha256("EXPIRED_PROCESSING|" + row.claimOperationId()); + int updated = + jdbc.update( + EXPIRE_PROCESSING_SQL, + resultDigest, + row.scopeHash(), + row.ownerToken(), + row.attempt(), + row.claimOperationId(), + row.stateRevision()); + if (updated != 1) { + throw indeterminate(); + } + } + + private InboxTransitionOutcome classifyMismatch( + InboxRow row, InboxOwner owner, InboxState expectedState) { + if (!sameOwnerIdentity(row, owner)) { + return InboxTransitionOutcome.NOT_OWNER; + } + if (row.stateRevision() != owner.stateRevision()) { + return InboxTransitionOutcome.STALE_REVISION; + } + if (row.state() != expectedState) { + return InboxTransitionOutcome.INVALID_STATE; + } + return null; + } + + private static InboxTransitionOutcome classifyDuplicate( + InboxRow row, String kind, OperationId operationId, String digest) { + if (kind.equals(row.lastTransitionKind()) + && operationId.value().equals(row.lastOperationId())) { + return digest.equals(row.lastResultDigest()) + ? InboxTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION + : InboxTransitionOutcome.RESULT_CONFLICT; + } + return null; + } + + private Optional findForUpdate(InboxScopeDigest scope) { + return queryOne(SELECT_FOR_UPDATE_SQL, scope); + } + + private Optional queryOne(String sql, InboxScopeDigest scope) { + List rows = jdbc.query(sql, this::mapRow, scope.value()); + if (rows.size() > 1) { + throw new IllegalStateException("multiple inbox rows for one scope"); + } + return rows.stream().findFirst(); + } + + private InboxRow mapRow(ResultSet resultSet, int rowNumber) throws SQLException { + return new InboxRow( + resultSet.getString("scope_hash"), + resultSet.getString("message_intent_digest"), + InboxState.valueOf(resultSet.getString("state")), + resultSet.getLong("state_revision"), + resultSet.getString("owner_token"), + resultSet.getLong("attempt"), + resultSet.getString("claim_operation_id"), + resultSet.getObject("processing_lease_until", OffsetDateTime.class).toInstant(), + resultSet.getString("last_operation_id"), + resultSet.getString("last_transition_kind"), + resultSet.getString("last_result_digest"), + nullableInstant(resultSet, "terminal_at"), + resultSet.getObject("retention_until", OffsetDateTime.class).toInstant()); + } + + private Instant databaseNowAfterLock() { + OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class); + if (value == null) { + throw new IllegalStateException("PostgreSQL returned no authoritative database time"); + } + return value.toInstant(); + } + + private void requireActiveCapability() { + Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class); + if (active == null || active != 1) { + throw new IllegalStateException( + "jpa-inbox-same-store-v1 is not active at core epoch 1/revision 1"); + } + } + + private void requireSameResourcePrimaryWriteTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || TransactionSynchronizationManager.isCurrentTransactionReadOnly() + || !TransactionSynchronizationManager.hasResource(dataSource)) { + throw new IllegalStateException( + "same-store inbox mutation requires the adapter datasource primary write transaction"); + } + } + + private static void requirePositiveRetention(Duration retention) { + Objects.requireNonNull(retention, "retention"); + if (retention.isZero() + || retention.isNegative() + || retention.compareTo(MAXIMUM_RETENTION) > 0) { + throw new IllegalArgumentException( + "retention must be positive and at most " + MAXIMUM_RETENTION); + } + } + + private static boolean sameClaimAttempt(InboxRow row, InboxClaimAttempt attempt) { + return row.ownerToken().equals(attempt.ownerToken()) + && row.claimOperationId().equals(attempt.operationId().value()); + } + + private static boolean sameOwnerIdentity(InboxRow row, InboxOwner owner) { + return row.scopeHash().equals(owner.scope().value()) + && row.ownerToken().equals(owner.ownerToken()) + && row.attempt() == owner.attempt() + && row.claimOperationId().equals(owner.claimOperationId().value()); + } + + private static boolean isDuplicate(InboxRow row, String kind, OperationId operationId) { + return kind.equals(row.lastTransitionKind()) + && operationId.value().equals(row.lastOperationId()); + } + + private static InboxOwner owner(InboxRow row) { + return new InboxOwner( + new InboxScopeDigest(row.scopeHash()), + row.ownerToken(), + row.attempt(), + row.stateRevision(), + new OperationId(row.claimOperationId())); + } + + private static InboxOwnerTransition transition(InboxTransitionOutcome outcome, InboxOwner owner) { + return new InboxOwnerTransition(outcome, Optional.ofNullable(owner)); + } + + private static String transitionDigest(String kind, OperationId operationId, InboxOwner owner) { + return sha256( + kind + + '|' + + operationId.value() + + '|' + + owner.ownerToken() + + '|' + + owner.attempt() + + '|' + + owner.stateRevision()); + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException { + OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class); + return value == null ? null : value.toInstant(); + } + + private IllegalStateException indeterminate() { + return new IllegalStateException("same-store inbox transition is indeterminate"); + } + + private record InboxRow( + String scopeHash, + String messageIntentDigest, + InboxState state, + long stateRevision, + String ownerToken, + long attempt, + String claimOperationId, + Instant processingLeaseUntil, + String lastOperationId, + String lastTransitionKind, + String lastResultDigest, + Instant terminalAt, + Instant retentionUntil) {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java new file mode 100644 index 00000000..99a31ad6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java @@ -0,0 +1,380 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox; + +import dev.caskeleton.application.outbox.v2.NewOutboxEventV2; +import dev.caskeleton.application.outbox.v2.OutboxAppendOutcome; +import dev.caskeleton.application.outbox.v2.OutboxAppendPortV2; +import dev.caskeleton.application.outbox.v2.OutboxAppendReceipt; +import dev.caskeleton.application.outbox.v2.OutboxDispatchAuthority; +import dev.caskeleton.application.outbox.v2.OutboxPublicationAuthority; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * PostgreSQL immutable outbox V2 append implementation. + * + *

The active publication control row is held {@code FOR SHARE} until the caller's transaction + * finishes. Identity and envelope inserts therefore cannot straddle an authority cutover. + */ +@Repository +public class PostgreSqlImmutableOutboxAppendAdapter implements OutboxAppendPortV2 { + + private static final String ACTIVE_CAPABILITY_SQL = + """ + select count(*) + from capability_schema_registry + where capability_id = 'jpa-outbox-storage-v2' + and core_epoch = 1 + and feature_revision = 2 + and lifecycle_state = 'ACTIVE' + """; + + private static final String LOCK_CONTROL_SQL = + """ + select control.active_epoch, control.active_authority + from outbox_publication_control_v2 control + join outbox_publication_cutover_v2 cutover + on cutover.scope_id = control.scope_id + and cutover.active_epoch = control.active_epoch + and cutover.active_authority = control.active_authority + where control.scope_id = 'PRIMARY' + and control.state = 'ACTIVE' + for share of control + """; + + private static final String INSERT_IDENTITY_SQL = + """ + insert into outbox_event_identity_v2 ( + event_id, + aggregate_type, + aggregate_id, + aggregate_version, + event_ordinal, + retention_bucket, + created_at + ) + select ?, ?, ?, ?, ?, (db_now at time zone 'UTC')::date, db_now + from (select clock_timestamp() as db_now) authority + on conflict do nothing + returning retention_bucket, created_at + """; + + private static final String INSERT_EVENT_SQL = + """ + insert into outbox_event_log_v2 ( + retention_bucket, + event_id, + aggregate_type, + aggregate_id, + aggregate_version, + event_ordinal, + event_type, + event_schema, + logical_destination, + partition_key, + publication_epoch, + dispatch_authority, + content_type, + correlation_id, + causation_id, + occurred_at, + payload, + payload_digest, + trace_parent, + created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?) + """; + + private static final String FIND_EVENT_SQL = + """ + select identity.event_id, + identity.aggregate_type, + identity.aggregate_id, + identity.aggregate_version, + identity.event_ordinal, + identity.retention_bucket, + event.event_type, + event.event_schema, + event.logical_destination, + event.partition_key, + event.publication_epoch, + event.dispatch_authority, + event.content_type, + event.correlation_id, + event.causation_id, + event.occurred_at, + event.payload_digest + from outbox_event_identity_v2 identity + left join outbox_event_log_v2 event + on event.event_id = identity.event_id + and event.retention_bucket = identity.retention_bucket + where identity.event_id = ? + """; + + private static final String FIND_ORDERING_IDENTITY_SQL = + """ + select event_id + from outbox_event_identity_v2 + where aggregate_type = ? + and aggregate_id = ? + and aggregate_version = ? + and event_ordinal = ? + """; + + private final DataSource dataSource; + private final JdbcOperations jdbc; + + @Autowired + public PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource) { + this(dataSource, new JdbcTemplate(dataSource)); + } + + PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource, JdbcOperations jdbc) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.jdbc = Objects.requireNonNull(jdbc, "jdbc"); + } + + @Override + public OutboxAppendReceipt append(NewOutboxEventV2 event) { + Objects.requireNonNull(event, "event"); + requireSameResourcePrimaryWriteTransaction(); + requireActiveCapability(); + PublicationControl control = lockPublicationControl(); + OutboxDispatchAuthority dispatchAuthority = dispatchAuthority(control.authority()); + + List inserted = + jdbc.query( + INSERT_IDENTITY_SQL, + (resultSet, rowNumber) -> + new IdentityInsert( + resultSet.getObject("retention_bucket", LocalDate.class), + resultSet.getObject("created_at", OffsetDateTime.class).toInstant()), + event.eventId(), + event.aggregateType(), + event.aggregateId(), + event.aggregateVersion(), + event.eventOrdinal()); + + if (inserted.isEmpty()) { + return classifyExisting(event); + } + IdentityInsert identity = inserted.getFirst(); + String payloadDigest = sha256(event.payload()); + int envelopeInserted = + jdbc.update( + INSERT_EVENT_SQL, + identity.retentionBucket(), + event.eventId(), + event.aggregateType(), + event.aggregateId(), + event.aggregateVersion(), + event.eventOrdinal(), + event.eventType(), + event.eventSchema(), + event.logicalDestination(), + event.partitionKey(), + control.activeEpoch(), + dispatchAuthority.name(), + event.contentType(), + event.correlationId(), + event.causationId(), + OffsetDateTime.ofInstant(event.occurredAt(), java.time.ZoneOffset.UTC), + event.payload(), + payloadDigest, + OffsetDateTime.ofInstant(identity.createdAt(), java.time.ZoneOffset.UTC)); + if (envelopeInserted != 1) { + throw new IllegalStateException("immutable outbox event envelope insert affected no row"); + } + return new OutboxAppendReceipt( + OutboxAppendOutcome.APPENDED, + event.eventId(), + identity.retentionBucket(), + control.activeEpoch(), + dispatchAuthority); + } + + private OutboxAppendReceipt classifyExisting(NewOutboxEventV2 requested) { + Optional byId = findEvent(requested.eventId()); + if (byId.isPresent()) { + StoredEvent stored = byId.get(); + OutboxAppendOutcome outcome = + sameIntent(stored, requested) + ? OutboxAppendOutcome.ALREADY_APPENDED_SAME_EVENT + : OutboxAppendOutcome.EVENT_ID_CONFLICT; + return new OutboxAppendReceipt( + outcome, + stored.eventId(), + stored.retentionBucket(), + stored.publicationEpoch(), + stored.dispatchAuthority()); + } + + List orderingOwner = + jdbc.query( + FIND_ORDERING_IDENTITY_SQL, + (resultSet, rowNumber) -> resultSet.getString("event_id"), + requested.aggregateType(), + requested.aggregateId(), + requested.aggregateVersion(), + requested.eventOrdinal()); + if (!orderingOwner.isEmpty()) { + PublicationControl control = lockPublicationControl(); + return new OutboxAppendReceipt( + OutboxAppendOutcome.AGGREGATE_ORDER_CONFLICT, + requested.eventId(), + currentRetentionBucket(), + control.activeEpoch(), + dispatchAuthority(control.authority())); + } + throw new IllegalStateException( + "outbox identity insert lost without an event-ID or aggregate-order conflict"); + } + + private Optional findEvent(String eventId) { + List rows = jdbc.query(FIND_EVENT_SQL, this::mapStoredEvent, eventId); + if (rows.size() > 1) { + throw new IllegalStateException("multiple immutable outbox identities for one event ID"); + } + return rows.stream().findFirst(); + } + + private StoredEvent mapStoredEvent(ResultSet resultSet, int rowNumber) throws SQLException { + String dispatch = resultSet.getString("dispatch_authority"); + if (dispatch == null) { + throw new IllegalStateException( + "outbox identity exists without its same-transaction immutable envelope"); + } + return new StoredEvent( + resultSet.getString("event_id"), + resultSet.getString("aggregate_type"), + resultSet.getString("aggregate_id"), + resultSet.getLong("aggregate_version"), + resultSet.getInt("event_ordinal"), + resultSet.getObject("retention_bucket", LocalDate.class), + resultSet.getString("event_type"), + resultSet.getInt("event_schema"), + resultSet.getString("logical_destination"), + resultSet.getString("partition_key"), + resultSet.getLong("publication_epoch"), + OutboxDispatchAuthority.valueOf(dispatch), + resultSet.getString("content_type"), + resultSet.getString("correlation_id"), + resultSet.getString("causation_id"), + resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(), + resultSet.getString("payload_digest")); + } + + private PublicationControl lockPublicationControl() { + List rows = + jdbc.query( + LOCK_CONTROL_SQL, + (resultSet, rowNumber) -> + new PublicationControl( + resultSet.getLong("active_epoch"), + OutboxPublicationAuthority.valueOf(resultSet.getString("active_authority")))); + if (rows.size() != 1) { + throw new IllegalStateException( + "outbox publication control has no exact active immutable sentinel"); + } + return rows.getFirst(); + } + + private LocalDate currentRetentionBucket() { + return jdbc.queryForObject( + "select (clock_timestamp() at time zone 'UTC')::date", LocalDate.class); + } + + private void requireActiveCapability() { + Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class); + if (active == null || active != 1) { + throw new IllegalStateException( + "jpa-outbox-storage-v2 is not active at core epoch 1/revision 2"); + } + } + + private void requireSameResourcePrimaryWriteTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "outbox V2 append requires an active primary write transaction"); + } + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + throw new IllegalStateException("outbox V2 append rejects a read-only transaction"); + } + if (!TransactionSynchronizationManager.hasResource(dataSource)) { + throw new IllegalStateException( + "outbox V2 append transaction is not bound to the adapter datasource"); + } + } + + private static OutboxDispatchAuthority dispatchAuthority(OutboxPublicationAuthority authority) { + return switch (authority) { + case LEGACY_POLLING -> OutboxDispatchAuthority.LEGACY_SHADOW; + case POLLING_V2 -> OutboxDispatchAuthority.POLLING_V2; + case CDC -> OutboxDispatchAuthority.CDC; + }; + } + + private static boolean sameIntent(StoredEvent stored, NewOutboxEventV2 requested) { + return stored.aggregateType().equals(requested.aggregateType()) + && stored.aggregateId().equals(requested.aggregateId()) + && stored.aggregateVersion() == requested.aggregateVersion() + && stored.eventOrdinal() == requested.eventOrdinal() + && stored.eventType().equals(requested.eventType()) + && stored.eventSchema() == requested.eventSchema() + && stored.logicalDestination().equals(requested.logicalDestination()) + && stored.partitionKey().equals(requested.partitionKey()) + && stored.contentType().equals(requested.contentType()) + && stored.correlationId().equals(requested.correlationId()) + && Objects.equals(stored.causationId(), requested.causationId()) + && stored.occurredAt().equals(requested.occurredAt()) + && stored.payloadDigest().equals(sha256(requested.payload())); + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private record PublicationControl(long activeEpoch, OutboxPublicationAuthority authority) {} + + private record IdentityInsert(LocalDate retentionBucket, Instant createdAt) {} + + private record StoredEvent( + String eventId, + String aggregateType, + String aggregateId, + long aggregateVersion, + int eventOrdinal, + LocalDate retentionBucket, + String eventType, + int eventSchema, + String logicalDestination, + String partitionKey, + long publicationEpoch, + OutboxDispatchAuthority dispatchAuthority, + String contentType, + String correlationId, + String causationId, + Instant occurredAt, + String payloadDigest) {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java new file mode 100644 index 00000000..64a01382 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java @@ -0,0 +1,531 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox; + +import dev.caskeleton.application.outbox.v2.ClaimedOutboxDelivery; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryClaimRequest; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryOwner; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransition; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransitionOutcome; +import dev.caskeleton.application.outbox.v2.OutboxPollingDeliveryPortV2; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** PostgreSQL {@code SKIP LOCKED} polling relay with strict aggregate order and owner-safe CAS. */ +@Repository +public class PostgreSqlPollingDeliveryAdapter implements OutboxPollingDeliveryPortV2 { + + private static final Pattern ERROR_CODE = Pattern.compile("[A-Z][A-Z0-9_.-]{0,63}"); + + private static final String ACTIVE_CAPABILITIES_SQL = + """ + select count(*) + from capability_schema_registry + where capability_id in ( + 'jpa-outbox-storage-v2', + 'jpa-outbox-polling-delivery-v2' + ) + and core_epoch = 1 + and feature_revision = 2 + and lifecycle_state = 'ACTIVE' + """; + + private static final String CLAIM_SQL = + """ + with authority as ( + select control.active_epoch + from outbox_publication_control_v2 control + join outbox_publication_cutover_v2 cutover + on cutover.scope_id = control.scope_id + and cutover.active_epoch = control.active_epoch + and cutover.active_authority = control.active_authority + where control.scope_id = 'PRIMARY' + and control.state = 'ACTIVE' + and control.active_authority = 'POLLING_V2' + for share of control + ), + db_clock as ( + select clock_timestamp() as db_now + ), + eligible as ( + select delivery.retention_bucket, delivery.event_id, delivery.destination + from outbox_delivery_v2 delivery + join outbox_event_log_v2 event + on event.retention_bucket = delivery.retention_bucket + and event.event_id = delivery.event_id + cross join authority + cross join db_clock + where delivery.destination = ? + and delivery.publication_epoch = authority.active_epoch + and ( + (delivery.state in ('PENDING', 'RETRY_WAIT') + and delivery.next_attempt_at <= db_clock.db_now) + or + (delivery.state = 'CLAIMED' + and delivery.claim_until <= db_clock.db_now) + ) + and not exists ( + select 1 + from outbox_delivery_v2 prior_delivery + join outbox_event_log_v2 prior_event + on prior_event.retention_bucket = prior_delivery.retention_bucket + and prior_event.event_id = prior_delivery.event_id + where prior_delivery.destination = delivery.destination + and prior_event.aggregate_type = event.aggregate_type + and prior_event.aggregate_id = event.aggregate_id + and ( + prior_event.aggregate_version, + prior_event.event_ordinal + ) < ( + event.aggregate_version, + event.event_ordinal + ) + and prior_delivery.state <> 'PUBLISHED' + ) + order by event.created_at, event.event_id + for update of delivery skip locked + limit ? + ), + claimed as ( + update outbox_delivery_v2 delivery + set state = 'CLAIMED', + claim_owner = ?, + claim_token = ?, + claim_until = db_clock.db_now + (? * interval '1 millisecond'), + attempt = delivery.attempt + 1, + version = delivery.version + 1, + updated_at = db_clock.db_now + from eligible, db_clock + where delivery.retention_bucket = eligible.retention_bucket + and delivery.event_id = eligible.event_id + and delivery.destination = eligible.destination + returning delivery.* + ) + select claimed.retention_bucket, + claimed.event_id, + claimed.destination, + claimed.claim_owner, + claimed.claim_token, + claimed.attempt, + claimed.version, + claimed.publication_epoch, + event.event_type, + event.event_schema, + event.aggregate_type, + event.aggregate_id, + event.aggregate_version, + event.event_ordinal, + event.partition_key, + event.content_type, + event.correlation_id, + event.causation_id, + event.occurred_at, + event.payload, + event.payload_digest + from claimed + join outbox_event_log_v2 event + on event.retention_bucket = claimed.retention_bucket + and event.event_id = claimed.event_id + order by event.created_at, event.event_id + """; + + private static final String MARK_PUBLISHED_SQL = + """ + update outbox_delivery_v2 delivery + set state = 'PUBLISHED', + claim_owner = null, + claim_token = null, + claim_until = null, + last_operation_id = ?, + last_result_digest = ?, + published_at = clock_timestamp(), + version = version + 1, + updated_at = clock_timestamp() + where retention_bucket = ? + and event_id = ? + and destination = ? + and state = 'CLAIMED' + and claim_owner = ? + and claim_token = ? + and attempt = ? + and version = ? + and publication_epoch = ? + and exists ( + select 1 + from outbox_publication_control_v2 control + join outbox_publication_cutover_v2 cutover + on cutover.scope_id = control.scope_id + and cutover.active_epoch = control.active_epoch + and cutover.active_authority = control.active_authority + where control.scope_id = 'PRIMARY' + and control.state = 'ACTIVE' + and control.active_authority = 'POLLING_V2' + and control.active_epoch = delivery.publication_epoch + ) + """; + + private static final String MARK_RETRY_SQL = + """ + update outbox_delivery_v2 delivery + set state = 'RETRY_WAIT', + claim_owner = null, + claim_token = null, + claim_until = null, + next_attempt_at = ?, + last_error_code = ?, + last_operation_id = ?, + last_result_digest = ?, + version = version + 1, + updated_at = clock_timestamp() + where retention_bucket = ? + and event_id = ? + and destination = ? + and state = 'CLAIMED' + and claim_owner = ? + and claim_token = ? + and attempt = ? + and version = ? + and publication_epoch = ? + and exists ( + select 1 + from outbox_publication_control_v2 control + where control.scope_id = 'PRIMARY' + and control.state = 'ACTIVE' + and control.active_authority = 'POLLING_V2' + and control.active_epoch = delivery.publication_epoch + ) + """; + + private static final String MARK_DEAD_SQL = + """ + update outbox_delivery_v2 delivery + set state = 'DEAD', + claim_owner = null, + claim_token = null, + claim_until = null, + last_error_code = ?, + last_operation_id = ?, + last_result_digest = ?, + dead_at = clock_timestamp(), + version = version + 1, + updated_at = clock_timestamp() + where retention_bucket = ? + and event_id = ? + and destination = ? + and state = 'CLAIMED' + and claim_owner = ? + and claim_token = ? + and attempt = ? + and version = ? + and publication_epoch = ? + and exists ( + select 1 + from outbox_publication_control_v2 control + where control.scope_id = 'PRIMARY' + and control.state = 'ACTIVE' + and control.active_authority = 'POLLING_V2' + and control.active_epoch = delivery.publication_epoch + ) + """; + + private static final String INSPECT_SQL = + """ + select delivery.state, + delivery.claim_owner, + delivery.claim_token, + delivery.attempt, + delivery.version, + delivery.publication_epoch, + delivery.last_operation_id, + delivery.last_result_digest, + control.active_epoch, + control.active_authority, + control.state as authority_state + from outbox_delivery_v2 delivery + cross join outbox_publication_control_v2 control + where delivery.retention_bucket = ? + and delivery.event_id = ? + and delivery.destination = ? + and control.scope_id = 'PRIMARY' + """; + + private final DataSource dataSource; + private final JdbcOperations jdbc; + private final SecureRandom secureRandom; + + @Autowired + public PostgreSqlPollingDeliveryAdapter(DataSource dataSource) { + this(dataSource, new JdbcTemplate(dataSource), new SecureRandom()); + } + + PostgreSqlPollingDeliveryAdapter( + DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.jdbc = Objects.requireNonNull(jdbc, "jdbc"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom"); + } + + @Override + public List claimBatch(OutboxDeliveryClaimRequest request) { + Objects.requireNonNull(request, "request"); + requireSameResourcePrimaryWriteTransaction(); + requireActiveCapabilities(); + String claimToken = newClaimToken(); + return jdbc.query( + CLAIM_SQL, + this::mapClaim, + request.destination(), + request.batchSize(), + request.claimOwner(), + claimToken, + request.claimLease().toMillis()); + } + + @Override + public OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition) { + Objects.requireNonNull(transition, "transition"); + requireSameResourcePrimaryWriteTransaction(); + String digest = transitionDigest("PUBLISHED", transition, null); + OutboxDeliveryOwner owner = transition.owner(); + int updated = + jdbc.update( + MARK_PUBLISHED_SQL, + transition.operationId().value(), + digest, + owner.retentionBucket(), + owner.eventId(), + owner.destination(), + owner.claimOwner(), + owner.claimToken(), + owner.attempt(), + owner.version(), + owner.publicationEpoch()); + return updated == 1 + ? OutboxDeliveryTransitionOutcome.PUBLISHED + : classifyFailedTransition(transition, digest); + } + + @Override + public OutboxDeliveryTransitionOutcome markRetryable( + OutboxDeliveryTransition transition, Instant nextAttemptAt, String errorCode) { + Objects.requireNonNull(transition, "transition"); + Objects.requireNonNull(nextAttemptAt, "nextAttemptAt"); + requireErrorCode(errorCode); + requireSameResourcePrimaryWriteTransaction(); + String digest = transitionDigest("RETRY_WAIT", transition, errorCode); + OutboxDeliveryOwner owner = transition.owner(); + int updated = + jdbc.update( + MARK_RETRY_SQL, + OffsetDateTime.ofInstant(nextAttemptAt, ZoneOffset.UTC), + errorCode, + transition.operationId().value(), + digest, + owner.retentionBucket(), + owner.eventId(), + owner.destination(), + owner.claimOwner(), + owner.claimToken(), + owner.attempt(), + owner.version(), + owner.publicationEpoch()); + return updated == 1 + ? OutboxDeliveryTransitionOutcome.RETRY_SCHEDULED + : classifyFailedTransition(transition, digest); + } + + @Override + public OutboxDeliveryTransitionOutcome markDead( + OutboxDeliveryTransition transition, String errorCode) { + Objects.requireNonNull(transition, "transition"); + requireErrorCode(errorCode); + requireSameResourcePrimaryWriteTransaction(); + String digest = transitionDigest("DEAD", transition, errorCode); + OutboxDeliveryOwner owner = transition.owner(); + int updated = + jdbc.update( + MARK_DEAD_SQL, + errorCode, + transition.operationId().value(), + digest, + owner.retentionBucket(), + owner.eventId(), + owner.destination(), + owner.claimOwner(), + owner.claimToken(), + owner.attempt(), + owner.version(), + owner.publicationEpoch()); + return updated == 1 + ? OutboxDeliveryTransitionOutcome.DEAD + : classifyFailedTransition(transition, digest); + } + + private ClaimedOutboxDelivery mapClaim(ResultSet resultSet, int rowNumber) throws SQLException { + OutboxDeliveryOwner owner = + new OutboxDeliveryOwner( + resultSet.getObject("retention_bucket", LocalDate.class), + resultSet.getString("event_id"), + resultSet.getString("destination"), + resultSet.getString("claim_owner"), + resultSet.getString("claim_token"), + resultSet.getInt("attempt"), + resultSet.getLong("version"), + resultSet.getLong("publication_epoch")); + return new ClaimedOutboxDelivery( + owner, + resultSet.getString("event_type"), + resultSet.getInt("event_schema"), + resultSet.getString("aggregate_type"), + resultSet.getString("aggregate_id"), + resultSet.getLong("aggregate_version"), + resultSet.getInt("event_ordinal"), + resultSet.getString("partition_key"), + resultSet.getString("content_type"), + resultSet.getString("correlation_id"), + resultSet.getString("causation_id"), + resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(), + resultSet.getString("payload"), + resultSet.getString("payload_digest")); + } + + private OutboxDeliveryTransitionOutcome classifyFailedTransition( + OutboxDeliveryTransition transition, String requestedDigest) { + OutboxDeliveryOwner owner = transition.owner(); + List rows = + jdbc.query( + INSPECT_SQL, + (resultSet, rowNumber) -> + new DeliveryState( + resultSet.getString("state"), + resultSet.getString("claim_owner"), + resultSet.getString("claim_token"), + resultSet.getInt("attempt"), + resultSet.getLong("version"), + resultSet.getLong("publication_epoch"), + resultSet.getString("last_operation_id"), + resultSet.getString("last_result_digest"), + resultSet.getLong("active_epoch"), + resultSet.getString("active_authority"), + resultSet.getString("authority_state")), + owner.retentionBucket(), + owner.eventId(), + owner.destination()); + if (rows.isEmpty()) { + return OutboxDeliveryTransitionOutcome.ABSENT; + } + DeliveryState row = rows.getFirst(); + if (transition.operationId().value().equals(row.lastOperationId())) { + return requestedDigest.equals(row.lastResultDigest()) + ? OutboxDeliveryTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION + : OutboxDeliveryTransitionOutcome.RESULT_CONFLICT; + } + if (!"ACTIVE".equals(row.authorityState()) + || !"POLLING_V2".equals(row.activeAuthority()) + || row.activeEpoch() != row.publicationEpoch()) { + return OutboxDeliveryTransitionOutcome.AUTHORITY_MISMATCH; + } + if (!Objects.equals(owner.claimOwner(), row.claimOwner()) + || !Objects.equals(owner.claimToken(), row.claimToken()) + || owner.attempt() != row.attempt()) { + return OutboxDeliveryTransitionOutcome.NOT_OWNER; + } + if (!"CLAIMED".equals(row.state())) { + return OutboxDeliveryTransitionOutcome.NOT_CLAIMED; + } + if (owner.version() != row.version()) { + return OutboxDeliveryTransitionOutcome.STALE_VERSION; + } + return OutboxDeliveryTransitionOutcome.RESULT_CONFLICT; + } + + private void requireActiveCapabilities() { + Integer active = jdbc.queryForObject(ACTIVE_CAPABILITIES_SQL, Integer.class); + if (active == null || active != 2) { + throw new IllegalStateException( + "outbox storage and polling delivery V2 must both be active at revision 2"); + } + } + + private void requireSameResourcePrimaryWriteTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || TransactionSynchronizationManager.isCurrentTransactionReadOnly() + || !TransactionSynchronizationManager.hasResource(dataSource)) { + throw new IllegalStateException( + "polling delivery mutation requires the adapter datasource primary write transaction"); + } + } + + private String newClaimToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private static void requireErrorCode(String errorCode) { + if (errorCode == null || !ERROR_CODE.matcher(errorCode).matches()) { + throw new IllegalArgumentException( + "error code must be 1-64 uppercase ASCII letters, digits, dot, dash, or underscore"); + } + } + + private static String transitionDigest( + String kind, OutboxDeliveryTransition transition, String detail) { + OutboxDeliveryOwner owner = transition.owner(); + return sha256( + kind + + '|' + + transition.operationId().value() + + '|' + + owner.eventId() + + '|' + + owner.destination() + + '|' + + owner.claimToken() + + '|' + + owner.attempt() + + '|' + + owner.version() + + '|' + + Objects.toString(detail, "")); + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private record DeliveryState( + String state, + String claimOwner, + String claimToken, + int attempt, + long version, + long publicationEpoch, + String lastOperationId, + String lastResultDigest, + long activeEpoch, + String activeAuthority, + String authorityState) {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EffectiveTransactionTimeouts.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EffectiveTransactionTimeouts.java new file mode 100644 index 00000000..3ad5ac6b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EffectiveTransactionTimeouts.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.time.Duration; +import java.util.Objects; + +/** PostgreSQL transaction-local timeouts bounded by the remaining absolute deadline. */ +public record EffectiveTransactionTimeouts( + Duration statementTimeout, Duration lockTimeout, Duration idleGuardTimeout) { + + public EffectiveTransactionTimeouts { + requirePositive(statementTimeout, "statementTimeout"); + requirePositive(lockTimeout, "lockTimeout"); + requirePositive(idleGuardTimeout, "idleGuardTimeout"); + if (lockTimeout.compareTo(statementTimeout) >= 0) { + throw new IllegalArgumentException("lockTimeout must be less than statementTimeout"); + } + } + + private static void requirePositive(Duration duration, String name) { + Objects.requireNonNull(duration, name + " must be non-null"); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java new file mode 100644 index 00000000..1804a722 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.validation.annotation.Validated; + +/** Finite deadline and PostgreSQL transaction-local timeout policy. */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.jpa.transaction") +public record JpaTransactionSettings( + Duration transactionTimeout, + Duration beginBudget, + Duration minimumActionWindow, + Duration completionMargin, + Duration statementTimeout, + Duration lockTimeout, + Duration idleGuardTimeout, + Duration transactionMargin, + Duration lockMargin, + Duration retryBaseDelay, + Duration retryMaximumDelay, + Integer retryMaximumAttempts) { + + private static final Duration MAXIMUM = Duration.ofDays(1); + + @ConstructorBinding + public JpaTransactionSettings { + transactionTimeout = + defaulted(transactionTimeout, Duration.ofSeconds(30), "transaction-timeout"); + beginBudget = defaulted(beginBudget, Duration.ofMillis(250), "begin-budget"); + minimumActionWindow = + defaulted(minimumActionWindow, Duration.ofSeconds(1), "minimum-action-window"); + completionMargin = defaulted(completionMargin, Duration.ofMillis(500), "completion-margin"); + statementTimeout = defaulted(statementTimeout, Duration.ofSeconds(10), "statement-timeout"); + lockTimeout = defaulted(lockTimeout, Duration.ofSeconds(2), "lock-timeout"); + idleGuardTimeout = defaulted(idleGuardTimeout, Duration.ofSeconds(15), "idle-guard-timeout"); + transactionMargin = defaulted(transactionMargin, Duration.ofMillis(250), "transaction-margin"); + lockMargin = defaulted(lockMargin, Duration.ofMillis(100), "lock-margin"); + retryBaseDelay = defaulted(retryBaseDelay, Duration.ofMillis(10), "retry-base-delay"); + retryMaximumDelay = defaulted(retryMaximumDelay, Duration.ofMillis(50), "retry-maximum-delay"); + retryMaximumAttempts = retryMaximumAttempts == null ? 2 : retryMaximumAttempts; + + if (statementTimeout.compareTo(transactionTimeout) > 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.statement-timeout must be <= transaction-timeout"); + } + if (lockTimeout.compareTo(statementTimeout) >= 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.lock-timeout must be < statement-timeout"); + } + if (transactionMargin.compareTo(statementTimeout) >= 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.transaction-margin must be < statement-timeout"); + } + if (lockMargin.compareTo(statementTimeout.minus(lockTimeout)) >= 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.lock-margin must leave lock-timeout below statement-timeout"); + } + if (retryBaseDelay.compareTo(retryMaximumDelay) > 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.retry-base-delay must be <= retry-maximum-delay"); + } + if (retryMaximumAttempts < 1 || retryMaximumAttempts > 5) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction.retry-maximum-attempts must be between 1 and 5"); + } + } + + public JpaTransactionSettings( + Duration transactionTimeout, + Duration beginBudget, + Duration minimumActionWindow, + Duration completionMargin, + Duration statementTimeout, + Duration lockTimeout, + Duration idleGuardTimeout, + Duration transactionMargin, + Duration lockMargin) { + this( + transactionTimeout, + beginBudget, + minimumActionWindow, + completionMargin, + statementTimeout, + lockTimeout, + idleGuardTimeout, + transactionMargin, + lockMargin, + null, + null, + null); + } + + private static Duration defaulted(Duration value, Duration fallback, String name) { + Duration selected = value == null ? fallback : value; + if (selected.isZero() || selected.isNegative() || selected.compareTo(MAXIMUM) > 0) { + throw new IllegalArgumentException( + "ca-skeleton.jpa.transaction." + name + " must be in (0, 1 day]"); + } + return selected; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java new file mode 100644 index 00000000..b9c94c80 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java @@ -0,0 +1,270 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.application.transaction.OperationId; +import dev.caskeleton.application.transaction.TransactionAdmissionException; +import dev.caskeleton.application.transaction.TransactionPhase; +import dev.caskeleton.application.transaction.TransactionPolicyId; +import dev.caskeleton.application.transaction.TransactionRequest; +import dev.caskeleton.application.transaction.TransactionResult; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.function.LongSupplier; +import java.util.function.Supplier; +import org.springframework.core.Ordered; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.UnexpectedRollbackException; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Internal Spring executor for the application-owned named transaction policies. */ +final class SpringPolicyTransactionPort { + + private final PlatformTransactionManager transactionManager; + private final LongSupplier monotonicNanos; + private final TransactionDeadlineCalculator deadlineCalculator; + private final TransactionRetryBackoff retryBackoff; + private final TransactionLocalTimeoutConfigurer localTimeoutConfigurer; + private final PersistenceExceptionTranslator exceptionTranslator; + + SpringPolicyTransactionPort( + PlatformTransactionManager transactionManager, + LongSupplier monotonicNanos, + TransactionDeadlineCalculator deadlineCalculator, + TransactionRetryBackoff retryBackoff, + TransactionLocalTimeoutConfigurer localTimeoutConfigurer, + PersistenceExceptionTranslator exceptionTranslator) { + this.transactionManager = + Objects.requireNonNull(transactionManager, "transactionManager must be non-null"); + this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos must be non-null"); + this.deadlineCalculator = + Objects.requireNonNull(deadlineCalculator, "deadlineCalculator must be non-null"); + this.retryBackoff = Objects.requireNonNull(retryBackoff, "retryBackoff must be non-null"); + this.localTimeoutConfigurer = + Objects.requireNonNull(localTimeoutConfigurer, "localTimeoutConfigurer must be non-null"); + this.exceptionTranslator = + Objects.requireNonNull(exceptionTranslator, "exceptionTranslator must be non-null"); + } + + TransactionResult execute(TransactionRequest request, Supplier action) { + Objects.requireNonNull(request, "request must be non-null"); + Objects.requireNonNull(action, "action must be non-null"); + + PolicySpec policy = policy(request.policyId()); + if (policy.replicaRequired()) { + throw new TransactionAdmissionException( + "replica transaction policy is unavailable until the replica capability is qualified"); + } + + int attempt = 1; + while (true) { + TransactionResult result = executeOnce(request, action, policy); + if (!shouldRetry(request.policyId(), result, attempt)) { + return result; + } + if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) { + return result; + } + attempt++; + } + } + + private TransactionResult executeOnce( + TransactionRequest request, Supplier action, PolicySpec policy) { + TransactionStartBudget startBudget = + deadlineCalculator.beforeAcquisition(request.callBudget(), monotonicNanos.getAsLong()); + DefaultTransactionDefinition definition = definition(request.policyId(), startBudget, policy); + TransactionStatus status; + try { + status = transactionManager.getTransaction(definition); + } catch (RuntimeException failure) { + throw new TransactionAdmissionException( + "transaction acquisition failed before application work started", failure); + } + + PhaseTracker tracker = new PhaseTracker(); + tracker.observe(TransactionPhase.CONNECTION_ACQUIRED); + boolean physicalOwner = status.isNewTransaction(); + PhaseSentinel sentinel = registerSentinelIfPossible(physicalOwner, tracker); + tracker.observe(TransactionPhase.ACTIVE); + + try { + EffectiveTransactionTimeouts effectiveTimeouts = + deadlineCalculator.afterBegin( + request.callBudget(), monotonicNanos.getAsLong(), startBudget); + localTimeoutConfigurer.apply(effectiveTimeouts); + } catch (RuntimeException localTimeoutFailure) { + return rollback(status, request.operationId(), tracker, localTimeoutFailure); + } + + T value; + try { + value = action.get(); + } catch (RuntimeException actionFailure) { + return rollback(status, request.operationId(), tracker, actionFailure); + } + + tracker.observe(TransactionPhase.COMMIT_REQUESTED); + try { + transactionManager.commit(status); + } catch (RuntimeException commitFailure) { + if (sentinel.commitAcknowledged()) { + return new TransactionResult.CommittedWithPostCommitFailure<>( + value, request.operationId(), commitFailure); + } + if (sentinel.rolledBack() + || commitFailure instanceof UnexpectedRollbackException + || TransactionRetryClassifier.isReplayCandidate(commitFailure)) { + return new TransactionResult.DeterminateRollback<>(translate(commitFailure)); + } + return new TransactionResult.Indeterminate<>( + request.operationId(), tracker.lastObserved(), Optional.empty()); + } + + if (!physicalOwner) { + return new TransactionResult.Participating<>(value); + } + tracker.observe(TransactionPhase.COMMIT_ACKED); + return new TransactionResult.Committed<>(value, request.operationId()); + } + + private boolean shouldRetry( + TransactionPolicyId policyId, TransactionResult result, int attempt) { + if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE + || attempt >= retryBackoff.maximumAttempts() + || Thread.currentThread().isInterrupted()) { + return false; + } + if (result instanceof TransactionResult.DeterminateRollback rollback) { + return TransactionRetryClassifier.isReplayCandidate(rollback.failure()); + } + return false; + } + + private TransactionResult rollback( + TransactionStatus status, + Optional operationId, + PhaseTracker tracker, + RuntimeException actionFailure) { + try { + transactionManager.rollback(status); + return new TransactionResult.DeterminateRollback<>(translate(actionFailure)); + } catch (RuntimeException rollbackFailure) { + actionFailure.addSuppressed(rollbackFailure); + return new TransactionResult.Indeterminate<>( + operationId, tracker.lastObserved(), Optional.empty()); + } + } + + private RuntimeException translate(RuntimeException failure) { + return exceptionTranslator.translate(failure).map(RuntimeException.class::cast).orElse(failure); + } + + private DefaultTransactionDefinition definition( + TransactionPolicyId policyId, TransactionStartBudget startBudget, PolicySpec policy) { + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setName("application-" + policyId.name().toLowerCase(Locale.ROOT)); + definition.setPropagationBehavior(policy.propagation()); + definition.setIsolationLevel(policy.isolation()); + definition.setReadOnly(policy.readOnly()); + definition.setTimeout(startBudget.springTimeoutSeconds()); + return definition; + } + + private static PolicySpec policy(TransactionPolicyId policyId) { + return switch (policyId) { + case COMMAND_DEFAULT, INBOX_AND_HANDLER -> + required(TransactionDefinition.ISOLATION_READ_COMMITTED, false); + case COMMAND_SERIALIZABLE_REPLAY_SAFE -> + required(TransactionDefinition.ISOLATION_SERIALIZABLE, false); + case QUERY_PRIMARY -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, true); + case QUERY_REPLICA_ELIGIBLE -> + new PolicySpec( + TransactionDefinition.PROPAGATION_REQUIRED, + TransactionDefinition.ISOLATION_READ_COMMITTED, + true, + true); + case OUTBOX_APPEND -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, false); + case MAINTENANCE_NEW -> + new PolicySpec( + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.ISOLATION_READ_COMMITTED, + false, + false); + }; + } + + private static PolicySpec required(int isolation, boolean readOnly) { + return new PolicySpec(TransactionDefinition.PROPAGATION_REQUIRED, isolation, readOnly, false); + } + + private static PhaseSentinel registerSentinelIfPossible( + boolean physicalOwner, PhaseTracker tracker) { + PhaseSentinel sentinel = new PhaseSentinel(tracker); + if (physicalOwner && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(sentinel); + } + return sentinel; + } + + private record PolicySpec( + int propagation, int isolation, boolean readOnly, boolean replicaRequired) {} + + private static final class PhaseTracker { + + private TransactionPhase lastObserved = TransactionPhase.ROUTE_ADMISSION; + + private void observe(TransactionPhase phase) { + lastObserved = phase; + } + + private TransactionPhase lastObserved() { + return lastObserved; + } + } + + private static final class PhaseSentinel implements TransactionSynchronization, Ordered { + + private final PhaseTracker tracker; + private boolean commitAcknowledged; + private int completionStatus = STATUS_UNKNOWN; + + private PhaseSentinel(PhaseTracker tracker) { + this.tracker = tracker; + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + @Override + public void beforeCommit(boolean readOnly) { + tracker.observe(TransactionPhase.FLUSHED); + } + + @Override + public void afterCommit() { + commitAcknowledged = true; + tracker.observe(TransactionPhase.COMMIT_ACKED); + } + + @Override + public void afterCompletion(int status) { + completionStatus = status; + tracker.observe(TransactionPhase.SYNCHRONIZATION_CLEANUP); + } + + private boolean commitAcknowledged() { + return commitAcknowledged || completionStatus == STATUS_COMMITTED; + } + + private boolean rolledBack() { + return completionStatus == STATUS_ROLLED_BACK; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java index cea0b805..edea3ed5 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java @@ -1,9 +1,21 @@ package dev.caskeleton.adapter.outbound.persistence.transaction; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; import dev.caskeleton.application.transaction.Isolation; +import dev.caskeleton.application.transaction.PolicyTransactionPort; import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.transaction.TransactionRequest; +import dev.caskeleton.application.transaction.TransactionResult; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Locale; +import java.util.function.LongSupplier; import java.util.function.Supplier; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; @@ -15,13 +27,86 @@ import org.springframework.transaction.support.TransactionTemplate; * pre-built (mutable-template race) and CLAUDE.md for the mode table. */ @Component -public class SpringTransactionPort implements TransactionPort { +public class SpringTransactionPort implements PolicyTransactionPort { private final TransactionTemplate writeTemplate; private final TransactionTemplate readTemplate; private final TransactionTemplate requiresNewTemplate; + private final SpringPolicyTransactionPort policyExecutor; + private final PersistenceExceptionTranslator exceptionTranslator; public SpringTransactionPort(PlatformTransactionManager transactionManager) { + this( + transactionManager, + System::nanoTime, + TransactionDeadlineCalculator.withoutAcquisitionEnvelope( + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)), + ignored -> {}, + defaultExceptionTranslator()); + } + + SpringTransactionPort( + PlatformTransactionManager transactionManager, LongSupplier monotonicNanos) { + this( + transactionManager, + monotonicNanos, + TransactionDeadlineCalculator.withoutAcquisitionEnvelope( + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)), + ignored -> {}, + defaultExceptionTranslator()); + } + + @Autowired + public SpringTransactionPort( + PlatformTransactionManager transactionManager, + JpaTransactionSettings settings, + TransactionLocalTimeoutConfigurer localTimeoutConfigurer, + DataSource dataSource, + PersistenceExceptionTranslator exceptionTranslator) { + this( + transactionManager, + System::nanoTime, + new TransactionDeadlineCalculator(connectionTimeout(dataSource), settings), + TransactionRetryBackoff.production(settings, System::nanoTime), + localTimeoutConfigurer, + exceptionTranslator); + } + + SpringTransactionPort( + PlatformTransactionManager transactionManager, + LongSupplier monotonicNanos, + TransactionDeadlineCalculator deadlineCalculator, + TransactionLocalTimeoutConfigurer localTimeoutConfigurer) { + this( + transactionManager, + monotonicNanos, + deadlineCalculator, + localTimeoutConfigurer, + defaultExceptionTranslator()); + } + + SpringTransactionPort( + PlatformTransactionManager transactionManager, + LongSupplier monotonicNanos, + TransactionDeadlineCalculator deadlineCalculator, + TransactionLocalTimeoutConfigurer localTimeoutConfigurer, + PersistenceExceptionTranslator exceptionTranslator) { + this( + transactionManager, + monotonicNanos, + deadlineCalculator, + TransactionRetryBackoff.production(defaultSettings(), monotonicNanos), + localTimeoutConfigurer, + exceptionTranslator); + } + + SpringTransactionPort( + PlatformTransactionManager transactionManager, + LongSupplier monotonicNanos, + TransactionDeadlineCalculator deadlineCalculator, + TransactionRetryBackoff retryBackoff, + TransactionLocalTimeoutConfigurer localTimeoutConfigurer, + PersistenceExceptionTranslator exceptionTranslator) { this.writeTemplate = template( transactionManager, @@ -40,21 +125,35 @@ public class SpringTransactionPort implements TransactionPort { TransactionMode.REQUIRES_NEW, TransactionDefinition.PROPAGATION_REQUIRES_NEW, false); + this.policyExecutor = + new SpringPolicyTransactionPort( + transactionManager, + monotonicNanos, + deadlineCalculator, + retryBackoff, + localTimeoutConfigurer, + exceptionTranslator); + this.exceptionTranslator = exceptionTranslator; } @Override public T inWrite(Supplier action) { - return writeTemplate.execute(status -> action.get()); + return executeLegacy(writeTemplate, action); } @Override public T inRead(Supplier action) { - return readTemplate.execute(status -> action.get()); + return executeLegacy(readTemplate, action); } @Override public T inNew(Supplier action) { - return requiresNewTemplate.execute(status -> action.get()); + return executeLegacy(requiresNewTemplate, action); + } + + @Override + public TransactionResult inTransaction(TransactionRequest request, Supplier action) { + return policyExecutor.execute(request, action); } private static TransactionTemplate template( @@ -63,10 +162,44 @@ public class SpringTransactionPort implements TransactionPort { int propagation, boolean readOnly) { TransactionTemplate template = new TransactionTemplate(transactionManager); - template.setName("application-" + mode.name().toLowerCase()); + template.setName("application-" + mode.name().toLowerCase(Locale.ROOT)); template.setPropagationBehavior(propagation); template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); template.setReadOnly(readOnly); return template; } + + private static Duration connectionTimeout(DataSource dataSource) { + try { + HikariDataSource hikariDataSource = + dataSource instanceof HikariDataSource hikari + ? hikari + : dataSource.unwrap(HikariDataSource.class); + return Duration.ofMillis(hikariDataSource.getConnectionTimeout()); + } catch (SQLException exception) { + throw new IllegalStateException( + "the JPA transaction deadline policy requires a HikariDataSource", exception); + } + } + + private T executeLegacy(TransactionTemplate template, Supplier action) { + try { + return template.execute(status -> action.get()); + } catch (RuntimeException failure) { + throw exceptionTranslator + .translate(failure) + .map(RuntimeException.class::cast) + .orElse(failure); + } + } + + private static PersistenceExceptionTranslator defaultExceptionTranslator() { + return new PersistenceExceptionTranslator( + java.util.List.of( + new StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping())); + } + + private static JpaTransactionSettings defaultSettings() { + return new JpaTransactionSettings(null, null, null, null, null, null, null, null, null); + } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java new file mode 100644 index 00000000..4a71a98d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.TransactionAdmissionException; +import java.time.Duration; +import java.util.Objects; + +/** Computes conservative Spring and PostgreSQL timeout windows from one monotonic deadline. */ +final class TransactionDeadlineCalculator { + + private static final long NANOS_PER_MILLISECOND = Duration.ofMillis(1).toNanos(); + private static final long NANOS_PER_SECOND = Duration.ofSeconds(1).toNanos(); + + private final Duration connectionTimeout; + private final JpaTransactionSettings settings; + private final boolean ignoreAcquisitionEnvelope; + + TransactionDeadlineCalculator(Duration connectionTimeout, JpaTransactionSettings settings) { + this(connectionTimeout, settings, false); + } + + private TransactionDeadlineCalculator( + Duration connectionTimeout, + JpaTransactionSettings settings, + boolean ignoreAcquisitionEnvelope) { + this.connectionTimeout = + Objects.requireNonNull(connectionTimeout, "connectionTimeout must be non-null"); + this.settings = Objects.requireNonNull(settings, "settings must be non-null"); + this.ignoreAcquisitionEnvelope = ignoreAcquisitionEnvelope; + if (connectionTimeout.isNegative()) { + throw new IllegalArgumentException("connectionTimeout must not be negative"); + } + } + + static TransactionDeadlineCalculator withoutAcquisitionEnvelope(JpaTransactionSettings settings) { + return new TransactionDeadlineCalculator(Duration.ZERO, settings, true); + } + + TransactionStartBudget beforeAcquisition(CallBudget callBudget, long nowNanos) { + Objects.requireNonNull(callBudget, "callBudget must be non-null"); + long remainingNanos = callBudget.remainingNanosAt(nowNanos); + long requiredNanos = + ignoreAcquisitionEnvelope + ? 0 + : sumNanos( + connectionTimeout, + settings.beginBudget(), + settings.minimumActionWindow(), + settings.completionMargin()); + if (remainingNanos < requiredNanos) { + throw new TransactionAdmissionException( + "remaining call budget cannot contain pool acquisition, begin, action, and completion"); + } + + long safeTransactionNanos = + ignoreAcquisitionEnvelope + ? remainingNanos + : remainingNanos + - connectionTimeout.toNanos() + - settings.beginBudget().toNanos() + - settings.completionMargin().toNanos(); + long boundedNanos = Math.min(safeTransactionNanos, settings.transactionTimeout().toNanos()); + int timeoutSeconds = (int) Math.min(Integer.MAX_VALUE, boundedNanos / NANOS_PER_SECOND); + if (timeoutSeconds < 1) { + throw new TransactionAdmissionException( + "at least one second must remain for the Spring transaction timeout"); + } + return new TransactionStartBudget(timeoutSeconds, nowNanos); + } + + EffectiveTransactionTimeouts afterBegin( + CallBudget callBudget, long nowNanos, TransactionStartBudget startBudget) { + Objects.requireNonNull(callBudget, "callBudget must be non-null"); + Objects.requireNonNull(startBudget, "startBudget must be non-null"); + long elapsedNanos = Math.max(0, nowNanos - startBudget.acquisitionStartedNanos()); + long springRemainingNanos = + (long) startBudget.springTimeoutSeconds() * NANOS_PER_SECOND - elapsedNanos; + long callRemainingNanos = callBudget.remainingNanosAt(nowNanos); + long completionNanos = ignoreAcquisitionEnvelope ? 0 : settings.completionMargin().toNanos(); + long transactionMarginNanos = + ignoreAcquisitionEnvelope ? 0 : settings.transactionMargin().toNanos(); + long lockMarginNanos = ignoreAcquisitionEnvelope ? 1 : settings.lockMargin().toNanos(); + + long statementWindowNanos = + Math.min(callRemainingNanos - completionNanos, springRemainingNanos) + - transactionMarginNanos; + long statementNanos = Math.min(settings.statementTimeout().toNanos(), statementWindowNanos); + long lockNanos = Math.min(settings.lockTimeout().toNanos(), statementNanos - lockMarginNanos); + long idleNanos = + Math.min(settings.idleGuardTimeout().toNanos(), callRemainingNanos - completionNanos); + if (statementNanos < NANOS_PER_MILLISECOND + || lockNanos < NANOS_PER_MILLISECOND + || idleNanos < NANOS_PER_MILLISECOND) { + throw new TransactionAdmissionException( + "remaining call budget after transaction begin cannot contain local timeout windows"); + } + + return new EffectiveTransactionTimeouts( + Duration.ofNanos(statementNanos), Duration.ofNanos(lockNanos), Duration.ofNanos(idleNanos)); + } + + private static long sumNanos(Duration... durations) { + long result = 0; + try { + for (Duration duration : durations) { + result = Math.addExact(result, duration.toNanos()); + } + return result; + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "transaction deadline settings exceed the supported range", exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionLocalTimeoutConfigurer.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionLocalTimeoutConfigurer.java new file mode 100644 index 00000000..147a2401 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionLocalTimeoutConfigurer.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +/** Vendor implementation applies timeout values to the active physical transaction. */ +@FunctionalInterface +public interface TransactionLocalTimeoutConfigurer { + + void apply(EffectiveTransactionTimeouts timeouts); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java new file mode 100644 index 00000000..674cf173 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.locks.LockSupport; +import java.util.function.LongSupplier; + +/** Bounded exponential full-jitter pause constrained by the caller's absolute budget. */ +final class TransactionRetryBackoff { + + @FunctionalInterface + interface NanosSleeper { + void sleep(long nanos); + } + + @FunctionalInterface + interface JitterSource { + long nextLong(long exclusiveBound); + } + + private final JpaTransactionSettings settings; + private final LongSupplier monotonicNanos; + private final NanosSleeper sleeper; + private final JitterSource jitter; + private final long minimumNextAttemptNanos; + + TransactionRetryBackoff( + JpaTransactionSettings settings, + LongSupplier monotonicNanos, + NanosSleeper sleeper, + JitterSource jitter) { + this.settings = Objects.requireNonNull(settings, "settings"); + this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos"); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); + this.jitter = Objects.requireNonNull(jitter, "jitter"); + try { + this.minimumNextAttemptNanos = + Math.addExact( + settings.minimumActionWindow().toNanos(), + Math.addExact( + settings.beginBudget().toNanos(), settings.completionMargin().toNanos())); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "retry minimum attempt window exceeds long range", exception); + } + } + + static TransactionRetryBackoff production( + JpaTransactionSettings settings, LongSupplier monotonicNanos) { + return new TransactionRetryBackoff( + settings, + monotonicNanos, + LockSupport::parkNanos, + bound -> ThreadLocalRandom.current().nextLong(bound)); + } + + boolean pauseBeforeRetry(CallBudget callBudget, int completedAttempts) { + Objects.requireNonNull(callBudget, "callBudget"); + if (completedAttempts < 1 || Thread.currentThread().isInterrupted()) { + return false; + } + long cappedDelayNanos = cappedExponentialDelay(completedAttempts); + long jitteredDelayNanos = jitter.nextLong(cappedDelayNanos + 1); + long remainingNanos = callBudget.remainingNanosAt(monotonicNanos.getAsLong()); + if (remainingNanos <= saturatedAdd(jitteredDelayNanos, minimumNextAttemptNanos)) { + return false; + } + if (jitteredDelayNanos > 0) { + sleeper.sleep(jitteredDelayNanos); + } + return !Thread.currentThread().isInterrupted() + && callBudget.remainingNanosAt(monotonicNanos.getAsLong()) > minimumNextAttemptNanos; + } + + int maximumAttempts() { + return settings.retryMaximumAttempts(); + } + + private long cappedExponentialDelay(int completedAttempts) { + long baseNanos = settings.retryBaseDelay().toNanos(); + long maximumNanos = settings.retryMaximumDelay().toNanos(); + int shift = Math.min(completedAttempts - 1, 62); + if (baseNanos > (maximumNanos >> shift)) { + return maximumNanos; + } + return Math.min(maximumNanos, baseNanos << shift); + } + + private static long saturatedAdd(long left, long right) { + try { + return Math.addExact(left, right); + } catch (ArithmeticException ignored) { + return Long.MAX_VALUE; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java new file mode 100644 index 00000000..f7b9526e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import java.sql.SQLException; + +/** Fail-closed SQLState classifier for replay-safe whole-transaction retries. */ +final class TransactionRetryClassifier { + + private static final String SERIALIZATION_FAILURE = "40001"; + private static final String POSTGRESQL_DEADLOCK = "40P01"; + + private TransactionRetryClassifier() {} + + static boolean isReplayCandidate(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof SQLException sqlException) { + String sqlState = sqlException.getSQLState(); + return SERIALIZATION_FAILURE.equals(sqlState) || POSTGRESQL_DEADLOCK.equals(sqlState); + } + if (current.getCause() == current) { + return false; + } + } + return false; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionStartBudget.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionStartBudget.java new file mode 100644 index 00000000..129ddd35 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionStartBudget.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +/** Conservative Spring transaction timeout selected before pool acquisition. */ +public record TransactionStartBudget(int springTimeoutSeconds, long acquisitionStartedNanos) { + + public TransactionStartBudget { + if (springTimeoutSeconds < 1) { + throw new IllegalArgumentException("springTimeoutSeconds must be positive"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/core/V1__initialize_or_adopt.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/core/V1__initialize_or_adopt.sql new file mode 100644 index 00000000..8dad7255 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/core/V1__initialize_or_adopt.sql @@ -0,0 +1,41 @@ +-- Independent core stream initialization. The bridge V6 creates the registry for an adopted +-- legacy database; a fresh target database may create it here before optional streams run. + +CREATE TABLE IF NOT EXISTS capability_schema_registry ( + capability_id varchar(128) NOT NULL, + schema_stream varchar(32) NOT NULL, + installation_origin varchar(32) NOT NULL, + core_epoch integer NOT NULL, + feature_revision integer NOT NULL, + lifecycle_state varchar(32) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id), + CONSTRAINT ck_capability_schema_registry_origin + CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')), + CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0), + CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0) +); + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) VALUES ( + 'jpa-flyway-migration', + 'db/migration/jpa/core', + CASE + WHEN EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE installation_origin = 'LEGACY_ADOPTED' + ) THEN 'LEGACY_ADOPTED' + ELSE 'FRESH' + END, + 1, + 1, + 'ACTIVE' +) +ON CONFLICT (capability_id) DO NOTHING; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/idempotency/V1__expand_owner_safe_v2.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/idempotency/V1__expand_owner_safe_v2.sql new file mode 100644 index 00000000..9fc60903 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/idempotency/V1__expand_owner_safe_v2.sql @@ -0,0 +1,141 @@ +-- Additive owner-safe idempotency V2 expansion. V1 columns remain readable throughout the +-- compatibility window; no synthetic owner is invented for legacy COMPLETED rows. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' + AND core_epoch >= 1 + AND lifecycle_state = 'ACTIVE' + ) THEN + RAISE EXCEPTION 'jpa idempotency V2 requires active core epoch 1'; + END IF; + IF to_regclass('public.idempotency_record') IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' + AND installation_origin = 'FRESH' + ) THEN + RAISE EXCEPTION 'legacy adoption requires the compatible idempotency_record'; + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS idempotency_record ( + id uuid NOT NULL, + tenant varchar(128) NOT NULL DEFAULT '', + principal varchar(256) NOT NULL, + idempotency_key varchar(256) NOT NULL, + use_case_name varchar(256) NOT NULL, + request_hash char(64) NOT NULL, + status varchar(16) NOT NULL, + response_payload text, + response_ref varchar(512), + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + CONSTRAINT pk_idempotency_record PRIMARY KEY (id), + CONSTRAINT uq_idempotency_scope + UNIQUE (tenant, principal, idempotency_key, use_case_name) +); + +CREATE INDEX IF NOT EXISTS ix_idempotency_record_expires_at + ON idempotency_record (expires_at); + +ALTER TABLE idempotency_record + ADD COLUMN IF NOT EXISTS scope_hash char(64), + ADD COLUMN IF NOT EXISTS key_digest_version integer, + ADD COLUMN IF NOT EXISTS operation_code varchar(64), + ADD COLUMN IF NOT EXISTS record_version integer, + ADD COLUMN IF NOT EXISTS state_revision bigint, + ADD COLUMN IF NOT EXISTS owner_token varchar(128), + ADD COLUMN IF NOT EXISTS attempt bigint, + ADD COLUMN IF NOT EXISTS claim_operation_id varchar(128), + ADD COLUMN IF NOT EXISTS last_transition_operation_id varchar(128), + ADD COLUMN IF NOT EXISTS last_transition_kind varchar(64), + ADD COLUMN IF NOT EXISTS last_transition_result_digest char(64), + ADD COLUMN IF NOT EXISTS reconciliation_evidence_digest char(64), + ADD COLUMN IF NOT EXISTS processing_lease_until timestamptz, + ADD COLUMN IF NOT EXISTS replay_until timestamptz, + ADD COLUMN IF NOT EXISTS policy_revision integer, + ADD COLUMN IF NOT EXISTS response_codec_id varchar(64), + ADD COLUMN IF NOT EXISTS response_codec_version integer, + ADD COLUMN IF NOT EXISTS response_digest char(64), + ADD COLUMN IF NOT EXISTS failure_disposition varchar(32), + ADD COLUMN IF NOT EXISTS updated_at timestamptz, + ADD COLUMN IF NOT EXISTS completed_at timestamptz; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_idempotency_record_v2_scope + ON idempotency_record (scope_hash) + WHERE record_version = 2; + +CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_lease + ON idempotency_record (status, processing_lease_until) + WHERE record_version = 2; + +CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_terminal + ON idempotency_record (status, replay_until) + WHERE record_version = 2 + AND status IN ('COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED'); + +ALTER TABLE idempotency_record + ADD CONSTRAINT ck_idempotency_record_v2_shape + CHECK ( + record_version IS NULL + OR ( + record_version = 2 + AND scope_hash IS NOT NULL + AND key_digest_version > 0 + AND operation_code IS NOT NULL + AND state_revision >= 0 + AND owner_token IS NOT NULL + AND attempt > 0 + AND claim_operation_id IS NOT NULL + AND processing_lease_until IS NOT NULL + AND policy_revision > 0 + AND response_codec_id IS NOT NULL + AND updated_at IS NOT NULL + ) + ) NOT VALID; + +ALTER TABLE idempotency_record + ADD CONSTRAINT ck_idempotency_record_v2_state + CHECK ( + record_version IS NULL + OR status IN ('CLAIMED', 'EXECUTING', 'COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED') + ) NOT VALID; + +ALTER TABLE idempotency_record + ADD CONSTRAINT ck_idempotency_record_v2_completed_response + CHECK ( + record_version IS NULL + OR status <> 'COMPLETED' + OR ( + response_payload IS NOT NULL + AND response_ref IS NULL + AND response_digest IS NOT NULL + AND replay_until IS NOT NULL + AND completed_at IS NOT NULL + ) + ) NOT VALID; + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) +SELECT + 'jpa-idempotency-owner-safe-v2', + 'db/migration/jpa/idempotency', + installation_origin, + 1, + 2, + 'INSTALLED_INACTIVE' +FROM capability_schema_registry +WHERE capability_id = 'jpa-flyway-migration' +ON CONFLICT (capability_id) DO NOTHING; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/inbox/V1__initialize_same_store_inbox.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/inbox/V1__initialize_same_store_inbox.sql new file mode 100644 index 00000000..e0479be4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/inbox/V1__initialize_same_store_inbox.sql @@ -0,0 +1,70 @@ +-- Same-store inbox state machine. The scope hash is the canonical digest of +-- consumer-group/handler/tenant/message-ID; raw broker metadata is not persisted here. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' + AND core_epoch >= 1 + AND lifecycle_state = 'ACTIVE' + ) THEN + RAISE EXCEPTION 'same-store inbox requires active core epoch 1'; + END IF; +END +$$; + +CREATE TABLE inbox_record_v1 ( + scope_hash char(64) NOT NULL, + message_intent_digest char(64) NOT NULL, + state varchar(16) NOT NULL, + state_revision bigint NOT NULL, + owner_token varchar(128) NOT NULL, + attempt bigint NOT NULL, + claim_operation_id varchar(128) NOT NULL, + processing_lease_until timestamptz NOT NULL, + last_operation_id varchar(128), + last_transition_kind varchar(32), + last_result_digest char(64), + terminal_at timestamptz, + retention_until timestamptz NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT pk_inbox_record_v1 PRIMARY KEY (scope_hash), + CONSTRAINT ck_inbox_record_v1_state + CHECK (state IN ('RECEIVED', 'PROCESSING', 'COMPLETED', 'RETRYABLE', 'DEAD')), + CONSTRAINT ck_inbox_record_v1_revision CHECK (state_revision >= 0), + CONSTRAINT ck_inbox_record_v1_attempt CHECK (attempt > 0), + CONSTRAINT ck_inbox_record_v1_terminal + CHECK ( + (state IN ('COMPLETED', 'DEAD') AND terminal_at IS NOT NULL) + OR (state NOT IN ('COMPLETED', 'DEAD') AND terminal_at IS NULL) + ) +); + +CREATE INDEX ix_inbox_record_v1_lease + ON inbox_record_v1 (state, processing_lease_until) + WHERE state IN ('RECEIVED', 'PROCESSING'); + +CREATE INDEX ix_inbox_record_v1_terminal + ON inbox_record_v1 (state, terminal_at, retention_until) + WHERE state IN ('COMPLETED', 'DEAD', 'RETRYABLE'); + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) +SELECT + 'jpa-inbox-same-store-v1', + 'db/migration/jpa/inbox', + installation_origin, + 1, + 1, + 'INSTALLED_INACTIVE' +FROM capability_schema_registry +WHERE capability_id = 'jpa-flyway-migration'; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-polling/V1__initialize_polling_delivery_v2.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-polling/V1__initialize_polling_delivery_v2.sql new file mode 100644 index 00000000..e00203a6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-polling/V1__initialize_polling_delivery_v2.sql @@ -0,0 +1,152 @@ +-- Mutable polling delivery state, separated from the immutable outbox identity/envelope. + +DO $$ +BEGIN + IF to_regclass('public.outbox_event_log_v2') IS NULL + OR to_regclass('public.outbox_publication_control_v2') IS NULL THEN + RAISE EXCEPTION 'polling delivery V2 requires outbox storage V2'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-outbox-storage-v2' + AND core_epoch = 1 + AND feature_revision = 2 + ) THEN + RAISE EXCEPTION 'polling delivery V2 requires outbox storage revision 2'; + END IF; +END +$$; + +CREATE TABLE outbox_delivery_v2 ( + retention_bucket date NOT NULL, + event_id varchar(64) NOT NULL, + destination varchar(256) NOT NULL, + publication_epoch bigint NOT NULL, + state varchar(16) NOT NULL, + claim_owner varchar(128), + claim_token char(64), + claim_until timestamptz, + attempt integer NOT NULL, + next_attempt_at timestamptz NOT NULL, + last_error_code varchar(64), + last_operation_id varchar(128), + last_result_digest char(64), + published_at timestamptz, + dead_at timestamptz, + version bigint NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT pk_outbox_delivery_v2 + PRIMARY KEY (retention_bucket, event_id, destination), + CONSTRAINT fk_outbox_delivery_v2_event + FOREIGN KEY (retention_bucket, event_id) + REFERENCES outbox_event_log_v2 (retention_bucket, event_id), + CONSTRAINT ck_outbox_delivery_v2_epoch CHECK (publication_epoch > 0), + CONSTRAINT ck_outbox_delivery_v2_state + CHECK (state IN ('PENDING', 'CLAIMED', 'PUBLISHED', 'RETRY_WAIT', 'DEAD')), + CONSTRAINT ck_outbox_delivery_v2_attempt CHECK (attempt >= 0), + CONSTRAINT ck_outbox_delivery_v2_version CHECK (version >= 0), + CONSTRAINT ck_outbox_delivery_v2_state_shape + CHECK ( + (state = 'CLAIMED' + AND claim_owner IS NOT NULL + AND claim_token IS NOT NULL + AND claim_until IS NOT NULL + AND published_at IS NULL + AND dead_at IS NULL) + OR + (state <> 'CLAIMED' + AND claim_owner IS NULL + AND claim_token IS NULL + AND claim_until IS NULL) + ), + CONSTRAINT ck_outbox_delivery_v2_terminal_shape + CHECK ( + (state = 'PUBLISHED' AND published_at IS NOT NULL AND dead_at IS NULL) + OR (state = 'DEAD' AND dead_at IS NOT NULL AND published_at IS NULL) + OR (state NOT IN ('PUBLISHED', 'DEAD') + AND published_at IS NULL + AND dead_at IS NULL) + ) +); + +CREATE INDEX ix_outbox_delivery_v2_claim + ON outbox_delivery_v2 (destination, next_attempt_at, created_at) + WHERE state IN ('PENDING', 'RETRY_WAIT', 'CLAIMED'); + +CREATE INDEX ix_outbox_delivery_v2_terminal + ON outbox_delivery_v2 (state, published_at, dead_at) + WHERE state IN ('PUBLISHED', 'DEAD'); + +CREATE OR REPLACE FUNCTION create_polling_delivery_v2() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.dispatch_authority = 'POLLING_V2' THEN + INSERT INTO outbox_delivery_v2 ( + retention_bucket, + event_id, + destination, + publication_epoch, + state, + claim_owner, + claim_token, + claim_until, + attempt, + next_attempt_at, + last_error_code, + last_operation_id, + last_result_digest, + published_at, + dead_at, + version, + created_at, + updated_at + ) VALUES ( + NEW.retention_bucket, + NEW.event_id, + NEW.logical_destination, + NEW.publication_epoch, + 'PENDING', + null, + null, + null, + 0, + NEW.created_at, + null, + null, + null, + null, + null, + 0, + NEW.created_at, + NEW.created_at + ); + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER trg_create_polling_delivery_v2 +AFTER INSERT ON outbox_event_log_v2 +FOR EACH ROW EXECUTE FUNCTION create_polling_delivery_v2(); + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) +SELECT + 'jpa-outbox-polling-delivery-v2', + 'db/migration/jpa/outbox-polling', + installation_origin, + 1, + 2, + 'INSTALLED_INACTIVE' +FROM capability_schema_registry +WHERE capability_id = 'jpa-flyway-migration'; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-storage/V1__initialize_or_adopt.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-storage/V1__initialize_or_adopt.sql new file mode 100644 index 00000000..0c49e84c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/outbox-storage/V1__initialize_or_adopt.sql @@ -0,0 +1,307 @@ +-- Immutable outbox storage V2. Publication authority remains LEGACY_POLLING after adoption until +-- an explicit cutover transaction writes the next immutable sentinel and advances the control row. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' + AND core_epoch >= 1 + AND lifecycle_state = 'ACTIVE' + ) THEN + RAISE EXCEPTION 'jpa outbox storage V2 requires active core epoch 1'; + END IF; + IF to_regclass('public.outbox_event') IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' + AND installation_origin = 'FRESH' + ) THEN + RAISE EXCEPTION 'legacy adoption requires the compatible outbox_event'; + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS outbox_event ( + event_id varchar(64) NOT NULL, + aggregate_id varchar(256) NOT NULL, + event_type varchar(256) NOT NULL, + payload text NOT NULL, + occurred_at timestamptz NOT NULL, + status varchar(16) NOT NULL, + attempt_count integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL, + correlation_id varchar(64) NOT NULL, + idempotency_key varchar(256) NOT NULL, + CONSTRAINT pk_outbox_event PRIMARY KEY (event_id) +); + +CREATE INDEX IF NOT EXISTS ix_outbox_event_eligible ON outbox_event (next_attempt_at) + WHERE status IN ('PENDING', 'FAILED', 'IN_FLIGHT'); +CREATE INDEX IF NOT EXISTS ix_outbox_event_aggregate_occurred + ON outbox_event (aggregate_id, occurred_at); +CREATE INDEX IF NOT EXISTS ix_outbox_event_published_occurred ON outbox_event (occurred_at) + WHERE status = 'PUBLISHED'; +CREATE INDEX IF NOT EXISTS ix_outbox_event_status_occurred ON outbox_event (status, occurred_at); + +CREATE TABLE outbox_publication_control_v2 ( + scope_id varchar(32) NOT NULL, + active_epoch bigint NOT NULL, + active_authority varchar(32) NOT NULL, + state varchar(16) NOT NULL, + revision bigint NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT pk_outbox_publication_control_v2 PRIMARY KEY (scope_id), + CONSTRAINT ck_outbox_publication_control_v2_scope CHECK (scope_id = 'PRIMARY'), + CONSTRAINT ck_outbox_publication_control_v2_epoch CHECK (active_epoch > 0), + CONSTRAINT ck_outbox_publication_control_v2_authority + CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')), + CONSTRAINT ck_outbox_publication_control_v2_state + CHECK (state IN ('PREPARING', 'ACTIVE', 'DRAINING')), + CONSTRAINT ck_outbox_publication_control_v2_revision CHECK (revision >= 0) +); + +CREATE TABLE outbox_publication_cutover_v2 ( + scope_id varchar(32) NOT NULL, + active_epoch bigint NOT NULL, + previous_epoch bigint NOT NULL, + transition_kind varchar(32) NOT NULL, + active_authority varchar(32) NOT NULL, + legacy_row_count bigint NOT NULL, + legacy_pending_count bigint NOT NULL, + legacy_digest char(64) NOT NULL, + schema_manifest_id varchar(128) NOT NULL, + external_manifest_id varchar(128), + activated_at timestamptz NOT NULL, + CONSTRAINT pk_outbox_publication_cutover_v2 PRIMARY KEY (scope_id, active_epoch), + CONSTRAINT fk_outbox_publication_cutover_v2_scope + FOREIGN KEY (scope_id) REFERENCES outbox_publication_control_v2 (scope_id), + CONSTRAINT ck_outbox_publication_cutover_v2_epoch + CHECK (active_epoch > 0 AND previous_epoch = active_epoch - 1), + CONSTRAINT ck_outbox_publication_cutover_v2_kind + CHECK (transition_kind IN ('GENESIS_FRESH', 'GENESIS_LEGACY', 'CUTOVER')), + CONSTRAINT ck_outbox_publication_cutover_v2_authority + CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')), + CONSTRAINT ck_outbox_publication_cutover_v2_counts + CHECK (legacy_row_count >= 0 AND legacy_pending_count >= 0) +); + +CREATE TABLE outbox_event_identity_v2 ( + event_id varchar(64) NOT NULL, + aggregate_type varchar(128) NOT NULL, + aggregate_id varchar(256) NOT NULL, + aggregate_version bigint NOT NULL, + event_ordinal integer NOT NULL, + retention_bucket date NOT NULL, + created_at timestamptz NOT NULL, + CONSTRAINT pk_outbox_event_identity_v2 PRIMARY KEY (event_id), + CONSTRAINT uq_outbox_event_identity_v2_aggregate_order + UNIQUE (aggregate_type, aggregate_id, aggregate_version, event_ordinal), + CONSTRAINT uq_outbox_event_identity_v2_bucket UNIQUE (event_id, retention_bucket), + CONSTRAINT ck_outbox_event_identity_v2_version CHECK (aggregate_version > 0), + CONSTRAINT ck_outbox_event_identity_v2_ordinal CHECK (event_ordinal BETWEEN 0 AND 1023) +); + +CREATE TABLE outbox_event_log_v2 ( + retention_bucket date NOT NULL, + event_id varchar(64) NOT NULL, + aggregate_type varchar(128) NOT NULL, + aggregate_id varchar(256) NOT NULL, + aggregate_version bigint NOT NULL, + event_ordinal integer NOT NULL, + event_type varchar(256) NOT NULL, + event_schema integer NOT NULL, + logical_destination varchar(256) NOT NULL, + partition_key varchar(256) NOT NULL, + publication_epoch bigint NOT NULL, + dispatch_authority varchar(32) NOT NULL, + content_type varchar(128) NOT NULL, + correlation_id varchar(128) NOT NULL, + causation_id varchar(128), + occurred_at timestamptz NOT NULL, + payload text NOT NULL, + payload_digest char(64) NOT NULL, + trace_parent varchar(256), + created_at timestamptz NOT NULL, + CONSTRAINT pk_outbox_event_log_v2 PRIMARY KEY (retention_bucket, event_id), + CONSTRAINT fk_outbox_event_log_v2_identity + FOREIGN KEY (event_id, retention_bucket) + REFERENCES outbox_event_identity_v2 (event_id, retention_bucket), + CONSTRAINT ck_outbox_event_log_v2_schema CHECK (event_schema > 0), + CONSTRAINT ck_outbox_event_log_v2_epoch CHECK (publication_epoch > 0), + CONSTRAINT ck_outbox_event_log_v2_authority + CHECK (dispatch_authority IN ('LEGACY_SHADOW', 'POLLING_V2', 'CDC')), + CONSTRAINT ck_outbox_event_log_v2_payload_size + CHECK (octet_length(payload) BETWEEN 1 AND 1048576) +) PARTITION BY RANGE (retention_bucket); + +CREATE TABLE outbox_event_log_v2_default + PARTITION OF outbox_event_log_v2 DEFAULT; + +CREATE INDEX ix_outbox_event_log_v2_aggregate_order + ON outbox_event_log_v2 ( + aggregate_type, + aggregate_id, + aggregate_version, + event_ordinal + ); + +CREATE OR REPLACE FUNCTION enforce_outbox_event_v2_authority() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + control_epoch bigint; + control_authority varchar(32); + control_state varchar(16); + expected_dispatch varchar(32); +BEGIN + SELECT active_epoch, active_authority, state + INTO control_epoch, control_authority, control_state + FROM outbox_publication_control_v2 + WHERE scope_id = 'PRIMARY'; + + IF control_state <> 'ACTIVE' THEN + RAISE EXCEPTION 'outbox publication authority is not ACTIVE'; + END IF; + + expected_dispatch := CASE control_authority + WHEN 'LEGACY_POLLING' THEN 'LEGACY_SHADOW' + WHEN 'POLLING_V2' THEN 'POLLING_V2' + WHEN 'CDC' THEN 'CDC' + END; + + IF NEW.publication_epoch <> control_epoch + OR NEW.dispatch_authority <> expected_dispatch THEN + RAISE EXCEPTION 'outbox publication epoch or dispatch authority mismatch'; + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER trg_outbox_event_v2_authority +BEFORE INSERT ON outbox_event_log_v2 +FOR EACH ROW EXECUTE FUNCTION enforce_outbox_event_v2_authority(); + +CREATE OR REPLACE FUNCTION fence_legacy_outbox_writer() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + authority varchar(32); +BEGIN + SELECT active_authority + INTO authority + FROM outbox_publication_control_v2 + WHERE scope_id = 'PRIMARY'; + IF authority <> 'LEGACY_POLLING' THEN + RAISE EXCEPTION 'legacy outbox writer is fenced after authority cutover'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER trg_fence_legacy_outbox_writer +BEFORE INSERT OR UPDATE OR DELETE ON outbox_event +FOR EACH ROW EXECUTE FUNCTION fence_legacy_outbox_writer(); + +CREATE OR REPLACE FUNCTION reject_outbox_cutover_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'outbox publication cutover sentinel is immutable'; +END +$$; + +CREATE TRIGGER trg_reject_outbox_cutover_mutation +BEFORE UPDATE OR DELETE ON outbox_publication_cutover_v2 +FOR EACH ROW EXECUTE FUNCTION reject_outbox_cutover_mutation(); + +INSERT INTO outbox_publication_control_v2 ( + scope_id, + active_epoch, + active_authority, + state, + revision, + updated_at +) VALUES ( + 'PRIMARY', + 1, + 'LEGACY_POLLING', + 'ACTIVE', + 0, + clock_timestamp() +); + +WITH legacy AS ( + SELECT + count(*) AS row_count, + count(*) FILTER (WHERE status <> 'PUBLISHED') AS pending_count, + coalesce( + string_agg( + event_id || ':' || status || ':' || attempt_count::text, + ',' ORDER BY event_id + ), + '' + ) AS intent + FROM outbox_event +), +origin AS ( + SELECT installation_origin + FROM capability_schema_registry + WHERE capability_id = 'jpa-flyway-migration' +) +INSERT INTO outbox_publication_cutover_v2 ( + scope_id, + active_epoch, + previous_epoch, + transition_kind, + active_authority, + legacy_row_count, + legacy_pending_count, + legacy_digest, + schema_manifest_id, + external_manifest_id, + activated_at +) +SELECT + 'PRIMARY', + 1, + 0, + CASE installation_origin + WHEN 'FRESH' THEN 'GENESIS_FRESH' + ELSE 'GENESIS_LEGACY' + END, + 'LEGACY_POLLING', + row_count, + pending_count, + md5(intent) || md5('outbox-v2:' || intent), + 'jpa-outbox-storage-v2-schema-revision-2', + null, + clock_timestamp() +FROM legacy +CROSS JOIN origin; + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) +SELECT + 'jpa-outbox-storage-v2', + 'db/migration/jpa/outbox-storage', + installation_origin, + 1, + 2, + 'INSTALLED_INACTIVE' +FROM capability_schema_registry +WHERE capability_id = 'jpa-flyway-migration'; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql new file mode 100644 index 00000000..c72e3802 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql @@ -0,0 +1,43 @@ +-- Bridge migration: preserve the immutable V1/V3/V4/V5 legacy history and record its +-- installation origin before independent JPA capability streams are adopted. + +DO $$ +BEGIN + IF to_regclass('public.idempotency_record') IS NULL THEN + RAISE EXCEPTION 'legacy adoption requires idempotency_record'; + END IF; + IF to_regclass('public.outbox_event') IS NULL THEN + RAISE EXCEPTION 'legacy adoption requires outbox_event'; + END IF; + IF to_regclass('public.int_lock') IS NULL THEN + RAISE EXCEPTION 'legacy adoption requires INT_LOCK'; + END IF; +END +$$; + +CREATE TABLE capability_schema_registry ( + capability_id varchar(128) NOT NULL, + schema_stream varchar(32) NOT NULL, + installation_origin varchar(32) NOT NULL, + core_epoch integer NOT NULL, + feature_revision integer NOT NULL, + lifecycle_state varchar(32) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id), + CONSTRAINT ck_capability_schema_registry_origin + CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')), + CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0), + CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0) +); + +INSERT INTO capability_schema_registry ( + capability_id, + schema_stream, + installation_origin, + core_epoch, + feature_revision, + lifecycle_state +) VALUES + ('legacy-idempotency-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'), + ('legacy-outbox-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'), + ('legacy-jdbc-coordination-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java new file mode 100644 index 00000000..f9c639bc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PostgreSqlAggregateIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + + @BeforeAll + static void startPostgreSql() throws Exception { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + postgres.execute( + "create table readiness_aggregate(" + + "id uuid primary key, title varchar(100) not null, " + + "occurred_at timestamptz not null, version bigint not null)"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @Test + void roundTripsUuidAndInstantAndDetectsExpectedVersionConflict() throws Exception { + UUID id = UUID.randomUUID(); + Instant occurredAt = Instant.parse("2026-07-28T12:00:00.123456Z"); + try (Connection connection = postgres.connection(); + PreparedStatement insert = + connection.prepareStatement( + "insert into readiness_aggregate(id,title,occurred_at,version) " + + "values (?,?,?,0)")) { + insert.setObject(1, id); + insert.setString(2, "aggregate"); + insert.setObject(3, occurredAt.atOffset(ZoneOffset.UTC)); + assertThat(insert.executeUpdate()).isOne(); + } + + try (Connection connection = postgres.connection(); + PreparedStatement query = + connection.prepareStatement( + "select id,title,occurred_at,version from readiness_aggregate where id=?")) { + query.setObject(1, id); + try (ResultSet row = query.executeQuery()) { + assertThat(row.next()).isTrue(); + assertThat(row.getObject(1, UUID.class)).isEqualTo(id); + assertThat(row.getString(2)).isEqualTo("aggregate"); + assertThat(row.getObject(3, java.time.OffsetDateTime.class).toInstant()) + .isEqualTo(occurredAt); + assertThat(row.getLong(4)).isZero(); + } + } + + try (Connection first = postgres.connection(); + Connection second = postgres.connection(); + PreparedStatement firstUpdate = + first.prepareStatement( + "update readiness_aggregate set title=?,version=version+1 " + + "where id=? and version=?"); + PreparedStatement secondUpdate = + second.prepareStatement( + "update readiness_aggregate set title=?,version=version+1 " + + "where id=? and version=?")) { + first.setAutoCommit(false); + second.setAutoCommit(false); + bindUpdate(firstUpdate, "first", id, 0); + bindUpdate(secondUpdate, "second", id, 0); + assertThat(firstUpdate.executeUpdate()).isOne(); + first.commit(); + assertThat(secondUpdate.executeUpdate()).isZero(); + second.rollback(); + } + } + + private static void bindUpdate( + PreparedStatement statement, String title, UUID id, long expectedVersion) throws Exception { + statement.setString(1, title); + statement.setObject(2, id); + statement.setLong(3, expectedVersion); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlIdempotencyIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlIdempotencyIntegrationTest.java new file mode 100644 index 00000000..d9fa7a06 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlIdempotencyIntegrationTest.java @@ -0,0 +1,298 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome; +import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.v2.IdempotencyOwner; +import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest; +import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome; +import dev.caskeleton.application.transaction.OperationId; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +class PostgreSqlIdempotencyIntegrationTest { + + private static final IdempotencyScopeDigest SCOPE = + new IdempotencyScopeDigest("a".repeat(64), 1, "CREATE_WORK_LOG"); + private static final RequestFingerprint FINGERPRINT = + RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8)); + + private static PostgreSqlReadinessSupport postgres; + private static JdbcTemplate jdbc; + private static TransactionTemplate transactions; + private static PostgreSqlOwnerSafeIdempotencyStore store; + + @BeforeAll + static void startAndMigratePostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + migrate("classpath:db/migration/postgresql", "flyway_schema_history"); + migrateIndependent( + "classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption"); + migrateIndependent( + "classpath:db/migration/jpa/idempotency", + "flyway_jpa_idempotency_history", + "explicit-jpa-idempotency-adoption"); + + jdbc = new JdbcTemplate(postgres.dataSource()); + transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource())); + store = new PostgreSqlOwnerSafeIdempotencyStore(jdbc); + jdbc.update( + "update capability_schema_registry set lifecycle_state = 'ACTIVE' " + + "where capability_id = 'jpa-idempotency-owner-safe-v2'"); + jdbc.execute( + "create table idempotency_business_probe (" + + "scope_hash char(64) primary key, mutation_count integer not null)"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @BeforeEach + void clearRows() { + jdbc.update("delete from idempotency_record where record_version = 2"); + jdbc.update("delete from idempotency_business_probe"); + } + + @Test + void sameStoreTransactionCommitsBusinessMutationAndCompletionTogetherThenReplays() { + IdempotencyClaimAttempt attempt = store.newClaimAttempt(new OperationId("claim-1")); + IdempotencyClaimRequest request = request(attempt, Duration.ofSeconds(5)); + + transactions.executeWithoutResult( + ignored -> { + IdempotencyOwner owner = + ((IdempotencyClaimOutcome.Acquired) store.claim(request)).owner(); + owner = + store.markExecutionStarted(owner, new OperationId("start-1")).owner().orElseThrow(); + jdbc.update( + "insert into idempotency_business_probe(scope_hash, mutation_count) values (?, 1)", + SCOPE.digest()); + + assertThat( + store.complete( + owner, + new StoredResponse("{\"workLogId\":\"42\"}"), + Duration.ofHours(24), + new OperationId("complete-1"))) + .isEqualTo(IdempotencyCompleteOutcome.COMPLETED); + }); + + IdempotencyClaimOutcome replay = + transactions.execute(ignored -> store.claim(request(attempt, Duration.ofSeconds(5)))); + + assertThat(replay) + .isInstanceOfSatisfying( + IdempotencyClaimOutcome.CompletedReplay.class, + completed -> + assertThat(completed.response().payload()).isEqualTo("{\"workLogId\":\"42\"}")); + assertThat( + jdbc.queryForObject( + "select mutation_count from idempotency_business_probe where scope_hash = ?", + Integer.class, + SCOPE.digest())) + .isEqualTo(1); + assertThat( + jdbc.queryForObject( + "select idempotency_key from idempotency_record where scope_hash = ?", + String.class, + SCOPE.digest())) + .isEqualTo(SCOPE.digest()); + } + + @Test + void expiredClaimCanBeTakenOverButTheStaleOwnerCannotStart() { + IdempotencyOwner staleOwner = + transactions.execute( + ignored -> + ((IdempotencyClaimOutcome.Acquired) + store.claim( + request( + store.newClaimAttempt(new OperationId("claim-old")), + Duration.ofMillis(25)))) + .owner()); + jdbc.queryForObject("select pg_sleep(0.05)", Object.class); + + IdempotencyClaimAttempt replacement = + store.newClaimAttempt(new OperationId("claim-replacement")); + IdempotencyClaimOutcome takeover = + transactions.execute(ignored -> store.claim(request(replacement, Duration.ofSeconds(5)))); + + assertThat(takeover) + .isInstanceOfSatisfying( + IdempotencyClaimOutcome.TakenOverClaimed.class, + result -> { + assertThat(result.owner().attempt()).isEqualTo(2); + assertThat(result.owner().ownerToken()).isEqualTo(replacement.ownerToken()); + }); + assertThat( + transactions + .execute( + ignored -> + store.markExecutionStarted(staleOwner, new OperationId("stale-start"))) + .outcome()) + .isEqualTo(IdempotencyStartOutcome.NOT_OWNER); + } + + @Test + void expiredExecutingRecordRequiresReconciliationAndIsNeverBlindlyTakenOver() { + IdempotencyClaimAttempt original = store.newClaimAttempt(new OperationId("claim-executing")); + IdempotencyOwner executing = + transactions.execute( + ignored -> { + IdempotencyOwner owner = + ((IdempotencyClaimOutcome.Acquired) + store.claim(request(original, Duration.ofMillis(25)))) + .owner(); + return store + .markExecutionStarted(owner, new OperationId("start-executing")) + .owner() + .orElseThrow(); + }); + jdbc.queryForObject("select pg_sleep(0.05)", Object.class); + + IdempotencyClaimOutcome outcome = + transactions.execute( + ignored -> + store.claim( + request( + store.newClaimAttempt(new OperationId("claim-after-unknown")), + Duration.ofSeconds(5)))); + + assertThat(outcome) + .isInstanceOfSatisfying( + IdempotencyClaimOutcome.RecoveryRequired.class, + recovery -> assertThat(recovery.currentAttempt()).isEqualTo(executing.attempt())); + assertThat( + store.inspect(new IdempotencyInspectionRequest(SCOPE, FINGERPRINT, original)).outcome()) + .isEqualTo(IdempotencyInspectionOutcome.ABANDONED); + } + + @Test + void competingTransactionCannotPassTheOwnerRowUntilTheFirstBusinessCommit() throws Exception { + CountDownLatch firstHasCompletedInsideTransaction = new CountDownLatch(1); + CountDownLatch allowFirstCommit = new CountDownLatch(1); + IdempotencyClaimRequest firstRequest = + request(store.newClaimAttempt(new OperationId("claim-first")), Duration.ofSeconds(5)); + IdempotencyClaimRequest secondRequest = + request(store.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5)); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future first = + executor.submit( + () -> { + transactions.executeWithoutResult( + ignored -> { + IdempotencyOwner owner = + ((IdempotencyClaimOutcome.Acquired) store.claim(firstRequest)).owner(); + owner = + store + .markExecutionStarted(owner, new OperationId("start-first")) + .owner() + .orElseThrow(); + jdbc.update( + "insert into idempotency_business_probe(scope_hash, mutation_count) " + + "values (?, 1)", + SCOPE.digest()); + assertThat( + store.complete( + owner, + new StoredResponse("done"), + Duration.ofHours(1), + new OperationId("complete-first"))) + .isEqualTo(IdempotencyCompleteOutcome.COMPLETED); + firstHasCompletedInsideTransaction.countDown(); + await(allowFirstCommit); + }); + return null; + }); + + firstHasCompletedInsideTransaction.await(); + Future second = + executor.submit(() -> transactions.execute(ignored -> store.claim(secondRequest))); + + assertThat(second.isDone()).isFalse(); + allowFirstCommit.countDown(); + + first.get(); + assertThat(second.get()).isInstanceOf(IdempotencyClaimOutcome.CompletedReplay.class); + } + + assertThat( + jdbc.queryForObject( + "select mutation_count from idempotency_business_probe where scope_hash = ?", + Integer.class, + SCOPE.digest())) + .isEqualTo(1); + } + + @Test + void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception { + PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.idempotency()); + } + + private static IdempotencyClaimRequest request( + IdempotencyClaimAttempt attempt, Duration processingLease) { + return new IdempotencyClaimRequest( + SCOPE, FINGERPRINT, attempt, processingLease, Duration.ofHours(24), "json.v1", 2); + } + + private static void migrate(String location, String historyTable) { + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + } + + private static void migrateIndependent( + String location, String historyTable, String baselineDescription) { + Flyway flyway = + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineVersion("0") + .baselineDescription(baselineDescription) + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + flyway.baseline(); + flyway.migrate(); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test synchronization interrupted", exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxIntegrationTest.java new file mode 100644 index 00000000..100ff1a0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxIntegrationTest.java @@ -0,0 +1,280 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter; +import dev.caskeleton.application.inbox.InboxClaimAttempt; +import dev.caskeleton.application.inbox.InboxClaimOutcome; +import dev.caskeleton.application.inbox.InboxClaimRequest; +import dev.caskeleton.application.inbox.InboxOwner; +import dev.caskeleton.application.inbox.InboxScopeDigest; +import dev.caskeleton.application.inbox.InboxTransitionOutcome; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +class PostgreSqlInboxIntegrationTest { + + private static final InboxScopeDigest SCOPE = new InboxScopeDigest("a".repeat(64)); + private static final String INTENT = "b".repeat(64); + + private static PostgreSqlReadinessSupport postgres; + private static JdbcTemplate jdbc; + private static TransactionTemplate transactions; + private static PostgreSqlSameStoreInboxAdapter inbox; + + @BeforeAll + static void startAndMigratePostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + migrate("classpath:db/migration/postgresql", "flyway_schema_history"); + migrateIndependent( + "classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption"); + migrateIndependent( + "classpath:db/migration/jpa/inbox", + "flyway_jpa_inbox_history", + "explicit-jpa-inbox-adoption"); + + jdbc = new JdbcTemplate(postgres.dataSource()); + transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource())); + inbox = new PostgreSqlSameStoreInboxAdapter(postgres.dataSource()); + jdbc.update( + "update capability_schema_registry set lifecycle_state = 'ACTIVE' " + + "where capability_id = 'jpa-inbox-same-store-v1'"); + jdbc.execute( + "create table inbox_business_probe (" + + "scope_hash char(64) primary key, mutation_count integer not null)"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @BeforeEach + void clearRows() { + jdbc.update("delete from inbox_record_v1"); + jdbc.update("delete from inbox_business_probe"); + } + + @Test + void claimBusinessMutationAndCompletionCommitOrRollbackAsOneUnit() { + InboxClaimAttempt rolledBackAttempt = inbox.newClaimAttempt(new OperationId("claim-rollback")); + assertThatThrownBy( + () -> + transactions.executeWithoutResult( + ignored -> { + InboxOwner owner = + ((InboxClaimOutcome.Acquired) + inbox.claim(request(rolledBackAttempt, Duration.ofSeconds(5)))) + .owner(); + owner = + inbox + .markProcessing(owner, new OperationId("start-rollback")) + .owner() + .orElseThrow(); + jdbc.update( + "insert into inbox_business_probe(scope_hash, mutation_count) " + + "values (?, 1)", + SCOPE.value()); + throw new IllegalStateException("rollback"); + })) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select count(*) from inbox_record_v1", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class)) + .isZero(); + + InboxClaimAttempt committedAttempt = inbox.newClaimAttempt(new OperationId("claim-commit")); + transactions.executeWithoutResult( + ignored -> { + InboxOwner owner = + ((InboxClaimOutcome.Acquired) + inbox.claim(request(committedAttempt, Duration.ofSeconds(5)))) + .owner(); + owner = + inbox.markProcessing(owner, new OperationId("start-commit")).owner().orElseThrow(); + jdbc.update( + "insert into inbox_business_probe(scope_hash, mutation_count) values (?, 1)", + SCOPE.value()); + assertThat(inbox.complete(owner, new OperationId("complete-commit"))) + .isEqualTo(InboxTransitionOutcome.COMPLETED); + }); + + InboxClaimOutcome redelivery = + transactions.execute( + ignored -> + inbox.claim( + request( + inbox.newClaimAttempt(new OperationId("claim-redelivery")), + Duration.ofSeconds(5)))); + assertThat(redelivery).isInstanceOf(InboxClaimOutcome.Completed.class); + assertThat( + jdbc.queryForObject( + "select mutation_count from inbox_business_probe where scope_hash = ?", + Integer.class, + SCOPE.value())) + .isEqualTo(1); + } + + @Test + void expiredReceivedCanBeTakenOverButStaleOwnerCannotStart() { + InboxOwner stale = + transactions.execute( + ignored -> + ((InboxClaimOutcome.Acquired) + inbox.claim( + request( + inbox.newClaimAttempt(new OperationId("claim-old")), + Duration.ofMillis(25)))) + .owner()); + jdbc.queryForObject("select pg_sleep(0.05)", Object.class); + + InboxClaimOutcome takeover = + transactions.execute( + ignored -> + inbox.claim( + request( + inbox.newClaimAttempt(new OperationId("claim-new")), + Duration.ofSeconds(5)))); + assertThat(takeover) + .isInstanceOfSatisfying( + InboxClaimOutcome.TakenOver.class, + result -> assertThat(result.owner().attempt()).isEqualTo(2)); + assertThat( + transactions + .execute(ignored -> inbox.markProcessing(stale, new OperationId("stale-start"))) + .outcome()) + .isEqualTo(InboxTransitionOutcome.NOT_OWNER); + } + + @Test + void expiredProcessingRequiresRecoveryInsteadOfBlindTakeover() { + InboxClaimAttempt attempt = inbox.newClaimAttempt(new OperationId("claim-processing")); + transactions.executeWithoutResult( + ignored -> { + InboxOwner owner = + ((InboxClaimOutcome.Acquired) inbox.claim(request(attempt, Duration.ofMillis(25)))) + .owner(); + inbox.markProcessing(owner, new OperationId("start-processing")); + }); + jdbc.queryForObject("select pg_sleep(0.05)", Object.class); + + InboxClaimOutcome outcome = + transactions.execute( + ignored -> + inbox.claim( + request( + inbox.newClaimAttempt(new OperationId("claim-after-unknown")), + Duration.ofSeconds(5)))); + assertThat(outcome).isInstanceOf(InboxClaimOutcome.RecoveryRequired.class); + assertThat( + jdbc.queryForObject( + "select state from inbox_record_v1 where scope_hash = ?", + String.class, + SCOPE.value())) + .isEqualTo("DEAD"); + } + + @Test + void takeoverCannotPassTheOwnerRowWhileBusinessTransactionIsOpen() throws Exception { + CountDownLatch firstCompletedInsideTransaction = new CountDownLatch(1); + CountDownLatch allowCommit = new CountDownLatch(1); + InboxClaimRequest first = + request(inbox.newClaimAttempt(new OperationId("claim-first")), Duration.ofMillis(25)); + InboxClaimRequest second = + request(inbox.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5)); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future firstHandler = + executor.submit( + () -> { + transactions.executeWithoutResult( + ignored -> { + InboxOwner owner = ((InboxClaimOutcome.Acquired) inbox.claim(first)).owner(); + owner = + inbox + .markProcessing(owner, new OperationId("start-first")) + .owner() + .orElseThrow(); + jdbc.update( + "insert into inbox_business_probe(scope_hash, mutation_count) " + + "values (?, 1)", + SCOPE.value()); + assertThat(inbox.complete(owner, new OperationId("complete-first"))) + .isEqualTo(InboxTransitionOutcome.COMPLETED); + firstCompletedInsideTransaction.countDown(); + await(allowCommit); + }); + return null; + }); + firstCompletedInsideTransaction.await(); + Future competing = + executor.submit(() -> transactions.execute(ignored -> inbox.claim(second))); + assertThat(competing.isDone()).isFalse(); + allowCommit.countDown(); + firstHandler.get(); + assertThat(competing.get()).isInstanceOf(InboxClaimOutcome.Completed.class); + } + + assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class)) + .isEqualTo(1); + } + + @Test + void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception { + PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.inbox()); + } + + private static InboxClaimRequest request(InboxClaimAttempt attempt, Duration processingLease) { + return new InboxClaimRequest(SCOPE, INTENT, attempt, processingLease, Duration.ofDays(7)); + } + + private static void migrate(String location, String historyTable) { + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + } + + private static void migrateIndependent( + String location, String historyTable, String baselineDescription) { + Flyway flyway = + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineVersion("0") + .baselineDescription(baselineDescription) + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + flyway.baseline(); + flyway.migrate(); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test synchronization interrupted", exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java new file mode 100644 index 00000000..85192b92 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +class PostgreSqlLifecycleIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + + @BeforeAll + static void startPostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @Test + void startsPostgreSql16WithUtcAndProvidesAValidConnection() throws Exception { + try (Connection connection = postgres.connection(); + Statement statement = connection.createStatement(); + ResultSet result = + statement.executeQuery( + "select current_setting('server_version_num'), current_setting('TimeZone')")) { + assertThat(connection.isValid(2)).isTrue(); + assertThat(result.next()).isTrue(); + assertThat(Integer.parseInt(result.getString(1))).isBetween(160_000, 169_999); + assertThat(result.getString(2)).isIn("UTC", "Etc/UTC"); + } + } + + @Test + @Timeout(10) + void poolCapacityExhaustionAndShutdownAreBoundedAndObservable() throws Exception { + PostgreSqlReadinessSupport bounded = PostgreSqlReadinessSupport.start(2, 300); + HikariDataSource dataSource = bounded.dataSource(); + try { + try (Connection first = bounded.connection(); + Connection second = bounded.connection()) { + PoolSnapshot saturated = snapshot(dataSource); + assertThat(saturated.state()).isEqualTo(PoolState.SATURATED); + assertThat(saturated.activeConnections()).isEqualTo(2); + assertThat(saturated.maximumConnections()).isEqualTo(2); + assertThat(saturated.boundedTags()) + .containsExactlyInAnyOrderEntriesOf( + java.util.Map.of("component", "postgresql-primary", "state", "saturated")); + + long started = System.nanoTime(); + assertThatThrownBy(() -> bounded.connection().close()).isInstanceOf(SQLException.class); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(250), Duration.ofSeconds(2)); + } + + assertThat(snapshot(dataSource).activeConnections()).isZero(); + } finally { + bounded.close(); + } + + PoolSnapshot closed = snapshot(dataSource); + assertThat(dataSource.isClosed()).isTrue(); + assertThat(closed.state()).isEqualTo(PoolState.CLOSED); + assertThat(closed.activeConnections()).isZero(); + } + + private static PoolSnapshot snapshot(HikariDataSource dataSource) { + int maximum = dataSource.getMaximumPoolSize(); + if (dataSource.isClosed()) { + return new PoolSnapshot(PoolState.CLOSED, 0, 0, 0, 0, maximum); + } + HikariPoolMXBean pool = dataSource.getHikariPoolMXBean(); + if (pool == null) { + return new PoolSnapshot(PoolState.STARTING, 0, 0, 0, 0, maximum); + } + int active = pool.getActiveConnections(); + int awaiting = pool.getThreadsAwaitingConnection(); + PoolState state = awaiting > 0 || active >= maximum ? PoolState.SATURATED : PoolState.READY; + return new PoolSnapshot( + state, active, pool.getIdleConnections(), pool.getTotalConnections(), awaiting, maximum); + } + + private enum PoolState { + STARTING, + READY, + SATURATED, + CLOSED + } + + private record PoolSnapshot( + PoolState state, + int activeConnections, + int idleConnections, + int totalConnections, + int awaitingConnections, + int maximumConnections) { + + private Map boundedTags() { + return Map.of( + "component", "postgresql-primary", "state", state.name().toLowerCase(Locale.ROOT)); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java new file mode 100644 index 00000000..0697bcbe --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlMigrationIntegrationTest.java @@ -0,0 +1,224 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PostgreSqlMigrationIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + + @BeforeAll + static void startPostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @Test + void adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() throws Exception { + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations("classpath:db/migration/postgresql") + .table("flyway_schema_history") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + + assertThat(appliedVersions(postgres, "flyway_schema_history")) + .containsExactly("1", "3", "4", "5", "6"); + + Flyway coreStream = + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations("classpath:db/migration/jpa/core") + .table("flyway_jpa_core_history") + .baselineVersion("0") + .baselineDescription("explicit-jpa-core-adoption") + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + coreStream.baseline(); + coreStream.migrate(); + + assertThat(appliedVersions(postgres, "flyway_jpa_core_history")).containsExactly("0", "1"); + try (Connection connection = postgres.connection(); + Statement statement = connection.createStatement(); + ResultSet result = + statement.executeQuery( + "select installation_origin, core_epoch, feature_revision " + + "from capability_schema_registry " + + "where capability_id = 'jpa-flyway-migration'")) { + assertThat(result.next()).isTrue(); + assertThat(result.getString(1)).isEqualTo("LEGACY_ADOPTED"); + assertThat(result.getInt(2)).isEqualTo(1); + assertThat(result.getInt(3)).isEqualTo(1); + } + } + + @Test + void freshCoreStreamInitializesWithoutLegacyHistory() throws Exception { + try (PostgreSqlReadinessSupport fresh = PostgreSqlReadinessSupport.start()) { + Flyway.configure() + .dataSource(fresh.dataSource()) + .locations("classpath:db/migration/jpa/core") + .table("flyway_jpa_core_history") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + + assertThat(appliedVersions(fresh, "flyway_jpa_core_history")).containsExactly("1"); + assertThat( + singleValue( + fresh, + "select installation_origin || ':' || lifecycle_state " + + "from capability_schema_registry " + + "where capability_id = 'jpa-flyway-migration'")) + .isEqualTo("FRESH:ACTIVE"); + } + } + + @Test + void interruptedTransactionalMigrationRollsBackThenForwardRecovers() throws Exception { + try (PostgreSqlReadinessSupport interrupted = PostgreSqlReadinessSupport.start()) { + Flyway failing = + Flyway.configure() + .dataSource(interrupted.dataSource()) + .locations("classpath:db/readiness/interrupted/failing") + .table("flyway_readiness_interrupted") + .load(); + + assertThatThrownBy(failing::migrate).isInstanceOf(FlywayException.class); + assertThat( + singleValue( + interrupted, "select to_regclass('public.readiness_interrupted') is null")) + .isEqualTo("t"); + + Flyway.configure() + .dataSource(interrupted.dataSource()) + .locations("classpath:db/readiness/interrupted/recovery") + .table("flyway_readiness_interrupted") + .load() + .migrate(); + + assertThat(appliedVersions(interrupted, "flyway_readiness_interrupted")).containsExactly("1"); + assertThat( + singleValue( + interrupted, "select recovery_marker from readiness_interrupted where id = 1")) + .isEqualTo("FORWARD_RECOVERED"); + } + } + + @Test + void additiveRollingWindowSupportsOldAndNewArtifactsWithFiniteLockTimeout() throws Exception { + try (PostgreSqlReadinessSupport rolling = PostgreSqlReadinessSupport.start()) { + Flyway versionOne = + Flyway.configure() + .dataSource(rolling.dataSource()) + .locations("classpath:db/readiness/rolling") + .table("flyway_readiness_rolling") + .target("1") + .load(); + versionOne.migrate(); + rolling.execute("insert into readiness_rolling(id, legacy_value) values (1, 'n-minus-one')"); + + try (Connection blocker = rolling.connection(); + Statement lock = blocker.createStatement()) { + blocker.setAutoCommit(false); + lock.execute("lock table readiness_rolling in access share mode"); + Flyway blockedExpansion = + Flyway.configure() + .dataSource(rolling.dataSource()) + .locations("classpath:db/readiness/rolling") + .table("flyway_readiness_rolling") + .initSql("set lock_timeout = '250ms'; set statement_timeout = '2s'") + .load(); + long started = System.nanoTime(); + assertThatThrownBy(blockedExpansion::migrate).isInstanceOf(FlywayException.class); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(200), Duration.ofSeconds(3)); + blocker.rollback(); + } + + assertThat( + singleValue( + rolling, + "select count(*) from information_schema.columns " + + "where table_schema = 'public' " + + "and table_name = 'readiness_rolling' " + + "and column_name = 'expanded_value'")) + .isEqualTo("0"); + + Flyway.configure() + .dataSource(rolling.dataSource()) + .locations("classpath:db/readiness/rolling") + .table("flyway_readiness_rolling") + .initSql("set lock_timeout = '2s'; set statement_timeout = '5s'") + .load() + .migrate(); + + rolling.execute( + "insert into readiness_rolling(id, legacy_value) values (2, 'old-after-expand')"); + rolling.execute( + "insert into readiness_rolling(id, legacy_value, expanded_value) " + + "values (3, 'new-compatible', 'new-value')"); + + assertThat( + singleValue( + rolling, + "select string_agg(legacy_value, ',' order by id) from readiness_rolling")) + .isEqualTo("n-minus-one,old-after-expand,new-compatible"); + assertThat( + singleValue( + rolling, + "select string_agg(coalesce(expanded_value, legacy_value), ',' order by id) " + + "from readiness_rolling")) + .isEqualTo("n-minus-one,old-after-expand,new-value"); + assertThat(appliedVersions(rolling, "flyway_readiness_rolling")).containsExactly("1", "2"); + } + } + + private static List appliedVersions( + PostgreSqlReadinessSupport database, String historyTable) throws Exception { + List versions = new ArrayList<>(); + try (Connection connection = database.connection(); + Statement statement = connection.createStatement(); + ResultSet rows = + statement.executeQuery( + "select version from " + historyTable + " where success order by installed_rank")) { + while (rows.next()) { + versions.add(rows.getString(1)); + } + } + return versions; + } + + private static String singleValue(PostgreSqlReadinessSupport database, String sql) + throws Exception { + try (Connection connection = database.connection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java new file mode 100644 index 00000000..e2270af5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java @@ -0,0 +1,220 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.Statement; +import java.time.Duration; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** Real-PostgreSQL lifecycle matrix shared by the schema-bearing optional capability cards. */ +final class PostgreSqlOptionalStreamLifecycle { + + record Stream( + String cardId, + String location, + String historyTable, + int featureRevision, + List ownedTables, + List prerequisites) {} + + private PostgreSqlOptionalStreamLifecycle() {} + + static Stream idempotency() { + return new Stream( + "jpa-idempotency-owner-safe-v2", + "classpath:db/migration/jpa/idempotency", + "flyway_jpa_idempotency_history", + 2, + List.of("idempotency_record"), + List.of()); + } + + static Stream outboxStorage() { + return new Stream( + "jpa-outbox-storage-v2", + "classpath:db/migration/jpa/outbox-storage", + "flyway_jpa_outbox_storage_history", + 2, + List.of( + "outbox_event", + "outbox_publication_control_v2", + "outbox_publication_cutover_v2", + "outbox_event_identity_v2", + "outbox_event_log_v2"), + List.of()); + } + + static Stream outboxPolling() { + return new Stream( + "jpa-outbox-polling-delivery-v2", + "classpath:db/migration/jpa/outbox-polling", + "flyway_jpa_outbox_polling_history", + 2, + List.of("outbox_delivery_v2"), + List.of(outboxStorage())); + } + + static Stream inbox() { + return new Stream( + "jpa-inbox-same-store-v1", + "classpath:db/migration/jpa/inbox", + "flyway_jpa_inbox_history", + 1, + List.of("inbox_record_v1"), + List.of()); + } + + static void verify(Stream stream) throws Exception { + verifyFreshDisabledEnableDisableReEnable(stream); + verifyInterruptedRecovery(stream); + } + + private static void verifyFreshDisabledEnableDisableReEnable(Stream stream) throws Exception { + try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) { + prepareFreshCoreAndPrerequisites(database, stream); + JdbcTemplate jdbc = new JdbcTemplate(database.dataSource()); + + assertThat(relationExists(jdbc, stream.historyTable())).isFalse(); + assertThat(markerCount(jdbc, stream.cardId())).isZero(); + stream.ownedTables().forEach(table -> assertThat(relationExists(jdbc, table)).isFalse()); + + Flyway flyway = independent(database, stream); + flyway.baseline(); + flyway.migrate(); + + assertThat(appliedVersions(jdbc, stream.historyTable())).containsExactly("0", "1"); + assertMarker(jdbc, stream, "INSTALLED_INACTIVE"); + stream.ownedTables().forEach(table -> assertThat(relationExists(jdbc, table)).isTrue()); + + setLifecycle(jdbc, stream.cardId(), "ACTIVE"); + assertMarker(jdbc, stream, "ACTIVE"); + setLifecycle(jdbc, stream.cardId(), "INSTALLED_INACTIVE"); + assertMarker(jdbc, stream, "INSTALLED_INACTIVE"); + assertThat(appliedVersions(jdbc, stream.historyTable())).containsExactly("0", "1"); + stream.ownedTables().forEach(table -> assertThat(relationExists(jdbc, table)).isTrue()); + + flyway.validate(); + flyway.migrate(); + setLifecycle(jdbc, stream.cardId(), "ACTIVE"); + assertMarker(jdbc, stream, "ACTIVE"); + assertThat(appliedVersions(jdbc, stream.historyTable())).containsExactly("0", "1"); + } + } + + private static void verifyInterruptedRecovery(Stream stream) throws Exception { + try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) { + prepareFreshCoreAndPrerequisites(database, stream); + JdbcTemplate jdbc = new JdbcTemplate(database.dataSource()); + Flyway flyway = + Flyway.configure() + .dataSource(database.dataSource()) + .locations(stream.location()) + .table(stream.historyTable()) + .baselineVersion("0") + .baselineDescription("explicit-" + stream.cardId() + "-interrupted") + .baselineOnMigrate(false) + .outOfOrder(false) + .initSql("set lock_timeout = '250ms'; set statement_timeout = '2s'") + .load(); + flyway.baseline(); + + try (Connection blocker = database.connection(); + Statement lock = blocker.createStatement()) { + blocker.setAutoCommit(false); + lock.execute("lock table capability_schema_registry in access exclusive mode"); + long started = System.nanoTime(); + assertThatThrownBy(flyway::migrate).isInstanceOf(FlywayException.class); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(200), Duration.ofSeconds(3)); + blocker.rollback(); + } + + assertThat(appliedVersions(jdbc, stream.historyTable())).containsExactly("0"); + assertThat(markerCount(jdbc, stream.cardId())).isZero(); + stream.ownedTables().forEach(table -> assertThat(relationExists(jdbc, table)).isFalse()); + + flyway.migrate(); + assertThat(appliedVersions(jdbc, stream.historyTable())).containsExactly("0", "1"); + assertMarker(jdbc, stream, "INSTALLED_INACTIVE"); + stream.ownedTables().forEach(table -> assertThat(relationExists(jdbc, table)).isTrue()); + } + } + + private static void prepareFreshCoreAndPrerequisites( + PostgreSqlReadinessSupport database, Stream target) { + Stream core = + new Stream( + "jpa-flyway-migration", + "classpath:db/migration/jpa/core", + "flyway_jpa_core_history", + 1, + List.of("capability_schema_registry"), + List.of()); + migrateAndActivate(database, core); + target.prerequisites().forEach(prerequisite -> migrateAndActivate(database, prerequisite)); + } + + private static void migrateAndActivate(PostgreSqlReadinessSupport database, Stream stream) { + Flyway flyway = independent(database, stream); + flyway.baseline(); + flyway.migrate(); + setLifecycle(new JdbcTemplate(database.dataSource()), stream.cardId(), "ACTIVE"); + } + + private static Flyway independent(PostgreSqlReadinessSupport database, Stream stream) { + return Flyway.configure() + .dataSource(database.dataSource()) + .locations(stream.location()) + .table(stream.historyTable()) + .baselineVersion("0") + .baselineDescription("explicit-" + stream.cardId() + "-adoption") + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + } + + private static boolean relationExists(JdbcTemplate jdbc, String relation) { + return Boolean.TRUE.equals( + jdbc.queryForObject( + "select to_regclass(?) is not null", Boolean.class, "public." + relation)); + } + + private static int markerCount(JdbcTemplate jdbc, String cardId) { + return jdbc.queryForObject( + "select count(*) from capability_schema_registry where capability_id = ?", + Integer.class, + cardId); + } + + private static List appliedVersions(JdbcTemplate jdbc, String historyTable) { + return jdbc.query( + "select version from " + historyTable + " where success order by installed_rank", + (result, row) -> result.getString(1)); + } + + private static void setLifecycle(JdbcTemplate jdbc, String cardId, String state) { + assertThat( + jdbc.update( + "update capability_schema_registry " + + "set lifecycle_state = ?, updated_at = clock_timestamp() " + + "where capability_id = ?", + state, + cardId)) + .isOne(); + } + + private static void assertMarker(JdbcTemplate jdbc, Stream stream, String lifecycle) { + List marker = + jdbc.query( + "select installation_origin || ':' || core_epoch || ':' || feature_revision || ':' " + + "|| lifecycle_state from capability_schema_registry where capability_id = ?", + (result, row) -> result.getString(1), + stream.cardId()); + assertThat(marker).containsExactly("FRESH:1:" + stream.featureRevision() + ":" + lifecycle); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxPollingIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxPollingIntegrationTest.java new file mode 100644 index 00000000..f70ded4b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxPollingIntegrationTest.java @@ -0,0 +1,267 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter; +import dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter; +import dev.caskeleton.application.outbox.v2.ClaimedOutboxDelivery; +import dev.caskeleton.application.outbox.v2.NewOutboxEventV2; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryClaimRequest; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransition; +import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransitionOutcome; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +class PostgreSqlOutboxPollingIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + private static JdbcTemplate jdbc; + private static TransactionTemplate transactions; + private static PostgreSqlImmutableOutboxAppendAdapter append; + private static PostgreSqlPollingDeliveryAdapter delivery; + + @BeforeAll + static void startAndMigratePostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + migrate("classpath:db/migration/postgresql", "flyway_schema_history"); + migrateIndependent( + "classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption"); + migrateIndependent( + "classpath:db/migration/jpa/outbox-storage", + "flyway_jpa_outbox_storage_history", + "explicit-jpa-outbox-storage-adoption"); + migrateIndependent( + "classpath:db/migration/jpa/outbox-polling", + "flyway_jpa_outbox_polling_history", + "explicit-jpa-outbox-polling-adoption"); + + jdbc = new JdbcTemplate(postgres.dataSource()); + transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource())); + append = new PostgreSqlImmutableOutboxAppendAdapter(postgres.dataSource()); + delivery = new PostgreSqlPollingDeliveryAdapter(postgres.dataSource()); + jdbc.update( + "update capability_schema_registry set lifecycle_state = 'ACTIVE' " + + "where capability_id in (" + + "'jpa-outbox-storage-v2', 'jpa-outbox-polling-delivery-v2')"); + transactions.executeWithoutResult(ignored -> cutoverToPollingV2()); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @BeforeEach + void clearRows() { + jdbc.update("delete from outbox_delivery_v2"); + jdbc.update("delete from outbox_event_log_v2"); + jdbc.update("delete from outbox_event_identity_v2"); + } + + @Test + void eventAndInitialDeliveryAreInsertedInTheSameBusinessTransaction() { + assertThatThrownBy(() -> append.append(event("event-no-tx", 1))) + .isInstanceOf(IllegalStateException.class); + + assertThatThrownBy( + () -> + transactions.executeWithoutResult( + ignored -> { + append.append(event("event-rollback", 1)); + throw new IllegalStateException("rollback"); + })) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select count(*) from outbox_event_log_v2", Integer.class)) + .isZero(); + assertThat(jdbc.queryForObject("select count(*) from outbox_delivery_v2", Integer.class)) + .isZero(); + + transactions.executeWithoutResult(ignored -> append.append(event("event-commit", 1))); + assertThat(jdbc.queryForObject("select count(*) from outbox_event_log_v2", Integer.class)) + .isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from outbox_delivery_v2", Integer.class)) + .isEqualTo(1); + } + + @Test + void strictAggregateOrderClaimsOnlyTheHeadUntilItIsPublished() { + transactions.executeWithoutResult( + ignored -> { + append.append(event("event-v1", 1)); + append.append(event("event-v2", 2)); + }); + + List first = + transactions.execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-1", 10, Duration.ofSeconds(30)))); + assertThat(first).extracting(item -> item.owner().eventId()).containsExactly("event-v1"); + + OutboxDeliveryTransition transition = + new OutboxDeliveryTransition(first.getFirst().owner(), new OperationId("publish-event-v1")); + transactions.executeWithoutResult( + ignored -> { + assertThat(delivery.markPublished(transition)) + .isEqualTo(OutboxDeliveryTransitionOutcome.PUBLISHED); + assertThat(delivery.markPublished(transition)) + .isEqualTo(OutboxDeliveryTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION); + }); + + List second = + transactions.execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-1", 10, Duration.ofSeconds(30)))); + assertThat(second).extracting(item -> item.owner().eventId()).containsExactly("event-v2"); + } + + @Test + void publishAckLossReclaimsTheStableEventIdButRejectsTheStaleOwner() { + transactions.executeWithoutResult(ignored -> append.append(event("event-ack-loss", 1))); + ClaimedOutboxDelivery first = + transactions + .execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-old", 1, Duration.ofMillis(25)))) + .getFirst(); + jdbc.queryForObject("select pg_sleep(0.05)", Object.class); + + ClaimedOutboxDelivery reclaimed = + transactions + .execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-new", 1, Duration.ofSeconds(30)))) + .getFirst(); + + assertThat(reclaimed.owner().eventId()).isEqualTo(first.owner().eventId()); + assertThat(reclaimed.owner().attempt()).isEqualTo(2); + OutboxDeliveryTransitionOutcome staleResult = + transactions.execute( + ignored -> + delivery.markPublished( + new OutboxDeliveryTransition(first.owner(), new OperationId("stale-publish")))); + assertThat(staleResult).isEqualTo(OutboxDeliveryTransitionOutcome.NOT_OWNER); + } + + @Test + void retryWaitUsesTheRequestedScheduleAndDeadHeadBlocksTheAggregate() { + transactions.executeWithoutResult( + ignored -> { + append.append(event("event-retry-v1", 1)); + append.append(event("event-retry-v2", 2)); + }); + ClaimedOutboxDelivery head = + transactions + .execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-1", 10, Duration.ofSeconds(30)))) + .getFirst(); + OutboxDeliveryTransition dead = + new OutboxDeliveryTransition(head.owner(), new OperationId("dead-head")); + OutboxDeliveryTransitionOutcome deadResult = + transactions.execute(ignored -> delivery.markDead(dead, "BROKER.PERMANENT")); + assertThat(deadResult).isEqualTo(OutboxDeliveryTransitionOutcome.DEAD); + + List blocked = + transactions.execute( + ignored -> + delivery.claimBatch( + new OutboxDeliveryClaimRequest( + "portfolio.events", "relay-1", 10, Duration.ofSeconds(30)))); + assertThat(blocked).isEmpty(); + } + + @Test + void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception { + PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.outboxPolling()); + } + + private static void cutoverToPollingV2() { + jdbc.queryForObject( + "select active_epoch from outbox_publication_control_v2 " + + "where scope_id = 'PRIMARY' for update", + Long.class); + jdbc.update( + "insert into outbox_publication_cutover_v2(" + + "scope_id, active_epoch, previous_epoch, transition_kind, active_authority, " + + "legacy_row_count, legacy_pending_count, legacy_digest, schema_manifest_id, " + + "external_manifest_id, activated_at" + + ") values (" + + "'PRIMARY', 2, 1, 'CUTOVER', 'POLLING_V2', 0, 0, ?, " + + "'jpa-outbox-storage-v2-schema-revision-2', null, clock_timestamp())", + "0".repeat(64)); + jdbc.update( + "update outbox_publication_control_v2 " + + "set active_epoch = 2, active_authority = 'POLLING_V2', " + + "revision = 1, updated_at = clock_timestamp() " + + "where scope_id = 'PRIMARY' and active_epoch = 1"); + } + + private static NewOutboxEventV2 event(String eventId, long aggregateVersion) { + return new NewOutboxEventV2( + eventId, + "WorkLog", + "work-log-42", + aggregateVersion, + 0, + "WorkLogChanged", + 1, + "portfolio.events", + "work-log-42", + "application/json", + "correlation-1", + null, + Instant.parse("2026-07-28T12:00:00Z"), + "{\"version\":" + aggregateVersion + "}"); + } + + private static void migrate(String location, String historyTable) { + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + } + + private static void migrateIndependent( + String location, String historyTable, String baselineDescription) { + Flyway flyway = + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineVersion("0") + .baselineDescription(baselineDescription) + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + flyway.baseline(); + flyway.migrate(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java new file mode 100644 index 00000000..0975e4ff --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java @@ -0,0 +1,291 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter; +import dev.caskeleton.application.outbox.v2.NewOutboxEventV2; +import dev.caskeleton.application.outbox.v2.OutboxAppendOutcome; +import dev.caskeleton.application.outbox.v2.OutboxAppendReceipt; +import dev.caskeleton.application.outbox.v2.OutboxDispatchAuthority; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class PostgreSqlOutboxStorageIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + private static JdbcTemplate jdbc; + private static TransactionTemplate transactions; + private static PostgreSqlImmutableOutboxAppendAdapter adapter; + + @BeforeAll + static void startAndMigratePostgreSql() { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + migrate("classpath:db/migration/postgresql", "flyway_schema_history"); + migrateIndependent( + "classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption"); + migrateIndependent( + "classpath:db/migration/jpa/outbox-storage", + "flyway_jpa_outbox_storage_history", + "explicit-jpa-outbox-storage-adoption"); + + jdbc = new JdbcTemplate(postgres.dataSource()); + transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource())); + adapter = new PostgreSqlImmutableOutboxAppendAdapter(postgres.dataSource()); + jdbc.update( + "update capability_schema_registry set lifecycle_state = 'ACTIVE' " + + "where capability_id = 'jpa-outbox-storage-v2'"); + jdbc.execute( + "create table outbox_business_probe (" + + "aggregate_id varchar(256) primary key, mutation_count integer not null)"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @BeforeEach + void clearRowsAndRestoreLegacyAuthority(TestInfo testInfo) { + if (testInfo + .getTestMethod() + .filter( + method -> + method + .getName() + .equals( + "optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration")) + .isPresent()) { + return; + } + jdbc.update("delete from outbox_event_log_v2"); + jdbc.update("delete from outbox_event_identity_v2"); + jdbc.update("delete from outbox_business_probe"); + jdbc.update( + "update outbox_publication_control_v2 " + + "set active_epoch = 1, active_authority = 'LEGACY_POLLING', " + + "state = 'ACTIVE', revision = 0, updated_at = clock_timestamp() " + + "where scope_id = 'PRIMARY'"); + jdbc.update("delete from outbox_publication_cutover_v2 where active_epoch > 1"); + } + + @Test + @Order(1) + void appendRequiresTheCallerSameDatasourceWriteTransaction() { + assertThatThrownBy(() -> adapter.append(event("event-no-tx", 1, 0, "{}"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active primary write transaction"); + } + + @Test + @Order(2) + void immutableIdentityEnvelopeAndBusinessMutationCommitOrRollbackTogether() { + assertThatThrownBy( + () -> + transactions.executeWithoutResult( + ignored -> { + jdbc.update( + "insert into outbox_business_probe(aggregate_id, mutation_count) " + + "values ('work-log-42', 1)"); + adapter.append(event("event-rollback", 1, 0, "{\"rollback\":true}")); + throw new IllegalStateException("force rollback"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessage("force rollback"); + assertThat(jdbc.queryForObject("select count(*) from outbox_event_identity_v2", Integer.class)) + .isZero(); + assertThat(jdbc.queryForObject("select count(*) from outbox_business_probe", Integer.class)) + .isZero(); + + OutboxAppendReceipt appended = + transactions.execute( + ignored -> { + jdbc.update( + "insert into outbox_business_probe(aggregate_id, mutation_count) " + + "values ('work-log-42', 1)"); + return adapter.append(event("event-commit", 1, 0, "{\"ok\":true}")); + }); + + assertThat(appended.outcome()).isEqualTo(OutboxAppendOutcome.APPENDED); + assertThat(appended.dispatchAuthority()).isEqualTo(OutboxDispatchAuthority.LEGACY_SHADOW); + assertThat(jdbc.queryForObject("select count(*) from outbox_event_log_v2", Integer.class)) + .isEqualTo(1); + assertThat(jdbc.queryForObject("select count(*) from outbox_business_probe", Integer.class)) + .isEqualTo(1); + } + + @Test + @Order(3) + void duplicateIdentityDistinguishesSameEventIdConflictAndAggregateOrderConflict() { + NewOutboxEventV2 original = event("event-1", 7, 0, "{\"value\":1}"); + OutboxAppendReceipt first = transactions.execute(ignored -> adapter.append(original)); + OutboxAppendReceipt replay = transactions.execute(ignored -> adapter.append(original)); + OutboxAppendReceipt eventIdConflict = + transactions.execute(ignored -> adapter.append(event("event-1", 7, 0, "{\"value\":2}"))); + OutboxAppendReceipt orderConflict = + transactions.execute( + ignored -> adapter.append(event("event-other", 7, 0, "{\"value\":1}"))); + + assertThat(first.outcome()).isEqualTo(OutboxAppendOutcome.APPENDED); + assertThat(replay.outcome()).isEqualTo(OutboxAppendOutcome.ALREADY_APPENDED_SAME_EVENT); + assertThat(eventIdConflict.outcome()).isEqualTo(OutboxAppendOutcome.EVENT_ID_CONFLICT); + assertThat(orderConflict.outcome()).isEqualTo(OutboxAppendOutcome.AGGREGATE_ORDER_CONFLICT); + assertThat(jdbc.queryForObject("select count(*) from outbox_event_identity_v2", Integer.class)) + .isEqualTo(1); + } + + @Test + @Order(4) + void publicationControlShareLockPreventsCutoverFromOvertakingAnAppend() throws Exception { + CountDownLatch appendHasLockedControl = new CountDownLatch(1); + CountDownLatch allowAppendCommit = new CountDownLatch(1); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future append = + executor.submit( + () -> { + transactions.executeWithoutResult( + ignored -> { + OutboxAppendReceipt receipt = + adapter.append(event("event-before-cutover", 1, 0, "{}")); + assertThat(receipt.dispatchAuthority()) + .isEqualTo(OutboxDispatchAuthority.LEGACY_SHADOW); + appendHasLockedControl.countDown(); + await(allowAppendCommit); + }); + return null; + }); + + appendHasLockedControl.await(); + Future cutover = + executor.submit( + () -> { + transactions.executeWithoutResult(ignored -> cutoverToPollingV2()); + return null; + }); + + assertThat(cutover.isDone()).isFalse(); + allowAppendCommit.countDown(); + append.get(); + cutover.get(); + } + + OutboxAppendReceipt afterCutover = + transactions.execute(ignored -> adapter.append(event("event-after-cutover", 2, 0, "{}"))); + assertThat(afterCutover.publicationEpoch()).isEqualTo(2); + assertThat(afterCutover.dispatchAuthority()).isEqualTo(OutboxDispatchAuthority.POLLING_V2); + + assertThatThrownBy( + () -> + jdbc.update( + "insert into outbox_event(" + + "event_id, aggregate_id, event_type, payload, occurred_at, status, " + + "attempt_count, next_attempt_at, correlation_id, idempotency_key" + + ") values (" + + "'legacy-after-cutover', 'work-log-42', 'Legacy', '{}', " + + "clock_timestamp(), 'PENDING', 0, clock_timestamp(), 'c', 'i')")) + .isInstanceOf(DataAccessException.class) + .hasMessageContaining("legacy outbox writer is fenced"); + } + + @Test + @Order(5) + void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception { + PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.outboxStorage()); + } + + private static void cutoverToPollingV2() { + jdbc.queryForObject( + "select active_epoch from outbox_publication_control_v2 " + + "where scope_id = 'PRIMARY' for update", + Long.class); + jdbc.update( + "insert into outbox_publication_cutover_v2(" + + "scope_id, active_epoch, previous_epoch, transition_kind, active_authority, " + + "legacy_row_count, legacy_pending_count, legacy_digest, schema_manifest_id, " + + "external_manifest_id, activated_at" + + ") values (" + + "'PRIMARY', 2, 1, 'CUTOVER', 'POLLING_V2', 0, 0, ?, " + + "'jpa-outbox-storage-v2-schema-revision-2', null, clock_timestamp())", + "0".repeat(64)); + jdbc.update( + "update outbox_publication_control_v2 " + + "set active_epoch = 2, active_authority = 'POLLING_V2', " + + "revision = revision + 1, updated_at = clock_timestamp() " + + "where scope_id = 'PRIMARY' and active_epoch = 1 and revision = 0"); + } + + private static NewOutboxEventV2 event( + String eventId, long aggregateVersion, int ordinal, String payload) { + return new NewOutboxEventV2( + eventId, + "WorkLog", + "work-log-42", + aggregateVersion, + ordinal, + "WorkLogChanged", + 1, + "portfolio.events", + "work-log-42", + "application/json", + "correlation-1", + null, + Instant.parse("2026-07-28T12:00:00Z"), + payload); + } + + private static void migrate(String location, String historyTable) { + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + } + + private static void migrateIndependent( + String location, String historyTable, String baselineDescription) { + Flyway flyway = + Flyway.configure() + .dataSource(postgres.dataSource()) + .locations(location) + .table(historyTable) + .baselineVersion("0") + .baselineDescription(baselineDescription) + .baselineOnMigrate(false) + .outOfOrder(false) + .load(); + flyway.baseline(); + flyway.migrate(); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test synchronization interrupted", exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java new file mode 100644 index 00000000..9ff0e5d3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PostgreSqlQueryIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + + @BeforeAll + static void startPostgreSql() throws Exception { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + postgres.execute( + "create table readiness_query(" + + "id uuid primary key, occurred_at timestamptz not null, payload text not null)"); + postgres.execute("create index ix_readiness_query_keyset on readiness_query(occurred_at,id)"); + postgres.execute( + "insert into readiness_query(id,occurred_at,payload) " + + "select gen_random_uuid(), " + + "timestamptz '2026-01-01T00:00:00Z' + (n || ' milliseconds')::interval, " + + "'payload-' || n from generate_series(1,1000) n"); + postgres.execute("analyze readiness_query"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @Test + void boundedKeysetQueryUsesTheRepresentativeIndex() throws Exception { + OffsetDateTime cursorTime = OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + UUID cursorId = new UUID(0, 0); + List ids = new ArrayList<>(); + String query = + "select id from readiness_query " + + "where (occurred_at,id) > (?,?) " + + "order by occurred_at,id limit ?"; + + try (Connection connection = postgres.connection(); + PreparedStatement statement = connection.prepareStatement(query)) { + statement.setObject(1, cursorTime); + statement.setObject(2, cursorId); + statement.setInt(3, 25); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + ids.add(rows.getObject(1, UUID.class)); + } + } + } + assertThat(ids).hasSize(25).doesNotHaveDuplicates(); + + try (Connection connection = postgres.connection(); + Statement setup = connection.createStatement()) { + setup.execute("set enable_seqscan=off"); + try (PreparedStatement explain = + connection.prepareStatement("explain (format json) " + query)) { + explain.setObject(1, cursorTime); + explain.setObject(2, cursorId); + explain.setInt(3, 25); + try (ResultSet plan = explain.executeQuery()) { + assertThat(plan.next()).isTrue(); + assertThat(plan.getString(1)).contains("ix_readiness_query_keyset"); + } + } + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java new file mode 100644 index 00000000..89feafdf --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlReadinessSupport.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.MountableFile; + +/** Shared no-skip PostgreSQL 16 container support for one readiness test class. */ +final class PostgreSqlReadinessSupport implements AutoCloseable { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private final PostgreSQLContainer container; + private final HikariDataSource dataSource; + + private PostgreSqlReadinessSupport(PostgreSQLContainer container, HikariDataSource dataSource) { + this.container = container; + this.dataSource = dataSource; + } + + static PostgreSqlReadinessSupport start() { + return start(5, 1_000); + } + + static PostgreSqlReadinessSupport start(int maximumPoolSize, long connectionTimeoutMillis) { + assertDockerAvailable(); + PostgreSQLContainer container = new PostgreSQLContainer(IMAGE).withReuse(false); + container.start(); + return support(container, maximumPoolSize, connectionTimeoutMillis); + } + + static PostgreSqlReadinessSupport startTls(PostgreSqlTlsMaterial tlsMaterial) + throws SQLException { + assertDockerAvailable(); + PostgreSQLContainer container = new PostgreSQLContainer(IMAGE).withReuse(false); + container.start(); + try { + configureTls(container, tlsMaterial); + return support(container, 5, 1_000); + } catch (SQLException | RuntimeException exception) { + container.stop(); + throw exception; + } + } + + private static PostgreSqlReadinessSupport support( + PostgreSQLContainer container, int maximumPoolSize, long connectionTimeoutMillis) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(maximumPoolSize); + config.setMinimumIdle(Math.min(1, maximumPoolSize)); + config.setConnectionTimeout(connectionTimeoutMillis); + return new PostgreSqlReadinessSupport(container, new HikariDataSource(config)); + } + + private static void configureTls(PostgreSQLContainer container, PostgreSqlTlsMaterial tlsMaterial) + throws SQLException { + container.copyFileToContainer( + MountableFile.forHostPath(tlsMaterial.serverCertificate()), + "/var/lib/postgresql/server.crt"); + container.copyFileToContainer( + MountableFile.forHostPath(tlsMaterial.serverPrivateKey()), + "/var/lib/postgresql/server.key"); + try { + ExecResult permissions = + container.execInContainer( + "sh", + "-c", + "chown postgres:postgres /var/lib/postgresql/server.crt " + + "/var/lib/postgresql/server.key " + + "&& chmod 0644 /var/lib/postgresql/server.crt " + + "&& chmod 0600 /var/lib/postgresql/server.key"); + if (permissions.getExitCode() != 0) { + throw new IllegalStateException( + "failed to secure PostgreSQL TLS fixture files: " + permissions.getStderr()); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while configuring PostgreSQL TLS", exception); + } catch (java.io.IOException exception) { + throw new IllegalStateException("failed to configure PostgreSQL TLS", exception); + } + + try (Connection connection = + DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + statement.execute("alter system set ssl = 'on'"); + statement.execute("alter system set ssl_cert_file = '/var/lib/postgresql/server.crt'"); + statement.execute("alter system set ssl_key_file = '/var/lib/postgresql/server.key'"); + statement.execute("select pg_reload_conf()"); + } + } + + static void assertDockerAvailable() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for JPA readiness evidence; skipping is forbidden"); + } + } + + HikariDataSource dataSource() { + return dataSource; + } + + Connection connection() throws SQLException { + return dataSource.getConnection(); + } + + Connection connection(String username, String password) throws SQLException { + return DriverManager.getConnection(container.getJdbcUrl(), username, password); + } + + Connection tlsConnection(String host, Path rootCertificate) throws SQLException { + String jdbcUrl = + "jdbc:postgresql://" + + host + + ":" + + container.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT) + + "/" + + container.getDatabaseName(); + Properties properties = new Properties(); + properties.setProperty("user", container.getUsername()); + properties.setProperty("password", container.getPassword()); + properties.setProperty("sslmode", "verify-full"); + properties.setProperty("sslrootcert", rootCertificate.toAbsolutePath().toString()); + properties.setProperty("connectTimeout", "3"); + return DriverManager.getConnection(jdbcUrl, properties); + } + + void execute(String sql) throws SQLException { + try (Connection connection = connection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + @Override + public void close() { + dataSource.close(); + container.stop(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java new file mode 100644 index 00000000..a77c76ac --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PostgreSqlSecurityBaselineIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + private static PostgreSqlTlsMaterial trustedTls; + private static PostgreSqlReadinessSupport tlsPostgres; + + @BeforeAll + static void startPostgreSql() throws Exception { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + trustedTls = PostgreSqlTlsMaterial.generate(false); + tlsPostgres = PostgreSqlReadinessSupport.startTls(trustedTls); + } + + @AfterAll + static void stopPostgreSql() { + if (tlsPostgres != null) { + tlsPostgres.close(); + } + if (trustedTls != null) { + trustedTls.close(); + } + if (postgres != null) { + postgres.close(); + } + } + + @Test + void runtimeRoleCannotCreateInApplicationSchemaOrTempAndUsesTrustedSearchPath() throws Exception { + String runtimePassword = UUID.randomUUID().toString(); + try (Connection connection = postgres.connection(); + Statement statement = connection.createStatement()) { + statement.execute("create schema ca_readiness authorization current_user"); + statement.execute( + """ + create function pg_temp.create_ca_readiness_runtime(role_password text) + returns void + language plpgsql + as $fixture$ + begin + execute format( + 'create role ca_readiness_runtime login password %L', + role_password + ); + end + $fixture$ + """); + try (PreparedStatement createRole = + connection.prepareStatement("select pg_temp.create_ca_readiness_runtime(?)")) { + createRole.setString(1, runtimePassword); + createRole.execute(); + } + statement.execute("revoke create on schema ca_readiness from public"); + statement.execute("grant usage on schema ca_readiness to ca_readiness_runtime"); + statement.execute("revoke create on schema public from public"); + statement.execute("revoke temporary on database test from public"); + statement.execute( + "alter role ca_readiness_runtime in database test " + + "set search_path = ca_readiness, pg_catalog"); + statement.execute("create table ca_readiness.allowed_table(id bigint primary key)"); + statement.execute("create schema ca_untrusted authorization ca_readiness_runtime"); + statement.execute("create table ca_untrusted.allowed_table(id bigint primary key)"); + statement.execute("insert into ca_untrusted.allowed_table(id) values (999)"); + statement.execute( + "grant select, insert, update, delete on ca_readiness.allowed_table " + + "to ca_readiness_runtime"); + } + + try (Connection runtime = postgres.connection("ca_readiness_runtime", runtimePassword); + Statement runtimeStatement = runtime.createStatement()) { + assertThat(singleValue(runtimeStatement, "show search_path")) + .isEqualTo("ca_readiness, pg_catalog"); + runtimeStatement.execute("insert into ca_readiness.allowed_table(id) values (1)"); + assertThat(singleValue(runtimeStatement, "select count(*) from ca_readiness.allowed_table")) + .isEqualTo("1"); + assertThat(singleValue(runtimeStatement, "select min(id) from allowed_table")).isEqualTo("1"); + + assertDenied( + runtimeStatement, "create table ca_readiness.forbidden_table(id bigint)", "42501"); + assertDenied(runtimeStatement, "create temporary table forbidden_temp(id bigint)", "42501"); + } + + try (Connection connection = postgres.connection(); + Statement statement = connection.createStatement()) { + try (ResultSet role = + statement.executeQuery( + "select rolsuper, rolcreatedb, rolcreaterole, rolbypassrls " + + "from pg_roles where rolname = 'ca_readiness_runtime'")) { + assertThat(role.next()).isTrue(); + assertThat(role.getBoolean(1)).isFalse(); + assertThat(role.getBoolean(2)).isFalse(); + assertThat(role.getBoolean(3)).isFalse(); + assertThat(role.getBoolean(4)).isFalse(); + } + + statement.execute("alter role ca_readiness_runtime nologin"); + assertThatThrownBy(() -> postgres.connection("ca_readiness_runtime", runtimePassword).close()) + .isInstanceOf(SQLException.class); + statement.execute("drop schema ca_readiness cascade"); + statement.execute("drop schema ca_untrusted cascade"); + statement.execute("drop role ca_readiness_runtime"); + } + } + + @Test + void verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate() + throws Exception { + try (Connection connection = + tlsPostgres.tlsConnection("localhost", trustedTls.caCertificate()); + Statement statement = connection.createStatement()) { + assertThat(singleValue(statement, "select ssl from pg_stat_ssl where pid = pg_backend_pid()")) + .isEqualTo("t"); + } + + assertThatThrownBy( + () -> tlsPostgres.tlsConnection("127.0.0.1", trustedTls.caCertificate()).close()) + .isInstanceOf(SQLException.class); + + try (PostgreSqlTlsMaterial untrustedTls = PostgreSqlTlsMaterial.generate(false)) { + assertThatThrownBy( + () -> tlsPostgres.tlsConnection("localhost", untrustedTls.caCertificate()).close()) + .isInstanceOf(SQLException.class); + } + } + + @Test + void verifyFullRejectsAnExpiredServerCertificate() throws Exception { + try (PostgreSqlTlsMaterial expiredTls = PostgreSqlTlsMaterial.generate(true); + PostgreSqlReadinessSupport expiredServer = + PostgreSqlReadinessSupport.startTls(expiredTls)) { + assertThatThrownBy( + () -> expiredServer.tlsConnection("localhost", expiredTls.caCertificate()).close()) + .isInstanceOf(SQLException.class); + } + } + + private static void assertDenied(Statement statement, String sql, String sqlState) { + SQLException denied = null; + try { + statement.execute(sql); + } catch (SQLException exception) { + denied = exception; + } + assertThat((Throwable) denied).isNotNull(); + assertThat(denied.getSQLState()).isEqualTo(sqlState); + } + + private static String singleValue(Statement statement, String sql) throws SQLException { + try (ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java new file mode 100644 index 00000000..06ff197b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +/** Ephemeral CA and server certificate material generated solely for the PostgreSQL TLS tests. */ +final class PostgreSqlTlsMaterial implements AutoCloseable { + + private final Path directory; + private final Path caCertificate; + private final Path serverCertificate; + private final Path serverPrivateKey; + + private PostgreSqlTlsMaterial( + Path directory, Path caCertificate, Path serverCertificate, Path serverPrivateKey) { + this.directory = directory; + this.caCertificate = caCertificate; + this.serverCertificate = serverCertificate; + this.serverPrivateKey = serverPrivateKey; + } + + static PostgreSqlTlsMaterial generate(boolean expired) throws IOException, InterruptedException { + Path directory = Files.createTempDirectory("jpa-postgresql-tls-"); + Path caKey = directory.resolve("ca.key"); + Path caCertificate = directory.resolve("ca.crt"); + Path serverKey = directory.resolve("server.key"); + Path serverRequest = directory.resolve("server.csr"); + Path serverCertificate = directory.resolve("server.crt"); + + run( + List.of( + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + caKey.toString(), + "-out", + caCertificate.toString(), + "-subj", + "/CN=JPA readiness ephemeral CA", + "-days", + "2")); + run( + List.of( + "openssl", + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + serverKey.toString(), + "-out", + serverRequest.toString(), + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost")); + run( + List.of( + "openssl", + "x509", + "-req", + "-in", + serverRequest.toString(), + "-CA", + caCertificate.toString(), + "-CAkey", + caKey.toString(), + "-CAcreateserial", + "-out", + serverCertificate.toString(), + "-days", + expired ? "-1" : "1", + "-copy_extensions", + "copy")); + + return new PostgreSqlTlsMaterial(directory, caCertificate, serverCertificate, serverKey); + } + + Path caCertificate() { + return caCertificate; + } + + Path serverCertificate() { + return serverCertificate; + } + + Path serverPrivateKey() { + return serverPrivateKey; + } + + private static void run(List command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IllegalStateException( + "ephemeral TLS material generation failed with exit code " + + exitCode + + ": " + + output.trim()); + } + } + + @Override + public void close() { + try (var paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(PostgreSqlTlsMaterial::delete); + } catch (IOException exception) { + throw new IllegalStateException("failed to remove ephemeral TLS material", exception); + } + } + + private static void delete(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + throw new IllegalStateException("failed to remove ephemeral TLS material", exception); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java new file mode 100644 index 00000000..042cf75a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java @@ -0,0 +1,396 @@ +package dev.caskeleton.adapter.outbound.persistence.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings; +import dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort; +import dev.caskeleton.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.OperationId; +import dev.caskeleton.application.transaction.TransactionAdmissionException; +import dev.caskeleton.application.transaction.TransactionOutcome; +import dev.caskeleton.application.transaction.TransactionPolicyId; +import dev.caskeleton.application.transaction.TransactionRequest; +import dev.caskeleton.application.transaction.TransactionResult; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CyclicBarrier; +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.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +class PostgreSqlTransactionIntegrationTest { + + private static PostgreSqlReadinessSupport postgres; + + @BeforeAll + static void startPostgreSql() throws Exception { + PostgreSqlReadinessSupport.assertDockerAvailable(); + postgres = PostgreSqlReadinessSupport.start(); + postgres.execute("create table readiness_tx(id bigint primary key)"); + postgres.execute("create table readiness_serial(id bigint primary key)"); + postgres.execute( + "create table readiness_deadlock(id bigint primary key, value bigint not null)"); + postgres.execute("insert into readiness_deadlock(id, value) values (1, 0), (2, 0)"); + postgres.execute( + "create table readiness_lock_timeout(id bigint primary key, value bigint not null)"); + postgres.execute("insert into readiness_lock_timeout(id, value) values (1, 0)"); + postgres.execute("create table readiness_commit_uncertain(id bigint primary key)"); + postgres.execute( + "create function readiness_pause_commit() returns trigger language plpgsql as $$ " + + "begin perform pg_sleep(5); return new; end $$"); + postgres.execute( + "create constraint trigger readiness_pause_commit_trigger " + + "after insert on readiness_commit_uncertain " + + "deferrable initially deferred for each row " + + "execute function readiness_pause_commit()"); + } + + @AfterAll + static void stopPostgreSql() { + if (postgres != null) { + postgres.close(); + } + } + + @Test + void appliesTransactionLocalTimeoutsBeforeWorkAndResetsThemAfterCommit() { + JdbcTemplate jdbc = new JdbcTemplate(postgres.dataSource()); + SpringTransactionPort port = transactionPort(jdbc); + + TransactionResult result = + port.inTransaction( + request("tx-local-timeout"), + () -> { + String statementTimeout = + jdbc.queryForObject("select current_setting('statement_timeout')", String.class); + String lockTimeout = + jdbc.queryForObject("select current_setting('lock_timeout')", String.class); + assertThat(statementTimeout).isNotEqualTo("0"); + assertThat(lockTimeout).isNotEqualTo("0"); + jdbc.update("insert into readiness_tx(id) values (?)", 1L); + return "committed"; + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(jdbc.queryForObject("select current_setting('statement_timeout')", String.class)) + .isEqualTo("0"); + } + + @Test + void actionFailureProducesAConfirmedRollback() { + JdbcTemplate jdbc = new JdbcTemplate(postgres.dataSource()); + SpringTransactionPort port = transactionPort(jdbc); + + TransactionResult result = + port.inTransaction( + request("tx-rollback"), + () -> { + jdbc.update("insert into readiness_tx(id) values (?)", 2L); + throw new IllegalStateException("rollback"); + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat( + jdbc.queryForObject("select count(*) from readiness_tx where id = ?", Long.class, 2L)) + .isZero(); + } + + @Test + @Timeout(15) + void serializableConflictIsRetriedOnlyByTheReplaySafePolicy() throws Exception { + JdbcTemplate jdbc = new JdbcTemplate(postgres.dataSource()); + SpringTransactionPort port = transactionPort(jdbc); + CyclicBarrier firstAttemptBarrier = new CyclicBarrier(2); + AtomicInteger actionCalls = new AtomicInteger(); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future> first = + executor.submit( + () -> + serializableInsert( + port, jdbc, 101L, "serial-first", actionCalls, firstAttemptBarrier)); + Future> second = + executor.submit( + () -> + serializableInsert( + port, jdbc, 102L, "serial-second", actionCalls, firstAttemptBarrier)); + + assertThat(first.get(10, TimeUnit.SECONDS).outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(second.get(10, TimeUnit.SECONDS).outcome()) + .isEqualTo(TransactionOutcome.COMMITTED); + } + + assertThat(actionCalls).hasValue(3); + assertThat(jdbc.queryForObject("select count(*) from readiness_serial", Long.class)) + .isEqualTo(2L); + } + + @Test + @Timeout(15) + void deterministicDeadlockProducesExactlyOneTypedDeadlockFailure() throws Exception { + CyclicBarrier lockedFirstRows = new CyclicBarrier(2); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future first = + executor.submit(() -> deadlockParticipant(1, 2, lockedFirstRows)); + Future second = + executor.submit(() -> deadlockParticipant(2, 1, lockedFirstRows)); + List failures = + java.util.Arrays.stream( + new SQLException[] { + first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS) + }) + .filter(java.util.Objects::nonNull) + .toList(); + + assertThat(failures).hasSize(1); + assertThat(failures.getFirst().getSQLState()).isEqualTo("40P01"); + PersistenceFailureException translated = + translator().translate(failures.getFirst()).orElseThrow(); + assertThat(translated.errorCode()).isEqualTo(OperationalError.DB_DEADLOCK); + } + } + + @Test + @Timeout(15) + void lockAndStatementTimeoutsRollbackWithinTheConfiguredBounds() throws Exception { + JdbcTemplate jdbc = new JdbcTemplate(postgres.dataSource()); + JpaTransactionSettings settings = + new JpaTransactionSettings( + Duration.ofSeconds(3), + Duration.ofMillis(300), + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(600), + Duration.ofMillis(200), + Duration.ofSeconds(1), + Duration.ofMillis(100), + Duration.ofMillis(50), + Duration.ofMillis(10), + Duration.ofMillis(10), + 1); + SpringTransactionPort port = transactionPort(postgres, jdbc, settings); + + try (Connection blocker = postgres.connection(); + Statement lock = blocker.createStatement()) { + blocker.setAutoCommit(false); + lock.execute("update readiness_lock_timeout set value = value + 1 where id = 1"); + long started = System.nanoTime(); + TransactionResult lockResult = + port.inTransaction( + request("tx-lock-timeout"), + () -> + jdbc.update("update readiness_lock_timeout set value = value + 1 where id = 1")); + assertThat(lockResult.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(sqlState(failure(lockResult))).isEqualTo("55P03"); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(100), Duration.ofSeconds(2)); + blocker.rollback(); + } + + long started = System.nanoTime(); + TransactionResult statementResult = + port.inTransaction( + request("tx-statement-timeout"), + () -> jdbc.queryForObject("select pg_sleep(2) is null", Boolean.class)); + assertThat(statementResult.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(sqlState(failure(statementResult))).isEqualTo("57014"); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(300), Duration.ofSeconds(2)); + } + + @Test + @Timeout(10) + void poolExhaustionRejectsBeforeApplicationWorkStarts() throws Exception { + try (PostgreSqlReadinessSupport constrained = PostgreSqlReadinessSupport.start(1, 300); + Connection held = constrained.connection()) { + JdbcTemplate jdbc = new JdbcTemplate(constrained.dataSource()); + SpringTransactionPort port = + transactionPort( + constrained, + jdbc, + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)); + AtomicBoolean applicationWorkStarted = new AtomicBoolean(); + long started = System.nanoTime(); + + assertThatThrownBy( + () -> + port.inTransaction( + request("tx-pool-exhaustion"), + () -> { + applicationWorkStarted.set(true); + return "must-not-run"; + })) + .isInstanceOf(TransactionAdmissionException.class) + .hasMessageContaining("before application work started"); + assertThat(applicationWorkStarted).isFalse(); + assertThat(Duration.ofNanos(System.nanoTime() - started)) + .isBetween(Duration.ofMillis(250), Duration.ofSeconds(2)); + } + } + + @Test + @Timeout(15) + void connectionLossDuringCommitIsIndeterminateAndNeverBlindlyRetried() throws Exception { + JdbcTemplate jdbc = new JdbcTemplate(postgres.dataSource()); + SpringTransactionPort port = transactionPort(jdbc); + AtomicInteger backendPid = new AtomicInteger(); + AtomicInteger actionCalls = new AtomicInteger(); + try (ExecutorService killer = Executors.newSingleThreadExecutor()) { + Future terminated = + killer.submit( + () -> { + int pid; + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while ((pid = backendPid.get()) == 0 && System.nanoTime() < deadline) { + Thread.sleep(10); + } + if (pid == 0) { + return false; + } + while (System.nanoTime() < deadline) { + String waitEvent = + jdbc.queryForObject( + "select wait_event from pg_stat_activity where pid = ?", + String.class, + pid); + if ("PgSleep".equals(waitEvent)) { + return Boolean.TRUE.equals( + jdbc.queryForObject("select pg_terminate_backend(?)", Boolean.class, pid)); + } + Thread.sleep(20); + } + return false; + }); + + TransactionResult result = + port.inTransaction( + request(TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, "tx-commit-uncertain"), + () -> { + actionCalls.incrementAndGet(); + backendPid.set(jdbc.queryForObject("select pg_backend_pid()", Integer.class)); + jdbc.update("insert into readiness_commit_uncertain(id) values (?)", 1L); + return 1L; + }); + + assertThat(terminated.get(5, TimeUnit.SECONDS)).isTrue(); + assertThat(result.outcome()).isEqualTo(TransactionOutcome.INDETERMINATE); + assertThat(actionCalls).hasValue(1); + assertThat(jdbc.queryForObject("select count(*) from readiness_commit_uncertain", Long.class)) + .isZero(); + } + } + + private static SpringTransactionPort transactionPort(JdbcTemplate jdbc) { + return transactionPort( + postgres, + jdbc, + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)); + } + + private static SpringTransactionPort transactionPort( + PostgreSqlReadinessSupport database, JdbcTemplate jdbc, JpaTransactionSettings settings) { + return new SpringTransactionPort( + new DataSourceTransactionManager(database.dataSource()), + settings, + new PostgreSqlLocalTimeoutConfigurer(jdbc), + database.dataSource(), + translator()); + } + + private static TransactionRequest request(String operationId) { + return request(TransactionPolicyId.COMMAND_DEFAULT, operationId); + } + + private static TransactionRequest request(TransactionPolicyId policyId, String operationId) { + return new TransactionRequest( + policyId, + CallBudget.fromNow(Duration.ofSeconds(10)), + Optional.empty(), + Optional.of(new OperationId(operationId))); + } + + private static TransactionResult serializableInsert( + SpringTransactionPort port, + JdbcTemplate jdbc, + long id, + String operationId, + AtomicInteger actionCalls, + CyclicBarrier firstAttemptBarrier) { + return port.inTransaction( + request(TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, operationId), + () -> { + jdbc.queryForObject("select count(*) from readiness_serial", Long.class); + if (actionCalls.incrementAndGet() <= 2) { + await(firstAttemptBarrier); + } + jdbc.update("insert into readiness_serial(id) values (?)", id); + return id; + }); + } + + private static SQLException deadlockParticipant( + long firstId, long secondId, CyclicBarrier lockedFirstRows) throws Exception { + try (Connection connection = postgres.connection(); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(false); + try { + statement.executeUpdate( + "update readiness_deadlock set value = value + 1 where id = " + firstId); + lockedFirstRows.await(); + statement.executeUpdate( + "update readiness_deadlock set value = value + 1 where id = " + secondId); + connection.commit(); + return null; + } catch (SQLException failure) { + connection.rollback(); + return failure; + } + } + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(); + } catch (Exception exception) { + throw new IllegalStateException("transaction concurrency barrier failed", exception); + } + } + + private static RuntimeException failure(TransactionResult result) { + assertThat(result).isInstanceOf(TransactionResult.DeterminateRollback.class); + return ((TransactionResult.DeterminateRollback) result).failure(); + } + + private static String sqlState(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof SQLException sqlException) { + return sqlException.getSQLState(); + } + } + return null; + } + + private static PersistenceExceptionTranslator translator() { + return new PersistenceExceptionTranslator( + List.of(new StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping())); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/failing/V1__interrupted.sql b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/failing/V1__interrupted.sql new file mode 100644 index 00000000..137e6b65 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/failing/V1__interrupted.sql @@ -0,0 +1,6 @@ +CREATE TABLE readiness_interrupted ( + id bigint PRIMARY KEY, + recovery_marker varchar(32) NOT NULL +); + +SELECT readiness_function_that_does_not_exist(); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/recovery/V1__recovered.sql b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/recovery/V1__recovered.sql new file mode 100644 index 00000000..bc2cf482 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/interrupted/recovery/V1__recovered.sql @@ -0,0 +1,7 @@ +CREATE TABLE readiness_interrupted ( + id bigint PRIMARY KEY, + recovery_marker varchar(32) NOT NULL +); + +INSERT INTO readiness_interrupted (id, recovery_marker) +VALUES (1, 'FORWARD_RECOVERED'); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V1__legacy_shape.sql b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V1__legacy_shape.sql new file mode 100644 index 00000000..e4bb6a85 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V1__legacy_shape.sql @@ -0,0 +1,4 @@ +CREATE TABLE readiness_rolling ( + id bigint PRIMARY KEY, + legacy_value varchar(128) NOT NULL +); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V2__expand_shape.sql b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V2__expand_shape.sql new file mode 100644 index 00000000..43c1db8b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/resources/db/readiness/rolling/V2__expand_shape.sql @@ -0,0 +1,2 @@ +ALTER TABLE readiness_rolling + ADD COLUMN expanded_value varchar(128); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java index 1a47af7b..4e84fedd 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java @@ -1,6 +1,7 @@ package dev.caskeleton.adapter.outbound.persistence.failure; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.shared.error.Category; import dev.caskeleton.shared.error.OperationalError; @@ -160,6 +161,17 @@ class PersistenceExceptionTranslatorTest { assertThat(carrier.errorCode()).isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); } + @Test + void transactionWrapperIsClassifiedByItsNestedSqlState() { + RuntimeException transactionFailure = + new IllegalStateException("transaction failed", new SQLException("driver detail", "40001")); + + PersistenceFailureException carrier = translator.translate(transactionFailure).orElseThrow(); + + assertThat(carrier.errorCode()).isEqualTo(OperationalError.DB_SERIALIZATION_FAILURE); + assertThat(carrier.getCause()).isSameAs(transactionFailure); + } + // ---- SPI merge: additional mapping contributes extra codes ---- @Test @@ -181,4 +193,38 @@ class PersistenceExceptionTranslatorTest { assertThat(withVendor.translate(daoWithSqlState("23505")).orElseThrow().errorCode()) .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); } + + @Test + void duplicateSqlStateWithSameErrorFailsFastAndNamesBothContributors() { + SqlStateErrorMapping first = + new FirstMapping(Map.of("23505", OperationalError.DB_UNIQUE_VIOLATION)); + SqlStateErrorMapping duplicate = + new SecondMapping(Map.of("23505", OperationalError.DB_UNIQUE_VIOLATION)); + + assertThatThrownBy(() -> new PersistenceExceptionTranslator(List.of(first, duplicate))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("23505") + .hasMessageContaining(FirstMapping.class.getName()) + .hasMessageContaining(SecondMapping.class.getName()); + } + + @Test + void duplicateSqlStateWithDifferentErrorAlsoFailsFastInsteadOfUsingLastWriter() { + SqlStateErrorMapping first = + new FirstMapping(Map.of("23505", OperationalError.DB_UNIQUE_VIOLATION)); + SqlStateErrorMapping duplicate = + new SecondMapping(Map.of("23505", OperationalError.DB_DEADLOCK)); + + assertThatThrownBy(() -> new PersistenceExceptionTranslator(List.of(first, duplicate))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("23505") + .hasMessageContaining(OperationalError.DB_UNIQUE_VIOLATION.name()) + .hasMessageContaining(OperationalError.DB_DEADLOCK.name()); + } + + private record FirstMapping(Map exactMappings) + implements SqlStateErrorMapping {} + + private record SecondMapping(Map exactMappings) + implements SqlStateErrorMapping {} } diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurerTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurerTest.java new file mode 100644 index 00000000..ab1792e6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurerTest.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcOperations; + +class PostgreSqlLocalTimeoutConfigurerTest { + + @Test + void usesParameterizedTransactionLocalSetConfigCalls() { + List invocations = new ArrayList<>(); + JdbcOperations jdbcOperations = + (JdbcOperations) + Proxy.newProxyInstance( + JdbcOperations.class.getClassLoader(), + new Class[] {JdbcOperations.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("queryForObject")) { + Object[] parameters = (Object[]) arguments[2]; + invocations.add(new Invocation((String) arguments[0], (String) parameters[0])); + return parameters[0]; + } + throw new UnsupportedOperationException(method.getName()); + }); + PostgreSqlLocalTimeoutConfigurer configurer = + new PostgreSqlLocalTimeoutConfigurer(jdbcOperations); + + configurer.apply( + new EffectiveTransactionTimeouts( + Duration.ofMillis(2_500), Duration.ofMillis(750), Duration.ofSeconds(5))); + + assertThat(invocations) + .containsExactly( + new Invocation("select set_config('statement_timeout', ?, true)", "2500ms"), + new Invocation("select set_config('lock_timeout', ?, true)", "750ms"), + new Invocation( + "select set_config('idle_in_transaction_session_timeout', ?, true)", "5000ms")); + } + + private record Invocation(String sql, String value) {} +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettingsTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettingsTest.java new file mode 100644 index 00000000..45b65084 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettingsTest.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 java.time.Duration; +import org.junit.jupiter.api.Test; + +class JpaTransactionSettingsTest { + + @Test + void suppliesFiniteDefaultsWithTheRequiredTimeoutHierarchy() { + JpaTransactionSettings settings = + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null); + + assertThat(settings.lockTimeout()).isLessThan(settings.statementTimeout()); + assertThat(settings.statementTimeout()).isLessThanOrEqualTo(settings.transactionTimeout()); + assertThat(settings.beginBudget()).isPositive(); + assertThat(settings.minimumActionWindow()).isPositive(); + assertThat(settings.completionMargin()).isPositive(); + } + + @Test + void rejectsNonPositiveValuesAndInvalidHierarchy() { + assertThatThrownBy( + () -> + settings( + Duration.ZERO, + Duration.ofSeconds(10), + Duration.ofSeconds(2), + Duration.ofSeconds(15))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + settings( + Duration.ofSeconds(30), + Duration.ofSeconds(10), + Duration.ofSeconds(10), + Duration.ofSeconds(15))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lock-timeout"); + assertThatThrownBy( + () -> + settings( + Duration.ofSeconds(5), + Duration.ofSeconds(10), + Duration.ofSeconds(2), + Duration.ofSeconds(15))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("statement-timeout"); + } + + private static JpaTransactionSettings settings( + Duration transactionTimeout, + Duration statementTimeout, + Duration lockTimeout, + Duration idleGuardTimeout) { + return new JpaTransactionSettings( + transactionTimeout, + Duration.ofMillis(250), + Duration.ofSeconds(1), + Duration.ofMillis(500), + statementTimeout, + lockTimeout, + idleGuardTimeout, + Duration.ofMillis(250), + Duration.ofMillis(100)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPortTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPortTest.java new file mode 100644 index 00000000..70507495 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPortTest.java @@ -0,0 +1,370 @@ +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.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.OperationId; +import dev.caskeleton.application.transaction.ReadConsistency; +import dev.caskeleton.application.transaction.TransactionAdmissionException; +import dev.caskeleton.application.transaction.TransactionOutcome; +import dev.caskeleton.application.transaction.TransactionPhase; +import dev.caskeleton.application.transaction.TransactionPolicyId; +import dev.caskeleton.application.transaction.TransactionRequest; +import dev.caskeleton.application.transaction.TransactionResult; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.UnexpectedRollbackException; +import org.springframework.transaction.support.SimpleTransactionStatus; + +class SpringPolicyTransactionPortTest { + + private static final long NOW = 10_000L; + private static final OperationId OPERATION_ID = new OperationId("operation-42"); + + @Test + void commandDefaultUsesRequiredReadCommittedPrimaryShapeAndReturnsCommitted() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), () -> "ok"); + + assertThat(result).isInstanceOf(TransactionResult.Committed.class); + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + TransactionDefinition definition = tm.definitions.getFirst(); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isFalse(); + assertThat(definition.getTimeout()).isEqualTo(5); + } + + @Test + void serializableReplaySafePolicyPinsSerializableIsolation() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)), + () -> "ok"); + + assertThat(tm.definitions.getFirst().getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_SERIALIZABLE); + } + + @Test + void primaryQueryIsReadOnlyAndDoesNotRequireAnOperationId() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + TransactionRequest request = + new TransactionRequest( + TransactionPolicyId.QUERY_PRIMARY, + CallBudget.after(NOW, Duration.ofSeconds(5)), + Optional.of(ReadConsistency.STRONG), + Optional.empty()); + + TransactionResult result = port.inTransaction(request, () -> "query"); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(tm.definitions.getFirst().isReadOnly()).isTrue(); + } + + @Test + void maintenancePolicyUsesRequiresNew() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + port.inTransaction( + commandRequest(TransactionPolicyId.MAINTENANCE_NEW, Duration.ofSeconds(5)), () -> "ok"); + + assertThat(tm.definitions.getFirst().getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + @Test + void participatingRequiredBoundaryNeverClaimsCommit() { + RecordingTransactionManager tm = new RecordingTransactionManager(false); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), + () -> "pending"); + + assertThat(result).isInstanceOf(TransactionResult.Participating.class); + assertThat(result.outcome()).isEqualTo(TransactionOutcome.PARTICIPATING_PENDING_OUTER); + } + + @Test + void actionFailureWithConfirmedRollbackIsDeterminate() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), + () -> { + throw new IllegalStateException("action failed"); + }); + + assertThat(result).isInstanceOf(TransactionResult.DeterminateRollback.class); + assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(tm.rollbacks).isOne(); + } + + @Test + void commitFailureWithoutAckIsIndeterminateAndIsNeverReplayed() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + tm.commitFailure = new TransactionException("connection lost during commit") {}; + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + int[] calls = {0}; + + TransactionResult result = + port.inTransaction( + commandRequest( + TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)), + () -> { + calls[0]++; + return "uncertain"; + }); + + assertThat(result).isInstanceOf(TransactionResult.Indeterminate.class); + assertThat(result.outcome()).isEqualTo(TransactionOutcome.INDETERMINATE); + assertThat(((TransactionResult.Indeterminate) result).lastObservedPhase()) + .isEqualTo(TransactionPhase.COMMIT_REQUESTED); + assertThat(calls[0]).isOne(); + } + + @Test + void serializationFailureReportedByCommitIsDeterminateAndReplaySafe() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + tm.commitFailure = + new TransactionException("serialization rejected at commit", dataFailure("40001")) {}; + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + int[] calls = {0}; + + TransactionResult result = + port.inTransaction( + commandRequest( + TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)), + () -> { + calls[0]++; + if (calls[0] > 1) { + tm.commitFailure = null; + } + return "replayed"; + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(calls[0]).isEqualTo(2); + assertThat(tm.definitions).hasSize(2); + } + + @Test + void unexpectedRollbackIsDeterminate() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + tm.commitFailure = new UnexpectedRollbackException("rollback-only"); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), + () -> "rolled back"); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + } + + @Test + void insufficientBudgetRejectsBeforeTransactionManagerAcquisition() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + assertThatThrownBy( + () -> + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofMillis(999)), + () -> "must-not-run")) + .isInstanceOf(TransactionAdmissionException.class) + .hasMessageContaining("one second"); + assertThat(tm.definitions).isEmpty(); + } + + @Test + void transactionTimeoutUsesAConservativeWholeSecondFloor() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofMillis(2_999)), () -> "ok"); + + assertThat(tm.definitions.getFirst().getTimeout()).isEqualTo(2); + } + + @Test + void unavailableReplicaPolicyFailsBeforeOpeningATransaction() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + TransactionRequest request = + new TransactionRequest( + TransactionPolicyId.QUERY_REPLICA_ELIGIBLE, + CallBudget.after(NOW, Duration.ofSeconds(5)), + Optional.of(ReadConsistency.EVENTUAL), + Optional.empty()); + + assertThatThrownBy(() -> port.inTransaction(request, () -> "must-not-run")) + .isInstanceOf(TransactionAdmissionException.class) + .hasMessageContaining("replica"); + assertThat(tm.definitions).isEmpty(); + } + + @Test + void appliesLocalTimeoutsBeforeTheBusinessAction() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + JpaTransactionSettings settings = + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null); + AtomicReference configured = new AtomicReference<>(); + SpringTransactionPort port = + new SpringTransactionPort( + tm, + () -> NOW, + TransactionDeadlineCalculator.withoutAcquisitionEnvelope(settings), + configured::set); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(20)), + () -> { + assertThat(configured.get()).isNotNull(); + return "configured"; + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(configured.get().statementTimeout()).isEqualTo(Duration.ofSeconds(10)); + } + + @Test + void localTimeoutFailureRollsBackBeforeTheBusinessAction() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + AtomicBoolean actionCalled = new AtomicBoolean(); + JpaTransactionSettings settings = + new JpaTransactionSettings(null, null, null, null, null, null, null, null, null); + SpringTransactionPort port = + new SpringTransactionPort( + tm, + () -> NOW, + TransactionDeadlineCalculator.withoutAcquisitionEnvelope(settings), + ignored -> { + throw new IllegalStateException("local timeout failed"); + }); + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(20)), + () -> { + actionCalled.set(true); + return "must-not-run"; + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(actionCalled).isFalse(); + assertThat(tm.rollbacks).isOne(); + } + + @Test + void replaySafeSerializablePolicyRetriesOnlyADeterminateSerializationRollback() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + int[] calls = {0}; + + TransactionResult result = + port.inTransaction( + commandRequest( + TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)), + () -> { + calls[0]++; + if (calls[0] == 1) { + throw dataFailure("40001"); + } + return "replayed"; + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(calls[0]).isEqualTo(2); + assertThat(tm.rollbacks).isOne(); + assertThat(tm.definitions).hasSize(2); + } + + @Test + void ordinaryCommandNeverRetriesTheSameSerializationFailure() { + RecordingTransactionManager tm = new RecordingTransactionManager(true); + SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW); + int[] calls = {0}; + + TransactionResult result = + port.inTransaction( + commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), + () -> { + calls[0]++; + throw dataFailure("40001"); + }); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(calls[0]).isOne(); + } + + private static TransactionRequest commandRequest( + TransactionPolicyId policyId, Duration duration) { + return new TransactionRequest( + policyId, CallBudget.after(NOW, duration), Optional.empty(), Optional.of(OPERATION_ID)); + } + + private static DataAccessResourceFailureException dataFailure(String sqlState) { + return new DataAccessResourceFailureException( + "database operation failed", new SQLException("sanitized", sqlState)); + } + + private static final class RecordingTransactionManager implements PlatformTransactionManager { + + private final boolean newTransaction; + private final List definitions = new ArrayList<>(); + private int rollbacks; + private RuntimeException commitFailure; + + private RecordingTransactionManager(boolean newTransaction) { + this.newTransaction = newTransaction; + } + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) + throws TransactionException { + definitions.add(definition); + return new SimpleTransactionStatus(newTransaction); + } + + @Override + public void commit(TransactionStatus status) throws TransactionException { + if (commitFailure != null) { + throw commitFailure; + } + } + + @Override + public void rollback(TransactionStatus status) throws TransactionException { + rollbacks++; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculatorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculatorTest.java new file mode 100644 index 00000000..abc0b1b4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculatorTest.java @@ -0,0 +1,84 @@ +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.application.outbound.CallBudget; +import dev.caskeleton.application.transaction.TransactionAdmissionException; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class TransactionDeadlineCalculatorTest { + + private static final long NOW = 1_000_000L; + private static final JpaTransactionSettings SETTINGS = + new JpaTransactionSettings( + Duration.ofSeconds(30), + Duration.ofMillis(250), + Duration.ofSeconds(1), + Duration.ofMillis(500), + Duration.ofSeconds(10), + Duration.ofSeconds(2), + Duration.ofSeconds(15), + Duration.ofMillis(250), + Duration.ofMillis(100)); + + @Test + void rejectsBeforePoolWhenTheAcquisitionAndActionEnvelopeDoesNotFit() { + TransactionDeadlineCalculator calculator = + new TransactionDeadlineCalculator(Duration.ofSeconds(3), SETTINGS); + CallBudget insufficient = CallBudget.after(NOW, Duration.ofMillis(4_749)); + + assertThatThrownBy(() -> calculator.beforeAcquisition(insufficient, NOW)) + .isInstanceOf(TransactionAdmissionException.class) + .hasMessageContaining("pool acquisition"); + } + + @Test + void springTimeoutUsesSafeWholeSecondFloorAtBoundary() { + TransactionDeadlineCalculator calculator = + TransactionDeadlineCalculator.withoutAcquisitionEnvelope(SETTINGS); + + assertThatThrownBy( + () -> calculator.beforeAcquisition(CallBudget.after(NOW, Duration.ofMillis(999)), NOW)) + .isInstanceOf(TransactionAdmissionException.class); + assertThat( + calculator + .beforeAcquisition(CallBudget.after(NOW, Duration.ofSeconds(1)), NOW) + .springTimeoutSeconds()) + .isOne(); + assertThat( + calculator + .beforeAcquisition(CallBudget.after(NOW, Duration.ofMillis(1_001)), NOW) + .springTimeoutSeconds()) + .isOne(); + } + + @Test + void computesStatementLockAndIdleTimeoutsInsideTheRemainingWindow() { + TransactionDeadlineCalculator calculator = + new TransactionDeadlineCalculator(Duration.ofSeconds(3), SETTINGS); + CallBudget budget = CallBudget.after(NOW, Duration.ofSeconds(20)); + TransactionStartBudget start = calculator.beforeAcquisition(budget, NOW); + + EffectiveTransactionTimeouts effective = + calculator.afterBegin(budget, NOW + Duration.ofSeconds(1).toNanos(), start); + + assertThat(effective.statementTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(effective.lockTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(effective.idleGuardTimeout()).isEqualTo(Duration.ofSeconds(15)); + } + + @Test + void poolWaitOvershootFailsBeforeLocalTimeoutOrBusinessStatement() { + TransactionDeadlineCalculator calculator = + new TransactionDeadlineCalculator(Duration.ofSeconds(3), SETTINGS); + CallBudget budget = CallBudget.after(NOW, Duration.ofSeconds(6)); + TransactionStartBudget start = calculator.beforeAcquisition(budget, NOW); + + assertThatThrownBy( + () -> calculator.afterBegin(budget, NOW + Duration.ofMillis(5_800).toNanos(), start)) + .isInstanceOf(TransactionAdmissionException.class) + .hasMessageContaining("after transaction begin"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java new file mode 100644 index 00000000..f501eff9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoffTest.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class TransactionRetryBackoffTest { + + @Test + void appliesBoundedFullJitterOnlyWhenTheAbsoluteBudgetCanContainTheNextAttempt() { + AtomicLong now = new AtomicLong(1_000); + AtomicLong slept = new AtomicLong(); + JpaTransactionSettings settings = + new JpaTransactionSettings( + null, + null, + null, + null, + null, + null, + null, + null, + null, + Duration.ofMillis(20), + Duration.ofMillis(80), + 3); + TransactionRetryBackoff backoff = + new TransactionRetryBackoff( + settings, + now::get, + nanos -> { + slept.addAndGet(nanos); + now.addAndGet(nanos); + }, + bound -> bound - 1); + + boolean retry = backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(5)), 1); + + assertThat(retry).isTrue(); + assertThat(slept.get()).isEqualTo(Duration.ofMillis(20).toNanos()); + } + + @Test + void refusesToSleepWhenBackoffWouldConsumeTheMinimumNextAttemptWindow() { + AtomicLong now = new AtomicLong(1_000); + AtomicLong slept = new AtomicLong(); + JpaTransactionSettings settings = + new JpaTransactionSettings( + null, + null, + null, + null, + null, + null, + null, + null, + null, + Duration.ofMillis(20), + Duration.ofMillis(80), + 3); + TransactionRetryBackoff backoff = + new TransactionRetryBackoff(settings, now::get, slept::addAndGet, bound -> bound - 1); + + boolean retry = + backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofMillis(200)), 1); + + assertThat(retry).isFalse(); + assertThat(slept).hasValue(0); + } + + @Test + void exponentialDelayIsCappedBeforeJitter() { + AtomicLong now = new AtomicLong(1_000); + AtomicLong slept = new AtomicLong(); + JpaTransactionSettings settings = + new JpaTransactionSettings( + null, + null, + null, + null, + null, + null, + null, + null, + null, + Duration.ofMillis(20), + Duration.ofMillis(30), + 4); + TransactionRetryBackoff backoff = + new TransactionRetryBackoff(settings, now::get, slept::addAndGet, bound -> bound - 1); + + backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(5)), 3); + + assertThat(slept).hasValue(Duration.ofMillis(30).toNanos()); + assertThat(backoff.maximumAttempts()).isEqualTo(4); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifierTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifierTest.java new file mode 100644 index 00000000..8cb3be85 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifierTest.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; + +class TransactionRetryClassifierTest { + + @Test + void onlySerializationAndDeadlockStatesAreWholeTransactionReplayCandidates() { + assertThat(TransactionRetryClassifier.isReplayCandidate(failure("40001"))).isTrue(); + assertThat(TransactionRetryClassifier.isReplayCandidate(failure("40P01"))).isTrue(); + assertThat(TransactionRetryClassifier.isReplayCandidate(failure("23505"))).isFalse(); + assertThat(TransactionRetryClassifier.isReplayCandidate(failure("08007"))).isFalse(); + } + + @Test + void selfReferentialOrMissingCauseChainsFailClosed() { + RuntimeException selfReferential = + new RuntimeException("loop") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + + assertThat(TransactionRetryClassifier.isReplayCandidate(selfReferential)).isFalse(); + assertThat(TransactionRetryClassifier.isReplayCandidate(new IllegalStateException("no sql"))) + .isFalse(); + } + + private static DataAccessResourceFailureException failure(String sqlState) { + return new DataAccessResourceFailureException( + "database operation failed", new SQLException("sanitized", sqlState)); + } +} diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md index c26b8a8e..a5b04fd6 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -217,11 +217,11 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌 흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다 있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이 아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐). -- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml`의 `connection-timeout` - 기본값은 Duration 문자열 `5s`인데 `src/.env`는 `30000`(ms)을 준다. `Environment#getProperty(..., - Long.class)`를 `"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로 - 읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주, - 조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다. +- **Spring Boot와 같은 Duration 문법을 검증한다.** `env-keys.yaml`의 `connection-timeout` + 기본값은 `5s`인데 `src/.env`는 `30000`(ms)을 준다. resolved 값을 `String`으로 읽은 뒤 + `DurationStyle`로 plain milliseconds, simple duration(`5s`)과 ISO-8601(`PT5S`)을 같은 + milliseconds 계약으로 변환한다. present-but-invalid 값은 부재로 조용히 취급하지 않고 property + 이름을 포함한 startup validation failure로 거절한다. - **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` / `max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time, leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다. @@ -237,6 +237,15 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌 으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off 가 기본)에 맡기며 *있는 `true`*만 거부한다. +### JpaSchemaSafetyValidator +- **Flyway를 production physical schema의 유일한 writer로 유지한다.** `prod` profile에서는 + `spring.jpa.hibernate.ddl-auto`가 `none` 또는 `validate`일 때만 허용한다. `update`, `create`, + `create-drop` 또는 그 밖의 값이면 `APP_DATASOURCE_DDL_AUTO`를 이름으로 포함한 + `PROFILE_MISMATCH`로 부팅을 중단한다. +- **local 개발 편의와 production 권위를 분리한다.** non-prod profile의 `update`/`create`는 이 + validator가 막지 않는다. prod profile 비교와 mode 비교는 대소문자를 무시해 `PROD`/`UPDATE` + 같은 변형도 guard를 우회하지 못한다. + ### RuntimeNumericBoundsValidator - **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는 Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이 diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java index 7fc4b152..b8393da0 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java @@ -1,9 +1,11 @@ package dev.caskeleton.bootstrap.runtime; import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.convert.DurationStyle; import org.springframework.core.env.Environment; /** @@ -35,11 +37,15 @@ public class HikariPoolConstraintValidator implements SmartInitializingSingleton public void afterSingletonsInstantiated() { List violations = new ArrayList<>(); - Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY)); - Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY)); - Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY)); - Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY)); - Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_KEY)); + Long connectionTimeout = + parseMillis(CONNECTION_TIMEOUT_KEY, environment.getProperty(CONNECTION_TIMEOUT_KEY)); + Long validationTimeout = + parseMillis(VALIDATION_TIMEOUT_KEY, environment.getProperty(VALIDATION_TIMEOUT_KEY)); + Long keepaliveTime = + parseMillis(KEEPALIVE_TIME_KEY, environment.getProperty(KEEPALIVE_TIME_KEY)); + Long maxLifetime = parseMillis(MAX_LIFETIME_KEY, environment.getProperty(MAX_LIFETIME_KEY)); + Long leakDetection = + parseMillis(LEAK_DETECTION_KEY, environment.getProperty(LEAK_DETECTION_KEY)); if (connectionTimeout != null && connectionTimeout < 250L) { violations.add( @@ -105,20 +111,23 @@ public class HikariPoolConstraintValidator implements SmartInitializingSingleton } /** - * Parses a raw property string as a plain long (milliseconds). A non-plain-integer value (e.g. a - * Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent. - * See README for the design rationale. + * Parses the same plain-millisecond, simple Duration ({@code 5s}) and ISO-8601 ({@code PT5S}) + * syntax that Spring Boot accepts for Duration-bound properties. A present invalid value is a + * startup error, never an absent-property fallback. * - * @return the parsed milliseconds, or {@code null} when absent/non-numeric + * @return the parsed milliseconds, or {@code null} when absent */ - private static Long parseMillis(String raw) { + private static Long parseMillis(String propertyKey, String raw) { if (raw == null || raw.isBlank()) { return null; } try { - return Long.parseLong(raw.trim()); - } catch (NumberFormatException e) { - return null; // non-numeric (e.g. Duration string) — treat as absent + return DurationStyle.detectAndParse(raw.trim(), ChronoUnit.MILLIS).toMillis(); + } catch (IllegalArgumentException | ArithmeticException e) { + throw StartupFailures.envValidation( + propertyKey + + " must be a valid duration (plain milliseconds, simple duration such as 5s, " + + "or ISO-8601 such as PT5S)"); } } } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java new file mode 100644 index 00000000..6543460b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java @@ -0,0 +1,59 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.Locale; +import java.util.Set; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema; + * production may only disable Hibernate DDL or validate the schema. + */ +public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { + + static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto"; + static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO"; + + private static final String PROD_PROFILE = "prod"; + private static final Set PROD_ALLOWED_MODES = Set.of("none", "validate"); + + private final Environment environment; + + public JpaSchemaSafetyValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + if (!isProdActive()) { + return; + } + + String rawMode = environment.getProperty(DDL_AUTO_KEY); + if (rawMode == null) { + return; + } + String mode = rawMode.trim().toLowerCase(Locale.ROOT); + if (!PROD_ALLOWED_MODES.contains(mode)) { + throw StartupFailures.profileMismatch( + "prod profile requires " + + DDL_AUTO_ENV_KEY + + " (" + + DDL_AUTO_KEY + + ") to be none or validate" + + ", but was " + + (mode.isEmpty() ? "" : mode) + + "; Flyway is the production schema writer"); + } + } + + private boolean isProdActive() { + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidator.java new file mode 100644 index 00000000..1d5463ce --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidator.java @@ -0,0 +1,94 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Fails production startup unless pgJDBC performs trust-chain and hostname verification. + * + *

The validator never includes a JDBC URL in its failure because URLs can carry credentials, + * endpoints, and database names. + */ +public final class PostgreSqlTransportSecurityValidator implements SmartInitializingSingleton { + + static final String JDBC_URL_KEY = "spring.datasource.url"; + static final String JDBC_URL_ENV_KEY = "APP_DATASOURCE_URL"; + static final String HIKARI_SSLMODE_KEY = + "spring.datasource.hikari.data-source-properties.sslmode"; + + private static final String PROD_PROFILE = "prod"; + private static final String POSTGRESQL_PREFIX = "jdbc:postgresql:"; + private static final String VERIFY_FULL = "verify-full"; + private static final Pattern URL_SSLMODE = Pattern.compile("(?i)(?:[?&])sslmode=([^&]*)"); + + private final Environment environment; + + public PostgreSqlTransportSecurityValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + if (!isProdActive()) { + return; + } + + String jdbcUrl = environment.getProperty(JDBC_URL_KEY); + if (jdbcUrl == null || !jdbcUrl.trim().toLowerCase(Locale.ROOT).startsWith(POSTGRESQL_PREFIX)) { + return; + } + + List urlModes = urlSslModes(jdbcUrl); + String propertyMode = normalized(environment.getProperty(HIKARI_SSLMODE_KEY)); + if (urlModes.size() > 1) { + reject("prod PostgreSQL transport has ambiguous duplicate sslmode declarations"); + } + String urlMode = urlModes.isEmpty() ? null : urlModes.getFirst(); + if (urlMode != null && propertyMode != null && !urlMode.equals(propertyMode)) { + reject("prod PostgreSQL transport has conflicting sslmode declarations"); + } + + String effectiveMode = propertyMode != null ? propertyMode : urlMode; + if (!VERIFY_FULL.equals(effectiveMode)) { + reject( + "prod PostgreSQL transport requires pgJDBC sslmode=verify-full through " + + JDBC_URL_ENV_KEY + + " (" + + JDBC_URL_KEY + + ") or " + + HIKARI_SSLMODE_KEY); + } + } + + private static List urlSslModes(String jdbcUrl) { + Matcher matcher = URL_SSLMODE.matcher(jdbcUrl); + List modes = new ArrayList<>(); + while (matcher.find()) { + modes.add(normalized(matcher.group(1))); + } + return modes; + } + + private static String normalized(String value) { + return value == null ? null : value.trim().toLowerCase(Locale.ROOT); + } + + private static void reject(String message) { + throw StartupFailures.profileMismatch(message); + } + + private boolean isProdActive() { + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java index f22e3c4c..8bcb05cc 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -29,6 +29,17 @@ public class RuntimeSafetyConfig { return new OpenInViewSafetyValidator(environment); } + @Bean + JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { + return new JpaSchemaSafetyValidator(environment); + } + + @Bean + PostgreSqlTransportSecurityValidator postgreSqlTransportSecurityValidator( + Environment environment) { + return new PostgreSqlTransportSecurityValidator(environment); + } + @Bean HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) { return new HikariPoolConstraintValidator(environment); diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index c5b33295..531a87f3 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -45,11 +45,8 @@ spring: # D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than # holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by # HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds. - # CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while - # src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a - # String to avoid ConversionFailedException on the drift value. Alignment is delegated to - # feature-env-driven-runtime-configuration (APP_DATASOURCE_CONNECTION_TIMEOUT). - # milliseconds (or Spring Duration string when env-keys default overrides) + # env-keys.yaml default "5s", plain milliseconds and ISO-8601 values are parsed by + # HikariPoolConstraintValidator with Spring Boot DurationStyle; invalid values fail startup. connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT} # milliseconds idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT} @@ -145,6 +142,7 @@ spring: jpa: hibernate: # none | validate | update | create | create-drop + # prod accepts only none|validate; JpaSchemaSafetyValidator rejects schema-writing modes. ddl-auto: ${APP_DATASOURCE_DDL_AUTO} # true | false show-sql: ${APP_DATASOURCE_SHOW_SQL} @@ -321,6 +319,23 @@ ca-skeleton: lock: wait-time: 3s lease-ttl: 30s + # JPA named-policy deadline envelope. JpaTransactionSettings validates the hierarchy; + # SpringPolicyTransactionPort intersects these limits with the caller's absolute CallBudget + # and the actual Hikari connection timeout before acquiring a transaction. + jpa: + transaction: + transaction-timeout: 30s + begin-budget: 250ms + minimum-action-window: 1s + completion-margin: 500ms + statement-timeout: 10s + lock-timeout: 2s + idle-guard-timeout: 15s + transaction-margin: 250ms + lock-margin: 100ms + retry-base-delay: 10ms + retry-maximum-delay: 50ms + retry-maximum-attempts: 2 presentation: # feature-api-contract-baseline D2: API version prefix. Default is the URI # prefix "/v1" (major-version path, AIP-185); override via env, or set "" for diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java index d93d37dc..4a6bf359 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java @@ -92,7 +92,7 @@ class DistributedLockProviderContractTest { // app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK). Flyway.configure() .dataSource(sharedDataSource) - .locations("classpath:db/migration") + .locations("classpath:db/migration/postgresql") .load() .migrate(); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java index 3dfbf6c2..cd3a3816 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java @@ -90,7 +90,7 @@ class IdempotencyUniqueScopeContractTest { // classpath via implementation project(':adapter:outbound:persistence-jpa')). Flyway.configure() .dataSource(sharedDataSource) - .locations("classpath:db/migration") + .locations("classpath:db/migration/postgresql") .load() .migrate(); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java index 9f245d75..53180502 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java @@ -59,7 +59,11 @@ final class OutboxContainerTestSupport { * work_log from sample-portfolio on app-bootstrap test classpath, V3 outbox_event) are applied. */ static void migrate(DataSource dataSource) { - Flyway.configure().dataSource(dataSource).locations("classpath:db/migration").load().migrate(); + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration/postgresql") + .load() + .migrate(); } /** Creates a HikariDataSource pointing to the given PostgreSQL container. */ diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java index 3165836d..e2e549c9 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java @@ -143,17 +143,61 @@ class HikariPoolConstraintValidatorTest { runner.run(context -> assertThat(context).hasNotFailed()); } - // --- CONNECTION_TIMEOUT_FORMAT_DRIFT: non-numeric duration string must not crash --- + // --- CONNECTION_TIMEOUT_FORMAT_DRIFT: Boot Duration syntax must be validated, never skipped --- @Test - void connectionTimeoutAsDurationStringIsDefensivelySkipped() { - // env-keys.yaml default for connection-timeout is "5s" (Duration string). - // The validator must not throw ConversionFailedException — it silently skips. + void connectionTimeoutAsSimpleDurationParticipatesInMinimumValidation() { runner - .withPropertyValues("spring.datasource.hikari.connection-timeout=5s") + .withPropertyValues("spring.datasource.hikari.connection-timeout=100ms") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("connection-timeout") + .hasStackTraceContaining(">= 250"); + }); + } + + @Test + void simpleAndIsoDurationStringsStartWhenValid() { + runner + .withPropertyValues( + "spring.datasource.hikari.connection-timeout=5s", + "spring.datasource.hikari.validation-timeout=PT3S") .run(context -> assertThat(context).hasNotFailed()); } + @Test + void durationStringsParticipateInCrossPropertyValidation() { + runner + .withPropertyValues( + "spring.datasource.hikari.connection-timeout=5s", + "spring.datasource.hikari.validation-timeout=PT5S") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("validation-timeout") + .hasStackTraceContaining("connection-timeout"); + }); + } + + @Test + void invalidDurationFailsStartupInsteadOfBeingTreatedAsAbsent() { + runner + .withPropertyValues("spring.datasource.hikari.connection-timeout=five-seconds") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("connection-timeout") + .hasStackTraceContaining("valid duration"); + }); + } + @Configuration static class ValidatorConfig { @Bean diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidatorTest.java new file mode 100644 index 00000000..f60e33e6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidatorTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +class JpaSchemaSafetyValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @ParameterizedTest + @ValueSource(strings = {"update", "create", "create-drop"}) + void prodRejectsHibernateSchemaMutationModes(String ddlAuto) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO") + .hasStackTraceContaining("none") + .hasStackTraceContaining("validate"); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"none", "validate"}) + void prodAllowsNonMutatingSchemaModes(String ddlAuto) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto) + .run(context -> assertThat(context).hasNotFailed()); + } + + @ParameterizedTest + @ValueSource(strings = {" ", "\t"}) + void prodRejectsPresentButBlankDdlMode(String ddlAuto) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO"); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"update", "create"}) + void nonProdMayUseLocalSchemaConvenienceModes(String ddlAuto) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("local")) + .withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto) + .run(context -> assertThat(context).hasNotFailed()); + } + + @ParameterizedTest + @ValueSource(strings = {"PROD", "Prod"}) + void profileAndDdlModeComparisonIsCaseInsensitive(String profile) { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles(profile)) + .withPropertyValues("spring.jpa.hibernate.ddl-auto=UPDATE") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO"); + }); + } + + @Configuration + static class ValidatorConfig { + + @Bean + JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { + return new JpaSchemaSafetyValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidatorTest.java new file mode 100644 index 00000000..49d114fc --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/PostgreSqlTransportSecurityValidatorTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException; +import java.io.PrintWriter; +import java.io.StringWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +class PostgreSqlTransportSecurityValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @ParameterizedTest + @ValueSource( + strings = { + "jdbc:postgresql://db.internal:5432/app", + "jdbc:postgresql://db.internal:5432/app?sslmode=disable", + "jdbc:postgresql://db.internal:5432/app?sslmode=require", + "jdbc:postgresql://db.internal:5432/app?sslmode=verify-ca" + }) + void prodRejectsPostgreSqlUrlsWithoutVerifyFull(String jdbcUrl) { + prod(jdbcUrl) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining("APP_DATASOURCE_URL") + .hasStackTraceContaining("verify-full"); + assertThat(stackTrace(context.getStartupFailure())).doesNotContain(jdbcUrl); + }); + } + + @Test + void prodAllowsVerifyFullInTheJdbcUrl() { + prod("jdbc:postgresql://db.internal:5432/app?sslmode=verify-full") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void prodAllowsVerifyFullAsAnExplicitHikariDataSourceProperty() { + prod("jdbc:postgresql://db.internal:5432/app") + .withPropertyValues("spring.datasource.hikari.data-source-properties.sslmode=verify-full") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void prodRejectsConflictingUrlAndDataSourcePropertyWithoutEchoingTheUrl() { + String jdbcUrl = + "jdbc:postgresql://db.internal:5432/app?sslmode=verify-full&password=do-not-log"; + + prod(jdbcUrl) + .withPropertyValues("spring.datasource.hikari.data-source-properties.sslmode=disable") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(ProfileMismatchException.class) + .hasStackTraceContaining("conflicting"); + assertThat(stackTrace(context.getStartupFailure())) + .doesNotContain("do-not-log") + .doesNotContain("db.internal"); + }); + } + + @Test + void nonProdMayUseAPlainLocalPostgreSqlUrl() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("local")) + .withPropertyValues("spring.datasource.url=jdbc:postgresql://localhost:5432/app") + .run(context -> assertThat(context).hasNotFailed()); + } + + private ApplicationContextRunner prod(String jdbcUrl) { + return runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.datasource.url=" + jdbcUrl); + } + + private static String stackTrace(Throwable failure) { + StringWriter output = new StringWriter(); + failure.printStackTrace(new PrintWriter(output)); + return output.toString(); + } + + @Configuration + static class ValidatorConfig { + + @Bean + PostgreSqlTransportSecurityValidator postgreSqlTransportSecurityValidator( + Environment environment) { + return new PostgreSqlTransportSecurityValidator(environment); + } + } +} diff --git a/src/application-core/README.md b/src/application-core/README.md index 124f0c41..72b819be 100644 --- a/src/application-core/README.md +++ b/src/application-core/README.md @@ -256,6 +256,21 @@ application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저 - retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지 단순 재시도를 하면 안 된다. +### Owner-safe idempotency V2 + +`idempotency.v2`는 V1의 scope-only `tryBegin/complete/discard`를 대체하는 additive contract다. +provider가 JPA인지 Redis인지와 무관하게 claim에는 secure owner token과 stable operation ID가 +필요하고, 모든 mutation은 owner/attempt/claim-operation/state-revision을 검증한다. + +- processing lease와 completed replay TTL을 분리한다. +- expired `CLAIMED`만 takeover하고 expired `EXECUTING`은 `RECOVERY_REQUIRED`로 닫는다. +- complete/fail/release는 operation ID와 result digest가 같은 재호출만 prior result로 replay한다. +- `SAME_STORE_TRANSACTIONAL` JPA profile의 response는 8 KiB 이하 inline 값만 지원한다. +- raw principal/client key는 versioned HMAC scope digest로 바꾼 뒤 adapter에 전달한다. + +V1은 rolling migration compatibility를 위해 유지된다. 새 reliability profile이 V2 claim과 V1 +scope-only mutation을 섞는 것은 금지한다. + --- ## 트랜잭셔널 아웃박스 릴레이 (outbox) @@ -383,6 +398,26 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장. adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은 messaging adapter가 소유한다. +### Immutable outbox/polling delivery V2 + +`outbox.v2`는 domain event intent와 delivery state를 분리한다. + +- `NewOutboxEventV2`의 aggregate version과 deterministic ordinal이 ordering authority다. +- `OutboxAppendPortV2`는 caller의 primary write transaction에 참여하고 publication epoch와 + authority를 DB control row에서 얻는다. +- immutable event ID 충돌과 aggregate ordering tuple 충돌은 서로 다른 outcome이다. +- `OutboxPollingDeliveryPortV2`는 publish 밖의 짧은 claim/completion transaction만 소유하고 + owner/token/attempt/version/epoch CAS로 stale relay를 거절한다. +- broker publish와 DB completion 사이 ACK 유실은 stable event ID의 duplicate publish를 만들 수 + 있으므로 exactly-once delivery로 표현하지 않는다. + +### Same-store inbox + +`inbox.InboxStorePort`는 broker redelivery를 DB business mutation과 같은 transaction에서 +deduplicate한다. `RECEIVED -> PROCESSING -> COMPLETED`가 기본이며 expired `PROCESSING`은 blind +takeover하지 않는다. broker ACK는 transaction commit 이후 adapter 바깥에서만 수행하고 remote +side effect는 outbox/workflow로 옮긴다. + --- ## 분산 락 (lock) diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimAttempt.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimAttempt.java new file mode 100644 index 00000000..7546d4cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimAttempt.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.transaction.OperationId; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Caller-retained identity for one claim send/retry sequence. */ +public record IdempotencyClaimAttempt(String ownerToken, OperationId operationId) { + + private static final Pattern OWNER_TOKEN = Pattern.compile("[0-9a-f]{64}"); + + public IdempotencyClaimAttempt { + Objects.requireNonNull(ownerToken, "ownerToken"); + Objects.requireNonNull(operationId, "operationId"); + if (!OWNER_TOKEN.matcher(ownerToken).matches()) { + throw new IllegalArgumentException( + "owner token must be a 64-character lowercase hexadecimal secure-random value"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimOutcome.java new file mode 100644 index 00000000..a94373dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimOutcome.java @@ -0,0 +1,73 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** Exhaustive result of an atomic owner-safe claim. */ +public sealed interface IdempotencyClaimOutcome { + + record Acquired(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + public Acquired { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record ReplayedAcquire(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + public ReplayedAcquire { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record TakenOverClaimed(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + public TakenOverClaimed { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record CompletedReplay(StoredResponse response, Instant replayUntil) + implements IdempotencyClaimOutcome { + public CompletedReplay { + Objects.requireNonNull(response, "response"); + Objects.requireNonNull(replayUntil, "replayUntil"); + } + } + + record InProgress(Duration retryAfter, long currentAttempt) implements IdempotencyClaimOutcome { + public InProgress { + Objects.requireNonNull(retryAfter, "retryAfter"); + if (retryAfter.isNegative() || currentAttempt < 1) { + throw new IllegalArgumentException( + "retry-after must be non-negative and current attempt must be positive"); + } + } + } + + record RecoveryRequired(long currentAttempt) implements IdempotencyClaimOutcome { + public RecoveryRequired { + if (currentAttempt < 1) { + throw new IllegalArgumentException("current attempt must be positive"); + } + } + } + + record FingerprintMismatch() implements IdempotencyClaimOutcome {} + + record OwnerOperationConflict() implements IdempotencyClaimOutcome {} + + record Indeterminate(OperationId operationId) implements IdempotencyClaimOutcome { + public Indeterminate { + Objects.requireNonNull(operationId, "operationId"); + } + } + + record Unavailable() implements IdempotencyClaimOutcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimRequest.java new file mode 100644 index 00000000..93d1bc88 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyClaimRequest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.time.Duration; +import java.util.Objects; + +/** Complete provider-neutral intent for one atomic owner-safe claim. */ +public record IdempotencyClaimRequest( + IdempotencyScopeDigest scope, + RequestFingerprint requestFingerprint, + IdempotencyClaimAttempt claimAttempt, + Duration processingLeaseTtl, + Duration replayTtl, + String responseCodecId, + int policyRevision) { + + private static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(1); + private static final Duration MAXIMUM_REPLAY_TTL = Duration.ofDays(30); + + public IdempotencyClaimRequest { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(requestFingerprint, "requestFingerprint"); + Objects.requireNonNull(claimAttempt, "claimAttempt"); + requirePositiveBounded("processing lease TTL", processingLeaseTtl, MAXIMUM_PROCESSING_LEASE); + requirePositiveBounded("replay TTL", replayTtl, MAXIMUM_REPLAY_TTL); + Objects.requireNonNull(responseCodecId, "responseCodecId"); + if (responseCodecId.isBlank() || responseCodecId.length() > 64) { + throw new IllegalArgumentException("response codec ID must contain 1-64 characters"); + } + if (policyRevision < 1) { + throw new IllegalArgumentException("policy revision must be positive"); + } + } + + private static void requirePositiveBounded(String name, Duration value, Duration maximum) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(name + " must be positive and at most " + maximum); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyCompleteOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyCompleteOutcome.java new file mode 100644 index 00000000..2bc615e9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyCompleteOutcome.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Outcomes of an owner-safe EXECUTING to COMPLETED transition. */ +public enum IdempotencyCompleteOutcome { + COMPLETED, + ALREADY_COMPLETED_SAME_RESULT, + RESPONSE_CONFLICT, + ABSENT, + NOT_OWNER, + NOT_IN_PROGRESS, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailOutcome.java new file mode 100644 index 00000000..b0e911dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailOutcome.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Outcomes of an owner-safe failure transition. */ +public enum IdempotencyFailOutcome { + MARKED_RETRYABLE, + MARKED_ABANDONED, + ALREADY_MARKED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + NOT_IN_PROGRESS, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailureDisposition.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailureDisposition.java new file mode 100644 index 00000000..d1037e41 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyFailureDisposition.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Whether a failed action is proven retryable or requires explicit effect reconciliation. */ +public enum IdempotencyFailureDisposition { + NO_EFFECT_RETRYABLE, + EFFECT_UNKNOWN_ABANDONED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspection.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspection.java new file mode 100644 index 00000000..1e8f2ea6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspection.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Inspection classification with only the data meaningful for that classification. */ +public record IdempotencyInspection( + IdempotencyInspectionOutcome outcome, + Optional owner, + Optional processingLeaseUntil, + Optional response, + Optional replayUntil) { + + public IdempotencyInspection { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(processingLeaseUntil, "processingLeaseUntil"); + Objects.requireNonNull(response, "response"); + Objects.requireNonNull(replayUntil, "replayUntil"); + } + + public static IdempotencyInspection outcome(IdempotencyInspectionOutcome outcome) { + return new IdempotencyInspection( + outcome, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionOutcome.java new file mode 100644 index 00000000..99a07513 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionOutcome.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Recovery-safe classifications returned by {@link IdempotencyStorePortV2#inspect}. */ +public enum IdempotencyInspectionOutcome { + ABSENT, + CLAIMED_SAME_OPERATION, + EXECUTING_SAME_OPERATION, + COMPLETED_REPLAY, + IN_PROGRESS_OTHER, + FAILED_RETRYABLE, + ABANDONED, + FINGERPRINT_MISMATCH, + OPERATION_CONFLICT, + UNAVAILABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionRequest.java new file mode 100644 index 00000000..1eadb5ba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyInspectionRequest.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.util.Objects; + +/** Read-only recovery request after a claim or transition response was lost. */ +public record IdempotencyInspectionRequest( + IdempotencyScopeDigest scope, + RequestFingerprint requestFingerprint, + IdempotencyClaimAttempt claimAttempt) { + + public IdempotencyInspectionRequest { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(requestFingerprint, "requestFingerprint"); + Objects.requireNonNull(claimAttempt, "claimAttempt"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyMutationResult.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyMutationResult.java new file mode 100644 index 00000000..082993af --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyMutationResult.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.idempotency.v2; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Operation-specific typed outcome plus a replacement owner handle when ownership remains valid. + */ +public final class IdempotencyMutationResult> { + + private final O outcome; + private final IdempotencyOwner owner; + + public IdempotencyMutationResult(O outcome, IdempotencyOwner owner, Predicate carriesOwner) { + this.outcome = Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(carriesOwner, "carriesOwner"); + boolean expectedOwner = carriesOwner.test(outcome); + if (expectedOwner && owner == null) { + throw new IllegalArgumentException(outcome + " must carry the current owner handle"); + } + if (!expectedOwner && owner != null) { + throw new IllegalArgumentException(outcome + " must not carry an owner handle"); + } + this.owner = owner; + } + + public O outcome() { + return outcome; + } + + public Optional owner() { + return Optional.ofNullable(owner); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyOwner.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyOwner.java new file mode 100644 index 00000000..5ae6bc37 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyOwner.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.transaction.OperationId; +import java.util.Objects; + +/** + * Owner handle carrying the full optimistic CAS tuple. + * + *

Every successful state-changing operation returns a replacement handle with the incremented + * state revision. A stale handle is never silently accepted. + */ +public record IdempotencyOwner( + IdempotencyScopeDigest scope, + String ownerToken, + long attempt, + long stateRevision, + OperationId claimOperationId) { + + public IdempotencyOwner { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(ownerToken, "ownerToken"); + Objects.requireNonNull(claimOperationId, "claimOperationId"); + if (ownerToken.isBlank() || ownerToken.length() > 128) { + throw new IllegalArgumentException("owner token must contain 1-128 characters"); + } + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be positive"); + } + if (stateRevision < 0) { + throw new IllegalArgumentException("state revision must be non-negative"); + } + } + + public IdempotencyOwner withStateRevision(long nextRevision) { + return new IdempotencyOwner(scope, ownerToken, attempt, nextRevision, claimOperationId); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyReleaseOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyReleaseOutcome.java new file mode 100644 index 00000000..fb49dd60 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyReleaseOutcome.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Outcomes of releasing a claim only while business execution has not started. */ +public enum IdempotencyReleaseOutcome { + RELEASED_BEFORE_EXECUTION, + ALREADY_RELEASED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + EXECUTION_ALREADY_STARTED, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyRenewOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyRenewOutcome.java new file mode 100644 index 00000000..29d1b8b7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyRenewOutcome.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Outcomes of an owner-safe processing lease renewal. */ +public enum IdempotencyRenewOutcome { + RENEWED(true), + ALREADY_RENEWED_SAME_OPERATION(true), + ABSENT(false), + NOT_OWNER(false), + NOT_IN_PROGRESS(false), + OPERATION_CONFLICT(false), + INDETERMINATE(false), + UNAVAILABLE(false); + + private final boolean carriesOwner; + + IdempotencyRenewOutcome(boolean carriesOwner) { + this.carriesOwner = carriesOwner; + } + + public boolean carriesOwner() { + return carriesOwner; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java new file mode 100644 index 00000000..123f9e1a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.idempotency.v2; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Provider-neutral, versioned HMAC digest of the canonical idempotency scope. + * + *

The raw client key and principal must be digested before this value is constructed. Database, + * Redis, logs, and metrics receive only this opaque value. + */ +public record IdempotencyScopeDigest(String digest, int keyDigestVersion, String operationCode) { + + private static final Pattern LOWERCASE_SHA_256 = Pattern.compile("[0-9a-f]{64}"); + private static final Pattern OPERATION_CODE = Pattern.compile("[A-Z][A-Z0-9_]{0,63}"); + + public IdempotencyScopeDigest { + Objects.requireNonNull(digest, "digest"); + Objects.requireNonNull(operationCode, "operationCode"); + if (!LOWERCASE_SHA_256.matcher(digest).matches()) { + throw new IllegalArgumentException( + "scope digest must be a 64-character lowercase hexadecimal HMAC-SHA-256 value"); + } + if (keyDigestVersion < 1) { + throw new IllegalArgumentException("key digest version must be positive"); + } + if (!OPERATION_CODE.matcher(operationCode).matches()) { + throw new IllegalArgumentException( + "operation code must be 1-64 uppercase ASCII letters, digits, or underscores"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStartOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStartOutcome.java new file mode 100644 index 00000000..21b1b5fa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStartOutcome.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Outcomes of the CLAIMED to EXECUTING owner-safe transition. */ +public enum IdempotencyStartOutcome { + STARTED(true), + ALREADY_STARTED_SAME_OPERATION(true), + ABSENT(false), + NOT_OWNER(false), + NOT_CLAIMED(false), + OPERATION_CONFLICT(false), + INDETERMINATE(false), + UNAVAILABLE(false); + + private final boolean carriesOwner; + + IdempotencyStartOutcome(boolean carriesOwner) { + this.carriesOwner = carriesOwner; + } + + public boolean carriesOwner() { + return carriesOwner; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyState.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyState.java new file mode 100644 index 00000000..52c27dfb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyState.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.idempotency.v2; + +/** Owner-safe idempotency V2 state machine. */ +public enum IdempotencyState { + CLAIMED, + EXECUTING, + COMPLETED, + FAILED_RETRYABLE, + ABANDONED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java new file mode 100644 index 00000000..762312d3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.idempotency.v2; + +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; + +/** + * Provider-neutral owner-safe idempotency state machine. + * + *

V1 remains source-compatible during migration, but new reliability profiles must use this + * complete contract rather than combining V2 claim with scope-only V1 mutations. + */ +public interface IdempotencyStorePortV2 { + + IdempotencyClaimAttempt newClaimAttempt(OperationId operationId); + + IdempotencyClaimOutcome claim(IdempotencyClaimRequest request); + + IdempotencyMutationResult markExecutionStarted( + IdempotencyOwner owner, OperationId operationId); + + IdempotencyMutationResult renew( + IdempotencyOwner owner, Duration processingLeaseTtl, OperationId operationId); + + IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, StoredResponse response, Duration replayTtl, OperationId operationId); + + IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + OperationId operationId); + + IdempotencyReleaseOutcome releaseBeforeExecution(IdempotencyOwner owner, OperationId operationId); + + IdempotencyInspection inspect(IdempotencyInspectionRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimAttempt.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimAttempt.java new file mode 100644 index 00000000..93b8d503 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimAttempt.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.inbox; + +import dev.caskeleton.application.transaction.OperationId; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Caller-retained inbox claim identity used to replay a lost claim response. */ +public record InboxClaimAttempt(String ownerToken, OperationId operationId) { + + private static final Pattern TOKEN = Pattern.compile("[0-9a-f]{64}"); + + public InboxClaimAttempt { + Objects.requireNonNull(ownerToken, "ownerToken"); + Objects.requireNonNull(operationId, "operationId"); + if (!TOKEN.matcher(ownerToken).matches()) { + throw new IllegalArgumentException( + "owner token must be a 64-character lowercase hexadecimal value"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimOutcome.java new file mode 100644 index 00000000..0c5c4909 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimOutcome.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.inbox; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** Exhaustive atomic inbox claim classification. */ +public sealed interface InboxClaimOutcome { + + record Acquired(InboxOwner owner, Instant leaseUntil) implements InboxClaimOutcome { + public Acquired { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(leaseUntil, "leaseUntil"); + } + } + + record ReplayedAcquire(InboxOwner owner, Instant leaseUntil) implements InboxClaimOutcome { + public ReplayedAcquire { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(leaseUntil, "leaseUntil"); + } + } + + record TakenOver(InboxOwner owner, Instant leaseUntil) implements InboxClaimOutcome { + public TakenOver { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(leaseUntil, "leaseUntil"); + } + } + + record Completed() implements InboxClaimOutcome {} + + record InProgress(Duration retryAfter, long attempt) implements InboxClaimOutcome {} + + record RecoveryRequired(long attempt) implements InboxClaimOutcome {} + + record IntentMismatch() implements InboxClaimOutcome {} + + record OwnerOperationConflict() implements InboxClaimOutcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimRequest.java new file mode 100644 index 00000000..09304bf1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxClaimRequest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.inbox; + +import java.time.Duration; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Atomic inbox claim request with separate processing and terminal retention windows. */ +public record InboxClaimRequest( + InboxScopeDigest scope, + String messageIntentDigest, + InboxClaimAttempt claimAttempt, + Duration processingLease, + Duration terminalRetention) { + + private static final Pattern DIGEST = Pattern.compile("[0-9a-f]{64}"); + + public InboxClaimRequest { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(messageIntentDigest, "messageIntentDigest"); + Objects.requireNonNull(claimAttempt, "claimAttempt"); + if (!DIGEST.matcher(messageIntentDigest).matches()) { + throw new IllegalArgumentException( + "message intent digest must be a 64-character lowercase hexadecimal value"); + } + requirePositiveBounded("processing lease", processingLease, Duration.ofHours(1)); + requirePositiveBounded("terminal retention", terminalRetention, Duration.ofDays(30)); + } + + private static void requirePositiveBounded(String name, Duration value, Duration maximum) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(name + " must be positive and at most " + maximum); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwner.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwner.java new file mode 100644 index 00000000..cabdf16f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwner.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.inbox; + +import dev.caskeleton.application.transaction.OperationId; +import java.util.Objects; + +/** Full owner/attempt/claim-operation/state-revision CAS tuple for one inbox message. */ +public record InboxOwner( + InboxScopeDigest scope, + String ownerToken, + long attempt, + long stateRevision, + OperationId claimOperationId) { + + public InboxOwner { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(ownerToken, "ownerToken"); + Objects.requireNonNull(claimOperationId, "claimOperationId"); + if (ownerToken.isBlank() || ownerToken.length() > 128) { + throw new IllegalArgumentException("owner token must contain 1-128 characters"); + } + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be positive"); + } + if (stateRevision < 0) { + throw new IllegalArgumentException("state revision must be non-negative"); + } + } + + public InboxOwner withStateRevision(long revision) { + return new InboxOwner(scope, ownerToken, attempt, revision, claimOperationId); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwnerTransition.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwnerTransition.java new file mode 100644 index 00000000..ce4119b9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxOwnerTransition.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.inbox; + +import java.util.Objects; +import java.util.Optional; + +/** Transition outcome and updated owner handle while ownership remains live. */ +public record InboxOwnerTransition(InboxTransitionOutcome outcome, Optional owner) { + + public InboxOwnerTransition { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(owner, "owner"); + boolean mustCarryOwner = outcome == InboxTransitionOutcome.PROCESSING_STARTED; + if (mustCarryOwner != owner.isPresent()) { + throw new IllegalArgumentException( + mustCarryOwner + ? "PROCESSING_STARTED must carry an updated owner" + : outcome + " must not carry an owner"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxScopeDigest.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxScopeDigest.java new file mode 100644 index 00000000..59500218 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxScopeDigest.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.inbox; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Versioned canonical digest of consumer-group, handler, tenant, and message ID scope. */ +public record InboxScopeDigest(String value) { + + private static final Pattern LOWERCASE_SHA_256 = Pattern.compile("[0-9a-f]{64}"); + + public InboxScopeDigest { + Objects.requireNonNull(value, "value"); + if (!LOWERCASE_SHA_256.matcher(value).matches()) { + throw new IllegalArgumentException( + "inbox scope must be a 64-character lowercase hexadecimal digest"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxState.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxState.java new file mode 100644 index 00000000..a58f993b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxState.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.inbox; + +/** Same-store inbox lifecycle. */ +public enum InboxState { + RECEIVED, + PROCESSING, + COMPLETED, + RETRYABLE, + DEAD +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxStorePort.java new file mode 100644 index 00000000..3df5294b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxStorePort.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.inbox; + +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; + +/** + * Owner-safe same-store inbox state machine. Broker ACK must happen only after transaction commit. + */ +public interface InboxStorePort { + + InboxClaimAttempt newClaimAttempt(OperationId operationId); + + InboxClaimOutcome claim(InboxClaimRequest request); + + InboxOwnerTransition markProcessing(InboxOwner owner, OperationId operationId); + + InboxTransitionOutcome complete(InboxOwner owner, OperationId operationId); + + InboxTransitionOutcome markRetryable( + InboxOwner owner, Duration retention, OperationId operationId); + + InboxTransitionOutcome markDead(InboxOwner owner, Duration retention, OperationId operationId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxTransitionOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxTransitionOutcome.java new file mode 100644 index 00000000..4009a624 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/inbox/InboxTransitionOutcome.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.inbox; + +/** Owner-safe inbox transition classification. */ +public enum InboxTransitionOutcome { + PROCESSING_STARTED, + COMPLETED, + RETRYABLE, + DEAD, + ALREADY_APPLIED_SAME_OPERATION, + RESULT_CONFLICT, + ABSENT, + NOT_OWNER, + INVALID_STATE, + STALE_REVISION +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java b/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java index 8db36f6f..18857c46 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java @@ -27,7 +27,12 @@ public record CallBudget(long monotonicDeadlineNanos) { throw new IllegalArgumentException( "call budget duration exceeds the supported range", exception); } - return new CallBudget(monotonicNowNanos + durationNanos); + try { + return new CallBudget(Math.addExact(monotonicNowNanos, durationNanos)); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "call budget deadline exceeds the monotonic range", exception); + } } public long remainingNanosAt(long monotonicNowNanos) { diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/ClaimedOutboxDelivery.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/ClaimedOutboxDelivery.java new file mode 100644 index 00000000..ba9d7cf2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/ClaimedOutboxDelivery.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.outbox.v2; + +import java.time.Instant; +import java.util.Objects; + +/** Immutable message envelope plus the mutable delivery owner handle. */ +public record ClaimedOutboxDelivery( + OutboxDeliveryOwner owner, + String eventType, + int eventSchema, + String aggregateType, + String aggregateId, + long aggregateVersion, + int eventOrdinal, + String partitionKey, + String contentType, + String correlationId, + String causationId, + Instant occurredAt, + String payload, + String payloadDigest) { + + public ClaimedOutboxDelivery { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(eventType, "eventType"); + Objects.requireNonNull(aggregateType, "aggregateType"); + Objects.requireNonNull(aggregateId, "aggregateId"); + Objects.requireNonNull(partitionKey, "partitionKey"); + Objects.requireNonNull(contentType, "contentType"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(occurredAt, "occurredAt"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(payloadDigest, "payloadDigest"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/NewOutboxEventV2.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/NewOutboxEventV2.java new file mode 100644 index 00000000..5b500afd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/NewOutboxEventV2.java @@ -0,0 +1,66 @@ +package dev.caskeleton.application.outbox.v2; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Objects; + +/** + * Immutable outbox event intent pinned before any whole-transaction retry begins. + * + *

The aggregate version plus deterministic event ordinal is the ordering authority. The stable + * partition key is mandatory because this baseline advertises ordered destinations only. + */ +public record NewOutboxEventV2( + String eventId, + String aggregateType, + String aggregateId, + long aggregateVersion, + int eventOrdinal, + String eventType, + int eventSchema, + String logicalDestination, + String partitionKey, + String contentType, + String correlationId, + String causationId, + Instant occurredAt, + String payload) { + + private static final int MAXIMUM_PAYLOAD_BYTES = 1024 * 1024; + + public NewOutboxEventV2 { + requireBounded("event ID", eventId, 64); + requireBounded("aggregate type", aggregateType, 128); + requireBounded("aggregate ID", aggregateId, 256); + if (aggregateVersion < 1) { + throw new IllegalArgumentException("aggregate version must be positive"); + } + if (eventOrdinal < 0 || eventOrdinal > 1023) { + throw new IllegalArgumentException("event ordinal must be between 0 and 1023"); + } + requireBounded("event type", eventType, 256); + if (eventSchema < 1) { + throw new IllegalArgumentException("event schema must be positive"); + } + requireBounded("logical destination", logicalDestination, 256); + requireBounded("partition key", partitionKey, 256); + requireBounded("content type", contentType, 128); + requireBounded("correlation ID", correlationId, 128); + if (causationId != null) { + requireBounded("causation ID", causationId, 128); + } + Objects.requireNonNull(occurredAt, "occurredAt"); + Objects.requireNonNull(payload, "payload"); + if (payload.isBlank() + || payload.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_PAYLOAD_BYTES) { + throw new IllegalArgumentException( + "payload must be non-blank and at most " + MAXIMUM_PAYLOAD_BYTES + " UTF-8 bytes"); + } + } + + private static void requireBounded(String name, String value, int maximumLength) { + if (value == null || value.isBlank() || value.length() > maximumLength) { + throw new IllegalArgumentException(name + " must contain 1-" + maximumLength + " characters"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendOutcome.java new file mode 100644 index 00000000..1c7d8e35 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendOutcome.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.outbox.v2; + +/** Conflict-safe result of immutable identity plus event-envelope append. */ +public enum OutboxAppendOutcome { + APPENDED, + ALREADY_APPENDED_SAME_EVENT, + EVENT_ID_CONFLICT, + AGGREGATE_ORDER_CONFLICT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendPortV2.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendPortV2.java new file mode 100644 index 00000000..26a980a2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendPortV2.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.outbox.v2; + +/** + * Same-store immutable outbox append. + * + *

The implementation must participate in the caller's active primary read-write transaction; it + * must never open a repository-local transaction. + */ +public interface OutboxAppendPortV2 { + + OutboxAppendReceipt append(NewOutboxEventV2 event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendReceipt.java new file mode 100644 index 00000000..5590584d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxAppendReceipt.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.outbox.v2; + +import java.time.LocalDate; +import java.util.Objects; + +/** Database-authoritative routing receipt for one immutable event append. */ +public record OutboxAppendReceipt( + OutboxAppendOutcome outcome, + String eventId, + LocalDate retentionBucket, + long publicationEpoch, + OutboxDispatchAuthority dispatchAuthority) { + + public OutboxAppendReceipt { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(eventId, "eventId"); + Objects.requireNonNull(retentionBucket, "retentionBucket"); + Objects.requireNonNull(dispatchAuthority, "dispatchAuthority"); + if (publicationEpoch < 1) { + throw new IllegalArgumentException("publication epoch must be positive"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryClaimRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryClaimRequest.java new file mode 100644 index 00000000..4abc745b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryClaimRequest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.outbox.v2; + +import java.time.Duration; + +/** Bounded strict-order polling claim request. */ +public record OutboxDeliveryClaimRequest( + String destination, String claimOwner, int batchSize, Duration claimLease) { + + public OutboxDeliveryClaimRequest { + if (destination == null || destination.isBlank()) { + throw new IllegalArgumentException("destination must be present"); + } + if (claimOwner == null || claimOwner.isBlank() || claimOwner.length() > 128) { + throw new IllegalArgumentException("claim owner must contain 1-128 characters"); + } + if (batchSize < 1 || batchSize > 100) { + throw new IllegalArgumentException("batch size must be between 1 and 100"); + } + if (claimLease == null + || claimLease.isZero() + || claimLease.isNegative() + || claimLease.compareTo(Duration.ofMinutes(5)) > 0) { + throw new IllegalArgumentException("claim lease must be positive and at most PT5M"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryOwner.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryOwner.java new file mode 100644 index 00000000..11b9c3d8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryOwner.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.outbox.v2; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Full owner/token/status-version CAS handle for one destination delivery. */ +public record OutboxDeliveryOwner( + LocalDate retentionBucket, + String eventId, + String destination, + String claimOwner, + String claimToken, + int attempt, + long version, + long publicationEpoch) { + + private static final Pattern CLAIM_TOKEN = Pattern.compile("[0-9a-f]{64}"); + + public OutboxDeliveryOwner { + Objects.requireNonNull(retentionBucket, "retentionBucket"); + requirePresent(eventId, "event ID"); + requirePresent(destination, "destination"); + requirePresent(claimOwner, "claim owner"); + if (claimToken == null || !CLAIM_TOKEN.matcher(claimToken).matches()) { + throw new IllegalArgumentException( + "claim token must be a 64-character lowercase hexadecimal value"); + } + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be positive"); + } + if (version < 1) { + throw new IllegalArgumentException("version must be positive"); + } + if (publicationEpoch < 1) { + throw new IllegalArgumentException("publication epoch must be positive"); + } + } + + private static void requirePresent(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must be present"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransition.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransition.java new file mode 100644 index 00000000..2962cb8f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransition.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.outbox.v2; + +import dev.caskeleton.application.transaction.OperationId; +import java.util.Objects; + +/** Idempotent transition request for a claimed delivery. */ +public record OutboxDeliveryTransition(OutboxDeliveryOwner owner, OperationId operationId) { + + public OutboxDeliveryTransition { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(operationId, "operationId"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransitionOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransitionOutcome.java new file mode 100644 index 00000000..023e1618 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryTransitionOutcome.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.outbox.v2; + +/** Owner-safe polling completion/failure result. */ +public enum OutboxDeliveryTransitionOutcome { + PUBLISHED, + RETRY_SCHEDULED, + DEAD, + ALREADY_APPLIED_SAME_OPERATION, + RESULT_CONFLICT, + ABSENT, + NOT_OWNER, + NOT_CLAIMED, + STALE_VERSION, + AUTHORITY_MISMATCH +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDispatchAuthority.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDispatchAuthority.java new file mode 100644 index 00000000..e6ad2527 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxDispatchAuthority.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.outbox.v2; + +/** Per-row dispatch authority derived from the locked publication control row. */ +public enum OutboxDispatchAuthority { + LEGACY_SHADOW, + POLLING_V2, + CDC +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java new file mode 100644 index 00000000..7e5005c6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPollingDeliveryPortV2.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.outbox.v2; + +import java.time.Instant; +import java.util.List; + +/** Owner-safe polling delivery state machine; broker publish occurs outside its transactions. */ +public interface OutboxPollingDeliveryPortV2 { + + List claimBatch(OutboxDeliveryClaimRequest request); + + OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition); + + OutboxDeliveryTransitionOutcome markRetryable( + OutboxDeliveryTransition transition, Instant nextAttemptAt, String errorCode); + + OutboxDeliveryTransitionOutcome markDead(OutboxDeliveryTransition transition, String errorCode); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPublicationAuthority.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPublicationAuthority.java new file mode 100644 index 00000000..6d580abe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/v2/OutboxPublicationAuthority.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.outbox.v2; + +/** The single database-controlled publisher authority for the primary outbox scope. */ +public enum OutboxPublicationAuthority { + LEGACY_POLLING, + POLLING_V2, + CDC +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/OperationId.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/OperationId.java new file mode 100644 index 00000000..f9704f17 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/OperationId.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.transaction; + +import java.util.Objects; + +/** + * Stable, caller-owned identity for one logical write operation. + * + *

The value is intentionally opaque. Adapters may use it for reconciliation, but must not invent + * a replacement identity after an uncertain commit. + */ +public record OperationId(String value) { + + private static final int MAXIMUM_LENGTH = 128; + + public OperationId { + Objects.requireNonNull(value, "value must be non-null"); + if (value.isBlank() || value.length() > MAXIMUM_LENGTH || !isPrintableAscii(value)) { + throw new IllegalArgumentException( + "operation ID must contain 1-128 printable non-whitespace ASCII characters"); + } + } + + private static boolean isPrintableAscii(String value) { + return value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java new file mode 100644 index 00000000..4029558a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.transaction; + +import java.util.function.Supplier; + +/** + * Additive transaction port for named policies and explicit outcomes. + * + *

{@link TransactionPort} remains source-compatible for existing callers and fakes. + */ +public interface PolicyTransactionPort extends TransactionPort { + + TransactionResult inTransaction(TransactionRequest request, Supplier action); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReadConsistency.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReadConsistency.java new file mode 100644 index 00000000..23912c34 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReadConsistency.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.transaction; + +/** Application-owned read consistency vocabulary. */ +public enum ReadConsistency { + STRONG, + READ_YOUR_WRITES, + EVENTUAL, + BOUNDED_STALENESS +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java new file mode 100644 index 00000000..b0b09730 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.transaction; + +import java.util.Objects; + +/** + * Sanitized, bounded reference that an operator or application workflow can use to reconcile an + * uncertain transaction outcome. + */ +public record ReconciliationReference(String value) { + + private static final int MAXIMUM_LENGTH = 256; + + public ReconciliationReference { + Objects.requireNonNull(value, "value must be non-null"); + if (value.isBlank() || value.length() > MAXIMUM_LENGTH || !isPrintableAscii(value)) { + throw new IllegalArgumentException( + "reconciliation reference must contain 1-256 printable non-whitespace ASCII characters"); + } + } + + private static boolean isPrintableAscii(String value) { + return value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionAdmissionException.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionAdmissionException.java new file mode 100644 index 00000000..5256ef45 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionAdmissionException.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.transaction; + +/** + * A transaction was rejected before application work started because its policy, route, or + * remaining deadline could not be honored. + */ +public final class TransactionAdmissionException extends RuntimeException { + + public TransactionAdmissionException(String message) { + super(message); + } + + public TransactionAdmissionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionOutcome.java new file mode 100644 index 00000000..9a3be547 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionOutcome.java @@ -0,0 +1,10 @@ +package dev.caskeleton.application.transaction; + +/** Framework-neutral outcome of an application transaction boundary. */ +public enum TransactionOutcome { + COMMITTED, + PARTICIPATING_PENDING_OUTER, + DETERMINATE_ROLLBACK, + INDETERMINATE, + COMMITTED_WITH_POST_COMMIT_FAILURE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPhase.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPhase.java new file mode 100644 index 00000000..080b68d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPhase.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.transaction; + +/** Last safely observed phase of a physical transaction. */ +public enum TransactionPhase { + ROUTE_ADMISSION, + CONNECTION_ACQUIRED, + ACTIVE, + FLUSHED, + COMMIT_REQUESTED, + COMMIT_ACKED, + SYNCHRONIZATION_CLEANUP +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java new file mode 100644 index 00000000..25763e3b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.transaction; + +/** + * Allowlisted application transaction policies. + * + *

Callers select semantic policy IDs rather than framework propagation, isolation, route, or + * timeout numbers. Legacy facade policies remain adapter-internal and are intentionally absent. + */ +public enum TransactionPolicyId { + COMMAND_DEFAULT(false, true), + COMMAND_SERIALIZABLE_REPLAY_SAFE(false, true), + QUERY_PRIMARY(true, false), + QUERY_REPLICA_ELIGIBLE(true, false), + OUTBOX_APPEND(false, false), + INBOX_AND_HANDLER(false, true), + MAINTENANCE_NEW(false, true); + + private final boolean readPolicy; + private final boolean operationIdRequired; + + TransactionPolicyId(boolean readPolicy, boolean operationIdRequired) { + this.readPolicy = readPolicy; + this.operationIdRequired = operationIdRequired; + } + + public boolean isReadPolicy() { + return readPolicy; + } + + public boolean requiresOperationId() { + return operationIdRequired; + } + + public boolean supports(ReadConsistency consistency) { + if (this == QUERY_PRIMARY) { + return consistency == ReadConsistency.STRONG + || consistency == ReadConsistency.READ_YOUR_WRITES; + } + if (this == QUERY_REPLICA_ELIGIBLE) { + return consistency == ReadConsistency.EVENTUAL + || consistency == ReadConsistency.BOUNDED_STALENESS; + } + return false; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java new file mode 100644 index 00000000..3029aacd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.transaction; + +import dev.caskeleton.application.outbound.CallBudget; +import java.util.Objects; +import java.util.Optional; + +/** One framework-neutral request to execute an allowlisted transaction policy. */ +public record TransactionRequest( + TransactionPolicyId policyId, + CallBudget callBudget, + Optional readConsistency, + Optional operationId) { + + public TransactionRequest { + Objects.requireNonNull(policyId, "policyId must be non-null"); + Objects.requireNonNull(callBudget, "callBudget must be non-null"); + Objects.requireNonNull(readConsistency, "readConsistency must be non-null"); + Objects.requireNonNull(operationId, "operationId must be non-null"); + + if (policyId.isReadPolicy()) { + ReadConsistency selected = + readConsistency.orElseThrow( + () -> + new IllegalArgumentException( + "readConsistency is required for " + policyId.name())); + if (!policyId.supports(selected)) { + throw new IllegalArgumentException( + "readConsistency " + selected + " is not allowed for " + policyId.name()); + } + } else if (readConsistency.isPresent()) { + throw new IllegalArgumentException("readConsistency is forbidden for " + policyId.name()); + } + + if (policyId.requiresOperationId() && operationId.isEmpty()) { + throw new IllegalArgumentException("operationId is required for " + policyId.name()); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionResult.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionResult.java new file mode 100644 index 00000000..55286e86 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionResult.java @@ -0,0 +1,83 @@ +package dev.caskeleton.application.transaction; + +import java.util.Objects; +import java.util.Optional; + +/** + * Outcome algebra for policy-based transactions. + * + *

A participant result never claims commit. An indeterminate result never grants replay + * authority. + */ +public sealed interface TransactionResult { + + TransactionOutcome outcome(); + + record Committed(T value, Optional operationId) implements TransactionResult { + + public Committed { + Objects.requireNonNull(operationId, "operationId must be non-null"); + } + + @Override + public TransactionOutcome outcome() { + return TransactionOutcome.COMMITTED; + } + } + + record Participating(T value) implements TransactionResult { + + @Override + public TransactionOutcome outcome() { + return TransactionOutcome.PARTICIPATING_PENDING_OUTER; + } + } + + record DeterminateRollback(RuntimeException failure) implements TransactionResult { + + public DeterminateRollback { + Objects.requireNonNull(failure, "failure must be non-null"); + } + + @Override + public TransactionOutcome outcome() { + return TransactionOutcome.DETERMINATE_ROLLBACK; + } + } + + record Indeterminate( + Optional operationId, + TransactionPhase lastObservedPhase, + Optional reconciliationReference) + implements TransactionResult { + + public Indeterminate { + Objects.requireNonNull(operationId, "operationId must be non-null"); + Objects.requireNonNull(lastObservedPhase, "lastObservedPhase must be non-null"); + Objects.requireNonNull(reconciliationReference, "reconciliationReference must be non-null"); + if (operationId.isEmpty() && reconciliationReference.isPresent()) { + throw new IllegalArgumentException("reconciliationReference requires a stable operationId"); + } + } + + @Override + public TransactionOutcome outcome() { + return TransactionOutcome.INDETERMINATE; + } + } + + record CommittedWithPostCommitFailure( + T value, Optional operationId, RuntimeException operationalFailure) + implements TransactionResult { + + public CommittedWithPostCommitFailure { + Objects.requireNonNull(operationId, "operationId must be non-null"); + Objects.requireNonNull(operationalFailure, "operationalFailure must be non-null"); + } + + @Override + public TransactionOutcome outcome() { + return TransactionOutcome.COMMITTED_WITH_POST_COMMIT_FAILURE; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyV2ContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyV2ContractTest.java new file mode 100644 index 00000000..2cab6e45 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/v2/IdempotencyV2ContractTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.application.idempotency.v2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.transaction.OperationId; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class IdempotencyV2ContractTest { + + private static final IdempotencyScopeDigest SCOPE = + new IdempotencyScopeDigest("a".repeat(64), 3, "CREATE_WORK_LOG"); + private static final RequestFingerprint FINGERPRINT = + RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8)); + private static final IdempotencyClaimAttempt ATTEMPT = + new IdempotencyClaimAttempt("b".repeat(64), new OperationId("claim-1")); + + @Test + void scopeAcceptsOnlyCanonicalLowercaseSha256AndPositiveKeyVersion() { + assertThat(SCOPE.digest()).hasSize(64); + + assertThatThrownBy(() -> new IdempotencyScopeDigest("A".repeat(64), 1, "CREATE_WORK_LOG")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lowercase"); + assertThatThrownBy(() -> new IdempotencyScopeDigest("a".repeat(64), 0, "CREATE_WORK_LOG")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("key digest version"); + } + + @Test + void claimRequestSeparatesFiniteProcessingAndReplayTtls() { + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + ATTEMPT, + Duration.ofSeconds(30), + Duration.ofHours(24), + "json.v1", + 2); + + assertThat(request.processingLeaseTtl()).isEqualTo(Duration.ofSeconds(30)); + assertThat(request.replayTtl()).isEqualTo(Duration.ofHours(24)); + + assertThatThrownBy( + () -> + new IdempotencyClaimRequest( + SCOPE, FINGERPRINT, ATTEMPT, Duration.ZERO, Duration.ofHours(24), "json.v1", 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("processing lease TTL"); + } + + @Test + void ownerCarriesTheFullCasTuple() { + IdempotencyOwner owner = + new IdempotencyOwner(SCOPE, ATTEMPT.ownerToken(), 2, 7, ATTEMPT.operationId()); + + assertThat(owner.attempt()).isEqualTo(2); + assertThat(owner.stateRevision()).isEqualTo(7); + assertThat(owner.claimOperationId()).isEqualTo(new OperationId("claim-1")); + + assertThatThrownBy( + () -> new IdempotencyOwner(SCOPE, ATTEMPT.ownerToken(), 0, 7, ATTEMPT.operationId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attempt"); + } + + @Test + void transitionResultRequiresAnOwnerOnlyForSuccessfulOwnerTransitions() { + IdempotencyOwner owner = + new IdempotencyOwner(SCOPE, ATTEMPT.ownerToken(), 1, 1, ATTEMPT.operationId()); + + assertThat( + new IdempotencyMutationResult<>( + IdempotencyStartOutcome.STARTED, owner, IdempotencyStartOutcome::carriesOwner) + .owner()) + .contains(owner); + + assertThatThrownBy( + () -> + new IdempotencyMutationResult<>( + IdempotencyStartOutcome.NOT_OWNER, + owner, + IdempotencyStartOutcome::carriesOwner)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not carry"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/inbox/InboxContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/inbox/InboxContractTest.java new file mode 100644 index 00000000..9caeda58 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/inbox/InboxContractTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.inbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.OperationId; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class InboxContractTest { + + @Test + void scopeIsAnOpaqueCanonicalDigestAndOwnerCarriesTheFullCasTuple() { + InboxScopeDigest scope = new InboxScopeDigest("a".repeat(64)); + InboxOwner owner = + new InboxOwner(scope, "b".repeat(64), 2, 7, new OperationId("claim-message-1")); + + assertThat(owner.attempt()).isEqualTo(2); + assertThat(owner.stateRevision()).isEqualTo(7); + + assertThatThrownBy(() -> new InboxScopeDigest("A".repeat(64))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lowercase"); + } + + @Test + void claimRequestSeparatesProcessingLeaseFromTerminalRetention() { + InboxClaimRequest request = + new InboxClaimRequest( + new InboxScopeDigest("a".repeat(64)), + "c".repeat(64), + new InboxClaimAttempt("b".repeat(64), new OperationId("claim-message-1")), + Duration.ofSeconds(30), + Duration.ofDays(7)); + + assertThat(request.processingLease()).isEqualTo(Duration.ofSeconds(30)); + assertThat(request.terminalRetention()).isEqualTo(Duration.ofDays(7)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java index 0d2e2df2..f63ca26c 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java @@ -33,4 +33,11 @@ class CallBudgetTest { assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ofDays(366))) .isInstanceOf(IllegalArgumentException.class); } + + @Test + void rejectsMonotonicDeadlineOverflow() { + assertThatThrownBy(() -> CallBudget.after(Long.MAX_VALUE - 10, Duration.ofNanos(11))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deadline"); + } } diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryV2ContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryV2ContractTest.java new file mode 100644 index 00000000..7d03a64e --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxDeliveryV2ContractTest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.application.outbox.v2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.OperationId; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class OutboxDeliveryV2ContractTest { + + @Test + void ownerHandleCarriesTheFullDeliveryCasTuple() { + OutboxDeliveryOwner owner = + new OutboxDeliveryOwner( + LocalDate.parse("2026-07-28"), + "event-1", + "portfolio.events", + "relay-1", + "a".repeat(64), + 2, + 7, + 3); + + assertThat(owner.attempt()).isEqualTo(2); + assertThat(owner.version()).isEqualTo(7); + assertThat(owner.publicationEpoch()).isEqualTo(3); + + assertThatThrownBy( + () -> + new OutboxDeliveryOwner( + LocalDate.parse("2026-07-28"), + "event-1", + "portfolio.events", + "relay-1", + "a".repeat(64), + 0, + 7, + 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attempt"); + } + + @Test + void completionRequestRequiresStableOperationIdentity() { + OutboxDeliveryTransition transition = + new OutboxDeliveryTransition( + new OutboxDeliveryOwner( + LocalDate.parse("2026-07-28"), + "event-1", + "portfolio.events", + "relay-1", + "a".repeat(64), + 1, + 2, + 1), + new OperationId("publish-complete-1")); + + assertThat(transition.operationId().value()).isEqualTo("publish-complete-1"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxV2ContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxV2ContractTest.java new file mode 100644 index 00000000..a9ef11c2 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/v2/OutboxV2ContractTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.application.outbox.v2; + +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.Test; + +class OutboxV2ContractTest { + + @Test + void eventRequiresPinnedAggregateVersionOrdinalAndBoundedEnvelopeIdentity() { + NewOutboxEventV2 event = + new NewOutboxEventV2( + "event-1", + "WorkLog", + "work-log-42", + 7, + 0, + "WorkLogCreated", + 1, + "portfolio.events", + "work-log-42", + "application/json", + "correlation-1", + null, + Instant.parse("2026-07-28T12:00:00Z"), + "{\"id\":\"42\"}"); + + assertThat(event.aggregateVersion()).isEqualTo(7); + assertThat(event.eventOrdinal()).isZero(); + + assertThatThrownBy( + () -> + new NewOutboxEventV2( + "event-1", + "WorkLog", + "work-log-42", + 0, + 0, + "WorkLogCreated", + 1, + "portfolio.events", + "work-log-42", + "application/json", + "correlation-1", + null, + Instant.parse("2026-07-28T12:00:00Z"), + "{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aggregate version"); + } + + @Test + void orderedDestinationDescriptorRequiresAStablePartitionKey() { + assertThatThrownBy( + () -> + new NewOutboxEventV2( + "event-1", + "WorkLog", + "work-log-42", + 1, + 0, + "WorkLogCreated", + 1, + "portfolio.events", + null, + "application/json", + "correlation-1", + null, + Instant.parse("2026-07-28T12:00:00Z"), + "{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("partition key"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/OperationIdTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/OperationIdTest.java new file mode 100644 index 00000000..5f7d0f63 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/OperationIdTest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class OperationIdTest { + + @Test + void preservesAnOpaqueStableIdentifier() { + OperationId operationId = new OperationId("job-20260728-item-42"); + + assertThat(operationId.value()).isEqualTo("job-20260728-item-42"); + } + + @Test + void rejectsBlankControlCharactersAndUnboundedValues() { + assertThatThrownBy(() -> new OperationId(" ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OperationId("operation\nid")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OperationId("a".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionRequestTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionRequestTest.java new file mode 100644 index 00000000..077e7de8 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionRequestTest.java @@ -0,0 +1,114 @@ +package dev.caskeleton.application.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.outbound.CallBudget; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TransactionRequestTest { + + private static final CallBudget BUDGET = CallBudget.after(1_000, Duration.ofSeconds(5)); + private static final OperationId OPERATION_ID = new OperationId("operation-42"); + + @Test + void commandPoliciesRequireAStableOperationIdAndRejectReadConsistency() { + TransactionRequest request = + new TransactionRequest( + TransactionPolicyId.COMMAND_DEFAULT, + BUDGET, + Optional.empty(), + Optional.of(OPERATION_ID)); + + assertThat(request.operationId()).contains(OPERATION_ID); + assertThatThrownBy( + () -> + new TransactionRequest( + TransactionPolicyId.COMMAND_DEFAULT, + BUDGET, + Optional.empty(), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationId"); + assertThatThrownBy( + () -> + new TransactionRequest( + TransactionPolicyId.COMMAND_DEFAULT, + BUDGET, + Optional.of(ReadConsistency.STRONG), + Optional.of(OPERATION_ID))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("readConsistency"); + } + + @Test + void queryPrimaryAcceptsOnlyStrongOrReadYourWrites() { + assertThat( + new TransactionRequest( + TransactionPolicyId.QUERY_PRIMARY, + BUDGET, + Optional.of(ReadConsistency.STRONG), + Optional.empty()) + .readConsistency()) + .contains(ReadConsistency.STRONG); + assertThat( + new TransactionRequest( + TransactionPolicyId.QUERY_PRIMARY, + BUDGET, + Optional.of(ReadConsistency.READ_YOUR_WRITES), + Optional.empty()) + .readConsistency()) + .contains(ReadConsistency.READ_YOUR_WRITES); + assertThatThrownBy( + () -> + new TransactionRequest( + TransactionPolicyId.QUERY_PRIMARY, + BUDGET, + Optional.of(ReadConsistency.EVENTUAL), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("QUERY_PRIMARY"); + } + + @Test + void replicaEligibleQueryAcceptsOnlyEventualOrBoundedStaleness() { + assertThat( + new TransactionRequest( + TransactionPolicyId.QUERY_REPLICA_ELIGIBLE, + BUDGET, + Optional.of(ReadConsistency.BOUNDED_STALENESS), + Optional.empty()) + .readConsistency()) + .contains(ReadConsistency.BOUNDED_STALENESS); + assertThatThrownBy( + () -> + new TransactionRequest( + TransactionPolicyId.QUERY_REPLICA_ELIGIBLE, + BUDGET, + Optional.of(ReadConsistency.STRONG), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("QUERY_REPLICA_ELIGIBLE"); + } + + @Test + void readPoliciesRequireAnExplicitConsistency() { + assertThatThrownBy( + () -> + new TransactionRequest( + TransactionPolicyId.QUERY_PRIMARY, BUDGET, Optional.empty(), Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("readConsistency"); + } + + @Test + void outboxAppendCanInheritTheOuterOperationIdentity() { + TransactionRequest request = + new TransactionRequest( + TransactionPolicyId.OUTBOX_APPEND, BUDGET, Optional.empty(), Optional.empty()); + + assertThat(request.operationId()).isEmpty(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionResultTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionResultTest.java new file mode 100644 index 00000000..70cef3c2 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionResultTest.java @@ -0,0 +1,58 @@ +package dev.caskeleton.application.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TransactionResultTest { + + private static final OperationId OPERATION_ID = new OperationId("operation-42"); + + @Test + void representsCommittedAndParticipatingResultsWithoutConflatingThem() { + TransactionResult committed = + new TransactionResult.Committed<>("value", Optional.of(OPERATION_ID)); + TransactionResult participating = new TransactionResult.Participating<>("value"); + + assertThat(committed.outcome()).isEqualTo(TransactionOutcome.COMMITTED); + assertThat(participating.outcome()).isEqualTo(TransactionOutcome.PARTICIPATING_PENDING_OUTER); + } + + @Test + void indeterminateOutcomeCarriesOnlyOptionalSafeReconciliationData() { + ReconciliationReference reference = + new ReconciliationReference("operation-ledger:operation-42"); + TransactionResult result = + new TransactionResult.Indeterminate<>( + Optional.of(OPERATION_ID), TransactionPhase.COMMIT_REQUESTED, Optional.of(reference)); + + assertThat(result.outcome()).isEqualTo(TransactionOutcome.INDETERMINATE); + assertThat(((TransactionResult.Indeterminate) result).reconciliationReference()) + .contains(reference); + } + + @Test + void determinateRollbackAndPostCommitFailureKeepDifferentOutcomes() { + RuntimeException rollback = new IllegalStateException("rolled back"); + RuntimeException cleanup = new IllegalStateException("cleanup failed"); + + TransactionResult rolledBack = new TransactionResult.DeterminateRollback<>(rollback); + TransactionResult committedWithFailure = + new TransactionResult.CommittedWithPostCommitFailure<>( + "value", Optional.of(OPERATION_ID), cleanup); + + assertThat(rolledBack.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK); + assertThat(committedWithFailure.outcome()) + .isEqualTo(TransactionOutcome.COMMITTED_WITH_POST_COMMIT_FAILURE); + } + + @Test + void reconciliationReferenceRejectsUnsafeOrUnboundedText() { + assertThatThrownBy(() -> new ReconciliationReference("raw\nsql")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ReconciliationReference("a".repeat(257))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/build.gradle b/src/build.gradle index ead48df9..1edf9ea7 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,4 +1,5 @@ import groovy.json.JsonSlurper +import groovy.json.JsonOutput import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask @@ -646,6 +647,615 @@ tasks.register('verifyCleanArchitectureDependencies') { } } +Set expectedJpaReadinessCardIds = [ + 'jpa-observability-lifecycle', + 'jpa-security-baseline', + 'jpa-flyway-migration', + 'jpa-transaction-runtime', + 'jpa-aggregate-store', + 'jpa-query-model', + 'jpa-primary-foundation', + 'jpa-idempotency-owner-safe-v2', + 'jpa-outbox-storage-v2', + 'jpa-outbox-polling-delivery-v2', + 'jpa-outbox-cdc-retention-v1', + 'jpa-inbox-same-store-v1', + 'jpa-primary-replica', + 'jpa-tenant-discriminator-rls', + 'jpa-jdbc-efficiency-coordination' +] as Set + +Set expectedJpaOwnedMigrationCardIds = [ + 'jpa-flyway-migration', + 'jpa-idempotency-owner-safe-v2', + 'jpa-outbox-storage-v2', + 'jpa-outbox-polling-delivery-v2', + 'jpa-inbox-same-store-v1', + 'jpa-tenant-discriminator-rls', + 'jpa-jdbc-efficiency-coordination' +] as Set + +Closure> validateJpaReadinessRegistry = { + Map registry, + String rawRegistry, + Closure taskExists -> + List violations = [] + Set rootKeys = registry.keySet().collect { it as String }.toSet() + Set expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set + if (rootKeys != expectedRootKeys) { + violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}" + } + if (registry['schema-version'] != 1) { + violations << "schema-version must be integer 1; got ${registry['schema-version']}" + } + + Map legacy = registry['legacy-adoption'] instanceof Map + ? registry['legacy-adoption'] as Map + : [:] + Set expectedLegacyKeys = [ + 'state', + 'location', + 'history-table', + 'immutable-applied-versions', + 'allowed-origin' + ] as Set + if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) { + violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}" + } + if (legacy.state != 'transition-only') { + violations << "legacy-adoption.state must be transition-only" + } + if (legacy.location != 'db/migration/postgresql') { + violations << "legacy-adoption.location must be db/migration/postgresql" + } + if (legacy['history-table'] != 'flyway_schema_history') { + violations << "legacy-adoption.history-table must be flyway_schema_history" + } + if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) { + violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]" + } + if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') { + violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED" + } + + Map cards = registry.cards instanceof Map + ? registry.cards as Map + : [:] + Set actualCardIds = cards.keySet().collect { it as String }.toSet() + Set missingCards = expectedJpaReadinessCardIds - actualCardIds + Set unknownCards = actualCardIds - expectedJpaReadinessCardIds + if (!missingCards.isEmpty()) { + violations << "missing card ids ${missingCards.toSorted()}" + } + if (!unknownCards.isEmpty()) { + violations << "unknown card ids ${unknownCards.toSorted()}" + } + + List rawCardKeys = [] + def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ + while (rawCardKeyMatcher.find()) { + rawCardKeys << rawCardKeyMatcher.group('card') + } + Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { + String ignored, Integer count -> count > 1 + }.keySet() + if (!duplicateRawCardKeys.isEmpty()) { + violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" + } + + Set allowedCardKeys = [ + 'state', + 'schema-stream', + 'prerequisites', + 'external-prerequisites', + 'readiness-task', + 'support-tasks', + 'required-evidence', + 'evidence', + 'dispatch-modes', + 'migration' + ] as Set + Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set + Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set + Map taskOwners = [:] + Map migrationLocationOwners = [:] + Map migrationHistoryOwners = [:] + Map evidenceSelectorOwners = [:] + Set actualOwnedMigrationCards = [] + + cards.each { String cardId, Object rawCard -> + if (!(rawCard instanceof Map)) { + violations << "${cardId}: card value must be an object" + return + } + Map card = rawCard as Map + Set unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys + if (!unknownKeys.isEmpty()) { + violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}" + } + + String state = card.state as String + String schemaStream = card['schema-stream'] as String + if (!allowedStates.contains(state)) { + violations << "${cardId}: invalid state '${state}'" + } + if (!allowedSchemaStreams.contains(schemaStream)) { + violations << "${cardId}: invalid schema-stream '${schemaStream}'" + } + + if (!(card.prerequisites instanceof List)) { + violations << "${cardId}: prerequisites must be a list" + } + List prerequisites = card.prerequisites instanceof List + ? (card.prerequisites as List).collect { it as String } + : [] + if (prerequisites.toSet().size() != prerequisites.size()) { + violations << "${cardId}: duplicate prerequisites ${prerequisites}" + } + prerequisites.each { String prerequisite -> + if (!cards.containsKey(prerequisite)) { + violations << "${cardId}: unknown prerequisite '${prerequisite}'" + } else if (state == 'selected' && + ((cards[prerequisite] as Map).state as String) != 'selected') { + violations << "${cardId}: selected card requires non-selected '${prerequisite}'" + } + } + + String readinessTask = card['readiness-task'] as String + if (readinessTask == null || !readinessTask.startsWith(':')) { + violations << "${cardId}: readiness-task must be an absolute Gradle task path" + } + List supportTasks = card['support-tasks'] instanceof List + ? (card['support-tasks'] as List).collect { it as String } + : [] + if (supportTasks.toSet().size() != supportTasks.size()) { + violations << "${cardId}: duplicate support-tasks ${supportTasks}" + } + ([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath -> + if (!taskPath.startsWith(':')) { + violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path" + return + } + String previousOwner = taskOwners.putIfAbsent(taskPath, cardId) + if (previousOwner != null) { + violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}" + } + if (state == 'selected' && !taskExists(taskPath)) { + violations << "${cardId}: selected task does not exist '${taskPath}'" + } + } + + List requiredEvidence = card['required-evidence'] instanceof List + ? (card['required-evidence'] as List).collect { it as String } + : [] + if (requiredEvidence.isEmpty()) { + violations << "${cardId}: required-evidence must be a non-empty list" + } else { + if (requiredEvidence.toSet().size() != requiredEvidence.size()) { + violations << "${cardId}: duplicate required-evidence ${requiredEvidence}" + } + if (!requiredEvidence.contains('no-skip')) { + violations << "${cardId}: required-evidence must include no-skip" + } + } + + Object migrationNode = card.migration + Set allowedEvidenceClaims = requiredEvidence + .findAll { String requirement -> requirement != 'no-skip' } + .toSet() + Map migrationForEvidence = migrationNode instanceof Map + ? migrationNode as Map + : [:] + Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence'] + if (lifecycleEvidenceNode instanceof List) { + (lifecycleEvidenceNode as List).each { + Object lifecycle -> + allowedEvidenceClaims << + "migration-lifecycle:${lifecycle as String}".toString() + } + } + + Object evidenceNode = card.evidence + if (state == 'not-implemented') { + if (evidenceNode != null) { + violations << "${cardId}: not-implemented card forbids evidence" + } + } else if (!(evidenceNode instanceof Map)) { + violations << "${cardId}: active card requires evidence" + } else { + Map evidence = evidenceNode as Map + Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() + Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set + if (evidenceKeys != expectedEvidenceKeys) { + violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" + } + + List scenarios = evidence.scenarios instanceof List + ? evidence.scenarios as List + : [] + if (!(evidence.scenarios instanceof List)) { + violations << "${cardId}: evidence scenarios must be a list" + } + List taskClaims = evidence['task-claims'] instanceof List + ? evidence['task-claims'] as List + : [] + if (!(evidence['task-claims'] instanceof List)) { + violations << "${cardId}: evidence task-claims must be a list" + } + if (scenarios.isEmpty() && taskClaims.isEmpty()) { + violations << "${cardId}: evidence must contain a scenario or task claim" + } + + scenarios.eachWithIndex { Object rawScenario, int index -> + if (!(rawScenario instanceof Map)) { + violations << "${cardId}: evidence scenario ${index} must be an object" + return + } + Map scenario = rawScenario as Map + Set scenarioKeys = + scenario.keySet().collect { it as String }.toSet() + if (scenarioKeys != ['selector', 'covers'] as Set) { + violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}" + } + String selector = scenario.selector as String + if (selector == null || + !(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) { + violations << "${cardId}: invalid evidence selector '${selector}'" + } else { + String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId) + if (previousOwner != null) { + violations << "duplicate evidence selector '${selector}' owned by " + + "${previousOwner} and ${cardId}" + } + } + List covers = scenario.covers instanceof List + ? (scenario.covers as List).collect { it as String } + : [] + if (covers.isEmpty()) { + violations << "${cardId}: evidence scenario ${index} covers must be non-empty" + } + if (covers.toSet().size() != covers.size()) { + violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}" + } + covers.each { String claim -> + if (!allowedEvidenceClaims.contains(claim)) { + violations << "${cardId}: evidence covers unknown requirement '${claim}'" + } + } + } + + Set ownedTasks = ([readinessTask] + supportTasks) + .findAll { it != null } + .toSet() + taskClaims.eachWithIndex { Object rawClaim, int index -> + if (!(rawClaim instanceof Map)) { + violations << "${cardId}: evidence task claim ${index} must be an object" + return + } + Map claim = rawClaim as Map + Set claimKeys = claim.keySet().collect { it as String }.toSet() + if (claimKeys != ['task', 'covers'] as Set) { + violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}" + } + String taskPath = claim.task as String + if (!ownedTasks.contains(taskPath)) { + violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'" + } + List covers = claim.covers instanceof List + ? (claim.covers as List).collect { it as String } + : [] + if (covers.isEmpty()) { + violations << "${cardId}: evidence task claim ${index} covers must be non-empty" + } + if (covers.toSet().size() != covers.size()) { + violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}" + } + covers.each { String evidenceClaim -> + if (!allowedEvidenceClaims.contains(evidenceClaim)) { + violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'" + } + } + } + } + + if (schemaStream == 'owned') { + actualOwnedMigrationCards << cardId + if (!(migrationNode instanceof Map)) { + violations << "${cardId}: owned schema-stream requires migration" + } + } else if (migrationNode != null) { + violations << "${cardId}: schema-stream ${schemaStream} forbids migration" + } + + if (migrationNode instanceof Map) { + Map migration = migrationNode as Map + Set expectedMigrationKeys = [ + 'location', + 'history-table', + 'required-core-epoch', + 'feature-revision', + 'lifecycle-evidence' + ] as Set + Set migrationKeys = migration.keySet().collect { it as String }.toSet() + if (migrationKeys != expectedMigrationKeys) { + violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}" + } + + String location = migration.location as String + String historyTable = migration['history-table'] as String + if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) { + violations << "${cardId}: invalid migration location '${location}'" + } else { + String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId) + if (previousOwner != null) { + violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}" + } + } + if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) { + violations << "${cardId}: invalid migration history-table '${historyTable}'" + } else { + String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId) + if (previousOwner != null) { + violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}" + } + } + + Object coreEpoch = migration['required-core-epoch'] + Object featureRevision = migration['feature-revision'] + if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) { + violations << "${cardId}: required-core-epoch must be a non-negative integer" + } + if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) { + violations << "${cardId}: feature-revision must be a positive integer" + } + List lifecycleEvidence = migration['lifecycle-evidence'] instanceof List + ? (migration['lifecycle-evidence'] as List).collect { it as String } + : [] + if (lifecycleEvidence.isEmpty()) { + violations << "${cardId}: lifecycle-evidence must be a non-empty list" + } else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) { + violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}" + } + } + + if (card['external-prerequisites'] != null) { + if (!(card['external-prerequisites'] instanceof List)) { + violations << "${cardId}: external-prerequisites must be a list" + } else { + (card['external-prerequisites'] as List).eachWithIndex { + Object rawExternal, int index -> + if (!(rawExternal instanceof Map)) { + violations << "${cardId}: external prerequisite ${index} must be an object" + return + } + Map external = rawExternal as Map + Set externalKeys = external.keySet() + .collect { it as String } + .toSet() + if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) { + violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}" + } + if (!((external.registry as String)?.startsWith('src/config/'))) { + violations << "${cardId}: external prerequisite ${index} has invalid registry" + } + if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) { + violations << "${cardId}: external prerequisite ${index} has invalid card-id" + } + if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) { + violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness" + } + } + } + } + } + + if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) { + violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " + + "got ${actualOwnedMigrationCards}" + } + + Map visitState = [:].withDefault { 0 } + Closure visitCard + visitCard = { String cardId -> + if (visitState[cardId] == 1) { + violations << "readiness prerequisite cycle includes '${cardId}'" + return + } + if (visitState[cardId] == 2 || !cards.containsKey(cardId)) { + return + } + visitState[cardId] = 1 + Map card = cards[cardId] as Map + if (card.prerequisites instanceof List) { + (card.prerequisites as List).each { Object prerequisite -> + visitCard(prerequisite as String) + } + } + visitState[cardId] = 2 + } + cards.keySet().each { Object cardId -> visitCard(cardId as String) } + + boolean pollingSelected = + ((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected' + boolean cdcSelected = + ((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected' + if (pollingSelected && cdcSelected) { + violations << 'polling and CDC outbox delivery cards cannot both be selected' + } + + violations +} + +Closure jpaTaskExists = { String absoluteTaskPath -> + int separator = absoluteTaskPath.lastIndexOf(':') + if (separator < 0 || separator == absoluteTaskPath.length() - 1) { + return false + } + String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator) + String taskName = absoluteTaskPath.substring(separator + 1) + Project targetProject = rootProject.findProject(projectPath) + targetProject != null && targetProject.tasks.findByName(taskName) != null +} + +def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') { + group = 'verification' + description = 'Mutation-tests the fail-closed JPA readiness registry validator.' + + File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") + inputs.file(registryFile) + + doLast { + String raw = registryFile.getText('UTF-8') + Map baseline = new JsonSlurper().parseText(raw) as Map + + Closure> copyRegistry = { + new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map + } + Closure expectViolation = { + String scenario, + String expectedText, + Closure mutation, + Closure taskExists = { String ignored -> true } -> + Map candidate = copyRegistry() + mutation(candidate) + List candidateViolations = validateJpaReadinessRegistry( + candidate, + JsonOutput.toJson(candidate), + taskExists) + if (!candidateViolations.any { String violation -> + violation.contains(expectedText) + }) { + throw new GradleException( + "verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " + + "produce '${expectedText}'; got ${candidateViolations}") + } + } + + expectViolation('unknown-card', 'unknown card ids', { Map candidate -> + (candidate.cards as Map)['jpa-primary-foundation-alias'] = + (candidate.cards as Map)['jpa-primary-foundation'] + }) + expectViolation('duplicate-task', 'duplicate task', { Map candidate -> + ((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] = + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task'] + }) + expectViolation('missing-prerequisite', 'unknown prerequisite', { + Map candidate -> + ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = + ['jpa-does-not-exist'] + }) + expectViolation('cycle', 'prerequisite cycle', { Map candidate -> + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = + ['jpa-security-baseline'] + }) + expectViolation('duplicate-location', 'duplicate migration location', { + Map candidate -> + (((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration + as Map).location = 'db/migration/jpa/core' + }) + expectViolation( + 'missing-selected-task', + 'selected task does not exist', + { Map ignored -> }, + { String taskPath -> + taskPath != + ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' + }) + expectViolation('missing-active-evidence', 'active card requires evidence', { + Map candidate -> + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) + .remove('evidence') + }) + expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { + Map candidate -> + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [ + scenarios: [[ + selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', + covers: ['not-a-card-requirement'] + ]], + 'task-claims': [] + ] + }) + expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', { + Map candidate -> + Map card = + (candidate.cards as Map)['jpa-observability-lifecycle'] as Map + card.evidence = [ + scenarios: [ + [ + selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', + covers: ['real-postgresql'] + ], + [ + selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', + covers: ['lifecycle'] + ] + ], + 'task-claims': [] + ] + }) + expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', { + Map candidate -> + ((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [ + scenarios: [], + 'task-claims': [[ + task: ':test', + covers: ['architecture'] + ]] + ] + }) + + logger.lifecycle( + 'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' + + 'missing prerequisite, cycle, duplicate migration ownership, missing ' + + 'selected task, and malformed evidence ownership all fail closed.') + } +} + +def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') { + group = 'verification' + description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.' + dependsOn verifyJpaReadinessRegistryContract + + File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") + inputs.file(registryFile) + + doLast { + if (!registryFile.isFile()) { + throw new GradleException( + "verifyJpaReadinessRegistry: missing registry ${registryFile}") + } + String raw = registryFile.getText('UTF-8') + Map registry + try { + registry = new JsonSlurper().parseText(raw) as Map + } catch (RuntimeException ex) { + throw new GradleException( + "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", + ex) + } + + List violations = + validateJpaReadinessRegistry(registry, raw, jpaTaskExists) + if (!violations.isEmpty()) { + throw new GradleException( + "verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " + + violations.toSorted().join('\n ')) + } + logger.lifecycle( + "verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " + + "cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " + + 'streams, acyclic prerequisites, unique tasks/locations/history tables, ' + + 'and selected task existence verified.') + } +} + +configure(subprojects.findAll { it.childProjects.isEmpty() }) { + tasks.named('check') { + dependsOn verifyJpaReadinessRegistry + } +} + def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') { group = 'verification' description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.' diff --git a/src/config/jpa/readiness-cards.yaml b/src/config/jpa/readiness-cards.yaml new file mode 100644 index 00000000..32e51e1b --- /dev/null +++ b/src/config/jpa/readiness-cards.yaml @@ -0,0 +1,589 @@ +{ + "schema-version": 1, + "legacy-adoption": { + "state": "transition-only", + "location": "db/migration/postgresql", + "history-table": "flyway_schema_history", + "immutable-applied-versions": [1, 3, 4, 5], + "allowed-origin": "LEGACY_ADOPTED" + }, + "cards": { + "jpa-observability-lifecycle": { + "state": "selected", + "schema-stream": "none", + "prerequisites": [], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest", + "required-evidence": ["real-postgresql", "lifecycle", "observability", "no-skip"], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest#startsPostgreSql16WithUtcAndProvidesAValidConnection", + "covers": ["real-postgresql", "lifecycle"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest#poolCapacityExhaustionAndShutdownAreBoundedAndObservable", + "covers": ["observability"] + } + ], + "task-claims": [] + } + }, + "jpa-security-baseline": { + "state": "selected", + "schema-stream": "none", + "prerequisites": ["jpa-observability-lifecycle"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest", + "support-tasks": [ + ":adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety", + ":adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures", + ":adapter:inbound:web:jpaPersistenceRedactionContractTest" + ], + "required-evidence": [ + "real-postgresql", + "tls", + "roles", + "namespace", + "redaction", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest#runtimeRoleCannotCreateInApplicationSchemaOrTempAndUsesTrustedSearchPath", + "covers": ["real-postgresql", "roles", "namespace"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest#verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate", + "covers": ["tls"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest#verifyFullRejectsAnExpiredServerCertificate", + "covers": ["tls"] + } + ], + "task-claims": [ + { + "task": ":adapter:inbound:web:jpaPersistenceRedactionContractTest", + "covers": ["redaction"] + } + ] + } + }, + "jpa-flyway-migration": { + "state": "selected", + "schema-stream": "owned", + "prerequisites": ["jpa-observability-lifecycle", "jpa-security-baseline"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest", + "required-evidence": [ + "real-postgresql", + "migration", + "rolling-compatibility", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest#adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream", + "covers": [ + "real-postgresql", + "migration", + "migration-lifecycle:legacy-adoption" + ] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest#freshCoreStreamInitializesWithoutLegacyHistory", + "covers": ["migration-lifecycle:fresh"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest#interruptedTransactionalMigrationRollsBackThenForwardRecovers", + "covers": ["migration-lifecycle:interrupted-recovery"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest#additiveRollingWindowSupportsOldAndNewArtifactsWithFiniteLockTimeout", + "covers": ["rolling-compatibility"] + } + ], + "task-claims": [] + }, + "migration": { + "location": "db/migration/jpa/core", + "history-table": "flyway_jpa_core_history", + "required-core-epoch": 0, + "feature-revision": 1, + "lifecycle-evidence": ["fresh", "legacy-adoption", "interrupted-recovery"] + } + }, + "jpa-transaction-runtime": { + "state": "selected", + "schema-stream": "none", + "prerequisites": ["jpa-observability-lifecycle", "jpa-security-baseline"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest", + "required-evidence": ["real-postgresql", "concurrency", "fault", "no-skip"], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#appliesTransactionLocalTimeoutsBeforeWorkAndResetsThemAfterCommit", + "covers": ["real-postgresql"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#actionFailureProducesAConfirmedRollback", + "covers": ["fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#serializableConflictIsRetriedOnlyByTheReplaySafePolicy", + "covers": ["concurrency"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#deterministicDeadlockProducesExactlyOneTypedDeadlockFailure", + "covers": ["concurrency", "fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#lockAndStatementTimeoutsRollbackWithinTheConfiguredBounds", + "covers": ["fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#poolExhaustionRejectsBeforeApplicationWorkStarts", + "covers": ["concurrency", "fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest#connectionLossDuringCommitIsIndeterminateAndNeverBlindlyRetried", + "covers": ["fault"] + } + ], + "task-claims": [] + } + }, + "jpa-aggregate-store": { + "state": "selected", + "schema-stream": "contributes-to-core", + "prerequisites": ["jpa-transaction-runtime", "jpa-flyway-migration"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest", + "required-evidence": [ + "real-postgresql", + "mapping", + "optimistic-conflict", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest#roundTripsUuidAndInstantAndDetectsExpectedVersionConflict", + "covers": ["real-postgresql", "mapping", "optimistic-conflict"] + } + ], + "task-claims": [] + } + }, + "jpa-query-model": { + "state": "selected", + "schema-stream": "contributes-to-core", + "prerequisites": ["jpa-transaction-runtime", "jpa-flyway-migration"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest", + "required-evidence": [ + "real-postgresql", + "query-contract", + "query-plan", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest#boundedKeysetQueryUsesTheRepresentativeIndex", + "covers": ["real-postgresql", "query-contract", "query-plan"] + } + ], + "task-claims": [] + } + }, + "jpa-primary-foundation": { + "state": "selected", + "schema-stream": "none", + "prerequisites": [ + "jpa-observability-lifecycle", + "jpa-security-baseline", + "jpa-flyway-migration", + "jpa-transaction-runtime", + "jpa-aggregate-store", + "jpa-query-model" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence", + "support-tasks": [ + ":adapter:outbound:persistence-jpa:test", + ":app-bootstrap:test", + ":verifyCleanArchitectureDependencies", + ":verifyEnvKeys", + ":verifyPublicPathSnapshot" + ], + "required-evidence": [ + "architecture", + "configuration", + "base-card-manifests", + "no-skip" + ], + "evidence": { + "scenarios": [], + "task-claims": [ + { + "task": ":verifyCleanArchitectureDependencies", + "covers": ["architecture"] + }, + { + "task": ":verifyEnvKeys", + "covers": ["configuration"] + } + ] + } + }, + "jpa-idempotency-owner-safe-v2": { + "state": "implemented-candidate", + "schema-stream": "owned", + "prerequisites": [ + "jpa-transaction-runtime", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest", + "required-evidence": [ + "real-postgresql", + "concurrency", + "fault", + "migration", + "stream-lifecycle", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest#sameStoreTransactionCommitsBusinessMutationAndCompletionTogetherThenReplays", + "covers": ["real-postgresql", "fault", "migration"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest#expiredClaimCanBeTakenOverButTheStaleOwnerCannotStart", + "covers": ["concurrency"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest#expiredExecutingRecordRequiresReconciliationAndIsNeverBlindlyTakenOver", + "covers": ["fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest#competingTransactionCannotPassTheOwnerRowUntilTheFirstBusinessCommit", + "covers": ["concurrency"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest#optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration", + "covers": [ + "stream-lifecycle", + "migration-lifecycle:fresh-disabled", + "migration-lifecycle:first-enable", + "migration-lifecycle:disable", + "migration-lifecycle:re-enable", + "migration-lifecycle:interrupted-recovery" + ] + } + ], + "task-claims": [] + }, + "migration": { + "location": "db/migration/jpa/idempotency", + "history-table": "flyway_jpa_idempotency_history", + "required-core-epoch": 1, + "feature-revision": 2, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + }, + "jpa-outbox-storage-v2": { + "state": "implemented-candidate", + "schema-stream": "owned", + "prerequisites": [ + "jpa-transaction-runtime", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest", + "dispatch-modes": ["polling", "cdc"], + "required-evidence": [ + "real-postgresql", + "same-resource", + "partition-uniqueness", + "publication-authority-fence", + "legacy-writer-rejection", + "migration", + "stream-lifecycle", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest#appendRequiresTheCallerSameDatasourceWriteTransaction", + "covers": ["real-postgresql", "same-resource", "migration"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest#immutableIdentityEnvelopeAndBusinessMutationCommitOrRollbackTogether", + "covers": ["same-resource"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest#duplicateIdentityDistinguishesSameEventIdConflictAndAggregateOrderConflict", + "covers": ["partition-uniqueness"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest#publicationControlShareLockPreventsCutoverFromOvertakingAnAppend", + "covers": ["publication-authority-fence", "legacy-writer-rejection"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest#optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration", + "covers": [ + "stream-lifecycle", + "migration-lifecycle:fresh-disabled", + "migration-lifecycle:first-enable", + "migration-lifecycle:disable", + "migration-lifecycle:re-enable", + "migration-lifecycle:interrupted-recovery" + ] + } + ], + "task-claims": [] + }, + "migration": { + "location": "db/migration/jpa/outbox-storage", + "history-table": "flyway_jpa_outbox_storage_history", + "required-core-epoch": 1, + "feature-revision": 2, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + }, + "jpa-outbox-polling-delivery-v2": { + "state": "implemented-candidate", + "schema-stream": "owned", + "prerequisites": [ + "jpa-outbox-storage-v2", + "jpa-transaction-runtime", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest", + "dispatch-modes": ["polling"], + "required-evidence": [ + "real-postgresql", + "concurrency", + "publish-fault", + "ordering", + "migration", + "stream-lifecycle", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest#eventAndInitialDeliveryAreInsertedInTheSameBusinessTransaction", + "covers": ["real-postgresql", "migration"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest#strictAggregateOrderClaimsOnlyTheHeadUntilItIsPublished", + "covers": ["concurrency", "ordering"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest#publishAckLossReclaimsTheStableEventIdButRejectsTheStaleOwner", + "covers": ["concurrency", "publish-fault"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest#retryWaitUsesTheRequestedScheduleAndDeadHeadBlocksTheAggregate", + "covers": ["ordering"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest#optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration", + "covers": [ + "stream-lifecycle", + "migration-lifecycle:fresh-disabled", + "migration-lifecycle:first-enable", + "migration-lifecycle:disable", + "migration-lifecycle:re-enable", + "migration-lifecycle:interrupted-recovery" + ] + } + ], + "task-claims": [] + }, + "migration": { + "location": "db/migration/jpa/outbox-polling", + "history-table": "flyway_jpa_outbox_polling_history", + "required-core-epoch": 1, + "feature-revision": 2, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + }, + "jpa-outbox-cdc-retention-v1": { + "state": "not-implemented", + "schema-stream": "none", + "prerequisites": ["jpa-outbox-storage-v2", "jpa-observability-lifecycle"], + "external-prerequisites": [ + { + "registry": "src/config/messaging/readiness-cards.yaml", + "card-id": "messaging-cdc-dispatch.v1", + "minimum-readiness": "R2" + } + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest", + "dispatch-modes": ["cdc"], + "required-evidence": [ + "real-postgresql", + "connector-checkpoint-high-watermark", + "outage-restart", + "replay-retention", + "delete-tombstone-filtering", + "mode-transition", + "no-skip" + ] + }, + "jpa-inbox-same-store-v1": { + "state": "implemented-candidate", + "schema-stream": "owned", + "prerequisites": [ + "jpa-transaction-runtime", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest", + "required-evidence": [ + "real-postgresql", + "redelivery", + "concurrency", + "migration", + "stream-lifecycle", + "no-skip" + ], + "evidence": { + "scenarios": [ + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest#claimBusinessMutationAndCompletionCommitOrRollbackAsOneUnit", + "covers": ["real-postgresql", "redelivery", "migration"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest#expiredReceivedCanBeTakenOverButStaleOwnerCannotStart", + "covers": ["redelivery", "concurrency"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest#expiredProcessingRequiresRecoveryInsteadOfBlindTakeover", + "covers": ["redelivery"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest#takeoverCannotPassTheOwnerRowWhileBusinessTransactionIsOpen", + "covers": ["concurrency"] + }, + { + "selector": "dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest#optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration", + "covers": [ + "stream-lifecycle", + "migration-lifecycle:fresh-disabled", + "migration-lifecycle:first-enable", + "migration-lifecycle:disable", + "migration-lifecycle:re-enable", + "migration-lifecycle:interrupted-recovery" + ] + } + ], + "task-claims": [] + }, + "migration": { + "location": "db/migration/jpa/inbox", + "history-table": "flyway_jpa_inbox_history", + "required-core-epoch": 1, + "feature-revision": 1, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + }, + "jpa-primary-replica": { + "state": "not-implemented", + "schema-stream": "none", + "prerequisites": [ + "jpa-transaction-runtime", + "jpa-query-model", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest", + "required-evidence": ["real-postgresql", "replica", "lag", "failover", "no-skip"] + }, + "jpa-tenant-discriminator-rls": { + "state": "not-implemented", + "schema-stream": "owned", + "prerequisites": ["jpa-primary-foundation"], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest", + "required-evidence": [ + "real-postgresql", + "tenant-isolation", + "rls", + "migration", + "stream-lifecycle", + "no-skip" + ], + "migration": { + "location": "db/migration/jpa/tenant", + "history-table": "flyway_jpa_tenant_history", + "required-core-epoch": 1, + "feature-revision": 1, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + }, + "jpa-jdbc-efficiency-coordination": { + "state": "not-implemented", + "schema-stream": "owned", + "prerequisites": [ + "jpa-transaction-runtime", + "jpa-flyway-migration", + "jpa-observability-lifecycle" + ], + "readiness-task": ":adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest", + "required-evidence": [ + "real-postgresql", + "contention", + "owner-safety", + "migration", + "stream-lifecycle", + "no-skip" + ], + "migration": { + "location": "db/migration/jpa/coordination", + "history-table": "flyway_jpa_coordination_history", + "required-core-epoch": 1, + "feature-revision": 2, + "lifecycle-evidence": [ + "fresh-disabled", + "first-enable", + "disable", + "re-enable", + "interrupted-recovery" + ] + } + } + } +} diff --git a/src/gradle/jpa-evidence.gradle b/src/gradle/jpa-evidence.gradle new file mode 100644 index 00000000..1ca36d9a --- /dev/null +++ b/src/gradle/jpa-evidence.gradle @@ -0,0 +1,930 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Instant +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.tasks.testing.Test + +/* + * JPA readiness evidence producer. + * + * The registry owns the card/task/scenario mapping. This script only accepts evidence emitted by + * tasks in that registry, reads their JUnit XML, and writes one content-addressed manifest per + * active card. The candidate verifier deliberately permits incomplete R2 dimensions while the + * canonical primary-foundation task requires a clean CI R2 profile and a complete prerequisite + * manifest DAG. + */ + +File jpaEvidenceRegistryFile = rootProject.file('config/jpa/readiness-cards.yaml') +def jpaEvidenceOutputDirectory = layout.buildDirectory.dir('jpa-evidence/manifests') +String jpaEvidenceImage = project.ext.jpaPostgreSqlEvidenceImage as String + +Closure canonicalizeJpaEvidence +canonicalizeJpaEvidence = { Object value -> + if (value instanceof Map) { + Map sorted = new TreeMap<>() + (value as Map).each { Object key, Object child -> + sorted[key as String] = canonicalizeJpaEvidence(child) + } + return sorted + } + if (value instanceof List) { + return (value as List).collect { Object child -> canonicalizeJpaEvidence(child) } + } + value +} + +Closure canonicalJpaEvidenceJson = { Object value -> + JsonOutput.toJson(canonicalizeJpaEvidence(value)) +} + +Closure sha256JpaEvidence = { String value -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + digest.digest(value.getBytes(StandardCharsets.UTF_8)).encodeHex().toString() +} + +Closure jpaEvidenceTaskAtPath = { String absoluteTaskPath -> + int separator = absoluteTaskPath.lastIndexOf(':') + if (separator < 0 || separator == absoluteTaskPath.length() - 1) { + throw new GradleException("Invalid absolute Gradle task path '${absoluteTaskPath}'") + } + String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator) + String taskName = absoluteTaskPath.substring(separator + 1) + Project owner = rootProject.findProject(projectPath) + if (owner == null) { + throw new GradleException("Unknown project for JPA evidence task '${absoluteTaskPath}'") + } + Task task = owner.tasks.findByName(taskName) + if (task == null) { + throw new GradleException("Missing JPA evidence task '${absoluteTaskPath}'") + } + task +} + +Closure runJpaEvidenceCommand = { List command -> + providers.exec { + commandLine command + ignoreExitValue = true + }.standardOutput.asText.get().trim() +} + +Closure> readJpaJUnitResult = { Test testTask -> + File resultDirectory = testTask.reports.junitXml.outputLocation.get().asFile + List resultFiles = resultDirectory.isDirectory() + ? (resultDirectory.listFiles() ?: [] as File[]) + .findAll { File result -> result.name.startsWith('TEST-') && result.name.endsWith('.xml') } + .toSorted { File left, File right -> left.name <=> right.name } + : [] + if (resultFiles.isEmpty()) { + throw new GradleException( + "${testTask.path}: JUnit XML evidence is missing from ${resultDirectory}") + } + + int executed = 0 + int skipped = 0 + int failures = 0 + int errors = 0 + Set selectors = new TreeSet<>() + resultFiles.each { File resultFile -> + def suite = new XmlSlurper(false, false).parse(resultFile) + executed += (suite.@tests.text() ?: '0') as int + skipped += (suite.@skipped.text() ?: '0') as int + failures += (suite.@failures.text() ?: '0') as int + errors += (suite.@errors.text() ?: '0') as int + suite.testcase.each { Object rawCase -> + String className = rawCase.@classname.text() + String methodName = rawCase.@name.text().replaceFirst(/\([^)]*\)$/, '') + selectors << "${className}#${methodName}".toString() + } + } + + [ + tasks: [testTask.path], + resultDirectories: [rootProject.relativePath(resultDirectory)], + executedTestCount: executed, + skippedOrAbortedCount: skipped, + failureCount: failures, + errorCount: errors, + noSkipResult: executed > 0 && skipped == 0 && failures == 0 && errors == 0, + executedSelectors: selectors.toList() + ] as Map +} + +Closure> requiredJpaEvidence = { Map card -> + List required = (card['required-evidence'] as List) + .collect { Object item -> item as String } + if (card.migration instanceof Map) { + Object rawLifecycle = (card.migration as Map)['lifecycle-evidence'] + if (rawLifecycle instanceof List) { + (rawLifecycle as List).each { Object lifecycle -> + required << "migration-lifecycle:${lifecycle as String}".toString() + } + } + } + required.toSet().toSorted() +} + +Closure> resolvedJpaEvidenceVersions = { + Map versions = [:] + configurations.postgresqlIntegrationTestRuntimeClasspath + .incoming + .resolutionResult + .allComponents + .each { component -> + if (component.id instanceof ModuleComponentIdentifier) { + ModuleComponentIdentifier id = component.id as ModuleComponentIdentifier + versions["${id.group}:${id.module}".toString()] = id.version + } + } + [ + pgjdbc: versions['org.postgresql:postgresql'] ?: '', + hibernate: versions['org.hibernate.orm:hibernate-core'] ?: '', + flyway: versions['org.flywaydb:flyway-core'] ?: '' + ] as Map +} + +Set expectedJpaEvidenceManifestKeys = [ + 'schemaVersion', + 'cardId', + 'cardVersion', + 'declaredState', + 'attainedReadiness', + 'evidenceGrade', + 'profile', + 'prerequisites', + 'source', + 'producer', + 'testResult', + 'requiredEvidence', + 'coveredEvidence', + 'missingEvidence', + 'readinessBlockers', + 'postgresql', + 'dependencies', + 'generatedAt', + 'date', + 'topology', + 'artifactLocation', + 'migration', + 'dispatchModes' +] as Set + +Closure> validateJpaEvidenceManifest = { + Map card, + Map manifest -> + List violations = [] + String cardId = manifest.cardId as String + Set actualKeys = manifest.keySet().collect { it as String }.toSet() + if (actualKeys != expectedJpaEvidenceManifestKeys) { + violations << "${cardId}: manifest keys must be exactly ${expectedJpaEvidenceManifestKeys}" + } + if (manifest.schemaVersion != 1) { + violations << "${cardId}: schemaVersion must be 1" + } + if (cardId == null || cardId.isBlank()) { + violations << 'manifest cardId must be non-blank' + } + if (!(manifest.declaredState in ['selected', 'implemented-candidate'])) { + violations << "${cardId}: invalid declaredState '${manifest.declaredState}'" + } + if (!(manifest.attainedReadiness in ['R1', 'R2'])) { + violations << "${cardId}: invalid attainedReadiness '${manifest.attainedReadiness}'" + } + if (!(manifest.evidenceGrade in ['E1', 'E2', 'E3'])) { + violations << "${cardId}: invalid evidenceGrade '${manifest.evidenceGrade}'" + } + if (!(manifest.profile in ['candidate', 'r2'])) { + violations << "${cardId}: invalid profile '${manifest.profile}'" + } + + Map source = manifest.source instanceof Map + ? manifest.source as Map + : [:] + if (source.keySet().collect { it as String }.toSet() != + ['revision', 'worktreeDirty', 'worktreeStatusDigest'] as Set) { + violations << "${cardId}: invalid source metadata keys" + } + if (!((source.revision as String) ==~ /[0-9a-f]{7,40}/)) { + violations << "${cardId}: invalid source revision '${source.revision}'" + } + if (!(source.worktreeDirty instanceof Boolean)) { + violations << "${cardId}: worktreeDirty must be boolean" + } + if (!((source.worktreeStatusDigest as String) ==~ /[0-9a-f]{64}/)) { + violations << "${cardId}: invalid worktree status digest" + } + + Map producer = manifest.producer instanceof Map + ? manifest.producer as Map + : [:] + if (producer.keySet().collect { it as String }.toSet() != + ['gradleTask', 'ciJob'] as Set) { + violations << "${cardId}: invalid producer metadata keys" + } + if (!((producer.gradleTask as String)?.startsWith(':'))) { + violations << "${cardId}: producer Gradle task must be absolute" + } + if ((producer.ciJob as String)?.isBlank()) { + violations << "${cardId}: producer CI job must be non-blank" + } + + Map testResult = manifest.testResult instanceof Map + ? manifest.testResult as Map + : [:] + Set expectedTestKeys = [ + 'tasks', + 'resultDirectories', + 'executedTestCount', + 'skippedOrAbortedCount', + 'failureCount', + 'errorCount', + 'noSkipResult', + 'executedSelectors' + ] as Set + if (testResult.keySet().collect { it as String }.toSet() != expectedTestKeys) { + violations << "${cardId}: invalid testResult keys" + } + if (!((testResult.executedTestCount ?: 0) instanceof Number) || + (testResult.executedTestCount as int) <= 0) { + violations << "${cardId}: executed test count must be positive" + } + ['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey -> + if (!((testResult[countKey] ?: 0) instanceof Number) || + (testResult[countKey] as int) != 0) { + violations << "${cardId}: ${countKey} must be zero" + } + } + if (testResult.noSkipResult != true) { + violations << "${cardId}: no-skip sentinel must be true" + } + + List required = manifest.requiredEvidence instanceof List + ? (manifest.requiredEvidence as List).collect { it as String }.toSorted() + : [] + List covered = manifest.coveredEvidence instanceof List + ? (manifest.coveredEvidence as List).collect { it as String }.toSorted() + : [] + List missing = manifest.missingEvidence instanceof List + ? (manifest.missingEvidence as List).collect { it as String }.toSorted() + : [] + if (required != requiredJpaEvidence(card)) { + violations << "${cardId}: required evidence drifted from registry" + } + if (missing != (required - covered).toSorted()) { + violations << "${cardId}: missing evidence is not required minus covered" + } + + Map postgresql = manifest.postgresql instanceof Map + ? manifest.postgresql as Map + : [:] + if (postgresql.keySet().collect { it as String }.toSet() != + ['image', 'imageDigest', 'managedEngineVersion'] as Set) { + violations << "${cardId}: invalid PostgreSQL metadata keys" + } + if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) { + violations << "${cardId}: PostgreSQL image digest must be immutable" + } + + Map dependencies = manifest.dependencies instanceof Map + ? manifest.dependencies as Map + : [:] + if (dependencies.keySet().collect { it as String }.toSet() != + ['pgjdbc', 'hibernate', 'flyway'] as Set || + dependencies.values().any { Object version -> (version as String)?.isBlank() }) { + violations << "${cardId}: pgjdbc/Hibernate/Flyway versions must be present" + } + + try { + Instant.parse(manifest.generatedAt as String) + } catch (RuntimeException ignored) { + violations << "${cardId}: generatedAt must be an ISO-8601 instant" + } + if (!((manifest.date as String) ==~ /\d{4}-\d{2}-\d{2}/)) { + violations << "${cardId}: date must be ISO-8601" + } + if ((manifest.topology as String)?.isBlank()) { + violations << "${cardId}: topology must be non-blank" + } + if ((manifest.artifactLocation as String)?.isBlank()) { + violations << "${cardId}: artifactLocation must be non-blank" + } + + List prerequisites = manifest.prerequisites instanceof List + ? manifest.prerequisites as List + : [] + prerequisites.eachWithIndex { Object rawPrerequisite, int index -> + Map prerequisite = rawPrerequisite instanceof Map + ? rawPrerequisite as Map + : [:] + if (prerequisite.keySet().collect { it as String }.toSet() != + ['cardId', 'cardVersion', 'manifestId', 'attainedReadiness'] as Set) { + violations << "${cardId}: prerequisite ${index} has invalid keys" + } + if (!((prerequisite.manifestId as String) ==~ /sha256:[0-9a-f]{64}/)) { + violations << "${cardId}: prerequisite ${index} has invalid manifest ID" + } + } + + if (card.migration instanceof Map) { + Map migration = manifest.migration instanceof Map + ? manifest.migration as Map + : [:] + Set expectedMigrationKeys = [ + 'location', + 'historyTable', + 'requiredCoreEpoch', + 'featureRevision', + 'streamLifecycleEvidenceIds' + ] as Set + if (migration.keySet().collect { it as String }.toSet() != expectedMigrationKeys) { + violations << "${cardId}: schema-bearing manifest has invalid migration metadata" + } + } else if (manifest.migration != null) { + violations << "${cardId}: non-schema card must not contain migration metadata" + } + + if (card['dispatch-modes'] instanceof List) { + if (manifest.dispatchModes != card['dispatch-modes']) { + violations << "${cardId}: dispatch modes drifted from registry" + } + } else if (manifest.dispatchModes != []) { + violations << "${cardId}: non-outbox card must have empty dispatch modes" + } + + if (manifest.attainedReadiness == 'R2') { + if (manifest.profile != 'r2') { + violations << "${cardId}: R2 requires the r2 profile" + } + if (source.worktreeDirty != false) { + violations << "${cardId}: R2 requires a clean worktree" + } + if (!missing.isEmpty()) { + violations << "${cardId}: R2 has missing evidence ${missing}" + } + if (producer.ciJob == 'local-unpublished') { + violations << "${cardId}: R2 requires a real CI job identity" + } + if (!((manifest.artifactLocation as String) ==~ + /(?i)(https|s3|gs):\/\/\S+/)) { + violations << "${cardId}: R2 requires an externally retained artifact location" + } + } + violations +} + +Closure> loadJpaEvidenceRegistry = { + new JsonSlurper().parse(jpaEvidenceRegistryFile) as Map +} + +Closure> verifyJpaEvidenceDirectory = { + File outputDirectory, + Map registry -> + List violations = [] + Map manifests = [:] + Map manifestIds = [:] + Map activeCards = (registry.cards as Map).findAll { + String ignored, Object rawCard -> + ((rawCard as Map).state as String) != 'not-implemented' + } + + activeCards.each { String cardId, Object rawCard -> + File cardDirectory = new File(outputDirectory, cardId) + List files = cardDirectory.isDirectory() + ? (cardDirectory.listFiles() ?: [] as File[]) + .findAll { File file -> file.name.endsWith('.json') } + : [] + if (files.size() != 1) { + violations << "${cardId}: expected exactly one content-addressed manifest; got ${files.size()}" + return + } + File manifestFile = files[0] + String fileHash = manifestFile.name.substring(0, manifestFile.name.length() - '.json'.length()) + Map manifest = + new JsonSlurper().parse(manifestFile) as Map + String contentHash = sha256JpaEvidence(canonicalJpaEvidenceJson(manifest)) + if (fileHash != contentHash) { + violations << "${cardId}: filename hash ${fileHash} does not match content ${contentHash}" + } + if ((manifest.cardId as String) != cardId) { + violations << "${cardId}: manifest cardId is '${manifest.cardId}'" + } + violations.addAll(validateJpaEvidenceManifest( + rawCard as Map, + manifest)) + manifests[cardId] = manifest + manifestIds[cardId] = "sha256:${contentHash}".toString() + } + + manifests.each { String cardId, Object rawManifest -> + Map manifest = rawManifest as Map + (manifest.prerequisites as List).each { Object rawPrerequisite -> + Map prerequisite = rawPrerequisite as Map + String prerequisiteId = prerequisite.cardId as String + if (manifestIds[prerequisiteId] != prerequisite.manifestId) { + violations << "${cardId}: prerequisite ${prerequisiteId} manifest ID does not match" + } + } + } + [violations: violations, manifests: manifests, manifestIds: manifestIds] +} + +def verifyJpaEvidenceHarnessContract = tasks.register('verifyJpaEvidenceHarnessContract') { + group = 'verification' + description = 'Mutation-tests JPA evidence schema, no-skip, content hash, and R2 provenance checks.' + + doLast { + Map card = [ + state: 'selected', + 'required-evidence': ['real-postgresql', 'no-skip'] + ] + Map valid = [ + schemaVersion: 1, + cardId: 'jpa-contract-fixture', + cardVersion: '1', + declaredState: 'selected', + attainedReadiness: 'R1', + evidenceGrade: 'E2', + profile: 'candidate', + prerequisites: [], + source: [ + revision: 'b3add0162df8', + worktreeDirty: true, + worktreeStatusDigest: '0' * 64 + ], + producer: [ + gradleTask: ':adapter:outbound:persistence-jpa:contractFixture', + ciJob: 'local-unpublished' + ], + testResult: [ + tasks: [':adapter:outbound:persistence-jpa:contractFixture'], + resultDirectories: ['build/test-results/contractFixture'], + executedTestCount: 1, + skippedOrAbortedCount: 0, + failureCount: 0, + errorCount: 0, + noSkipResult: true, + executedSelectors: ['dev.caskeleton.ContractFixture#passes'] + ], + requiredEvidence: ['no-skip', 'real-postgresql'], + coveredEvidence: ['no-skip', 'real-postgresql'], + missingEvidence: [], + readinessBlockers: ['candidate-profile-is-not-release-evidence'], + postgresql: [ + image: 'postgres:16-alpine', + imageDigest: "postgres@sha256:${'1' * 64}".toString(), + managedEngineVersion: '16' + ], + dependencies: [ + pgjdbc: '42.7.8', + hibernate: '7.1.8.Final', + flyway: '11.14.1' + ], + generatedAt: '2026-07-28T00:00:00Z', + date: '2026-07-28', + topology: 'single-postgresql-testcontainer', + artifactLocation: 'build/jpa-evidence/manifests', + migration: null, + dispatchModes: [] + ] + + List baseline = validateJpaEvidenceManifest(card, valid) + if (!baseline.isEmpty()) { + throw new GradleException( + "verifyJpaEvidenceHarnessContract: valid fixture failed ${baseline}") + } + + Map skipped = + new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map + (skipped.testResult as Map).skippedOrAbortedCount = 1 + (skipped.testResult as Map).noSkipResult = false + List skippedViolations = validateJpaEvidenceManifest(card, skipped) + if (!skippedViolations.any { String violation -> violation.contains('must be zero') } || + !skippedViolations.any { String violation -> violation.contains('sentinel must be true') }) { + throw new GradleException( + "verifyJpaEvidenceHarnessContract: skip mutation escaped ${skippedViolations}") + } + + Map dirtyR2 = + new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map + dirtyR2.attainedReadiness = 'R2' + dirtyR2.profile = 'r2' + List dirtyViolations = validateJpaEvidenceManifest(card, dirtyR2) + if (!dirtyViolations.any { String violation -> violation.contains('clean worktree') } || + !dirtyViolations.any { String violation -> violation.contains('real CI job') }) { + throw new GradleException( + "verifyJpaEvidenceHarnessContract: R2 provenance mutation escaped ${dirtyViolations}") + } + + String validHash = sha256JpaEvidence(canonicalJpaEvidenceJson(valid)) + Map mutated = + new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map + mutated.topology = 'mutated-topology' + String mutatedHash = sha256JpaEvidence(canonicalJpaEvidenceJson(mutated)) + if (validHash == mutatedHash) { + throw new GradleException( + 'verifyJpaEvidenceHarnessContract: content mutation did not change manifest ID') + } + + logger.lifecycle( + 'verifyJpaEvidenceHarnessContract: OK — skip, dirty/local R2, and content mutation fail closed.') + } +} + +def generateJpaEvidenceManifests = tasks.register('generateJpaEvidenceManifests') { + group = 'verification' + description = 'Runs active JPA card producers and writes content-addressed candidate/R2 manifests.' + dependsOn verifyJpaEvidenceHarnessContract + dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry') + + Map configuredRegistry = loadJpaEvidenceRegistry() + Map configuredActiveCards = + (configuredRegistry.cards as Map).findAll { + String ignored, Object rawCard -> + ((rawCard as Map).state as String) != 'not-implemented' + } + configuredActiveCards.each { String cardId, Object rawCard -> + Map card = rawCard as Map + if (cardId != 'jpa-primary-foundation') { + dependsOn jpaEvidenceTaskAtPath(card['readiness-task'] as String) + } + ((card['support-tasks'] ?: []) as List).each { Object taskPath -> + dependsOn jpaEvidenceTaskAtPath(taskPath as String) + } + } + Map primaryCard = + configuredActiveCards['jpa-primary-foundation'] as Map + (primaryCard['support-tasks'] as List).each { Object taskPath -> + dependsOn jpaEvidenceTaskAtPath(taskPath as String) + } + + outputs.dir(jpaEvidenceOutputDirectory) + outputs.upToDateWhen { false } + + doLast { + Map registry = loadJpaEvidenceRegistry() + Map cards = registry.cards as Map + Map activeCards = cards.findAll { + String ignored, Object rawCard -> + ((rawCard as Map).state as String) != 'not-implemented' + } + + String profile = providers.gradleProperty('jpaEvidenceProfile') + .orElse(providers.environmentVariable('JPA_EVIDENCE_PROFILE')) + .getOrElse('candidate') + if (!(profile in ['candidate', 'r2'])) { + throw new GradleException( + "jpaEvidenceProfile must be candidate or r2; got '${profile}'") + } + String ciJob = providers.environmentVariable('JPA_EVIDENCE_CI_JOB') + .getOrElse(profile == 'candidate' ? 'local-unpublished' : '') + String configuredArtifactLocation = + providers.environmentVariable('JPA_EVIDENCE_ARTIFACT_LOCATION') + .getOrElse(profile == 'candidate' + ? rootProject.relativePath(jpaEvidenceOutputDirectory.get().asFile) + : '') + String topology = providers.environmentVariable('JPA_EVIDENCE_TOPOLOGY') + .getOrElse('single-postgresql-testcontainer') + + String worktreeStatus = runJpaEvidenceCommand( + ['git', 'status', '--porcelain=v1', '--untracked-files=all']) + boolean worktreeDirty = !worktreeStatus.isBlank() + String worktreeStatusDigest = sha256JpaEvidence(worktreeStatus) + String imageDigest = runJpaEvidenceCommand([ + 'docker', + 'image', + 'inspect', + '--format={{index .RepoDigests 0}}', + jpaEvidenceImage + ]) + Map dependencyVersions = resolvedJpaEvidenceVersions() + List productionMetadataBlockers = [] + if (profile == 'r2') { + if (worktreeDirty) { + productionMetadataBlockers << 'worktree-is-dirty' + } + if (ciJob.isBlank()) { + productionMetadataBlockers << 'missing-JPA_EVIDENCE_CI_JOB' + } + if (configuredArtifactLocation.isBlank()) { + productionMetadataBlockers << 'missing-JPA_EVIDENCE_ARTIFACT_LOCATION' + } else if (!(configuredArtifactLocation ==~ /(?i)(https|s3|gs):\/\/\S+/)) { + productionMetadataBlockers << 'artifact-location-is-not-externally-retained' + } + } + if (!(imageDigest ==~ /.+@sha256:[0-9a-f]{64}/)) { + productionMetadataBlockers << 'missing-immutable-postgresql-image-digest' + } + dependencyVersions.each { String component, String version -> + if (version.isBlank()) { + productionMetadataBlockers << "missing-${component}-version".toString() + } + } + + File outputDirectory = jpaEvidenceOutputDirectory.get().asFile + delete(outputDirectory) + outputDirectory.mkdirs() + + Map manifests = [:] + Map manifestIds = [:] + activeCards.each { String cardId, Object rawCard -> + Map card = rawCard as Map + List> testResults = [] + if (cardId == 'jpa-primary-foundation') { + (card.prerequisites as List).each { Object prerequisite -> + Map prerequisiteManifest = + manifests[prerequisite as String] as Map + if (prerequisiteManifest != null) { + testResults << (prerequisiteManifest.testResult as Map) + } + } + } else { + Task readinessTask = jpaEvidenceTaskAtPath(card['readiness-task'] as String) + if (!(readinessTask instanceof Test)) { + throw new GradleException( + "${cardId}: readiness task ${readinessTask.path} must be a Test task") + } + testResults << readJpaJUnitResult(readinessTask as Test) + ((card['support-tasks'] ?: []) as List).each { Object taskPath -> + Task supportTask = jpaEvidenceTaskAtPath(taskPath as String) + if (supportTask instanceof Test) { + testResults << readJpaJUnitResult(supportTask as Test) + } + } + } + + Set executedSelectors = testResults + .collectMany { Map result -> + result.executedSelectors as List + } + .toSet() + Set covered = new TreeSet<>() + List> scenarios = + ((card.evidence as Map).scenarios as List>) + scenarios.each { Map scenario -> + if (executedSelectors.contains(scenario.selector as String)) { + covered.addAll((scenario.covers as List).collect { it as String }) + } + } + List> taskClaims = + ((card.evidence as Map)['task-claims'] as List>) + taskClaims.each { Map taskClaim -> + Task evidenceTask = jpaEvidenceTaskAtPath(taskClaim.task as String) + if (evidenceTask.state.executed && + evidenceTask.state.failure == null && + !evidenceTask.state.skipped) { + covered.addAll((taskClaim.covers as List).collect { it as String }) + } + } + + int executedTestCount = testResults.sum { + Map result -> result.executedTestCount as int + } as int + int skippedOrAbortedCount = testResults.sum { + Map result -> result.skippedOrAbortedCount as int + } as int + int failureCount = testResults.sum { + Map result -> result.failureCount as int + } as int + int errorCount = testResults.sum { + Map result -> result.errorCount as int + } as int + boolean noSkipResult = executedTestCount > 0 && + skippedOrAbortedCount == 0 && + failureCount == 0 && + errorCount == 0 + if (noSkipResult) { + covered << 'no-skip' + } + if (cardId == 'jpa-primary-foundation' && + (card.prerequisites as List).every { + Object prerequisite -> manifestIds.containsKey(prerequisite as String) + }) { + covered << 'base-card-manifests' + } + + List required = requiredJpaEvidence(card) + List coveredList = covered.findAll { + String claim -> required.contains(claim) + }.toList().sort() + List missing = (required - coveredList).toSorted() + List> prerequisites = (card.prerequisites as List).collect { + Object rawPrerequisite -> + String prerequisiteId = rawPrerequisite as String + Map prerequisiteManifest = + manifests[prerequisiteId] as Map + if (prerequisiteManifest == null || manifestIds[prerequisiteId] == null) { + throw new GradleException( + "${cardId}: prerequisite manifest '${prerequisiteId}' was not produced first") + } + [ + cardId: prerequisiteId, + cardVersion: prerequisiteManifest.cardVersion, + manifestId: manifestIds[prerequisiteId], + attainedReadiness: prerequisiteManifest.attainedReadiness + ] as Map + } + + List readinessBlockers = [] + if (profile == 'candidate') { + readinessBlockers << 'candidate-profile-is-not-release-evidence' + } + readinessBlockers.addAll(productionMetadataBlockers) + missing.each { String requirement -> + readinessBlockers << "missing-evidence:${requirement}".toString() + } + prerequisites.findAll { + Map prerequisite -> + prerequisite.attainedReadiness != 'R2' + }.each { Map prerequisite -> + readinessBlockers << + "prerequisite-not-R2:${prerequisite.cardId}".toString() + } + + boolean attainedR2 = profile == 'r2' && + readinessBlockers.isEmpty() && + missing.isEmpty() + String generatedAt = Instant.now().toString() + String cardVersion = card.migration instanceof Map + ? ((card.migration as Map)['feature-revision'] as Integer).toString() + : rootProject.ext.traceableVersion as String + String evidenceGrade = cardId == 'jpa-primary-foundation' + ? 'E1' + : (covered.any { String claim -> + claim in [ + 'concurrency', + 'fault', + 'publish-fault', + 'migration', + 'query-plan', + 'optimistic-conflict' + ] + } ? 'E3' : 'E2') + Map migration = card.migration instanceof Map + ? [ + location: (card.migration as Map).location, + historyTable: (card.migration as Map)['history-table'], + requiredCoreEpoch: (card.migration as Map)['required-core-epoch'], + featureRevision: (card.migration as Map)['feature-revision'], + streamLifecycleEvidenceIds: + (card.migration as Map)['lifecycle-evidence'] + ] as Map + : null + + Map manifest = [ + schemaVersion: 1, + cardId: cardId, + cardVersion: cardVersion, + declaredState: card.state, + attainedReadiness: attainedR2 ? 'R2' : 'R1', + evidenceGrade: evidenceGrade, + profile: profile, + prerequisites: prerequisites, + source: [ + revision: rootProject.ext.sourceRevision as String, + worktreeDirty: worktreeDirty, + worktreeStatusDigest: worktreeStatusDigest + ], + producer: [ + gradleTask: card['readiness-task'], + ciJob: ciJob + ], + testResult: [ + tasks: testResults.collectMany { + Map result -> result.tasks as List + }.toSet().toList().sort(), + resultDirectories: testResults.collectMany { + Map result -> result.resultDirectories as List + }.toSet().toList().sort(), + executedTestCount: executedTestCount, + skippedOrAbortedCount: skippedOrAbortedCount, + failureCount: failureCount, + errorCount: errorCount, + noSkipResult: noSkipResult, + executedSelectors: executedSelectors.toSorted() + ], + requiredEvidence: required, + coveredEvidence: coveredList, + missingEvidence: missing, + readinessBlockers: readinessBlockers.toSet().toList().sort(), + postgresql: [ + image: jpaEvidenceImage, + imageDigest: imageDigest, + managedEngineVersion: '16' + ], + dependencies: dependencyVersions, + generatedAt: generatedAt, + date: generatedAt.substring(0, 10), + topology: topology, + artifactLocation: configuredArtifactLocation, + migration: migration, + dispatchModes: card['dispatch-modes'] instanceof List + ? card['dispatch-modes'] + : [] + ] as Map + + String contentHash = sha256JpaEvidence(canonicalJpaEvidenceJson(manifest)) + File cardDirectory = new File(outputDirectory, cardId) + cardDirectory.mkdirs() + File manifestFile = new File(cardDirectory, "${contentHash}.json") + manifestFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + '\n', 'UTF-8') + manifests[cardId] = manifest + manifestIds[cardId] = "sha256:${contentHash}".toString() + } + + logger.lifecycle( + "generateJpaEvidenceManifests: wrote ${manifests.size()} ${profile} " + + "content-addressed card manifests to ${outputDirectory}") + } +} + +gradle.taskGraph.whenReady { graph -> + if (graph.hasTask(generateJpaEvidenceManifests.get())) { + [ + tasks.named('test').get(), + project(':app-bootstrap').tasks.named('test').get() + ].each { Task testTask -> + testTask.outputs.upToDateWhen { false } + } + } +} + +def verifyJpaCandidateEvidence = tasks.register('verifyJpaCandidateEvidence') { + group = 'verification' + description = 'Validates hashes, schema, exact JUnit selectors, no-skip, and prerequisite links without claiming R2.' + dependsOn generateJpaEvidenceManifests + outputs.upToDateWhen { false } + + doLast { + Map result = verifyJpaEvidenceDirectory( + jpaEvidenceOutputDirectory.get().asFile, + loadJpaEvidenceRegistry()) + List violations = result.violations as List + if (!violations.isEmpty()) { + throw new GradleException( + "verifyJpaCandidateEvidence: ${violations.size()} violation(s):\n " + + violations.toSorted().join('\n ')) + } + Map manifests = result.manifests as Map + manifests.each { String cardId, Object rawManifest -> + Map manifest = rawManifest as Map + List missing = manifest.missingEvidence as List + logger.lifecycle( + "${cardId}: ${manifest.attainedReadiness}/${manifest.evidenceGrade}, " + + "${manifest.testResult.executedTestCount} tests, " + + "missing=${missing.isEmpty() ? 'none' : missing.join(',')}") + } + logger.lifecycle( + "verifyJpaCandidateEvidence: OK — ${manifests.size()} manifests are " + + 'content-addressed, linked, zero-skip candidate evidence; no R2 claim was made.') + } +} + +tasks.register('verifyJpaPrimaryFoundationEvidence') { + group = 'verification' + description = 'Requires complete immutable base-card manifests from a clean, retained CI R2 evidence lane.' + dependsOn generateJpaEvidenceManifests + outputs.upToDateWhen { false } + + doLast { + Map result = verifyJpaEvidenceDirectory( + jpaEvidenceOutputDirectory.get().asFile, + loadJpaEvidenceRegistry()) + List violations = result.violations as List + Map manifests = result.manifests as Map + Map primary = + manifests['jpa-primary-foundation'] as Map + if ((primary?.profile as String) != 'r2') { + violations << 'jpa-primary-foundation: run with -PjpaEvidenceProfile=r2 in the dedicated CI lane' + } + [ + 'jpa-observability-lifecycle', + 'jpa-security-baseline', + 'jpa-flyway-migration', + 'jpa-transaction-runtime', + 'jpa-aggregate-store', + 'jpa-query-model', + 'jpa-primary-foundation' + ].each { String cardId -> + Map manifest = manifests[cardId] as Map + if (manifest == null) { + violations << "${cardId}: manifest is missing" + } else if (manifest.attainedReadiness != 'R2') { + violations << "${cardId}: attained ${manifest.attainedReadiness}; blockers=" + + "${(manifest.readinessBlockers as List).join(',')}" + } + } + if (!violations.isEmpty()) { + throw new GradleException( + "verifyJpaPrimaryFoundationEvidence: ${violations.size()} violation(s):\n " + + violations.toSorted().join('\n ')) + } + logger.lifecycle( + 'verifyJpaPrimaryFoundationEvidence: OK — six immutable R2 base manifests and the primary DAG are verified.') + } +} + +tasks.named('check') { + dependsOn verifyJpaEvidenceHarnessContract +} diff --git a/src/gradlew.bat b/src/gradlew.bat index e509b2dd..c4bdd3ab 100644 --- a/src/gradlew.bat +++ b/src/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java index fde89e9d..f4dc108b 100644 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java @@ -3,13 +3,16 @@ package dev.caskeleton.sample.portfolio.bootstrap.persistence; import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; import jakarta.persistence.EntityManager; import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcOperations; /** * Sample-local replacement for {@code PostgreSqlPersistenceConfig}. Exists to break a @@ -43,4 +46,10 @@ public class SamplePostgreSqlPersistenceConfig { public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() { return new PostgreSqlSqlStateErrorMapping(); } + + @Bean + public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer( + JdbcOperations jdbcOperations) { + return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations); + } } diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml index 8910d12e..9884c045 100644 --- a/src/sample-portfolio/src/main/resources/application.yml +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -46,8 +46,8 @@ spring: clean-disabled: true # NOTE: this property is overridden at runtime by the static # SamplePostgreSqlPersistenceConfig.postgreSqlFlywayLocationCustomizer @Bean, which - # sets both "classpath:db/migration/postgresql" (V1, V3, V4) and - # "classpath:db/sample-migration" (V2 work_log). The FlywayConfigurationCustomizer + # sets both "classpath:db/migration/postgresql" (the legacy production timeline) and + # "classpath:db/sample-migration" (V2 work_log, V7 poster). The FlywayConfigurationCustomizer # replaces whatever is declared here, so this entry is documentation-only. locations: classpath:db/migration/postgresql,classpath:db/sample-migration jpa: diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql index 0b5e7e26..ae950f7a 100644 --- a/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql @@ -21,8 +21,8 @@ -- V3 was applied without V2, Flyway validation failed in BOTH directions (resolved-not- -- applied from the IDE, applied-not-resolved from Gradle; out-of-order=false is pinned by -- FLYWAY-C5). The sibling location keeps every launcher resolving only the production --- migrations (V1, V3). To actually run this file, add --- `spring.flyway.locations: classpath:db/migration,classpath:db/sample-migration` +-- migrations (V1, V3+). To actually run this file, add +-- `spring.flyway.locations: classpath:db/migration/postgresql,classpath:db/sample-migration` -- in a sample-enabled deployment; local dev relies on ddl-auto=update for the sample -- schema instead. diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql similarity index 93% rename from src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql rename to src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql index 8ed795e2..1822400f 100644 --- a/src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql @@ -9,7 +9,8 @@ -- INSERT; updated_* move on every modification. version is the optimistic-lock column. -- -- Location: db/sample-migration (a sibling of db/migration/postgresql). The sample --- application.yml activates both locations, so the merged Flyway timeline is V1..V6. +-- application.yml activates both locations, so the merged Flyway timeline is V1..V7. +-- V7 intentionally follows the production capability-registry adoption at V6. CREATE TABLE poster ( id uuid NOT NULL, diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java index daa08195..475dc5ee 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java @@ -29,7 +29,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer; /** * Persistence-adapter INTEGRATION test: boots a disposable Postgres via Testcontainers and drives - * the real Flyway schema (V6 {@code poster}) + JPA mapping, so regressions a mock cannot see fail + * the real Flyway schema (V7 {@code poster}) + JPA mapping, so regressions a mock cannot see fail * here: column mapping, {@code @Enumerated(STRING)}, the UUID string<->native-uuid * conversion, audit stamping, and {@code @Version} assignment. {@code ddl-auto=validate} makes * Hibernate verify {@code PosterEntity} against the Flyway-built schema (schema-drift gate). @@ -47,7 +47,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer; @TestPropertySource( properties = { "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration,classpath:db/sample-migration", + "spring.flyway.locations=classpath:db/migration/postgresql,classpath:db/sample-migration", "spring.jpa.hibernate.ddl-auto=validate" }) class PosterRepositoryAdapterIntegrationTest { diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java index 2477a77a..ab92b9b5 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java @@ -40,10 +40,11 @@ import org.testcontainers.postgresql.PostgreSQLContainer; * {@code @Enumerated(STRING)}, the {@code @ElementCollection} join tables, the UUID * string<->native-uuid conversion, and {@code @Version} assignment. * - *

Flyway is pointed at both production migrations (V1, V3 in {@code db/migration}) and the - * sample's {@code db/sample-migration} (V2 {@code work_log}); {@code ddl-auto=validate} makes - * Hibernate verify {@code WorkLogEntity} against that Flyway-built schema (schema-drift gate). - * Skipped automatically when no Docker daemon is present. + *

Flyway is pointed at the legacy production stream ({@code db/migration/postgresql}) and the + * sample's {@code db/sample-migration} (V2 {@code work_log}); independently versioned JPA + * capability streams are deliberately excluded. {@code ddl-auto=validate} makes Hibernate verify + * {@code WorkLogEntity} against that Flyway-built schema (schema-drift gate). Skipped automatically + * when no Docker daemon is present. */ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -56,7 +57,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer; @TestPropertySource( properties = { "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration,classpath:db/sample-migration", + "spring.flyway.locations=classpath:db/migration/postgresql,classpath:db/sample-migration", "spring.jpa.hibernate.ddl-auto=validate" }) class WorkLogRepositoryAdapterIntegrationTest { diff --git a/src/sample-portfolio/src/test/resources/application-test.yml b/src/sample-portfolio/src/test/resources/application-test.yml index 1e51a66e..5df0a088 100644 --- a/src/sample-portfolio/src/test/resources/application-test.yml +++ b/src/sample-portfolio/src/test/resources/application-test.yml @@ -38,7 +38,7 @@ spring: baseline-on-migrate: false out-of-order: false clean-disabled: true - locations: classpath:db/migration,classpath:db/sample-migration + locations: classpath:db/migration/postgresql,classpath:db/sample-migration jpa: hibernate: ddl-auto: none diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java index 0e248194..0f6e9700 100644 --- a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java @@ -8,7 +8,9 @@ package dev.caskeleton.shared.error; * log only. * *

See the module README for the leak-prevention contract (SQLState / SQL / exception class never - * reach the client) and why it lives in {@code shared.error}. + * reach the client) and why it lives in {@code shared.error}. Observation adapters must never log + * or trace this carrier directly because its cause can contain SQL values, constraints, + * credentials, and endpoints; they emit a cause-free event containing only {@link #errorCode()}. */ public class PersistenceFailureException extends RuntimeException implements ApiErrorCarrier { @@ -18,9 +20,8 @@ public class PersistenceFailureException extends RuntimeException implements Api /** * @param errorCode the classified, client-facing code (a {@code DB_*} {@link OperationalError}) - * @param diagnosticMessage server-log-only detail (may name the SQLState) — never surfaced to the - * client by the web adapter - * @param cause the raw persistence exception, kept for the server log + * @param diagnosticMessage internal classification detail — never surfaced to the client + * @param cause the raw persistence exception for in-process classification/reconciliation only */ public PersistenceFailureException( ApiErrorCode errorCode, String diagnosticMessage, Throwable cause) {