feat: add JPA production capability

This commit is contained in:
donghyeon-ka
2026-07-31 23:48:51 +09:00
parent b3add0162d
commit 7eb6af5d5f
141 changed files with 13094 additions and 319 deletions
+14
View File
@@ -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
+1 -1
View File
@@ -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
+36 -1
View File
@@ -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
+53
View File
@@ -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
+69 -8
View File
@@ -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 <owned_history_table>
order by installed_rank;
select capability_id, installation_origin, core_epoch, feature_revision, lifecycle_state
from capability_schema_registry
where capability_id = '<card-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.
@@ -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 57
상태: 선택된 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 환경이 필요하다.
+16
View File
@@ -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 }
}
@@ -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<Envelope<Void>> 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
@@ -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<ILoggingEvent> 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 =
@@ -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());
}
@@ -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/<card-id>/<sha256>.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 와이어링)다.
@@ -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<String> 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')
@@ -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=
@@ -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<String, OperationalError> byExactSqlState;
public PersistenceExceptionTranslator(Collection<SqlStateErrorMapping> mappings) {
Map<String, OperationalError> merged = new HashMap<>();
for (SqlStateErrorMapping m : mappings) {
merged.putAll(m.exactMappings());
Objects.requireNonNull(mappings, "mappings");
Map<String, OperationalError> merged = new LinkedHashMap<>();
Map<String, String> contributors = new LinkedHashMap<>();
for (SqlStateErrorMapping mapping : mappings) {
Objects.requireNonNull(mapping, "mapping");
String contributor = mapping.getClass().getName();
Map<String, OperationalError> exactMappings =
Objects.requireNonNull(mapping.exactMappings(), contributor + ".exactMappings()");
for (Map.Entry<String, OperationalError> 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<PersistenceFailureException> translate(DataAccessException ex) {
return translate((Throwable) ex);
}
/**
* Classify a transaction or persistence wrapper by walking its cause chain for the first
* SQLState.
*/
public Optional<PersistenceFailureException> translate(Throwable ex) {
Objects.requireNonNull(ex, "ex");
String sqlState = extractSqlState(ex);
if (sqlState == null) {
return Optional.empty();
@@ -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);
}
}
@@ -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");
@@ -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.
*
* <p>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<IdempotencyStartOutcome> 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<IdempotencyRenewOutcome> 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<Row> 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<Row> findForUpdate(String scopeHash) {
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
}
private Optional<Row> find(String scopeHash) {
return queryOne(SELECT_ROW_SQL, scopeHash);
}
private Optional<Row> queryOne(String sql, String scopeHash) {
List<Row> 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<IdempotencyStartOutcome> startResult(
IdempotencyStartOutcome outcome, IdempotencyOwner owner) {
return new IdempotencyMutationResult<>(outcome, owner, IdempotencyStartOutcome::carriesOwner);
}
private static IdempotencyMutationResult<IdempotencyRenewOutcome> 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) {}
}
@@ -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.
*
* <p>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<InboxRow> findForUpdate(InboxScopeDigest scope) {
return queryOne(SELECT_FOR_UPDATE_SQL, scope);
}
private Optional<InboxRow> queryOne(String sql, InboxScopeDigest scope) {
List<InboxRow> 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) {}
}
@@ -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.
*
* <p>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<IdentityInsert> 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<StoredEvent> 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<String> 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<StoredEvent> findEvent(String eventId) {
List<StoredEvent> 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<PublicationControl> 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) {}
}
@@ -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<ClaimedOutboxDelivery> 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<DeliveryState> 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) {}
}
@@ -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");
}
}
}
@@ -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;
}
}
@@ -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");
}
<T> TransactionResult<T> execute(TransactionRequest request, Supplier<T> 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<T> result = executeOnce(request, action, policy);
if (!shouldRetry(request.policyId(), result, attempt)) {
return result;
}
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
return result;
}
attempt++;
}
}
private <T> TransactionResult<T> executeOnce(
TransactionRequest request, Supplier<T> 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 <T> TransactionResult<T> rollback(
TransactionStatus status,
Optional<OperationId> 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;
}
}
}
@@ -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> T inWrite(Supplier<T> action) {
return writeTemplate.execute(status -> action.get());
return executeLegacy(writeTemplate, action);
}
@Override
public <T> T inRead(Supplier<T> action) {
return readTemplate.execute(status -> action.get());
return executeLegacy(readTemplate, action);
}
@Override
public <T> T inNew(Supplier<T> action) {
return requiresNewTemplate.execute(status -> action.get());
return executeLegacy(requiresNewTemplate, action);
}
@Override
public <T> TransactionResult<T> inTransaction(TransactionRequest request, Supplier<T> 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> T executeLegacy(TransactionTemplate template, Supplier<T> 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);
}
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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;
@@ -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;
@@ -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';
@@ -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';
@@ -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';
@@ -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');
@@ -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);
}
}
@@ -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<Void> 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<IdempotencyClaimOutcome> 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);
}
}
}
@@ -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<Void> 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<InboxClaimOutcome> 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);
}
}
}
@@ -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<String, String> boundedTags() {
return Map.of(
"component", "postgresql-primary", "state", state.name().toLowerCase(Locale.ROOT));
}
}
}
@@ -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<String> appliedVersions(
PostgreSqlReadinessSupport database, String historyTable) throws Exception {
List<String> 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);
}
}
}
@@ -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<String> ownedTables,
List<Stream> 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<String> 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<String> 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);
}
}
@@ -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<ClaimedOutboxDelivery> 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<ClaimedOutboxDelivery> 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<ClaimedOutboxDelivery> 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();
}
}
@@ -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<Void> 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<Void> 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);
}
}
}
@@ -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<UUID> 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");
}
}
}
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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<String> 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);
}
}
}
@@ -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<String> 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<String> 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<TransactionResult<Long>> first =
executor.submit(
() ->
serializableInsert(
port, jdbc, 101L, "serial-first", actionCalls, firstAttemptBarrier));
Future<TransactionResult<Long>> 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<SQLException> first =
executor.submit(() -> deadlockParticipant(1, 2, lockedFirstRows));
Future<SQLException> second =
executor.submit(() -> deadlockParticipant(2, 1, lockedFirstRows));
List<SQLException> 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<Integer> 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<Boolean> 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<Boolean> 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<Long> 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<Long> 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()));
}
}
@@ -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();
@@ -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');
@@ -0,0 +1,4 @@
CREATE TABLE readiness_rolling (
id bigint PRIMARY KEY,
legacy_value varchar(128) NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE readiness_rolling
ADD COLUMN expanded_value varchar(128);
@@ -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<String, OperationalError> exactMappings)
implements SqlStateErrorMapping {}
private record SecondMapping(Map<String, OperationalError> exactMappings)
implements SqlStateErrorMapping {}
}
@@ -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<Invocation> 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) {}
}
@@ -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));
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String>) 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<String> 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<String> 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<EffectiveTransactionTimeouts> configured = new AtomicReference<>();
SpringTransactionPort port =
new SpringTransactionPort(
tm,
() -> NOW,
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(settings),
configured::set);
TransactionResult<String> 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<String> 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<String> 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<String> 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<TransactionDefinition> 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++;
}
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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));
}
}
+14 -5
View File
@@ -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`
@@ -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<String> 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)");
}
}
}
@@ -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<String> 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() ? "<blank>" : mode)
+ "; Flyway is the production schema writer");
}
}
private boolean isProdActive() {
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -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.
*
* <p>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<String> 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<String> urlSslModes(String jdbcUrl) {
Matcher matcher = URL_SSLMODE.matcher(jdbcUrl);
List<String> 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;
}
}
@@ -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);
@@ -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
@@ -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();
}
@@ -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();
}
@@ -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. */
@@ -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
@@ -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);
}
}
}
@@ -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);
}
}
}
+35
View File
@@ -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)
@@ -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");
}
}
}
@@ -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 {}
}
@@ -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);
}
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<IdempotencyOwner> owner,
Optional<Instant> processingLeaseUntil,
Optional<StoredResponse> response,
Optional<Instant> 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());
}
}
@@ -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
}
@@ -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");
}
}
@@ -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<O extends Enum<O>> {
private final O outcome;
private final IdempotencyOwner owner;
public IdempotencyMutationResult(O outcome, IdempotencyOwner owner, Predicate<O> 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<IdempotencyOwner> owner() {
return Optional.ofNullable(owner);
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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
}
@@ -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;
}
}
@@ -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.
*
* <p>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");
}
}
}
@@ -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;
}
}
@@ -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
}
@@ -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.
*
* <p>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<IdempotencyStartOutcome> markExecutionStarted(
IdempotencyOwner owner, OperationId operationId);
IdempotencyMutationResult<IdempotencyRenewOutcome> 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);
}
@@ -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");
}
}
}
@@ -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 {}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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<InboxOwner> 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");
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.inbox;
/** Same-store inbox lifecycle. */
public enum InboxState {
RECEIVED,
PROCESSING,
COMPLETED,
RETRYABLE,
DEAD
}
@@ -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);
}
@@ -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
}
@@ -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) {
@@ -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");
}
}

Some files were not shown because too many files have changed in this diff Show More