feat: add JPA production capability
This commit is contained in:
@@ -101,6 +101,20 @@ gates:
|
|||||||
workflow: ci-quality-gates.yml
|
workflow: ci-quality-gates.yml
|
||||||
job: gate-matrix-lint
|
job: gate-matrix-lint
|
||||||
execution: job
|
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
|
- id: quality-release-gate
|
||||||
release_blocking: true
|
release_blocking: true
|
||||||
mechanism: workflow-job
|
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 REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||||||
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
||||||
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
||||||
readonly EXPECTED_GATE_COUNT=19
|
readonly EXPECTED_GATE_COUNT=21
|
||||||
|
|
||||||
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||||
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
||||||
|
|||||||
@@ -70,6 +70,35 @@ jobs:
|
|||||||
- name: Verify the gate matrix against the repository
|
- name: Verify the gate matrix against the repository
|
||||||
run: bash .github/scripts/verify-gate-matrix.sh
|
run: bash .github/scripts/verify-gate-matrix.sh
|
||||||
|
|
||||||
|
jpa-candidate-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Produce zero-skip JPA candidate manifests
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Retain content-addressed JPA candidate manifests
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: jpa-candidate-evidence-${{ github.sha }}
|
||||||
|
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
||||||
quarantine:
|
quarantine:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -94,6 +123,7 @@ jobs:
|
|||||||
- quality-gates
|
- quality-gates
|
||||||
- sample-off
|
- sample-off
|
||||||
- gate-matrix-lint
|
- gate-matrix-lint
|
||||||
|
- jpa-candidate-evidence
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -102,9 +132,14 @@ jobs:
|
|||||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||||
|
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}"; do
|
for result in \
|
||||||
|
"${QUALITY_RESULT}" \
|
||||||
|
"${SAMPLE_OFF_RESULT}" \
|
||||||
|
"${MATRIX_RESULT}" \
|
||||||
|
"${JPA_CANDIDATE_RESULT}"; do
|
||||||
if [[ "${result}" != "success" ]]; then
|
if [[ "${result}" != "success" ]]; then
|
||||||
echo "::error::release-gate: required job result was ${result}"
|
echo "::error::release-gate: required job result was ${result}"
|
||||||
exit 1
|
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]
|
error_codes: [MIGRATION_FAILED]
|
||||||
severity: P1
|
severity: P1
|
||||||
owner: oncall
|
owner: oncall
|
||||||
last_updated: 2026-06-15
|
last_updated: 2026-07-29
|
||||||
status: stub
|
status: active
|
||||||
---
|
---
|
||||||
|
|
||||||
# Runbook: MIGRATION_FAILED (`runbook://migration/failed`)
|
# Runbook: MIGRATION_FAILED (`runbook://migration/failed`)
|
||||||
@@ -15,21 +15,82 @@ status: stub
|
|||||||
- Container exits with code 70 (migration failure exit)
|
- Container exits with code 70 (migration failure exit)
|
||||||
- Structured log with `error.code=MIGRATION_FAILED`, `startup.phase=migration`
|
- Structured log with `error.code=MIGRATION_FAILED`, `startup.phase=migration`
|
||||||
- App refuses to start (fail-fast)
|
- App refuses to start (fail-fast)
|
||||||
|
- JPA capability adapter refuses activation because its
|
||||||
|
`capability_schema_registry.lifecycle_state` is not `ACTIVE`
|
||||||
|
|
||||||
## Diagnosis
|
## Diagnosis
|
||||||
|
|
||||||
- Check Flyway migration log for which script failed and why
|
1. Stop rollout and keep the failed revision out of readiness. Do not route traffic to a partially
|
||||||
- Review latest migration script for SQL errors
|
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
|
## Action
|
||||||
|
|
||||||
- Fix migration script or roll back to previous migration version
|
1. Prefer forward recovery. Fix the environmental blocker or add a new immutable migration, then
|
||||||
- Run migration manually in repair mode if checksum mismatch
|
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
|
## 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 환경이 필요하다.
|
||||||
@@ -16,3 +16,19 @@ dependencies {
|
|||||||
// owned by feature-contract-verification-test-suite (planned).
|
// owned by feature-contract-verification-test-suite (planned).
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register('jpaPersistenceRedactionContractTest', Test) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
|
||||||
|
testClassesDirs = sourceSets.test.output.classesDirs
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
useJUnitPlatform()
|
||||||
|
filter {
|
||||||
|
includeTestsMatching(
|
||||||
|
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails')
|
||||||
|
includeTestsMatching(
|
||||||
|
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
|
||||||
|
}
|
||||||
|
failOnNoDiscoveredTests = true
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|||||||
+9
-5
@@ -218,8 +218,8 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
/**
|
/**
|
||||||
* Handles a pre-classified {@link PersistenceFailureException}: its {@link
|
* Handles a pre-classified {@link PersistenceFailureException}: its {@link
|
||||||
* PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a
|
* 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
|
* category-derived safe string. Logs and traces receive only the stable classification because a
|
||||||
* rationale.
|
* JDBC cause can contain SQL values, constraints, credentials, and endpoints.
|
||||||
*/
|
*/
|
||||||
@ExceptionHandler(PersistenceFailureException.class)
|
@ExceptionHandler(PersistenceFailureException.class)
|
||||||
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
|
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
|
||||||
@@ -228,13 +228,17 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
"persistence failure classified as {} (category={}, retryable={})",
|
"persistence failure classified as {} (category={}, retryable={})",
|
||||||
code.code(),
|
code.code(),
|
||||||
code.category(),
|
code.category(),
|
||||||
code.retryable(),
|
code.retryable());
|
||||||
ex);
|
spanErrorRecorder.recordException(sanitizedPersistenceFailure(code), code.code());
|
||||||
spanErrorRecorder.recordException(ex, code.code());
|
|
||||||
return ErrorResponseFactory.envelope(
|
return ErrorResponseFactory.envelope(
|
||||||
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
|
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
|
* Handles a pre-classified {@link DependencyFailureException}: its {@link
|
||||||
* DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a
|
* 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 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.adapter.inbound.web.http.ApiHeaders;
|
||||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||||
@@ -12,6 +14,7 @@ import dev.caskeleton.shared.response.Envelope;
|
|||||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -139,6 +142,47 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(body.error().details()).isNull();
|
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
|
@Test
|
||||||
void persistenceFailureTransientMapsTo503RetryableWithSafeMessage() {
|
void persistenceFailureTransientMapsTo503RetryableWithSafeMessage() {
|
||||||
PersistenceFailureException carrier =
|
PersistenceFailureException carrier =
|
||||||
|
|||||||
+6
-2
@@ -46,7 +46,7 @@ class SpanErrorRecorderHookTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void persistenceFailureHandlerRecordsExceptionWithClassifiedCode() {
|
void persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode() {
|
||||||
CapturingRecorder recorder = new CapturingRecorder();
|
CapturingRecorder recorder = new CapturingRecorder();
|
||||||
GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder);
|
GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder);
|
||||||
|
|
||||||
@@ -59,7 +59,11 @@ class SpanErrorRecorderHookTest {
|
|||||||
handler.handlePersistenceFailure(ex);
|
handler.handlePersistenceFailure(ex);
|
||||||
|
|
||||||
assertThat(recorder.calls).hasSize(1);
|
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())
|
assertThat(recorder.calls.get(0).errorCode())
|
||||||
.isEqualTo(OperationalError.DB_UNIQUE_VIOLATION.code());
|
.isEqualTo(OperationalError.DB_UNIQUE_VIOLATION.code());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ failure translation contract 다. 여기서는 SPI 구조와 fallback 근거만
|
|||||||
기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql`
|
기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql`
|
||||||
가 추가 `SqlStateErrorMapping` 빈으로 기여한다.
|
가 추가 `SqlStateErrorMapping` 빈으로 기여한다.
|
||||||
|
|
||||||
|
- 서로 다른 contributor가 같은 exact SQLState를 등록하면 code가 같더라도 startup construction을
|
||||||
|
실패시킨다. last-writer-wins merge는 mapping ownership drift를 숨기므로 허용하지 않는다.
|
||||||
- `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접
|
- `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접
|
||||||
처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다.
|
처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다.
|
||||||
- **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에
|
- **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에
|
||||||
@@ -192,6 +194,120 @@ retention 보다 오래된 PUBLISHED row 를 주기적으로 비워 테이블
|
|||||||
cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property
|
cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property
|
||||||
문서는 app-bootstrap `OutboxSettings` / `application.yml` 에 있다.
|
문서는 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 — 분산 락
|
## lock — 분산 락
|
||||||
|
|
||||||
provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다.
|
provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다.
|
||||||
|
|||||||
@@ -3,6 +3,24 @@
|
|||||||
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
|
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
|
||||||
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
|
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
|
||||||
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
|
// 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 {
|
dependencies {
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
@@ -18,6 +36,122 @@ dependencies {
|
|||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
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' }
|
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.
|
# This is a Gradle generated file for dependency locking.
|
||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,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,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,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,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath
|
com.fasterxml:classmate:1.7.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,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.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.spotbugs:spotbugs:4.10.2=spotbugs
|
||||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=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.service:auto-service-annotations:1.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
com.google.auto:auto-common:1.2.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath
|
||||||
com.google.code.gson:gson:2.13.2=spotbugs
|
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_annotation:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
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.41.0=spotbugs
|
||||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
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_annotations:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
com.google.guava:guava:33.5.0-jre=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
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.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
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.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||||
com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath
|
com.sun.istack:istack-commons-runtime:4.1.2=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
com.zaxxer:HikariCP:7.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
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-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-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
|
info.picocli:picocli:4.7.7=checkstyle
|
||||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=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,testAnnotationProcessor
|
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-commons:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-observation:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.projectreactor:reactor-core:3.8.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.activation:jakarta.activation-api:2.1.4=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,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=runtimeClasspath,testRuntimeClasspath
|
jakarta.inject:jakarta.inject-api:2.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,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=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
javax.inject:javax.inject:1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
jaxen:jaxen:2.0.0=spotbugs
|
jaxen:jaxen:2.0.0=spotbugs
|
||||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
net.bytebuddy:byte-buddy-agent:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
net.bytebuddy:byte-buddy:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
net.java.dev.jna:jna:5.18.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
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
|
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.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.15.0=spotbugs
|
||||||
org.apache.commons:commons-text:1.3=checkstyle
|
org.apache.commons:commons-text:1.3=checkstyle
|
||||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||||
org.apache.httpcomponents:httpcore:4.4.16=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-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-core:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-logging-api: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-module-xdoc:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-sink-api: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-core:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=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=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.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
org.apiguardian:apiguardian-api:1.1.2=postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||||
org.aspectj:aspectjweaver:1.9.25=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.aspectj:aspectjweaver:1.9.25=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
org.assertj:assertj-core:3.27.6=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
org.awaitility:awaitility:4.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.checkerframework:checker-qual:3.49.5=runtimeClasspath,testRuntimeClasspath
|
org.checkerframework:checker-qual:3.49.5=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-component-annotations:2.1.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-container-default:2.1.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||||
org.dom4j:dom4j:2.2.0=spotbugs
|
org.dom4j:dom4j:2.2.0=spotbugs
|
||||||
org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
|
org.eclipse.angus:angus-activation:2.0.3=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.flywaydb:flyway-core:11.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.flywaydb:flyway-core:11.14.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.flywaydb:flyway-database-postgresql:11.14.1=runtimeClasspath,testRuntimeClasspath
|
org.flywaydb:flyway-database-postgresql:11.14.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.glassfish.jaxb:jaxb-core:4.0.6=runtimeClasspath,testRuntimeClasspath
|
org.glassfish.jaxb:jaxb-core:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.glassfish.jaxb:jaxb-runtime:4.0.6=runtimeClasspath,testRuntimeClasspath
|
org.glassfish.jaxb:jaxb-runtime:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.glassfish.jaxb:txw2:4.0.6=runtimeClasspath,testRuntimeClasspath
|
org.glassfish.jaxb:txw2:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
org.hamcrest:hamcrest:3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspath
|
org.hibernate.models:hibernate-models:1.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,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.javassist:javassist:3.28.0-GA=checkstyle
|
||||||
org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath
|
org.jboss.logging:jboss-logging:3.6.1.Final=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
org.jetbrains:annotations:17.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-api:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-params:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
org.junit.platform:junit-platform-commons:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
org.junit.platform:junit-platform-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||||
org.junit:junit-bom:6.0.1=testCompileClasspath,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.junit:junit-bom:6.1.0=spotbugs
|
||||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-core:5.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-junit-jupiter:5.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
org.objenesis:objenesis:3.3=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
org.opentest4j:opentest4j:1.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
org.osgi:org.osgi.resource:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,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-analysis:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-commons: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-tree:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-util: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.10.1=spotbugs
|
||||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
org.ow2.asm:asm:9.7.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
org.pcollections:pcollections:4.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||||
org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath
|
org.postgresql:postgresql:42.7.8=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.reflections:reflections:0.10.2=checkstyle
|
org.reflections:reflections:0.10.2=checkstyle
|
||||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
org.rnorth.duct-tape:duct-tape:1.0.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.skyscreamer:jsonassert:1.5.3=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,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.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-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-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,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,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,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=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=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=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,runtimeClasspath,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,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,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=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-restclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-resttestclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-servlet:4.0.0=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,runtimeClasspath,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,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,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=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=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,runtimeClasspath,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,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=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=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=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=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=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,runtimeClasspath,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=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=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-tomcat:4.0.0=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,runtimeClasspath,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=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=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=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,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,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,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,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,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-aop:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-aspects:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-aspects:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-beans:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-context:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-core:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-expression:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-jdbc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-jdbc:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-messaging:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-orm:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-orm:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-test:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-tx:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-web:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-webmvc:7.0.1=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.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
empty=
|
empty=
|
||||||
|
|||||||
+43
-4
@@ -5,8 +5,9 @@ import dev.caskeleton.shared.error.OperationalError;
|
|||||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -27,15 +28,53 @@ public class PersistenceExceptionTranslator {
|
|||||||
private final Map<String, OperationalError> byExactSqlState;
|
private final Map<String, OperationalError> byExactSqlState;
|
||||||
|
|
||||||
public PersistenceExceptionTranslator(Collection<SqlStateErrorMapping> mappings) {
|
public PersistenceExceptionTranslator(Collection<SqlStateErrorMapping> mappings) {
|
||||||
Map<String, OperationalError> merged = new HashMap<>();
|
Objects.requireNonNull(mappings, "mappings");
|
||||||
for (SqlStateErrorMapping m : mappings) {
|
Map<String, OperationalError> merged = new LinkedHashMap<>();
|
||||||
merged.putAll(m.exactMappings());
|
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);
|
this.byExactSqlState = Map.copyOf(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Classify {@code ex}, or {@link Optional#empty()} when its SQLState is unmapped or absent. */
|
/** Classify {@code ex}, or {@link Optional#empty()} when its SQLState is unmapped or absent. */
|
||||||
public Optional<PersistenceFailureException> translate(DataAccessException ex) {
|
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);
|
String sqlState = extractSqlState(ex);
|
||||||
if (sqlState == null) {
|
if (sqlState == null) {
|
||||||
return Optional.empty();
|
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.config.PersistenceJpaConfig;
|
||||||
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
|
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
|
||||||
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
|
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
|
||||||
|
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer;
|
import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Import;
|
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
|
* PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor
|
||||||
@@ -27,6 +29,12 @@ public class PostgreSqlPersistenceConfig {
|
|||||||
return new PostgreSqlSqlStateErrorMapping();
|
return new PostgreSqlSqlStateErrorMapping();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer(
|
||||||
|
JdbcOperations jdbcOperations) {
|
||||||
|
return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
|
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
|
||||||
return configuration -> configuration.locations("classpath:db/migration/postgresql");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator;
|
||||||
|
import dev.caskeleton.application.transaction.OperationId;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionAdmissionException;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPhase;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPolicyId;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionRequest;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionResult;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.TransactionStatus;
|
||||||
|
import org.springframework.transaction.UnexpectedRollbackException;
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
/** Internal Spring executor for the application-owned named transaction policies. */
|
||||||
|
final class SpringPolicyTransactionPort {
|
||||||
|
|
||||||
|
private final PlatformTransactionManager transactionManager;
|
||||||
|
private final LongSupplier monotonicNanos;
|
||||||
|
private final TransactionDeadlineCalculator deadlineCalculator;
|
||||||
|
private final TransactionRetryBackoff retryBackoff;
|
||||||
|
private final TransactionLocalTimeoutConfigurer localTimeoutConfigurer;
|
||||||
|
private final PersistenceExceptionTranslator exceptionTranslator;
|
||||||
|
|
||||||
|
SpringPolicyTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
LongSupplier monotonicNanos,
|
||||||
|
TransactionDeadlineCalculator deadlineCalculator,
|
||||||
|
TransactionRetryBackoff retryBackoff,
|
||||||
|
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
|
||||||
|
PersistenceExceptionTranslator exceptionTranslator) {
|
||||||
|
this.transactionManager =
|
||||||
|
Objects.requireNonNull(transactionManager, "transactionManager must be non-null");
|
||||||
|
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos must be non-null");
|
||||||
|
this.deadlineCalculator =
|
||||||
|
Objects.requireNonNull(deadlineCalculator, "deadlineCalculator must be non-null");
|
||||||
|
this.retryBackoff = Objects.requireNonNull(retryBackoff, "retryBackoff must be non-null");
|
||||||
|
this.localTimeoutConfigurer =
|
||||||
|
Objects.requireNonNull(localTimeoutConfigurer, "localTimeoutConfigurer must be non-null");
|
||||||
|
this.exceptionTranslator =
|
||||||
|
Objects.requireNonNull(exceptionTranslator, "exceptionTranslator must be non-null");
|
||||||
|
}
|
||||||
|
|
||||||
|
<T> TransactionResult<T> execute(TransactionRequest request, Supplier<T> action) {
|
||||||
|
Objects.requireNonNull(request, "request must be non-null");
|
||||||
|
Objects.requireNonNull(action, "action must be non-null");
|
||||||
|
|
||||||
|
PolicySpec policy = policy(request.policyId());
|
||||||
|
if (policy.replicaRequired()) {
|
||||||
|
throw new TransactionAdmissionException(
|
||||||
|
"replica transaction policy is unavailable until the replica capability is qualified");
|
||||||
|
}
|
||||||
|
|
||||||
|
int attempt = 1;
|
||||||
|
while (true) {
|
||||||
|
TransactionResult<T> result = executeOnce(request, action, policy);
|
||||||
|
if (!shouldRetry(request.policyId(), result, attempt)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> TransactionResult<T> executeOnce(
|
||||||
|
TransactionRequest request, Supplier<T> action, PolicySpec policy) {
|
||||||
|
TransactionStartBudget startBudget =
|
||||||
|
deadlineCalculator.beforeAcquisition(request.callBudget(), monotonicNanos.getAsLong());
|
||||||
|
DefaultTransactionDefinition definition = definition(request.policyId(), startBudget, policy);
|
||||||
|
TransactionStatus status;
|
||||||
|
try {
|
||||||
|
status = transactionManager.getTransaction(definition);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw new TransactionAdmissionException(
|
||||||
|
"transaction acquisition failed before application work started", failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhaseTracker tracker = new PhaseTracker();
|
||||||
|
tracker.observe(TransactionPhase.CONNECTION_ACQUIRED);
|
||||||
|
boolean physicalOwner = status.isNewTransaction();
|
||||||
|
PhaseSentinel sentinel = registerSentinelIfPossible(physicalOwner, tracker);
|
||||||
|
tracker.observe(TransactionPhase.ACTIVE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
EffectiveTransactionTimeouts effectiveTimeouts =
|
||||||
|
deadlineCalculator.afterBegin(
|
||||||
|
request.callBudget(), monotonicNanos.getAsLong(), startBudget);
|
||||||
|
localTimeoutConfigurer.apply(effectiveTimeouts);
|
||||||
|
} catch (RuntimeException localTimeoutFailure) {
|
||||||
|
return rollback(status, request.operationId(), tracker, localTimeoutFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
T value;
|
||||||
|
try {
|
||||||
|
value = action.get();
|
||||||
|
} catch (RuntimeException actionFailure) {
|
||||||
|
return rollback(status, request.operationId(), tracker, actionFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.observe(TransactionPhase.COMMIT_REQUESTED);
|
||||||
|
try {
|
||||||
|
transactionManager.commit(status);
|
||||||
|
} catch (RuntimeException commitFailure) {
|
||||||
|
if (sentinel.commitAcknowledged()) {
|
||||||
|
return new TransactionResult.CommittedWithPostCommitFailure<>(
|
||||||
|
value, request.operationId(), commitFailure);
|
||||||
|
}
|
||||||
|
if (sentinel.rolledBack()
|
||||||
|
|| commitFailure instanceof UnexpectedRollbackException
|
||||||
|
|| TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
|
||||||
|
return new TransactionResult.DeterminateRollback<>(translate(commitFailure));
|
||||||
|
}
|
||||||
|
return new TransactionResult.Indeterminate<>(
|
||||||
|
request.operationId(), tracker.lastObserved(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!physicalOwner) {
|
||||||
|
return new TransactionResult.Participating<>(value);
|
||||||
|
}
|
||||||
|
tracker.observe(TransactionPhase.COMMIT_ACKED);
|
||||||
|
return new TransactionResult.Committed<>(value, request.operationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldRetry(
|
||||||
|
TransactionPolicyId policyId, TransactionResult<?> result, int attempt) {
|
||||||
|
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|
||||||
|
|| attempt >= retryBackoff.maximumAttempts()
|
||||||
|
|| Thread.currentThread().isInterrupted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result instanceof TransactionResult.DeterminateRollback<?> rollback) {
|
||||||
|
return TransactionRetryClassifier.isReplayCandidate(rollback.failure());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> TransactionResult<T> rollback(
|
||||||
|
TransactionStatus status,
|
||||||
|
Optional<OperationId> operationId,
|
||||||
|
PhaseTracker tracker,
|
||||||
|
RuntimeException actionFailure) {
|
||||||
|
try {
|
||||||
|
transactionManager.rollback(status);
|
||||||
|
return new TransactionResult.DeterminateRollback<>(translate(actionFailure));
|
||||||
|
} catch (RuntimeException rollbackFailure) {
|
||||||
|
actionFailure.addSuppressed(rollbackFailure);
|
||||||
|
return new TransactionResult.Indeterminate<>(
|
||||||
|
operationId, tracker.lastObserved(), Optional.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeException translate(RuntimeException failure) {
|
||||||
|
return exceptionTranslator.translate(failure).map(RuntimeException.class::cast).orElse(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefaultTransactionDefinition definition(
|
||||||
|
TransactionPolicyId policyId, TransactionStartBudget startBudget, PolicySpec policy) {
|
||||||
|
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
|
||||||
|
definition.setName("application-" + policyId.name().toLowerCase(Locale.ROOT));
|
||||||
|
definition.setPropagationBehavior(policy.propagation());
|
||||||
|
definition.setIsolationLevel(policy.isolation());
|
||||||
|
definition.setReadOnly(policy.readOnly());
|
||||||
|
definition.setTimeout(startBudget.springTimeoutSeconds());
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PolicySpec policy(TransactionPolicyId policyId) {
|
||||||
|
return switch (policyId) {
|
||||||
|
case COMMAND_DEFAULT, INBOX_AND_HANDLER ->
|
||||||
|
required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
|
||||||
|
case COMMAND_SERIALIZABLE_REPLAY_SAFE ->
|
||||||
|
required(TransactionDefinition.ISOLATION_SERIALIZABLE, false);
|
||||||
|
case QUERY_PRIMARY -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, true);
|
||||||
|
case QUERY_REPLICA_ELIGIBLE ->
|
||||||
|
new PolicySpec(
|
||||||
|
TransactionDefinition.PROPAGATION_REQUIRED,
|
||||||
|
TransactionDefinition.ISOLATION_READ_COMMITTED,
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
case OUTBOX_APPEND -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
|
||||||
|
case MAINTENANCE_NEW ->
|
||||||
|
new PolicySpec(
|
||||||
|
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||||
|
TransactionDefinition.ISOLATION_READ_COMMITTED,
|
||||||
|
false,
|
||||||
|
false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PolicySpec required(int isolation, boolean readOnly) {
|
||||||
|
return new PolicySpec(TransactionDefinition.PROPAGATION_REQUIRED, isolation, readOnly, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhaseSentinel registerSentinelIfPossible(
|
||||||
|
boolean physicalOwner, PhaseTracker tracker) {
|
||||||
|
PhaseSentinel sentinel = new PhaseSentinel(tracker);
|
||||||
|
if (physicalOwner && TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(sentinel);
|
||||||
|
}
|
||||||
|
return sentinel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PolicySpec(
|
||||||
|
int propagation, int isolation, boolean readOnly, boolean replicaRequired) {}
|
||||||
|
|
||||||
|
private static final class PhaseTracker {
|
||||||
|
|
||||||
|
private TransactionPhase lastObserved = TransactionPhase.ROUTE_ADMISSION;
|
||||||
|
|
||||||
|
private void observe(TransactionPhase phase) {
|
||||||
|
lastObserved = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransactionPhase lastObserved() {
|
||||||
|
return lastObserved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PhaseSentinel implements TransactionSynchronization, Ordered {
|
||||||
|
|
||||||
|
private final PhaseTracker tracker;
|
||||||
|
private boolean commitAcknowledged;
|
||||||
|
private int completionStatus = STATUS_UNKNOWN;
|
||||||
|
|
||||||
|
private PhaseSentinel(PhaseTracker tracker) {
|
||||||
|
this.tracker = tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return Ordered.HIGHEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeCommit(boolean readOnly) {
|
||||||
|
tracker.observe(TransactionPhase.FLUSHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
commitAcknowledged = true;
|
||||||
|
tracker.observe(TransactionPhase.COMMIT_ACKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(int status) {
|
||||||
|
completionStatus = status;
|
||||||
|
tracker.observe(TransactionPhase.SYNCHRONIZATION_CLEANUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean commitAcknowledged() {
|
||||||
|
return commitAcknowledged || completionStatus == STATUS_COMMITTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean rolledBack() {
|
||||||
|
return completionStatus == STATUS_ROLLED_BACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-6
@@ -1,9 +1,21 @@
|
|||||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
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.Isolation;
|
||||||
|
import dev.caskeleton.application.transaction.PolicyTransactionPort;
|
||||||
import dev.caskeleton.application.transaction.TransactionMode;
|
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 java.util.function.Supplier;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.transaction.TransactionDefinition;
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
@@ -15,13 +27,86 @@ import org.springframework.transaction.support.TransactionTemplate;
|
|||||||
* pre-built (mutable-template race) and CLAUDE.md for the mode table.
|
* pre-built (mutable-template race) and CLAUDE.md for the mode table.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class SpringTransactionPort implements TransactionPort {
|
public class SpringTransactionPort implements PolicyTransactionPort {
|
||||||
|
|
||||||
private final TransactionTemplate writeTemplate;
|
private final TransactionTemplate writeTemplate;
|
||||||
private final TransactionTemplate readTemplate;
|
private final TransactionTemplate readTemplate;
|
||||||
private final TransactionTemplate requiresNewTemplate;
|
private final TransactionTemplate requiresNewTemplate;
|
||||||
|
private final SpringPolicyTransactionPort policyExecutor;
|
||||||
|
private final PersistenceExceptionTranslator exceptionTranslator;
|
||||||
|
|
||||||
public SpringTransactionPort(PlatformTransactionManager transactionManager) {
|
public SpringTransactionPort(PlatformTransactionManager transactionManager) {
|
||||||
|
this(
|
||||||
|
transactionManager,
|
||||||
|
System::nanoTime,
|
||||||
|
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(
|
||||||
|
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)),
|
||||||
|
ignored -> {},
|
||||||
|
defaultExceptionTranslator());
|
||||||
|
}
|
||||||
|
|
||||||
|
SpringTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager, LongSupplier monotonicNanos) {
|
||||||
|
this(
|
||||||
|
transactionManager,
|
||||||
|
monotonicNanos,
|
||||||
|
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(
|
||||||
|
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)),
|
||||||
|
ignored -> {},
|
||||||
|
defaultExceptionTranslator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public SpringTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
JpaTransactionSettings settings,
|
||||||
|
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
|
||||||
|
DataSource dataSource,
|
||||||
|
PersistenceExceptionTranslator exceptionTranslator) {
|
||||||
|
this(
|
||||||
|
transactionManager,
|
||||||
|
System::nanoTime,
|
||||||
|
new TransactionDeadlineCalculator(connectionTimeout(dataSource), settings),
|
||||||
|
TransactionRetryBackoff.production(settings, System::nanoTime),
|
||||||
|
localTimeoutConfigurer,
|
||||||
|
exceptionTranslator);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpringTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
LongSupplier monotonicNanos,
|
||||||
|
TransactionDeadlineCalculator deadlineCalculator,
|
||||||
|
TransactionLocalTimeoutConfigurer localTimeoutConfigurer) {
|
||||||
|
this(
|
||||||
|
transactionManager,
|
||||||
|
monotonicNanos,
|
||||||
|
deadlineCalculator,
|
||||||
|
localTimeoutConfigurer,
|
||||||
|
defaultExceptionTranslator());
|
||||||
|
}
|
||||||
|
|
||||||
|
SpringTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
LongSupplier monotonicNanos,
|
||||||
|
TransactionDeadlineCalculator deadlineCalculator,
|
||||||
|
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
|
||||||
|
PersistenceExceptionTranslator exceptionTranslator) {
|
||||||
|
this(
|
||||||
|
transactionManager,
|
||||||
|
monotonicNanos,
|
||||||
|
deadlineCalculator,
|
||||||
|
TransactionRetryBackoff.production(defaultSettings(), monotonicNanos),
|
||||||
|
localTimeoutConfigurer,
|
||||||
|
exceptionTranslator);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpringTransactionPort(
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
LongSupplier monotonicNanos,
|
||||||
|
TransactionDeadlineCalculator deadlineCalculator,
|
||||||
|
TransactionRetryBackoff retryBackoff,
|
||||||
|
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
|
||||||
|
PersistenceExceptionTranslator exceptionTranslator) {
|
||||||
this.writeTemplate =
|
this.writeTemplate =
|
||||||
template(
|
template(
|
||||||
transactionManager,
|
transactionManager,
|
||||||
@@ -40,21 +125,35 @@ public class SpringTransactionPort implements TransactionPort {
|
|||||||
TransactionMode.REQUIRES_NEW,
|
TransactionMode.REQUIRES_NEW,
|
||||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||||
false);
|
false);
|
||||||
|
this.policyExecutor =
|
||||||
|
new SpringPolicyTransactionPort(
|
||||||
|
transactionManager,
|
||||||
|
monotonicNanos,
|
||||||
|
deadlineCalculator,
|
||||||
|
retryBackoff,
|
||||||
|
localTimeoutConfigurer,
|
||||||
|
exceptionTranslator);
|
||||||
|
this.exceptionTranslator = exceptionTranslator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <T> T inWrite(Supplier<T> action) {
|
public <T> T inWrite(Supplier<T> action) {
|
||||||
return writeTemplate.execute(status -> action.get());
|
return executeLegacy(writeTemplate, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <T> T inRead(Supplier<T> action) {
|
public <T> T inRead(Supplier<T> action) {
|
||||||
return readTemplate.execute(status -> action.get());
|
return executeLegacy(readTemplate, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <T> T inNew(Supplier<T> action) {
|
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(
|
private static TransactionTemplate template(
|
||||||
@@ -63,10 +162,44 @@ public class SpringTransactionPort implements TransactionPort {
|
|||||||
int propagation,
|
int propagation,
|
||||||
boolean readOnly) {
|
boolean readOnly) {
|
||||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||||
template.setName("application-" + mode.name().toLowerCase());
|
template.setName("application-" + mode.name().toLowerCase(Locale.ROOT));
|
||||||
template.setPropagationBehavior(propagation);
|
template.setPropagationBehavior(propagation);
|
||||||
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||||
template.setReadOnly(readOnly);
|
template.setReadOnly(readOnly);
|
||||||
return template;
|
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);
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.outbound.CallBudget;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
/** Bounded exponential full-jitter pause constrained by the caller's absolute budget. */
|
||||||
|
final class TransactionRetryBackoff {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface NanosSleeper {
|
||||||
|
void sleep(long nanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface JitterSource {
|
||||||
|
long nextLong(long exclusiveBound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JpaTransactionSettings settings;
|
||||||
|
private final LongSupplier monotonicNanos;
|
||||||
|
private final NanosSleeper sleeper;
|
||||||
|
private final JitterSource jitter;
|
||||||
|
private final long minimumNextAttemptNanos;
|
||||||
|
|
||||||
|
TransactionRetryBackoff(
|
||||||
|
JpaTransactionSettings settings,
|
||||||
|
LongSupplier monotonicNanos,
|
||||||
|
NanosSleeper sleeper,
|
||||||
|
JitterSource jitter) {
|
||||||
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
|
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos");
|
||||||
|
this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
|
||||||
|
this.jitter = Objects.requireNonNull(jitter, "jitter");
|
||||||
|
try {
|
||||||
|
this.minimumNextAttemptNanos =
|
||||||
|
Math.addExact(
|
||||||
|
settings.minimumActionWindow().toNanos(),
|
||||||
|
Math.addExact(
|
||||||
|
settings.beginBudget().toNanos(), settings.completionMargin().toNanos()));
|
||||||
|
} catch (ArithmeticException exception) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"retry minimum attempt window exceeds long range", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static TransactionRetryBackoff production(
|
||||||
|
JpaTransactionSettings settings, LongSupplier monotonicNanos) {
|
||||||
|
return new TransactionRetryBackoff(
|
||||||
|
settings,
|
||||||
|
monotonicNanos,
|
||||||
|
LockSupport::parkNanos,
|
||||||
|
bound -> ThreadLocalRandom.current().nextLong(bound));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean pauseBeforeRetry(CallBudget callBudget, int completedAttempts) {
|
||||||
|
Objects.requireNonNull(callBudget, "callBudget");
|
||||||
|
if (completedAttempts < 1 || Thread.currentThread().isInterrupted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long cappedDelayNanos = cappedExponentialDelay(completedAttempts);
|
||||||
|
long jitteredDelayNanos = jitter.nextLong(cappedDelayNanos + 1);
|
||||||
|
long remainingNanos = callBudget.remainingNanosAt(monotonicNanos.getAsLong());
|
||||||
|
if (remainingNanos <= saturatedAdd(jitteredDelayNanos, minimumNextAttemptNanos)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (jitteredDelayNanos > 0) {
|
||||||
|
sleeper.sleep(jitteredDelayNanos);
|
||||||
|
}
|
||||||
|
return !Thread.currentThread().isInterrupted()
|
||||||
|
&& callBudget.remainingNanosAt(monotonicNanos.getAsLong()) > minimumNextAttemptNanos;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maximumAttempts() {
|
||||||
|
return settings.retryMaximumAttempts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long cappedExponentialDelay(int completedAttempts) {
|
||||||
|
long baseNanos = settings.retryBaseDelay().toNanos();
|
||||||
|
long maximumNanos = settings.retryMaximumDelay().toNanos();
|
||||||
|
int shift = Math.min(completedAttempts - 1, 62);
|
||||||
|
if (baseNanos > (maximumNanos >> shift)) {
|
||||||
|
return maximumNanos;
|
||||||
|
}
|
||||||
|
return Math.min(maximumNanos, baseNanos << shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long saturatedAdd(long left, long right) {
|
||||||
|
try {
|
||||||
|
return Math.addExact(left, right);
|
||||||
|
} catch (ArithmeticException ignored) {
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
package dev.caskeleton.adapter.outbound.persistence.failure;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.Category;
|
||||||
import dev.caskeleton.shared.error.OperationalError;
|
import dev.caskeleton.shared.error.OperationalError;
|
||||||
@@ -160,6 +161,17 @@ class PersistenceExceptionTranslatorTest {
|
|||||||
assertThat(carrier.errorCode()).isEqualTo(OperationalError.DB_UNIQUE_VIOLATION);
|
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 ----
|
// ---- SPI merge: additional mapping contributes extra codes ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -181,4 +193,38 @@ class PersistenceExceptionTranslatorTest {
|
|||||||
assertThat(withVendor.translate(daoWithSqlState("23505")).orElseThrow().errorCode())
|
assertThat(withVendor.translate(daoWithSqlState("23505")).orElseThrow().errorCode())
|
||||||
.isEqualTo(OperationalError.DB_UNIQUE_VIOLATION);
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.outbound.CallBudget;
|
||||||
|
import dev.caskeleton.application.transaction.OperationId;
|
||||||
|
import dev.caskeleton.application.transaction.ReadConsistency;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionAdmissionException;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionOutcome;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPhase;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionPolicyId;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionRequest;
|
||||||
|
import dev.caskeleton.application.transaction.TransactionResult;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.TransactionException;
|
||||||
|
import org.springframework.transaction.TransactionStatus;
|
||||||
|
import org.springframework.transaction.UnexpectedRollbackException;
|
||||||
|
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||||
|
|
||||||
|
class SpringPolicyTransactionPortTest {
|
||||||
|
|
||||||
|
private static final long NOW = 10_000L;
|
||||||
|
private static final OperationId OPERATION_ID = new OperationId("operation-42");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commandDefaultUsesRequiredReadCommittedPrimaryShapeAndReturnsCommitted() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)), () -> "ok");
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransactionResult.Committed.class);
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED);
|
||||||
|
TransactionDefinition definition = tm.definitions.getFirst();
|
||||||
|
assertThat(definition.getPropagationBehavior())
|
||||||
|
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED);
|
||||||
|
assertThat(definition.getIsolationLevel())
|
||||||
|
.isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||||
|
assertThat(definition.isReadOnly()).isFalse();
|
||||||
|
assertThat(definition.getTimeout()).isEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void serializableReplaySafePolicyPinsSerializableIsolation() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)),
|
||||||
|
() -> "ok");
|
||||||
|
|
||||||
|
assertThat(tm.definitions.getFirst().getIsolationLevel())
|
||||||
|
.isEqualTo(TransactionDefinition.ISOLATION_SERIALIZABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void primaryQueryIsReadOnlyAndDoesNotRequireAnOperationId() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
TransactionRequest request =
|
||||||
|
new TransactionRequest(
|
||||||
|
TransactionPolicyId.QUERY_PRIMARY,
|
||||||
|
CallBudget.after(NOW, Duration.ofSeconds(5)),
|
||||||
|
Optional.of(ReadConsistency.STRONG),
|
||||||
|
Optional.empty());
|
||||||
|
|
||||||
|
TransactionResult<String> result = port.inTransaction(request, () -> "query");
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED);
|
||||||
|
assertThat(tm.definitions.getFirst().isReadOnly()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maintenancePolicyUsesRequiresNew() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.MAINTENANCE_NEW, Duration.ofSeconds(5)), () -> "ok");
|
||||||
|
|
||||||
|
assertThat(tm.definitions.getFirst().getPropagationBehavior())
|
||||||
|
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void participatingRequiredBoundaryNeverClaimsCommit() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(false);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)),
|
||||||
|
() -> "pending");
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransactionResult.Participating.class);
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.PARTICIPATING_PENDING_OUTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actionFailureWithConfirmedRollbackIsDeterminate() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)),
|
||||||
|
() -> {
|
||||||
|
throw new IllegalStateException("action failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransactionResult.DeterminateRollback.class);
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK);
|
||||||
|
assertThat(tm.rollbacks).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commitFailureWithoutAckIsIndeterminateAndIsNeverReplayed() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
tm.commitFailure = new TransactionException("connection lost during commit") {};
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
int[] calls = {0};
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(
|
||||||
|
TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)),
|
||||||
|
() -> {
|
||||||
|
calls[0]++;
|
||||||
|
return "uncertain";
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransactionResult.Indeterminate.class);
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.INDETERMINATE);
|
||||||
|
assertThat(((TransactionResult.Indeterminate<String>) result).lastObservedPhase())
|
||||||
|
.isEqualTo(TransactionPhase.COMMIT_REQUESTED);
|
||||||
|
assertThat(calls[0]).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void serializationFailureReportedByCommitIsDeterminateAndReplaySafe() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
tm.commitFailure =
|
||||||
|
new TransactionException("serialization rejected at commit", dataFailure("40001")) {};
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
int[] calls = {0};
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(
|
||||||
|
TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)),
|
||||||
|
() -> {
|
||||||
|
calls[0]++;
|
||||||
|
if (calls[0] > 1) {
|
||||||
|
tm.commitFailure = null;
|
||||||
|
}
|
||||||
|
return "replayed";
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED);
|
||||||
|
assertThat(calls[0]).isEqualTo(2);
|
||||||
|
assertThat(tm.definitions).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unexpectedRollbackIsDeterminate() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
tm.commitFailure = new UnexpectedRollbackException("rollback-only");
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)),
|
||||||
|
() -> "rolled back");
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insufficientBudgetRejectsBeforeTransactionManagerAcquisition() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() ->
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofMillis(999)),
|
||||||
|
() -> "must-not-run"))
|
||||||
|
.isInstanceOf(TransactionAdmissionException.class)
|
||||||
|
.hasMessageContaining("one second");
|
||||||
|
assertThat(tm.definitions).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transactionTimeoutUsesAConservativeWholeSecondFloor() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofMillis(2_999)), () -> "ok");
|
||||||
|
|
||||||
|
assertThat(tm.definitions.getFirst().getTimeout()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unavailableReplicaPolicyFailsBeforeOpeningATransaction() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
TransactionRequest request =
|
||||||
|
new TransactionRequest(
|
||||||
|
TransactionPolicyId.QUERY_REPLICA_ELIGIBLE,
|
||||||
|
CallBudget.after(NOW, Duration.ofSeconds(5)),
|
||||||
|
Optional.of(ReadConsistency.EVENTUAL),
|
||||||
|
Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> port.inTransaction(request, () -> "must-not-run"))
|
||||||
|
.isInstanceOf(TransactionAdmissionException.class)
|
||||||
|
.hasMessageContaining("replica");
|
||||||
|
assertThat(tm.definitions).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appliesLocalTimeoutsBeforeTheBusinessAction() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
JpaTransactionSettings settings =
|
||||||
|
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null);
|
||||||
|
AtomicReference<EffectiveTransactionTimeouts> configured = new AtomicReference<>();
|
||||||
|
SpringTransactionPort port =
|
||||||
|
new SpringTransactionPort(
|
||||||
|
tm,
|
||||||
|
() -> NOW,
|
||||||
|
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(settings),
|
||||||
|
configured::set);
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(20)),
|
||||||
|
() -> {
|
||||||
|
assertThat(configured.get()).isNotNull();
|
||||||
|
return "configured";
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED);
|
||||||
|
assertThat(configured.get().statementTimeout()).isEqualTo(Duration.ofSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localTimeoutFailureRollsBackBeforeTheBusinessAction() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
AtomicBoolean actionCalled = new AtomicBoolean();
|
||||||
|
JpaTransactionSettings settings =
|
||||||
|
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null);
|
||||||
|
SpringTransactionPort port =
|
||||||
|
new SpringTransactionPort(
|
||||||
|
tm,
|
||||||
|
() -> NOW,
|
||||||
|
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(settings),
|
||||||
|
ignored -> {
|
||||||
|
throw new IllegalStateException("local timeout failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(20)),
|
||||||
|
() -> {
|
||||||
|
actionCalled.set(true);
|
||||||
|
return "must-not-run";
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK);
|
||||||
|
assertThat(actionCalled).isFalse();
|
||||||
|
assertThat(tm.rollbacks).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaySafeSerializablePolicyRetriesOnlyADeterminateSerializationRollback() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
int[] calls = {0};
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(
|
||||||
|
TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE, Duration.ofSeconds(5)),
|
||||||
|
() -> {
|
||||||
|
calls[0]++;
|
||||||
|
if (calls[0] == 1) {
|
||||||
|
throw dataFailure("40001");
|
||||||
|
}
|
||||||
|
return "replayed";
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.COMMITTED);
|
||||||
|
assertThat(calls[0]).isEqualTo(2);
|
||||||
|
assertThat(tm.rollbacks).isOne();
|
||||||
|
assertThat(tm.definitions).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryCommandNeverRetriesTheSameSerializationFailure() {
|
||||||
|
RecordingTransactionManager tm = new RecordingTransactionManager(true);
|
||||||
|
SpringTransactionPort port = new SpringTransactionPort(tm, () -> NOW);
|
||||||
|
int[] calls = {0};
|
||||||
|
|
||||||
|
TransactionResult<String> result =
|
||||||
|
port.inTransaction(
|
||||||
|
commandRequest(TransactionPolicyId.COMMAND_DEFAULT, Duration.ofSeconds(5)),
|
||||||
|
() -> {
|
||||||
|
calls[0]++;
|
||||||
|
throw dataFailure("40001");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(TransactionOutcome.DETERMINATE_ROLLBACK);
|
||||||
|
assertThat(calls[0]).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TransactionRequest commandRequest(
|
||||||
|
TransactionPolicyId policyId, Duration duration) {
|
||||||
|
return new TransactionRequest(
|
||||||
|
policyId, CallBudget.after(NOW, duration), Optional.empty(), Optional.of(OPERATION_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataAccessResourceFailureException dataFailure(String sqlState) {
|
||||||
|
return new DataAccessResourceFailureException(
|
||||||
|
"database operation failed", new SQLException("sanitized", sqlState));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingTransactionManager implements PlatformTransactionManager {
|
||||||
|
|
||||||
|
private final boolean newTransaction;
|
||||||
|
private final List<TransactionDefinition> definitions = new ArrayList<>();
|
||||||
|
private int rollbacks;
|
||||||
|
private RuntimeException commitFailure;
|
||||||
|
|
||||||
|
private RecordingTransactionManager(boolean newTransaction) {
|
||||||
|
this.newTransaction = newTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionStatus getTransaction(TransactionDefinition definition)
|
||||||
|
throws TransactionException {
|
||||||
|
definitions.add(definition);
|
||||||
|
return new SimpleTransactionStatus(newTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commit(TransactionStatus status) throws TransactionException {
|
||||||
|
if (commitFailure != null) {
|
||||||
|
throw commitFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rollback(TransactionStatus status) throws TransactionException {
|
||||||
|
rollbacks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.outbound.CallBudget;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class TransactionRetryBackoffTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appliesBoundedFullJitterOnlyWhenTheAbsoluteBudgetCanContainTheNextAttempt() {
|
||||||
|
AtomicLong now = new AtomicLong(1_000);
|
||||||
|
AtomicLong slept = new AtomicLong();
|
||||||
|
JpaTransactionSettings settings =
|
||||||
|
new JpaTransactionSettings(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Duration.ofMillis(20),
|
||||||
|
Duration.ofMillis(80),
|
||||||
|
3);
|
||||||
|
TransactionRetryBackoff backoff =
|
||||||
|
new TransactionRetryBackoff(
|
||||||
|
settings,
|
||||||
|
now::get,
|
||||||
|
nanos -> {
|
||||||
|
slept.addAndGet(nanos);
|
||||||
|
now.addAndGet(nanos);
|
||||||
|
},
|
||||||
|
bound -> bound - 1);
|
||||||
|
|
||||||
|
boolean retry = backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(5)), 1);
|
||||||
|
|
||||||
|
assertThat(retry).isTrue();
|
||||||
|
assertThat(slept.get()).isEqualTo(Duration.ofMillis(20).toNanos());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesToSleepWhenBackoffWouldConsumeTheMinimumNextAttemptWindow() {
|
||||||
|
AtomicLong now = new AtomicLong(1_000);
|
||||||
|
AtomicLong slept = new AtomicLong();
|
||||||
|
JpaTransactionSettings settings =
|
||||||
|
new JpaTransactionSettings(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Duration.ofMillis(20),
|
||||||
|
Duration.ofMillis(80),
|
||||||
|
3);
|
||||||
|
TransactionRetryBackoff backoff =
|
||||||
|
new TransactionRetryBackoff(settings, now::get, slept::addAndGet, bound -> bound - 1);
|
||||||
|
|
||||||
|
boolean retry =
|
||||||
|
backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofMillis(200)), 1);
|
||||||
|
|
||||||
|
assertThat(retry).isFalse();
|
||||||
|
assertThat(slept).hasValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exponentialDelayIsCappedBeforeJitter() {
|
||||||
|
AtomicLong now = new AtomicLong(1_000);
|
||||||
|
AtomicLong slept = new AtomicLong();
|
||||||
|
JpaTransactionSettings settings =
|
||||||
|
new JpaTransactionSettings(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Duration.ofMillis(20),
|
||||||
|
Duration.ofMillis(30),
|
||||||
|
4);
|
||||||
|
TransactionRetryBackoff backoff =
|
||||||
|
new TransactionRetryBackoff(settings, now::get, slept::addAndGet, bound -> bound - 1);
|
||||||
|
|
||||||
|
backoff.pauseBeforeRetry(CallBudget.after(now.get(), Duration.ofSeconds(5)), 3);
|
||||||
|
|
||||||
|
assertThat(slept).hasValue(Duration.ofMillis(30).toNanos());
|
||||||
|
assertThat(backoff.maximumAttempts()).isEqualTo(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,11 +217,11 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌
|
|||||||
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
|
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
|
||||||
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
|
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
|
||||||
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
|
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
|
||||||
- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml`의 `connection-timeout`
|
- **Spring Boot와 같은 Duration 문법을 검증한다.** `env-keys.yaml`의 `connection-timeout`
|
||||||
기본값은 Duration 문자열 `5s`인데 `src/.env`는 `30000`(ms)을 준다. `Environment#getProperty(...,
|
기본값은 `5s`인데 `src/.env`는 `30000`(ms)을 준다. resolved 값을 `String`으로 읽은 뒤
|
||||||
Long.class)`를 `"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로
|
`DurationStyle`로 plain milliseconds, simple duration(`5s`)과 ISO-8601(`PT5S`)을 같은
|
||||||
읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주,
|
milliseconds 계약으로 변환한다. present-but-invalid 값은 부재로 조용히 취급하지 않고 property
|
||||||
조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다.
|
이름을 포함한 startup validation failure로 거절한다.
|
||||||
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
|
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
|
||||||
`max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
|
`max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
|
||||||
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
|
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
|
||||||
@@ -237,6 +237,15 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌
|
|||||||
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
|
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
|
||||||
가 기본)에 맡기며 *있는 `true`*만 거부한다.
|
가 기본)에 맡기며 *있는 `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
|
### RuntimeNumericBoundsValidator
|
||||||
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
|
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
|
||||||
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이
|
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이
|
||||||
|
|||||||
+22
-13
@@ -1,9 +1,11 @@
|
|||||||
package dev.caskeleton.bootstrap.runtime;
|
package dev.caskeleton.bootstrap.runtime;
|
||||||
|
|
||||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||||
|
import org.springframework.boot.convert.DurationStyle;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +37,15 @@ public class HikariPoolConstraintValidator implements SmartInitializingSingleton
|
|||||||
public void afterSingletonsInstantiated() {
|
public void afterSingletonsInstantiated() {
|
||||||
List<String> violations = new ArrayList<>();
|
List<String> violations = new ArrayList<>();
|
||||||
|
|
||||||
Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY));
|
Long connectionTimeout =
|
||||||
Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY));
|
parseMillis(CONNECTION_TIMEOUT_KEY, environment.getProperty(CONNECTION_TIMEOUT_KEY));
|
||||||
Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY));
|
Long validationTimeout =
|
||||||
Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY));
|
parseMillis(VALIDATION_TIMEOUT_KEY, environment.getProperty(VALIDATION_TIMEOUT_KEY));
|
||||||
Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_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) {
|
if (connectionTimeout != null && connectionTimeout < 250L) {
|
||||||
violations.add(
|
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
|
* Parses the same plain-millisecond, simple Duration ({@code 5s}) and ISO-8601 ({@code PT5S})
|
||||||
* Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent.
|
* syntax that Spring Boot accepts for Duration-bound properties. A present invalid value is a
|
||||||
* See README for the design rationale.
|
* 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()) {
|
if (raw == null || raw.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Long.parseLong(raw.trim());
|
return DurationStyle.detectAndParse(raw.trim(), ChronoUnit.MILLIS).toMillis();
|
||||||
} catch (NumberFormatException e) {
|
} catch (IllegalArgumentException | ArithmeticException e) {
|
||||||
return null; // non-numeric (e.g. Duration string) — treat as absent
|
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);
|
return new OpenInViewSafetyValidator(environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
|
||||||
|
return new JpaSchemaSafetyValidator(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PostgreSqlTransportSecurityValidator postgreSqlTransportSecurityValidator(
|
||||||
|
Environment environment) {
|
||||||
|
return new PostgreSqlTransportSecurityValidator(environment);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) {
|
HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) {
|
||||||
return new HikariPoolConstraintValidator(environment);
|
return new HikariPoolConstraintValidator(environment);
|
||||||
|
|||||||
@@ -45,11 +45,8 @@ spring:
|
|||||||
# D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than
|
# 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
|
# holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by
|
||||||
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
|
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
|
||||||
# CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while
|
# env-keys.yaml default "5s", plain milliseconds and ISO-8601 values are parsed by
|
||||||
# src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a
|
# HikariPoolConstraintValidator with Spring Boot DurationStyle; invalid values fail startup.
|
||||||
# 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)
|
|
||||||
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
|
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
|
||||||
# milliseconds
|
# milliseconds
|
||||||
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
|
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
|
||||||
@@ -145,6 +142,7 @@ spring:
|
|||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
# none | validate | update | create | create-drop
|
# none | validate | update | create | create-drop
|
||||||
|
# prod accepts only none|validate; JpaSchemaSafetyValidator rejects schema-writing modes.
|
||||||
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
|
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
|
||||||
# true | false
|
# true | false
|
||||||
show-sql: ${APP_DATASOURCE_SHOW_SQL}
|
show-sql: ${APP_DATASOURCE_SHOW_SQL}
|
||||||
@@ -321,6 +319,23 @@ ca-skeleton:
|
|||||||
lock:
|
lock:
|
||||||
wait-time: 3s
|
wait-time: 3s
|
||||||
lease-ttl: 30s
|
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:
|
presentation:
|
||||||
# feature-api-contract-baseline D2: API version prefix. Default is the URI
|
# 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
|
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ class DistributedLockProviderContractTest {
|
|||||||
// app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK).
|
// app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK).
|
||||||
Flyway.configure()
|
Flyway.configure()
|
||||||
.dataSource(sharedDataSource)
|
.dataSource(sharedDataSource)
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration/postgresql")
|
||||||
.load()
|
.load()
|
||||||
.migrate();
|
.migrate();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ class IdempotencyUniqueScopeContractTest {
|
|||||||
// classpath via implementation project(':adapter:outbound:persistence-jpa')).
|
// classpath via implementation project(':adapter:outbound:persistence-jpa')).
|
||||||
Flyway.configure()
|
Flyway.configure()
|
||||||
.dataSource(sharedDataSource)
|
.dataSource(sharedDataSource)
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration/postgresql")
|
||||||
.load()
|
.load()
|
||||||
.migrate();
|
.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.
|
* work_log from sample-portfolio on app-bootstrap test classpath, V3 outbox_event) are applied.
|
||||||
*/
|
*/
|
||||||
static void migrate(DataSource dataSource) {
|
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. */
|
/** Creates a HikariDataSource pointing to the given PostgreSQL container. */
|
||||||
|
|||||||
+49
-5
@@ -143,17 +143,61 @@ class HikariPoolConstraintValidatorTest {
|
|||||||
runner.run(context -> assertThat(context).hasNotFailed());
|
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
|
@Test
|
||||||
void connectionTimeoutAsDurationStringIsDefensivelySkipped() {
|
void connectionTimeoutAsSimpleDurationParticipatesInMinimumValidation() {
|
||||||
// env-keys.yaml default for connection-timeout is "5s" (Duration string).
|
|
||||||
// The validator must not throw ConversionFailedException — it silently skips.
|
|
||||||
runner
|
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());
|
.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
|
@Configuration
|
||||||
static class ValidatorConfig {
|
static class ValidatorConfig {
|
||||||
@Bean
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,6 +256,21 @@ application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저
|
|||||||
- retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지
|
- 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)
|
## 트랜잭셔널 아웃박스 릴레이 (outbox)
|
||||||
@@ -383,6 +398,26 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
|
|||||||
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
|
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
|
||||||
messaging adapter가 소유한다.
|
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)
|
## 분산 락 (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(
|
throw new IllegalArgumentException(
|
||||||
"call budget duration exceeds the supported range", exception);
|
"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) {
|
public long remainingNanosAt(long monotonicNowNanos) {
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package dev.caskeleton.application.outbox.v2;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Immutable message envelope plus the mutable delivery owner handle. */
|
||||||
|
public record ClaimedOutboxDelivery(
|
||||||
|
OutboxDeliveryOwner owner,
|
||||||
|
String eventType,
|
||||||
|
int eventSchema,
|
||||||
|
String aggregateType,
|
||||||
|
String aggregateId,
|
||||||
|
long aggregateVersion,
|
||||||
|
int eventOrdinal,
|
||||||
|
String partitionKey,
|
||||||
|
String contentType,
|
||||||
|
String correlationId,
|
||||||
|
String causationId,
|
||||||
|
Instant occurredAt,
|
||||||
|
String payload,
|
||||||
|
String payloadDigest) {
|
||||||
|
|
||||||
|
public ClaimedOutboxDelivery {
|
||||||
|
Objects.requireNonNull(owner, "owner");
|
||||||
|
Objects.requireNonNull(eventType, "eventType");
|
||||||
|
Objects.requireNonNull(aggregateType, "aggregateType");
|
||||||
|
Objects.requireNonNull(aggregateId, "aggregateId");
|
||||||
|
Objects.requireNonNull(partitionKey, "partitionKey");
|
||||||
|
Objects.requireNonNull(contentType, "contentType");
|
||||||
|
Objects.requireNonNull(correlationId, "correlationId");
|
||||||
|
Objects.requireNonNull(occurredAt, "occurredAt");
|
||||||
|
Objects.requireNonNull(payload, "payload");
|
||||||
|
Objects.requireNonNull(payloadDigest, "payloadDigest");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user