merge: integrate JPA production capability
# Conflicts: # .github/ci-gate-matrix.yml # .github/scripts/verify-gate-matrix.sh # .github/workflows/ci-quality-gates.yml # src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
This commit is contained in:
@@ -108,6 +108,20 @@ gates:
|
||||
workflow: ci-quality-gates.yml
|
||||
job: redis-standalone
|
||||
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
|
||||
|
||||
@@ -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=20
|
||||
readonly EXPECTED_GATE_COUNT=22
|
||||
|
||||
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
||||
|
||||
@@ -97,6 +97,35 @@ jobs:
|
||||
verifyConfigurationPropertiesProcessor
|
||||
--no-daemon --stacktrace
|
||||
|
||||
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
|
||||
@@ -122,6 +151,7 @@ jobs:
|
||||
- sample-off
|
||||
- gate-matrix-lint
|
||||
- redis-standalone
|
||||
- jpa-candidate-evidence
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -131,9 +161,15 @@ jobs:
|
||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||
REDIS_RESULT: ${{ needs.redis-standalone.result }}
|
||||
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}" "${REDIS_RESULT}"; do
|
||||
for result in \
|
||||
"${QUALITY_RESULT}" \
|
||||
"${SAMPLE_OFF_RESULT}" \
|
||||
"${MATRIX_RESULT}" \
|
||||
"${REDIS_RESULT}" \
|
||||
"${JPA_CANDIDATE_RESULT}"; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
echo "::error::release-gate: required job result was ${result}"
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
@@ -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 5–7
|
||||
|
||||
상태: 선택된 replica topology, tenant mode/RLS policy, target-like backup/failover environment가
|
||||
없으므로 registry에서 `not-implemented`를 유지한다. 로컬 단일 PostgreSQL 테스트를 해당
|
||||
운영 보장의 대체 evidence로 사용하지 않는다.
|
||||
|
||||
### Phase 5 — Primary/replica
|
||||
|
||||
- 별도 pool/route context
|
||||
- explicit `ReadConsistency`
|
||||
- endpoint-bound lag evidence
|
||||
- strong/RYW primary default
|
||||
- failover authority reconciliation
|
||||
|
||||
### Phase 6 — Tenant/RLS와 JDBC coordination
|
||||
|
||||
- tenant-prefixed unique/FK/query
|
||||
- missing context fail-closed
|
||||
- optional FORCE RLS
|
||||
- runtime role bypass negative test
|
||||
- JDBC coordination은 `EFFICIENCY_ONLY`
|
||||
|
||||
### Phase 7 — R3
|
||||
|
||||
- target-like load/capacity
|
||||
- failover, rolling migration, certificate rotation
|
||||
- backup/PITR restore
|
||||
- outbox/idempotency/inbox reconciliation
|
||||
- measured RPO/RTO와 operator game day
|
||||
|
||||
## 9. 공통 verification ladder
|
||||
|
||||
변경 leaf focused test부터 실행한다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :application-core:test --console=plain
|
||||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew verifyPublicPathSnapshot --console=plain
|
||||
./gradlew verifyEnvKeys --console=plain
|
||||
```
|
||||
|
||||
전체 `test`/`check`와 real PostgreSQL task는 해당 phase가 경계를 실제로 변경하거나 required
|
||||
task를 추가한 시점에 실행한다. 실행하지 못한 명령은 이유와 남은 위험을 branch-note와 최종
|
||||
응답에 기록한다.
|
||||
|
||||
## 10. Wiki capture
|
||||
|
||||
각 의미 있는 slice가 끝날 때 실제 vault의 branch-note:
|
||||
|
||||
```text
|
||||
raw/branch-notes/codex-jpa-production-capability.md
|
||||
```
|
||||
|
||||
에 다음을 누적한다.
|
||||
|
||||
- design hash와 plan path
|
||||
- 변경 파일/decision ID
|
||||
- RED/GREEN/architecture command와 결과
|
||||
- 실패/차단/known baseline drift
|
||||
- evidence grade와 아직 R2가 아닌 이유
|
||||
- 실제 파생 raw interview/blog/error 판단
|
||||
|
||||
canonical 문서는 별도 요청 전 생성하지 않는다.
|
||||
|
||||
## 11. 최종 실행 결과
|
||||
|
||||
2026-07-28:
|
||||
|
||||
- `./gradlew :sample-portfolio:test --console=plain`
|
||||
→ 성공, 176 tests.
|
||||
- `./gradlew test --console=plain`
|
||||
→ 성공, 1m 59s.
|
||||
- PostgreSQL readiness task 10개
|
||||
(`lifecycle`, `security`, `migration`, `transaction`, `aggregate`, `query`, `idempotency`,
|
||||
`outbox-storage`, `outbox-polling`, `inbox`)
|
||||
→ 성공, 49s. XML 합계 23 tests, `skipped=0`, `failures=0`, `errors=0`.
|
||||
- `./gradlew check --console=plain`
|
||||
→ 성공, 2m 9s, 209 actionable tasks. 같은 실행에서 root architecture policy,
|
||||
Checkstyle, Spotless, SpotBugs와 custom PostgreSQL source set 검증을 통과했다.
|
||||
- `./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys
|
||||
verifyJpaReadinessRegistry --console=plain`
|
||||
→ 성공. 19개 leaf edge, 1개 public path, 113 env keys, exact 15 cards/7 streams 검증.
|
||||
- `git diff --check`
|
||||
→ 진단 없음.
|
||||
- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence --console=plain`
|
||||
→ 기존 unconditional sentinel을 제거했다. content-addressed candidate manifest를 검증한 뒤
|
||||
candidate profile과 observability, TLS/role/redaction, fresh/interrupted/rolling migration,
|
||||
transaction concurrency 누락을 card별 blocker로 보고 R2를 차단한다.
|
||||
- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain`
|
||||
→ 성공, active card 11개 manifest 생성. PostgreSQL 23 tests와 primary base aggregation
|
||||
7 tests 모두 zero-skip이고 content hash/prerequisite link를 검증했다.
|
||||
- `bash .github/scripts/verify-gate-matrix.sh`
|
||||
→ 성공, 21 gates verified. PR candidate evidence job과 conditional R2 workflow가 registry에
|
||||
반영됐다.
|
||||
- CI metadata를 주입한
|
||||
`verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2`
|
||||
→ PostgreSQL 23 tests와 r2-profile manifest 11개 생성 뒤 의도된 실패, 1m 21s.
|
||||
`worktree-is-dirty`, observability, TLS/roles/redaction, migration
|
||||
fresh/interrupted/rolling, transaction concurrency를 실제 blocker로 보고했다.
|
||||
- `./gradlew test --console=plain`
|
||||
→ 성공, 15s, 78 tasks up-to-date. 직전 evidence lane에서 persistence/app test는 강제
|
||||
재실행했다.
|
||||
- `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --console=plain`
|
||||
→ 성공, 9s, 230 actionable tasks(37 executed, 193 up-to-date).
|
||||
|
||||
전체 test에서 발견한 sample Flyway 회귀는 independent `V1` stream을 broad
|
||||
`classpath:db/migration`으로 합친 문제와 production/sample `V6` 충돌이었다. sample slice를
|
||||
legacy PostgreSQL location으로 한정하고 disposable poster migration을 `V7`로 이동했다. 세부
|
||||
재현·해결 기록은 Wiki
|
||||
`raw/errors/flyway-independent-stream-broad-root-collision-2026-07-28.md`에 남겼다.
|
||||
|
||||
2026-07-29 completion pass:
|
||||
|
||||
- primary foundation의 pool lifecycle/observability, TLS verify-full/role/redaction,
|
||||
fresh/interrupted/rolling migration, transaction concurrency/fault evidence를 추가했다.
|
||||
- idempotency/outbox storage/outbox polling/inbox 네 독립 stream에
|
||||
fresh-disabled/first-enable/disable/re-enable/interrupted-recovery 실제 PostgreSQL
|
||||
lifecycle test를 추가했다.
|
||||
- `./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain`
|
||||
→ **BUILD SUCCESSFUL in 1m 55s**. 11개 manifest 모두 `missing=none`, zero-skip.
|
||||
PostgreSQL producer 38 tests와 web redaction support 2 tests가 실행됐으며 primary
|
||||
aggregation은 20 tests다.
|
||||
- `./gradlew test --console=plain`
|
||||
→ **BUILD SUCCESSFUL in 55s**, 78 actionable tasks.
|
||||
- `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --console=plain`
|
||||
→ 포맷과 test fixture SQL construction을 수정한 뒤 **BUILD SUCCESSFUL in 12s**,
|
||||
231 actionable tasks. 19 leaf architecture, Checkstyle, Spotless, SpotBugs, dependency lock,
|
||||
env/readiness/public-path gate를 통과했다.
|
||||
- CI 메타데이터 형식만 주입한
|
||||
`verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2`
|
||||
→ **의도된 BUILD FAILED in 2m 6s**. missing evidence는 없고 root blocker는
|
||||
`worktree-is-dirty`; 다른 blocker는 prerequisite R2 전파뿐이다.
|
||||
- `bash .github/scripts/verify-gate-matrix.sh`
|
||||
→ **OK**, 21 gates/21 verified.
|
||||
- `git diff --check`
|
||||
→ 진단 없음.
|
||||
|
||||
현재 환경에서 선택된 Phase 0~4 후보의 로컬 구현·검증은 완료됐다. R2 승격은 사람의
|
||||
commit/push, clean revision에서의 retained CI artifact가 필요하고, R3는 target-like
|
||||
backup/failover/load/operator rehearsal 환경이 필요하다.
|
||||
@@ -18,3 +18,19 @@ dependencies {
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
+9
-5
@@ -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
|
||||
|
||||
+44
@@ -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 =
|
||||
|
||||
+6
-2
@@ -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());
|
||||
}
|
||||
|
||||
@@ -98,6 +98,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 에
|
||||
@@ -205,6 +207,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=
|
||||
|
||||
+43
-4
@@ -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();
|
||||
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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");
|
||||
|
||||
+882
@@ -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) {}
|
||||
}
|
||||
+588
@@ -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) {}
|
||||
}
|
||||
+380
@@ -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) {}
|
||||
}
|
||||
+531
@@ -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) {}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
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) {
|
||||
AttemptResult<T> attemptResult = executeOnce(request, action, policy);
|
||||
if (!shouldRetry(request.policyId(), attemptResult, attempt)) {
|
||||
return attemptResult.result();
|
||||
}
|
||||
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
|
||||
return attemptResult.result();
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> AttemptResult<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 new AttemptResult<>(
|
||||
rollback(status, request.operationId(), tracker, localTimeoutFailure), physicalOwner);
|
||||
}
|
||||
|
||||
T value;
|
||||
try {
|
||||
value = action.get();
|
||||
} catch (RuntimeException actionFailure) {
|
||||
return new AttemptResult<>(
|
||||
rollback(status, request.operationId(), tracker, actionFailure), physicalOwner);
|
||||
}
|
||||
|
||||
tracker.observe(TransactionPhase.COMMIT_REQUESTED);
|
||||
try {
|
||||
transactionManager.commit(status);
|
||||
} catch (RuntimeException commitFailure) {
|
||||
if (sentinel.commitAcknowledged()) {
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.CommittedWithPostCommitFailure<>(
|
||||
value, request.operationId(), commitFailure),
|
||||
physicalOwner);
|
||||
}
|
||||
if (sentinel.rolledBack()
|
||||
|| commitFailure instanceof UnexpectedRollbackException
|
||||
|| TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.DeterminateRollback<>(translate(commitFailure)), physicalOwner);
|
||||
}
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.Indeterminate<>(
|
||||
request.operationId(), tracker.lastObserved(), Optional.empty()),
|
||||
physicalOwner);
|
||||
}
|
||||
|
||||
if (!physicalOwner) {
|
||||
return new AttemptResult<>(new TransactionResult.Participating<>(value), false);
|
||||
}
|
||||
tracker.observe(TransactionPhase.COMMIT_ACKED);
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.Committed<>(value, request.operationId()), true);
|
||||
}
|
||||
|
||||
private boolean shouldRetry(
|
||||
TransactionPolicyId policyId, AttemptResult<?> attemptResult, int attempt) {
|
||||
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|
||||
|| !attemptResult.physicalOwner()
|
||||
|| attempt >= retryBackoff.maximumAttempts()
|
||||
|| Thread.currentThread().isInterrupted()) {
|
||||
return false;
|
||||
}
|
||||
TransactionResult<?> result = attemptResult.result();
|
||||
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 record AttemptResult<T>(TransactionResult<T> result, boolean physicalOwner) {
|
||||
|
||||
private AttemptResult {
|
||||
Objects.requireNonNull(result, "result must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-10
@@ -1,10 +1,22 @@
|
||||
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.NestedRootTransactionRejectedException;
|
||||
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;
|
||||
@@ -12,18 +24,92 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Spring-backed {@link TransactionPort}: one pre-built {@link TransactionTemplate} per mode, all
|
||||
* pinned to {@link Isolation#READ_COMMITTED}. See README "transaction" for why the templates are
|
||||
* pre-built (mutable-template race) and CLAUDE.md for the mode table.
|
||||
* Spring-backed {@link PolicyTransactionPort}: one pre-built {@link TransactionTemplate} per mode,
|
||||
* all pinned to {@link Isolation#READ_COMMITTED}. See README "transaction" for why the templates
|
||||
* are 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, connectionTimeout(dataSource), 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,
|
||||
@@ -42,11 +128,20 @@ 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
|
||||
@@ -54,17 +149,22 @@ public class SpringTransactionPort implements TransactionPort {
|
||||
if (TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
throw new NestedRootTransactionRejectedException();
|
||||
}
|
||||
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(
|
||||
@@ -73,10 +173,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);
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import java.time.Duration;
|
||||
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, Duration.ZERO, monotonicNanos, sleeper, jitter);
|
||||
}
|
||||
|
||||
TransactionRetryBackoff(
|
||||
JpaTransactionSettings settings,
|
||||
Duration connectionAcquisitionTimeout,
|
||||
LongSupplier monotonicNanos,
|
||||
NanosSleeper sleeper,
|
||||
JitterSource jitter) {
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
Objects.requireNonNull(connectionAcquisitionTimeout, "connectionAcquisitionTimeout");
|
||||
if (connectionAcquisitionTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("connectionAcquisitionTimeout must not be negative");
|
||||
}
|
||||
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos");
|
||||
this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
|
||||
this.jitter = Objects.requireNonNull(jitter, "jitter");
|
||||
try {
|
||||
this.minimumNextAttemptNanos =
|
||||
Math.addExact(
|
||||
connectionAcquisitionTimeout.toNanos(),
|
||||
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 production(settings, Duration.ZERO, monotonicNanos);
|
||||
}
|
||||
|
||||
static TransactionRetryBackoff production(
|
||||
JpaTransactionSettings settings,
|
||||
Duration connectionAcquisitionTimeout,
|
||||
LongSupplier monotonicNanos) {
|
||||
return new TransactionRetryBackoff(
|
||||
settings,
|
||||
connectionAcquisitionTimeout,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
+141
@@ -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;
|
||||
+70
@@ -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';
|
||||
+152
@@ -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';
|
||||
+307
@@ -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';
|
||||
+43
@@ -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');
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
+298
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
@@ -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);
|
||||
}
|
||||
}
|
||||
+267
@@ -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();
|
||||
}
|
||||
}
|
||||
+291
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -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();
|
||||
}
|
||||
}
|
||||
+169
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+396
@@ -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()));
|
||||
}
|
||||
}
|
||||
+6
@@ -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();
|
||||
+7
@@ -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');
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE readiness_rolling (
|
||||
id bigint PRIMARY KEY,
|
||||
legacy_value varchar(128) NOT NULL
|
||||
);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE readiness_rolling
|
||||
ADD COLUMN expanded_value varchar(128);
|
||||
+46
@@ -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 {}
|
||||
}
|
||||
|
||||
+47
@@ -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) {}
|
||||
}
|
||||
+69
@@ -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));
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
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 participatingSerializableBoundaryNeverRetriesInsideTheAmbientTransaction() {
|
||||
RecordingTransactionManager tm = new RecordingTransactionManager(false);
|
||||
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 "must-not-replay-inside-ambient-transaction";
|
||||
});
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionResult.DeterminateRollback.class);
|
||||
assertThat(calls[0]).isOne();
|
||||
assertThat(tm.definitions).hasSize(1);
|
||||
}
|
||||
|
||||
@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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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");
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
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 reservesConnectionAcquisitionEnvelopeForTheNextAttempt() {
|
||||
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,
|
||||
Duration.ofSeconds(1),
|
||||
now::get,
|
||||
slept::addAndGet,
|
||||
bound -> bound - 1);
|
||||
|
||||
boolean retry =
|
||||
backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(2)), 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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -226,11 +226,11 @@ refresh 완료 전에 실패시킨다.
|
||||
흔한 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 문구를 메시지에 넣는다.
|
||||
@@ -246,6 +246,15 @@ refresh 완료 전에 실패시킨다.
|
||||
으로 한 번만 검사하고, 값이 *없으면* 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`이
|
||||
|
||||
+22
-13
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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}
|
||||
@@ -444,6 +442,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
|
||||
|
||||
+9
-2
@@ -14,7 +14,12 @@ import org.junit.jupiter.api.Test;
|
||||
class RedisCiAggregatorContractTest {
|
||||
|
||||
private static final Set<String> BLOCKING_JOBS =
|
||||
Set.of("quality-gates", "sample-off", "gate-matrix-lint", "redis-standalone");
|
||||
Set.of(
|
||||
"quality-gates",
|
||||
"sample-off",
|
||||
"gate-matrix-lint",
|
||||
"redis-standalone",
|
||||
"jpa-candidate-evidence");
|
||||
|
||||
@Test
|
||||
void releaseAggregatorNeedsAndChecksEveryBlockingJob() throws IOException {
|
||||
@@ -28,7 +33,9 @@ class RedisCiAggregatorContractTest {
|
||||
.contains("SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}")
|
||||
.contains("MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}")
|
||||
.contains("REDIS_RESULT: ${{ needs.redis-standalone.result }}")
|
||||
.contains("\"${REDIS_RESULT}\"");
|
||||
.contains("JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}")
|
||||
.contains("\"${REDIS_RESULT}\"")
|
||||
.contains("\"${JPA_CANDIDATE_RESULT}\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
+5
-1
@@ -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. */
|
||||
|
||||
+49
-5
@@ -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
|
||||
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,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)
|
||||
@@ -423,6 +438,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)
|
||||
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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 {}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+28
@@ -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());
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+17
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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 {}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+6
-1
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user