chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
@@ -0,0 +1,247 @@
# adapter:outbound:persistence-jpa — JPA/PostgreSQL persistence adapter
## Registered identity
- Module ID: `adapter-outbound-persistence-jpa`
- Gradle path: `:adapter:outbound:persistence-jpa`
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:persistence-jpa:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.persistence`.
Design decisions previously kept as code comments (transaction templates, auditing capture,
failure-translation SPI, idempotency/outbox concurrency, distributed-lock TTL) live in
[README.md](README.md). This file stays the SSOT for module rules and contract tables.
This module is the RDBMS/JPA implementation base. It is not a datastore-neutral
core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence
adapters implement application/domain ports directly and must not depend on this module.
## Responsibility
- JPA entities.
- Spring Data repositories.
- Persistence mappers.
- Repository adapter implementations.
- `TransactionPort` implementation (`SpringTransactionPort`) — the bridge between
application transactional intent and Spring's `PlatformTransactionManager`.
- Audit-metadata base + actor seam (`audit/AuditableEntity`, `audit/AuditContextPort`,
`audit/DomainContextAuditContextPort`) — see "Persistence auditing contract" below.
- Vendor SPI extension points shared by all RDBMS vendors:
- `outbox/OutboxClaimRepository` — vendor module implements claim strategy (e.g. FOR UPDATE SKIP LOCKED).
- `idempotency/IdempotencyClaimRepository` — vendor implements insert-or-expired-reclaim.
- `failure/SqlStateErrorMapping` — vendor module contributes vendor-specific SQLState rows.
- `transaction/TransactionLocalTimeoutConfigurer` — vendor applies statement/lock guards.
## Vendor selection
Two vendor compositions live in this module, each in its own subpackage, each registering the same
four SPI beans:
| Vendor | Package | Selected by | Schema owner |
| --- | --- | --- | --- |
| PostgreSQL | `.postgresql` | `ca-skeleton.persistence.vendor=postgresql` (also the default) | Flyway, `db/migration/postgresql` |
| H2 | `.h2` | `ca-skeleton.persistence.vendor=h2` | Hibernate `ddl-auto`, entities only |
`config/PersistenceVendorSettings` binds the selector to an enum, so an unknown value fails at
startup instead of loading neither composition and surfacing as a missing `OutboxClaimRepository`.
The profiles state the choice: `application-local.yml` selects H2, `application-dev.yml` and
`application-prod.yml` select PostgreSQL, and `PersistenceVendorProdSafetyValidator` (app-bootstrap)
refuses H2 under prod whatever property source supplies it.
H2 is the local-development datastore, not a second production target. It has no migration tree, so
tables that exist only in migrations — capability schema registry, polling-delivery and inbox
streams, the Spring Integration lock table — do not exist under it. Vendor concurrency and migration
fidelity stay with `postgresqlIntegrationTest`.
Two H2 statements diverge from PostgreSQL and the reasons are measured, not assumed (H2 2.4.240):
- the outbox claim is identical — H2 accepts `FOR UPDATE SKIP LOCKED` and genuinely skips locked
rows, so the claim keeps its meaning;
- the idempotency claim is not — H2 has no `INSERT ... ON CONFLICT ... RETURNING`, so it is a
`MERGE ... USING` with the same three outcomes. `H2ClaimSqlTest` executes both against a real H2.
### Capability-gated stores
Adapters that serve one optional capability carry that capability's switch, unlike the rest of this
module. The `fileserver` package is the current case: every `Jpa*` adapter there is annotated
`@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")`.
Without the gate a composition root that merely includes this module builds those beans, and each of
them needs collaborators only the Fileserver configuration supplies — which is how `sample-portfolio`
came to fail on a `FileStateMachine` it has no use for. A store for a capability nobody enabled
should not exist.
## Allowed
- `:application-core`
- `:domain-core`
- `:shared-contract`
- Spring Data JPA and Spring transaction.
## Forbidden
- `adapter-web`, `adapter-outbound`, or `app-bootstrap`.
- Presentation DTOs.
- Business policy decisions.
- Use case orchestration hidden inside persistence adapters.
- Repository adapters owning `@Transactional` boundaries — the application use case owns
the transaction via `TransactionPort` (see
[application-core/CLAUDE.md](../../../application-core/CLAUDE.md)).
- **DB drivers** (`org.postgresql..`, `org.h2..`) or **`org.flywaydb.database.postgresql..`** —
those are vendor-specific and belong only in this module's matching vendor package (`.postgresql`,
`.h2`); NoSQL-specific dependencies belong only in their own future modules
(persistence-multi-db-extensibility D3). This is enforced by ArchUnit
`PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` and `PERSISTENCE_RDBMS_STAYS_NEUTRAL_OF_H2` in
`CleanArchitectureTest`.
- NoSQL adapter code. MongoDB/Redis/DynamoDB adapters are sibling modules, not children of this module.
- Any sibling persistence or inbound/outbound adapter not allowed by the registry.
## TransactionPort implementation contract
`SpringTransactionPort` pre-builds one `TransactionTemplate` per mode:
| Mode | Propagation | Isolation | Read-only |
|---|---|---|---|
| `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
| `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
| `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` |
| `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` |
Pre-built templates are immutable after construction so concurrent callers cannot
observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template,
but first checks `TransactionSynchronizationManager.isActualTransactionActive()`.
When an actual ambient transaction exists it MUST throw
`NestedRootTransactionRejectedException` before invoking either the action or the
`PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`.
`inRootWrite` returns its action value only after `TransactionTemplate.execute` has
committed. A commit failure propagates the transaction exception and no success value
is returned to the caller.
### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract)
`REQUIRES_NEW` acquires a NEW physical JDBC connection while pinning the outer
transaction's connection. Provision the pool to satisfy:
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
Loop-per-record `inNew` calls are forbidden (pool exhaustion + deadlock risk).
Batch records inside ONE `inNew`, or move the loop outside the transaction.
## Persistence failure translation contract (feature-persistence-failure-baseline D1)
A raw Spring `DataAccessException` (and the JPA exception / SQLState / constraint name
inside it) must never reach the presentation layer. The
`failure/PersistenceExceptionTranslator` classifies a `DataAccessException` by its
SQLState against the §SQLState → Error Code Matrix and returns a
framework-neutral `shared.error.PersistenceFailureException` carrying one of the
`DB_*` `OperationalError` codes.
**Standard rows (core):**
| SQLState | code | category | http | retryable |
|---|---|---|---|---|
| `08*` | `DB_UNAVAILABLE` | `TRANSIENT_DEPENDENCY` | 503 | true |
| `40001` | `DB_SERIALIZATION_FAILURE` | `CONFLICT` | 409 | true |
| `23502` | `DB_NULL_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
| `23503` | `DB_FK_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
| `23505` | `DB_UNIQUE_VIOLATION` | `CONFLICT` | 409 | false |
| `23514` | `DB_CHECK_VIOLATION` | `DATA_INTEGRITY` | 409 | false |
**Vendor-specific rows (contributed by vendor module via `SqlStateErrorMapping` SPI):**
| SQLState | code | vendor |
|---|---|---|
| `40P01` | `DB_DEADLOCK` | PostgreSQL (`.postgresql` package) |
| `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL |
| `57014` | `DB_QUERY_CANCELED` | PostgreSQL |
| `23513` | `DB_CHECK_VIOLATION` | H2 (`.h2` package) — H2 reports CHECK as 23513, not the standard 23514 the core table maps |
| `HYT00` | `DB_QUERY_CANCELED` | H2 — H2 collapses statement and lock timeout into one state |
- A repository adapter that catches a `DataAccessException` calls
`translator.translate(ex)` and rethrows the carrier (`ifPresent(e -> { throw e; })`);
an empty result means an unknown SQLState — rethrow the original so the web catch-all
answers a generic `INTERNAL` envelope (no leak).
- The category SSOT is the 10-value `Category` enum — there is **no** `PERSISTENCE`
category (branch-note §Audit CATEGORY_DRIFT).
## Persistence auditing contract (feature-persistence-auditing-contract)
Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an
infrastructure concern that must never reach `domain-core` (D2). It lives only on the
`audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in
by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries
zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus
`domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy`
fields under `..domain..`).
- **Capture = Manual explicit-set (D1 current default).** The repository adapter
constructor-injects `Clock` (D4) and `AuditContextPort` (D5) and stamps audit on `save`:
INSERT (null version) → `initializeAudit(now, actor)`; UPDATE (non-null version) →
carry the persisted `created_*` forward + `applyModification(now, actor)`. This mirrors
the `IdempotencyStoreAdapter` precedent. `created_*` is `updatable = false`.
- **Actor seam.** `AuditContextPort.currentActor()` reads the runtime-context-propagation
seam and falls back to `"system"` when no principal is bound (scheduler / Flyway / anonymous).
The actor's value semantics are owned by feature-authentication-authorization-contract
(UNSUPPORTED here); the type is fixed to `String`.
- **Excluded (D6).** Infra/immutable entities such as `IdempotencyRecordEntity` (own
`created_at`, no `updated_at`) do NOT extend `AuditableEntity`. `version`/optimistic-lock
is owned by feature-persistence-failure-baseline / feature-transaction-concurrency-contract,
not by this audit base.
- **Growth path (D1, deferred).** Migrate to Spring Data JPA Auditing
(`@EntityListeners(AuditingEntityListener)` + `@CreatedDate`/`@LastModifiedDate`/… on the
base, `@EnableJpaAuditing(dateTimeProviderRef, auditorAwareRef)` in the composition root,
`DateTimeProvider` wrapping the same `Clock`, `AuditorAware<String>` delegating to
`AuditContextPort`) when manual set risks omission. Bulk/native `@Query` UPDATEs bypass
both capture paths — stamp audit explicitly there if added.
## MapStruct generated mapper exemption (D9 of feature-architecture-enforcement-rules)
If MapStruct is introduced for persistence mappers, the generated mapper class will
be annotated with `javax.annotation.processing.Generated`. Architecture rules that
forbid mapper boundary violations MUST exempt generated code via ArchUnit predicate:
```java
import javax.annotation.processing.Generated;
classes()
.that().resideInAPackage("..adapter.persistence.mapper..")
.and().areNotAnnotatedWith(Generated.class)
.should() /* ... boundary rule ... */;
```
> Note the annotation FQN: MapStruct uses
> `javax.annotation.processing.Generated`. Spring AOT uses
> `org.springframework.aot.generate.Generated` — do **not** mix the two. The
> exemption MUST scope to the specific annotation expected for the build step
> being exempted.
Current ca-tmpl mappers are hand-written so no MapStruct exemption is wired into
ArchUnit yet — when generation is added, follow the predicate above and add a
red/green test using a fixture mapper.
## NoSQL extension rule
Do not create `adapter-persistence-nosql-core` preemptively. NoSQL stores have different
models and operational contracts. When a real MongoDB, Redis, or DynamoDB adapter is needed,
create a sibling module:
```text
adapter-persistence-mongodb
adapter-persistence-redis
adapter-persistence-dynamodb
```
Such modules implement application/domain ports directly and must not depend on
`adapter:outbound:persistence-jpa`.
## Test
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
```
@@ -0,0 +1,379 @@
# adapter-persistence-rdbms — 설계 결정 참조
RDBMS/JPA 퍼시스턴스 베이스 모듈. 패키지 루트: `dev.caskeleton.adapter.persistence`.
허용/금지 의존, 테스트 명령, 그리고 **계약 테이블**(TransactionPort 모드표, SQLState → Error
Code 매트릭스, auditing 계약, 분산 락 provider 선택표)의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다.
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
"왜 이렇게 했나"가 궁금할 때 본다. 표가 CLAUDE.md 에 있으면 여기서는 중복하지 않고 그 근거만
적는다.
## transaction — `SpringTransactionPort`
### 왜 모드별 템플릿을 미리 만들어 두나
`TransactionTemplate` 은 문서상 thread-safe 지만 **mutable** 하다. 매 호출마다 propagation /
readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window 가 생긴다. 모드별로
(`WRITE` / `READ_ONLY` / `REQUIRES_NEW`) 템플릿을 생성 시점에 하나씩 만들어 두면 그 race 가
사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED`
로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract).
### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나
`inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존
write template을 재사용한다. 차이는 실행 전 precondition이다.
`TransactionSynchronizationManager.isActualTransactionActive()``true`이면 action과
`PlatformTransactionManager`를 호출하기 전에
`NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서
"root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다.
`TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서
`inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction
예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라,
경계의 반환값을 성공으로 노출하지 않는 계약이다.
## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort`
### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값)
`AuditableEntity` 의 네 필드(`created_at` / `updated_at` / `created_by` / `updated_by`)는 평범한
`@Column` 이다 — Spring Data 의 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy`
도, `@EntityListeners(AuditingEntityListener.class)`**붙이지 않는다**. 퍼시스턴스 어댑터가
`initializeAudit`(INSERT) 와 `carryCreation` + `applyModification`(UPDATE)로 명시적으로 값을
세팅한다. 공유 `Clock` 빈(D4)과 `AuditContextPort` actor(D5)를 재사용하는 방식으로,
`IdempotencyStoreAdapter` 가 생성자 주입 `Clock` 으로 row 를 재구성하는 선례와 동일하다.
- INSERT 는 `created_*``updated_*` 를 같은 `now`/`actor` 로 찍는다. NOT NULL 인 `updated_*`
를 신규 row 에서 채우기 위함이며, JPA-auditing 의 `modifyOnCreate` 기본동작에 의존하지 않는다.
- UPDATE 는 두 단계다: `carryCreation` 으로 (Vernon Option A 재구성된) 엔티티가 잃어버린
`created_*` 를 다시 채워 넣고, `applyModification` 으로 `updated_*` 만 옮긴다.
- `created_*``updatable = false` 라 INSERT 이후 모든 UPDATE 문에서 제외된다 — 생성
actor/시각이 덮어써질 수 없다.
- `version`/optimistic-lock 은 여기 두지 않는다. 이 베이스는 audit 전용으로 남기고,
낙관적 잠금 정책은 개별 영속성 모델이 소유한다.
### Growth path (D1, deferred — 여기 와이어링 안 됨)
audited 애그리거트 수가 늘어 수동 세팅이 누락 위험을 키우면 Spring Data JPA Auditing 으로 이전한다:
필드에 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` +
`@EntityListeners(AuditingEntityListener.class)` 를 붙이고, 컴포지션 루트에
`@EnableJpaAuditing(dateTimeProviderRef=..., auditorAwareRef=...)` 를 둔다. `DateTimeProvider`
같은 `Clock`(D4)을, `AuditorAware<String>``AuditContextPort`(D5)를 감싼다. bulk/native `@Query`
UPDATE 는 두 캡처 경로를 모두 우회하므로 거기서는 audit 를 명시적으로 찍어야 한다.
### actor seam 을 왜 별도 포트로 격리하나
`AuditContextPort` 는 퍼시스턴스가 "누가 행위하는가"에 대해 의존하는 단 하나의 seam 이다.
actor 의 **값 의미론**(user id vs email vs subject claim)은 application/web 쪽의 사용자 모델
책임이고 여기서는 의도적으로 다루지 않는다. 그래서 타입을 `String` 으로 못박고, 이
포트가 보장하는 건 오직 "non-null actor"(프레임워크의 blank-on-absent 가 아니라) 하나 —
principal 이 없으면 `"system"`.
`DomainContextAuditContextPort` 는 actor id 를 runtime-context-propagation seam
(`DomainContextPropagator`)에서 읽는다. 그 브랜치가 canonical actor key 를 소유하지만 API 가
확정되기 전까지 이 어댑터가 `ACTOR_KEY` 뒤로 격리해, 키가 바뀌어도 정확히 한 클래스만 손대게
한다(D5 Open Risk). 빈/blank context 값은 null/blank actor 가 아니라 `"system"` 으로 떨어뜨려
scheduler / Flyway / anonymous 경로에서도 NOT NULL `created_by`/`updated_by` 를 항상 만족시킨다.
## config — `PersistenceJpaConfig`
### 왜 명시적 `@EntityScan` / `@EnableJpaRepositories` 가 필요한가
Spring Boot 메인 클래스는 `dev.caskeleton.bootstrap` 에 있어서, `@AutoConfigurationPackage`
앵커로 삼는 기본 엔티티/리포지토리 스캔이 `dev.caskeleton.adapter.persistence.*` 를 놓친다.
`@SpringBootApplication``scanBasePackages` 는 컴포넌트 스캔만 넓힐 뿐 JPA 엔티티/리포지토리
스캔은 넓히지 않는다. 이 설정이 없으면 production 리포지토리(예: `IdempotencyRecordJpaRepository`)
가 생성되지 않아 소비자(예: `IdempotencyReaper`)가 부팅에서 와이어링 실패한다. 스캔을 엔티티를
소유한 모듈에 둬서 와이어링을 그 자리에 유지한다.
### 왜 이름이 `JpaConfig` 가 아닌가
sample 모듈에 이미 `...sample.portfolio.adapter.persistence.config.JpaConfig` 가 있다. IDE 의
"Run main class" 가 테스트 스코프 sample 모듈을 클래스패스에 올리면, simple name `JpaConfig`
공유하는 두 `@Configuration` 이 기본 빈 이름 `jpaConfig` 에서 충돌한다
(`ConflictingBeanDefinitionException`). 다른 simple name 으로 이를 피한다.
## failure — 퍼시스턴스 실패 변환 SPI
매트릭스(SQLState → `DB_*` 코드, category, http, retryable)의 SSOT 는 CLAUDE.md §Persistence
failure translation contract 다. 여기서는 SPI 구조와 fallback 근거만 적는다.
### SPI-pluggable 설계 (vendor 추출)
`PersistenceExceptionTranslator` 는 exact-state → code 맵을 등록된 모든 `SqlStateErrorMapping`
빈을 생성 시점에 merge 해서 만든다. core 모듈(`StandardSqlStateErrorMapping`)은 모든 RDBMS 가
공통으로 반환하는 portable/vendor-neutral 5개 row(`40001`, `23502`, `23503`, `23505`, `23514`)만
기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql`
가 추가 `SqlStateErrorMapping` 빈으로 기여한다.
- 서로 다른 contributor가 같은 exact SQLState를 등록하면 code가 같더라도 startup construction을
실패시킨다. last-writer-wins merge는 mapping ownership drift를 숨기므로 허용하지 않는다.
- `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접
처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다.
- **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에
`SQLException` 자체가 없는 경우 — 는 `Optional.empty()` 를 반환한다. 호출부는 원본 예외를 web
catch-all 까지 전파시켜 detail 누설 없는 generic `INTERNAL` 엔벨로프로 답하게 한다. translator
는 unknown state 에 대해 `DB_*` 코드를 절대 지어내지 않는다.
- 변환된 carrier 의 진단 메시지에 SQLState 를 넣는 건 server-log 전용이다(web 어댑터가 절대
surface 하지 않음) — triage 를 돕되 클라이언트로 새지 않는다.
## idempotency — `IdempotencyStoreAdapter` 외
스키마는 Flyway(`V1__idempotency_record.sql`)가 소유하고 엔티티는 그것을 매핑만 한다.
### 동시성 중재 (D7 insert-or-read)
`tryBegin``uq_idempotency_scope` unique 제약을 동시성 중재자로 쓴다 — 동시 중복은 insert 를
잃고 `false` 를 받는다. lookup 과 flush 사이에 다른 호출자가 끼어들면
`DataIntegrityViolationException` 으로 잡아 `false` 를 반환한다(`saveAndFlush` 의 flush 가 unique
제약 검사를 그 자리에서 강제한다). 같은 scope 의 만료 row 는 insert 전에 reclaim(delete)해, stale
record 가 새 요청을 영구히 막지 못하게 한다. 이 delete + insert 는 호출자 트랜잭션(use case 가
`TransactionPort` 로 소유) 안에서 도는 것을 전제로 atomic 하다.
### 엔티티 불변/스코프 (D3 / §B / §F)
- `tenant` 은 절대 `null` 이 아니다(single-tenant 는 빈 문자열). PostgreSQL 은 NULL 을 서로
distinct 로 취급하므로, null 을 허용하면 single-tenant row 의 unique scope dedup 이 깨진다.
매퍼가 `null` 애플리케이션 tenant ↔ row 의 `""` 를 왕복시킨다.
- `status = COMPLETED` 가 되면 `responsePayload` / `responseRef` 중 정확히 하나만 채워진다(§F
≤8KB inline / >8KB ref).
- 엔티티에 setter 가 없는 건 의도다: 상태 전이 때 row 를 재구성·재저장한다(Vernon Option A) —
애플리케이션 관점에서 엔티티를 immutable 로 유지한다.
### §F responseRef split & 만료
- `complete` 의 §F 분기: payload ≤ 8KB 는 row 에 inline 저장, 더 크면
`IdempotencyResponseObjectStore`(와이어링된 경우)로 offload 하고 reference 만 보관한다. object
store 가 없으면 큰 payload 도 경고와 함께 inline 으로 안전 degrade 한다(D9 프로젝트 선택).
- 만료는 세 곳에서 강제된다: read(`find` 가 만료 row 를 부재로 취급), reclaim(`tryBegin` 이 만료
row 를 재claim 전에 delete), 그리고 `IdempotencyReaper`. reaper 는 유일한 만료 수단이 아니라
테이블 성장을 묶는 backstop 이다. reaper 의 고정 interval 과 clock-skew 미처리는 source-mandated
가 아니다(프로젝트 선택 — 부하 하 cadence 측정 필요).
### `IdempotencyResponseObjectStore` 는 왜 optional 인가 (D9 프로젝트 선택)
8KB 임계와 object-store 분리는 cited normative basis 가 없고 production 빈도도 미측정이다.
S3 호환 클라이언트가 템플릿에 없으므로 어댑터는 이 포트를 optional 로 취급한다 — 빈이 없으면 inline
DB 저장으로 fallback. 실제 구현(S3 / GCS / MinIO)을 와이어링하면 offload 가 활성화된다.
## outbox — `OutboxStoreAdapter` 외
스키마는 Flyway(`V3__outbox_event.sql`)가 소유한다.
### 트랜잭션 경계 계약
- **append**: 호출자의 `TransactionPort.inWrite()` 경계 안에서 호출되어야 하며, 내부에서 새
트랜잭션을 열지 않는다. `@Repository` 가 빈만 등록하고 TX 는 use case 가 소유한다.
- **No `@Transactional`**: 이 코드베이스의 퍼시스턴스 어댑터는 `@Transactional` 을 선언하지
않는다. 유일한 트랜잭션 경계는 use case 가 소유한 `TransactionPort` 다(CLAUDE.md "Forbidden").
### claim 계약 — vendor SPI (`OutboxClaimRepository`)
row claim 은 vendor 별 락 전략이 필요해 `OutboxClaimRepository` SPI 로 추출했다. canonical
PostgreSQL 구현은 native query 의 `FOR UPDATE SKIP LOCKED` 를 쓰고, 다른 벤더는 등가물을
공급한다(예: SQL Server `WITH (UPDLOCK, READPAST)`). eligibility predicate(I3 — concurrent-relay
안전을 위한 `SKIP LOCKED`)와 per-aggregate FIFO gate(I4 — `NOT EXISTS` correlated subquery)를
전부 SQL 에서 강제한다. 한 배치에 aggregate 당 최대 한 row(head)만 나타난다. 어댑터는 추가
in-memory 필터링을 하지 않는다 — 리포지토리가 반환한 모든 row 를 IN_FLIGHT 로 전이시켜 호출자에
돌려준다. 전체 SQL gate 동작은 PG contract 테스트(Testcontainers)가 검증한다.
eligible row(I3/I4/I6):
- `PENDING``next_attempt_at <= now`(insert 시 즉시 eligible)
- `FAILED``next_attempt_at <= now`(backoff 경과)
- `IN_FLIGHT``next_attempt_at <= now`(orphaned row)
### `markPublished` / `markFailed` / `markDead` 는 왜 row 부재 시 throw 하나
row 가 없으면 `IllegalStateException` 을 던진다. relay 가 방금 같은 서비스 인스턴스에서 claim 한
row 이므로, 부재는 프로그래밍/동시성 버그다. 조용히 no-op 하면 row 가 영원히 `IN_FLIGHT` 로 남아
그 aggregate 의 FIFO 큐를 막고, 호출자나 로그에 아무 신호도 남지 않는다(markFailed 는 relay 가
재시도 예약을 믿게, markDead 는 runbook 가시성·수동 DEAD 해결을 막게 된다).
### `OutboxEventEntity` 의 결정
- **`AuditableEntity` 미상속(D6 — infra 엔티티).** `IdempotencyRecordEntity` 처럼 outbox row 는
도메인 애그리거트가 아니라 인프라 record 다. 자체 temporal 필드(`occurred_at`,
`next_attempt_at`)가 도메인 의미를 갖고, generic `created_at`/`updated_at` audit 컬럼과 섞이면
안 된다.
- **mutating setter 노출은 의도.** relay 어댑터가 managed 엔티티 위에서 상태(status /
attempt_count / next_attempt_at)를 전이시키되 full reload-and-replace 없이 한다. outbox 어댑터만
이 필드를 건드리고, 모든 mutation 이 use case 소유 `TransactionPort.inWrite()` 경계 안에서
돌기에 안전하다.
- **`next_attempt_at` dual-purpose(I6 — 추가 컬럼 없음):**
- `PENDING`: insert 때 `occurred_at` 으로 세팅 → 최초 claim 체크(`next_attempt_at <= now`)가
즉시 만족.
- `IN_FLIGHT`: `claim_time + in_flight_timeout` → orphaned row 가 visibility window 만료 후
재claim 가능.
- `FAILED`: `now + backoff` → backoff window 경과 후에만 재시도.
- `status``PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD` 문자열이다.
### metric 쿼리 반환 형태
`OutboxEventJpaRepository.countGroupedByStatus()``[status(String), count(Long)]`,
`findOldestUnpublishedOccurredAtByEventType()``[eventType(String), oldestOccurredAt(Instant)]`
2-요소 배열 리스트를 돌려준다(각각 outbox.pending.size, outbox.publisher.lag gauge 용).
`OutboxStoreAdapter.oldestUnpublishedAgeSecondsByEventType``HashMap` 을 쓰는 건 키가
enum 이 아니라 String(event-type 이름)이기 때문이다 — `countByStatus()` 는 키가
`OutboxEventStatus` enum 이라 `EnumMap` 을 쓴다(두 반환 타입이 의도적으로 다름).
### `OutboxReaper`
PUBLISHED row 는 이미 전달된 terminal-success record 라 무한 보관할 필요가 없다. reaper 가
retention 보다 오래된 PUBLISHED row 를 주기적으로 비워 테이블 성장과 metric gauge 를 묶는다.
스케줄링은 컴포지션 루트의 `@EnableScheduling` 으로 켜지고, `@Transactional` bulk delete 가 purge
를 한 문장으로 유지한다. 고정 interval / clock-skew 미처리는 프로젝트 선택(부하 하
cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property
문서는 app-bootstrap `OutboxSettings` / `application.yml` 에 있다.
## JPA production capability candidate
`src/config/jpa/readiness-cards.yaml`이 15개 capability와 7개 독립 schema stream의
machine-readable SSOT다. `selected` base card와 `implemented-candidate` reliability card를
구분하며, 실제 PostgreSQL 테스트 통과만으로 immutable 운영 evidence가 필요한 R2를 주장하지
않는다.
독립 Flyway stream은 broad `classpath:db/migration`으로 함께 실행하지 않는다. 각 stream은
자기 location/history table을 사용하고 non-empty schema adoption 때 version 0 baseline을 명시한
뒤 V1부터 실행한다.
| Capability | Location | History table | 상태 |
|---|---|---|---|
| core/adoption | `db/migration/jpa/core` | `flyway_jpa_core_history` | selected candidate |
| idempotency V2 | `db/migration/jpa/idempotency` | `flyway_jpa_idempotency_history` | implemented-candidate |
| outbox storage V2 | `db/migration/jpa/outbox-storage` | `flyway_jpa_outbox_storage_history` | implemented-candidate |
| polling delivery V2 | `db/migration/jpa/outbox-polling` | `flyway_jpa_outbox_polling_history` | implemented-candidate |
| inbox V1 | `db/migration/jpa/inbox` | `flyway_jpa_inbox_history` | implemented-candidate |
### owner-safe idempotency V2
`PostgreSqlOwnerSafeIdempotencyStore`는 row lock을 얻은 뒤 `clock_timestamp()`를 평가한다.
claim takeover와 start/renew/complete/fail/release는 scope/state/owner/attempt/claim operation/
state revision을 SQL predicate로 다시 검증한다. expired `CLAIMED`만 takeover하며 expired
`EXECUTING``ABANDONED`로 닫고 reconciliation을 요구한다. raw client key는 저장하지 않고
versioned HMAC scope digest만 쓴다.
### immutable outbox storage와 polling delivery V2
`PostgreSqlImmutableOutboxAppendAdapter`는 publication control을 `FOR SHARE`로 잠근 상태에서
compact global identity guard와 partitioned immutable envelope를 같은 business transaction에
기록한다. cutover는 control `FOR UPDATE`와 충돌하므로 시작된 append를 추월하지 못하며, target
authority 활성화 뒤 legacy V1 writer trigger가 실패한다.
polling mode일 때 database trigger가 initial `outbox_delivery_v2` row를 같은 transaction에
생성한다. `PostgreSqlPollingDeliveryAdapter`는 bounded `FOR UPDATE SKIP LOCKED` claim,
aggregate version/ordinal strict-order head gate, owner/token/attempt/version/epoch completion
CAS를 사용한다. broker 호출은 transaction 밖이고 duplicate publish 가능성은 stable event ID로
consumer inbox에서 처리한다.
### same-store inbox
`PostgreSqlSameStoreInboxAdapter`의 transactional claim은 business mutation/outgoing outbox/
completion과 caller의 한 primary write transaction에 참여한다. received lease expiry는 takeover할
수 있지만 processing lease expiry는 blind retry하지 않고 recovery-required terminal state로
보낸다. broker ACK는 commit 이후에만 실행한다.
### 실제 PostgreSQL task
base 6개 task 외에 다음 candidate task가 Docker 부재 시 skip이 아니라 실패하도록 등록돼 있다.
```text
postgresqlIdempotencyIntegrationTest
postgresqlOutboxStorageIntegrationTest
postgresqlOutboxPollingIntegrationTest
postgresqlInboxIntegrationTest
```
### evidence manifest와 R2 gate
`readiness-cards.yaml``evidence.scenarios``evidence.task-claims`가 required evidence를 실제
JUnit selector/Gradle task에 연결한다. `verifyJpaReadinessRegistryContract`는 unknown claim,
duplicate selector와 다른 card task 차용을 mutation test로 거절한다.
```bash
./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain
```
위 task는 active card 11개의 producer를 실행하고 JUnit XML에서 exact selector와
executed/skipped/failure/error 수를 읽는다. 각 manifest는 source revision/dirty digest,
prerequisite manifest ID, PostgreSQL image digest, pgjdbc/Hibernate/Flyway version, topology와
migration/dispatch metadata를 담고 다음 위치에 canonical JSON SHA-256 이름으로 생성된다.
```text
build/jpa-evidence/manifests/<card-id>/<sha256>.json
```
후보 검증은 zero-skip, schema, content hash와 prerequisite link가 맞으면 성공하지만
`attainedReadiness=R1`을 유지한다. 로컬 후보 lane은 다음 E2/E3 동작을 실제 PostgreSQL에서
검증한다.
- bounded pool saturation과 shutdown 뒤 connection 거부
- runtime/migration role 분리, trusted namespace, TLS `verify-full`의 정상·hostname mismatch·
untrusted CA·expired certificate 경로
- persistence failure의 HTTP/log/span redaction
- fresh/legacy adoption, interrupted migration forward recovery, N/N-1 additive rolling shape
- serialization/deadlock/lock/statement timeout, pool exhaustion, commit transport 단절
- idempotency/outbox/inbox 독립 stream의 disabled/first-enable/disable/re-enable/interrupted
lifecycle
각 manifest는 그래도 candidate profile, dirty source와 아직 R2가 아닌 prerequisite를
`readinessBlockers`에 보존하므로 후보 통과를 R2로 오인할 수 없다.
실제 aggregation gate는 별도 명령이다.
```bash
./gradlew \
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \
-PjpaEvidenceProfile=r2 \
--console=plain
```
이 task는 clean revision, `JPA_EVIDENCE_CI_JOB`,
`JPA_EVIDENCE_ARTIFACT_LOCATION`, immutable PostgreSQL image digest, 모든 required evidence와
R2 prerequisite DAG가 있어야만 성공한다. `.github/workflows/ci-quality-gates.yml`의 candidate
job은 PR에서 zero-skip manifest를 보존하고, `.github/workflows/jpa-r2-evidence.yml`은 명시적으로
실행하는 production-profile lane이다. 로컬 dirty worktree 또는 unpublished 실행은
`worktree-is-dirty`/CI provenance blocker를 보고 실패하는 것이 정식 동작이다. R2 승격은 clean
revision에서 workflow를 실행하고 보존된 manifest artifact를 검토한 뒤에만 가능하다.
stream migration이 중단되면 history/registry/object 상태를 먼저 확인하고 기존 migration을
임의 수정하거나 history를 바로 `repair`하지 않는다. 장애를 수정한 forward migration으로
복구하는 운영 절차는 `docs/runbooks/migration-failed.md`를 따른다.
## lock — 분산 락
provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다.
여기서는 어댑터/설정 결정 근거만 적는다.
### `LockRegistryDistributedLockAdapter` 계약
- **D4 — transaction-commit ordering invariant.** 이 어댑터는 트랜잭션 경계를 관리하지 않는다.
호출자는 보호된 트랜잭션이 **커밋된 뒤에만** 핸들(`DistributedLock.close()`)을 release 해야
한다. 커밋 전(트랜잭션 안)에 release 하면 lost-update race 가 생긴다.
- **D5 — finite waitTime + lease TTL.** `tryAcquire` 는 Spring Integration `DistributedLock`
이면 `tryLock(waitTime, leaseTtl)` 로, 일반 `Lock` 이면 `Lock.tryLock(long, TimeUnit)` 으로 최대
`waitTime` 만 블록하고, 잡으면 핸들을, 못 잡으면 `LockAcquisitionTimeoutException` 을 던진다.
무한 블로킹은 쓰지 않는다.
- `leaseTtl > configuredTtl``IllegalArgumentException` 으로 거부한다. provider 기본 TTL 보다
긴 lease 를 약속하는 건 false contract 다. shipped 와이어링에선 `leaseTtl == configuredTtl`
(둘 다 `LockSettings.leaseTtl()` 바인딩)이라 이 가드는 mis-wired 호출자/테스트에서만 fail-fast
로 발동한다.
- 반환 핸들은 `lock::unlock` 람다(SAM 인터페이스 충족). `InterruptedException` 은 interrupt flag
를 복원하고 `LockAcquisitionTimeoutException` 으로 변환한다.
### TTL 주의 (SI 7.0)
- `JdbcLockRegistry`: 기본 TTL 은 `JdbcLockRegistry(LockRepository, Duration)` 생성자로 설정하고,
acquisition 별 TTL 은 `DistributedLock.tryLock(Duration waitTime, Duration ttl)` 로 전달한다. 크래시한
JVM 의 row 는 lock TTL 만료 후 다음 acquire 시도에서 회수된다.
- `DefaultLockRegistry`: TTL 은 advisory 이고 무의미하다 — JVM 크래시가 in-JVM 락을 프로세스와
함께 자동으로 떨군다.
### SI-LOCK-C5 — lease 만료 후 release
반환 핸들은 `lock::unlock` 이다. lease TTL 이 `close()` 전에 만료된 `JdbcLockRegistry`
의 경우, 내부 `JdbcLock.unlock()``ConcurrentModificationException` 을 던진다(row 가 이미
회수됨). 이 어댑터는 여기서 일부러 잡지 않는다 — metered `distributedLockProvider` 데코레이터
(컴포지션 루트)가 SI-LOCK-C5 계약을 소유한다: 로그 + `lock.lease.expired` metric 후 `close()` 에서
정상 return 해, 만료가 호출자의 보호작업 예외를 가리지 않게 한다. in-process `DefaultLockRegistry`
경로는 만료가 없어 그 `close()` 가 이 예외를 던질 수 없다.
### `DistributedLockPersistenceConfig` 와이어링 결정
- `jdbcDistributedLock` 빈은 일부러 `@Primary` 가 아니고 이름도 `distributedLockProvider`
아니다. app-bootstrap 이 이를 metrics 데코레이터(`MeteredDistributedLockPort`)로 감싸
`@Primary`/`distributedLockProvider` 빈을 노출한다. 이렇게 해서 SI 타입이 컴파일 타임에
adapter-persistence 위 레이어에 보이지 않게 유지된다(SI 는 `implementation` 의존).
- `DefaultLockRepository``InitializingBean`/`SmartLifecycle` 을 구현해 Spring 이 lifecycle 을
자동 관리하고, `ApplicationContextAware``PlatformTransactionManager` 를 auto-discover 한다.
app-bootstrap Testcontainers 테스트가 와이어링 갭을 드러내면 부트스트랩이 `setTransactionManager`
로 명시 전달할 수 있다.
- `setCheckDatabaseOnStart(false)`: `INT_LOCK` 테이블은 첫 lock acquire 전에 Flyway V4/V5 가
provision 하므로 DDL 체크를 건너뛴다.
- `JdbcLockRegistry(lockRepository, settings.leaseTtl())`: Spring Integration 7.0 이후 기본 TTL 은
repository setter 가 아니라 registry 생성자에서 설정한다. 어댑터는 호출별 `leaseTtl`
`DistributedLock.tryLock(waitTime, leaseTtl)` 로 전달한다.
### `LockSettings`
`ca-skeleton.lock.*` 에서 바인딩되는 yaml-only 기본값이다. 새 `APP_*` env 키가 아니므로
`env-keys.yaml` 엔트리가 필요 없다. bootstrap 의 `@ConfigurationPropertiesScan` 으로 잡혀
`@EnableConfigurationProperties` 명시가 필요 없다. cross-field invariant: `leaseTtl >= waitTime`
이어야 한다 — TTL 이 waitTime 보다 먼저 만료되면 첫 holder 의 보호작업이 끝나기 전에 두 번째
holder 가 락을 잡을 수 있다.
@@ -0,0 +1,172 @@
// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module.
// Owns JPA entities, Spring Data repositories, mappers, transaction/audit/lock/outbox port
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
sourceSets {
postgresqlIntegrationTest {
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
}
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
implementation 'org.springframework.integration:spring-integration-jdbc'
// Vendor (PostgreSQL): Flyway migration API + PostgreSQL driver/dialect. Used only by the
// .postgresql subpackage; the RDBMS base stays vendor-neutral (PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL).
implementation 'org.springframework.boot:spring-boot-starter-flyway'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
// Vendor (H2): the local-profile driver. Used only by the .h2 subpackage, which reaches it
// through JDBC/JPA rather than by importing org.h2 types — the same shape as the PostgreSQL
// driver above. Not `developmentOnly`: local is a deployable profile of this artifact, and the
// vendor selector, not the packaging, decides which driver a deployment loads.
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql'
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
def registerPostgreSqlReadinessTest = { String taskName, String testClass ->
tasks.register(taskName, Test) {
group = 'verification'
description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}."
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching testClass
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs(
'-Duser.timezone=UTC',
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
}
}
def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlLifecycleIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest')
def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlSecurityBaselineIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest')
def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest')
def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTransactionIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest')
def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlAggregateIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest')
def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest')
def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlIdempotencyIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest')
def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxStorageIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest')
def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxPollingIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest')
def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlInboxIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest')
def postgresqlFileserverMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMigrationIntegrationTest')
def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMetadataIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMetadataStoreIntegrationTest')
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
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')
@@ -0,0 +1,211 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
com.h2database:h2:2.4.240=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.sun.istack:istack-commons-runtime:4.1.2=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:7.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=postgresqlIntegrationTestCompileClasspath,testCompileClasspath
org.aspectj:aspectjweaver:1.9.25=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.49.5=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.flywaydb:flyway-core:11.14.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.flywaydb:flyway-database-postgresql:11.14.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-core:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:txw2:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.models:hibernate-models:1.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-database-commons:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.testcontainers:testcontainers-jdbc:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.testcontainers:testcontainers-postgresql:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.persistence.audit;
/**
* The single seam the persistence layer depends on for "who is acting": supplies the actor id
* stamped into the {@code created_by} / {@code updated_by} audit columns. Fixed to {@code String}
* and guarantees a non-null actor. See README "audit" for why the value semantics are isolated
* here.
*/
public interface AuditContextPort {
/**
* The current actor id for audit columns. Returns {@code "system"} when no principal is bound
* (scheduler / Flyway migration / anonymous) — never {@code null} and never blank.
*/
String currentActor();
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.outbound.persistence.audit;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.Instant;
/**
* Audit-metadata {@code @MappedSuperclass} for domain aggregate JPA entities: keeps the {@code
* created_*}/{@code updated_*} infrastructure concern out of domain-core. The adapter stamps the
* fields manually (see README "audit" for the capture mechanism, growth path, and why this base
* stays audit-only).
*/
@MappedSuperclass
public abstract class AuditableEntity {
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "created_by", nullable = false, updatable = false, length = 256)
private String createdBy;
@Column(name = "updated_by", nullable = false, length = 256)
private String updatedBy;
protected AuditableEntity() {}
/** INSERT path: stamp both creation and modification with the same {@code now}/{@code actor}. */
public void initializeAudit(Instant now, String actor) {
this.createdAt = now;
this.updatedAt = now;
this.createdBy = actor;
this.updatedBy = actor;
}
/** UPDATE path, step 1: carry persisted creation metadata forward on a reconstructed entity. */
public void carryCreation(Instant createdAt, String createdBy) {
this.createdAt = createdAt;
this.createdBy = createdBy;
}
/** UPDATE path, step 2: move {@code updated_*} forward, leaving {@code created_*} untouched. */
public void applyModification(Instant now, String actor) {
this.updatedAt = now;
this.updatedBy = actor;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public String getCreatedBy() {
return createdBy;
}
public String getUpdatedBy() {
return updatedBy;
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.persistence.audit;
import dev.caskeleton.shared.concurrency.DomainContextKey;
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
import org.springframework.stereotype.Component;
/**
* Default {@link AuditContextPort}: reads the audit actor from the runtime-context-propagation seam
* ({@link DomainContextPropagator}) and falls back to {@code "system"}. The canonical actor key is
* isolated behind {@link #ACTOR_KEY} so a future key change touches one class (see README "audit").
*/
@Component
public class DomainContextAuditContextPort implements AuditContextPort {
static final String SYSTEM_ACTOR = "system";
static final DomainContextKey<String> ACTOR_KEY = DomainContextKey.of("actor", String.class);
private final DomainContextPropagator propagator;
public DomainContextAuditContextPort(DomainContextPropagator propagator) {
this.propagator = propagator;
}
@Override
public String currentActor() {
return propagator.get(ACTOR_KEY).filter(actor -> !actor.isBlank()).orElse(SYSTEM_ACTOR);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.persistence.config;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Registers this module's JPA entities and Spring Data repositories. The explicit scans are
* required because the Boot main class lives in another package; the simple name avoids {@code
* JpaConfig} to dodge a bean-name collision with the sample module. See README "config".
*/
@Configuration
@EntityScan(basePackages = "dev.caskeleton.adapter.outbound.persistence")
@EnableJpaRepositories(basePackages = "dev.caskeleton.adapter.outbound.persistence")
public class PersistenceJpaConfig {}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Selects which RDBMS vendor composition this deployment runs.
*
* <p>The selector is a property rather than a profile name because the vendor is a property of the
* datastore, not of the environment that happens to use it. A fork that runs PostgreSQL under a
* profile named something other than {@code dev}/{@code prod}, or that wants H2 in a throwaway
* demo, sets this key; it does not have to rename its profiles or edit a condition.
*
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that
* caused it.
*/
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
public record PersistenceVendorSettings(Vendor vendor) {
public static final String PREFIX = "ca-skeleton.persistence";
public static final String VENDOR_PROPERTY = PREFIX + ".vendor";
/** The RDBMS vendors this repository composes a persistence adapter for. */
public enum Vendor {
POSTGRESQL,
H2
}
public PersistenceVendorSettings {
// Absent means PostgreSQL: the vendor every deployment before this selector existed ran, so an
// upgrade that does not set the key keeps its datastore.
vendor = vendor == null ? Vendor.POSTGRESQL : vendor;
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.persistence.failure;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.error.PersistenceFailureException;
import java.sql.SQLException;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Component;
/**
* Classifies a Spring {@link DataAccessException} into a framework-neutral {@link OperationalError}
* {@code DB_*} code by its SQLState, returning a {@link PersistenceFailureException} carrier (or
* {@link Optional#empty()} for an unknown state). Mappings are merged from all {@link
* SqlStateErrorMapping} SPI beans. See README "failure" for the SPI/fallback rationale and
* CLAUDE.md for the SQLState → Error Code matrix.
*/
@Component
public class PersistenceExceptionTranslator {
/** SQLState class prefix for connection failures → {@code DB_UNAVAILABLE}. */
private static final String CONNECTION_CLASS_PREFIX = "08";
private final Map<String, OperationalError> byExactSqlState;
public PersistenceExceptionTranslator(Collection<SqlStateErrorMapping> mappings) {
Objects.requireNonNull(mappings, "mappings");
Map<String, OperationalError> merged = new LinkedHashMap<>();
Map<String, String> contributors = new LinkedHashMap<>();
for (SqlStateErrorMapping mapping : mappings) {
Objects.requireNonNull(mapping, "mapping");
String contributor = mapping.getClass().getName();
Map<String, OperationalError> exactMappings =
Objects.requireNonNull(mapping.exactMappings(), contributor + ".exactMappings()");
for (Map.Entry<String, OperationalError> entry : exactMappings.entrySet()) {
String sqlState = Objects.requireNonNull(entry.getKey(), contributor + " SQLState");
OperationalError candidate =
Objects.requireNonNull(entry.getValue(), contributor + " mapping for " + sqlState);
if (sqlState.isBlank()) {
throw new IllegalArgumentException(contributor + " contributed a blank SQLState");
}
OperationalError previous = merged.putIfAbsent(sqlState, candidate);
if (previous != null) {
throw new IllegalStateException(
"Duplicate exact SQLState mapping "
+ sqlState
+ ": "
+ contributors.get(sqlState)
+ " -> "
+ previous.name()
+ " conflicts with "
+ contributor
+ " -> "
+ candidate.name());
}
contributors.put(sqlState, contributor);
}
}
this.byExactSqlState = Map.copyOf(merged);
}
/** Classify {@code ex}, or {@link Optional#empty()} when its SQLState is unmapped or absent. */
public Optional<PersistenceFailureException> translate(DataAccessException ex) {
return translate((Throwable) ex);
}
/**
* Classify a transaction or persistence wrapper by walking its cause chain for the first
* SQLState.
*/
public Optional<PersistenceFailureException> translate(Throwable ex) {
Objects.requireNonNull(ex, "ex");
String sqlState = extractSqlState(ex);
if (sqlState == null) {
return Optional.empty();
}
ApiErrorCode code = classify(sqlState);
if (code == null) {
return Optional.empty();
}
// The diagnostic is server-log-only (the web adapter never surfaces it); naming the
// SQLState here aids triage without leaking it to the client.
return Optional.of(
new PersistenceFailureException(
code, "persistence failure classified from SQLState=" + sqlState, ex));
}
/** The matrix lookup: exact codes first, then the {@code 08*} connection-class prefix. */
private ApiErrorCode classify(String sqlState) {
OperationalError exact = byExactSqlState.get(sqlState);
if (exact != null) {
return exact;
}
if (sqlState.startsWith(CONNECTION_CLASS_PREFIX)) {
return OperationalError.DB_UNAVAILABLE;
}
return null;
}
/** Walk the cause chain for the first {@link SQLException} and return its SQLState. */
private static String extractSqlState(Throwable ex) {
for (Throwable t = ex; t != null; t = t.getCause()) {
if (t instanceof SQLException sqlException) {
String state = sqlException.getSQLState();
if (state != null && !state.isBlank()) {
return state;
}
}
if (t.getCause() == t) {
break; // self-referential cause guard
}
}
return null;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.persistence.failure;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Map;
/**
* SPI for contributing exact SQLState → {@link OperationalError} mappings to the {@link
* PersistenceExceptionTranslator}. The core module contributes vendor-neutral rows; vendor modules
* contribute their own. See README "failure" and CLAUDE.md for the matrix.
*/
public interface SqlStateErrorMapping {
/**
* Immutable exact SQLState → code map; never {@code null}. The {@code 08*} prefix is the
* translator's responsibility, not a map entry.
*/
Map<String, OperationalError> exactMappings();
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.persistence.failure;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Core {@link SqlStateErrorMapping}: the portable, vendor-neutral SQLState rows every RDBMS
* returns. Vendor-specific states are contributed by the vendor module. See CLAUDE.md for the
* SQLState → Error Code matrix.
*/
@Component
public class StandardSqlStateErrorMapping implements SqlStateErrorMapping {
private static final Map<String, OperationalError> MAPPINGS =
Map.of(
"40001", OperationalError.DB_SERIALIZATION_FAILURE,
"23502", OperationalError.DB_NULL_VIOLATION,
"23503", OperationalError.DB_FK_VIOLATION,
"23505", OperationalError.DB_UNIQUE_VIOLATION,
"23514", OperationalError.DB_CHECK_VIOLATION);
@Override
public Map<String, OperationalError> exactMappings() {
return MAPPINGS;
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.QuotaReservation;
import dev.caskeleton.application.fileserver.api.metadata.QuotaReservationStatus;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Row-to-port translation for the Fileserver metadata tables.
*
* <p>Mapping carries no business rule: it only rebuilds the application record from the columns the
* conditional statements maintain.
*/
final class FileEntityMapper {
private FileEntityMapper() {}
static FileRecord toRecord(FileEntity entity) {
return new FileRecord(
FileId.of(entity.getFileId()),
StorageNamespace.of(entity.getNamespace()),
FileState.valueOf(entity.getState()),
Optional.ofNullable(entity.getContentKey()).map(ContentKey::of),
entity.getOriginalName(),
Optional.ofNullable(entity.getClaimedMediaType()),
Optional.ofNullable(entity.getVerifiedMediaType()),
toOptionalLong(entity.getExpectedSize()),
toOptionalLong(entity.getActualSize()),
Optional.ofNullable(entity.getSha256()),
Optional.ofNullable(entity.getStrongEtag()),
Optional.ofNullable(entity.getPublishedAt()),
Optional.ofNullable(entity.getLastErrorCode()),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
static UploadSession toSession(UploadSessionEntity entity) {
return new UploadSession(
UploadId.of(entity.getUploadId()),
FileId.of(entity.getFileId()),
UploadProtocol.valueOf(entity.getProtocol()),
toOptionalLong(entity.getExpectedLength()),
entity.getCommittedOffset(),
entity.getExpiresAt(),
Optional.ofNullable(entity.getLeaseOwner()),
Optional.ofNullable(entity.getLeaseToken()),
Optional.ofNullable(entity.getLeaseUntil()),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
static QuotaReservation toReservation(QuotaReservationEntity entity) {
return new QuotaReservation(
entity.getReservationId(),
new QuotaScope(entity.getScopeType(), entity.getScopeValue()),
entity.getReservedBytes(),
entity.getCommittedBytes(),
entity.getExpiresAt(),
QuotaReservationStatus.valueOf(entity.getStatus()),
entity.getVersion());
}
private static OptionalLong toOptionalLong(Long value) {
return value == null ? OptionalLong.empty() : OptionalLong.of(value);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import org.springframework.jdbc.core.JdbcOperations;
/**
* Proves the Fileserver schema stream was applied and promoted before the capability serves a
* request.
*
* <p>The stream is operator-applied, like every other optional capability stream: the application
* migrates {@code db/migration/postgresql} only, and {@code db/migration/jpa/fileserver} is applied
* and promoted to {@code ACTIVE} deliberately. Until that happens the {@code fs_*} tables either do
* not exist or are not sanctioned for use.
*
* <p>The check runs once, at startup, rather than per operation. The sibling capabilities verify on
* every call because they are low-frequency; a file download is not, and a registry round trip on
* the metadata read path would be paid by every byte served. Startup is also the honest place for
* it — an unpromoted stream is a deployment state, not a per-request condition.
*
* <p>Failing here rather than at the first upload is the point. The alternative is a raw "relation
* fs_file does not exist" surfacing as a 500 to whoever happened to upload first.
*/
public final class FileserverSchemaActivation {
static final String CAPABILITY_ID = "jpa-fileserver-metadata-v1";
private static final String ACTIVE_CAPABILITY_SQL =
"""
select count(*)
from capability_schema_registry
where capability_id = 'jpa-fileserver-metadata-v1'
and core_epoch = 1
and feature_revision >= 2
and lifecycle_state = 'ACTIVE'
""";
private final JdbcOperations jdbc;
public FileserverSchemaActivation(JdbcOperations jdbc) {
this.jdbc = jdbc;
}
/**
* Fails closed unless the stream is applied and promoted.
*
* <p>An unreadable registry is treated as "not promoted" rather than "assume fine": the registry
* table itself is created by the core stream, so its absence means the prerequisite chain was
* never established.
*/
public void requireActive() {
Integer active;
try {
active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
} catch (RuntimeException unreadable) {
throw new IllegalStateException(
CAPABILITY_ID
+ " could not be verified: the capability schema registry is unreadable, so the "
+ "Fileserver schema stream cannot be confirmed as applied",
unreadable);
}
if (active == null || active != 1) {
throw new IllegalStateException(
CAPABILITY_ID
+ " is not ACTIVE at core epoch 1 revision 2. Apply db/migration/jpa/fileserver "
+ "against history table flyway_jpa_fileserver_history and promote the capability "
+ "before enabling app.fileserver-platform.enabled");
}
}
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.cleanup.CleanupItem;
import dev.caskeleton.application.fileserver.cleanup.CleanupQueue;
import dev.caskeleton.application.fileserver.cleanup.CleanupRequest;
import dev.caskeleton.application.fileserver.cleanup.CleanupType;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* Durable, at-most-one-worker cleanup queue over {@code fs_cleanup_item}.
*
* <p>Claiming is a conditional update rather than a read followed by a write, so two instances
* running the same batch cannot both execute the same physical delete.
*
* <p>An item that keeps failing is eventually abandoned instead of retried forever. A poison entry
* that never succeeds would otherwise occupy a slot in every batch and starve the work behind it,
* and an abandoned row is still visible to an operator — it is parked, not discarded.
*
* <p>Staging cleanups carry an upload id and published cleanups carry a content key. The row keeps
* both columns nullable for that reason; which one is set is what tells the worker where to look.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaCleanupQueue implements CleanupQueue {
/** After this many failed attempts an item stops being rescheduled. */
public static final int MAXIMUM_ATTEMPTS = 8;
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_DONE = "DONE";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_ABANDONED = "ABANDONED";
private final FileserverCleanupRepository items;
private final Clock clock;
public JpaCleanupQueue(FileserverCleanupRepository items, Clock clock) {
this.items = items;
this.clock = clock;
}
@Override
public void enqueue(CleanupRequest request) {
Instant now = clock.instant();
items.save(
new CleanupItemEntity(
UUID.randomUUID(),
request.fileId().map(FileId::value).orElse(null),
request.contentKey().map(ContentKey::value).orElse(null),
request.uploadId().map(UploadId::value).orElse(null),
request.type().name(),
now,
STATUS_PENDING,
now));
}
@Override
public List<CleanupItem> claimDue(Instant now, int limit) {
if (limit < 1) {
throw new IllegalArgumentException("limit must be positive");
}
List<CleanupItem> claimed = new ArrayList<>();
for (CleanupItemEntity due : items.findDue(now, Limit.of(limit))) {
if (items.claim(due.getCleanupId(), now) == 1) {
claimed.add(toItem(due));
}
}
return List.copyOf(claimed);
}
@Override
public void markDone(CleanupItem item) {
Instant now = clock.instant();
items.recordAttempt(item.cleanupId(), STATUS_DONE, now, null, now);
}
@Override
public void markFailed(CleanupItem item, String reasonCode, Instant nextAttemptAt) {
Instant now = clock.instant();
boolean exhausted = item.attempt() + 1 >= MAXIMUM_ATTEMPTS;
items.recordAttempt(
item.cleanupId(),
exhausted ? STATUS_ABANDONED : STATUS_FAILED,
nextAttemptAt,
reasonCode,
now);
}
private static CleanupItem toItem(CleanupItemEntity entity) {
return new CleanupItem(
entity.getCleanupId(),
new CleanupRequest(
CleanupType.valueOf(entity.getType()),
Optional.ofNullable(entity.getFileId()).map(FileId::of),
Optional.ofNullable(entity.getUploadId()).map(UploadId::of),
Optional.ofNullable(entity.getContentKey()).map(ContentKey::of)),
entity.getAttempt());
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository;
import dev.caskeleton.application.fileserver.admin.ContentReferenceLedger;
import dev.caskeleton.application.fileserver.api.ContentKey;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Repository;
/**
* Answers the orphan scan's one question against {@code fs_file}.
*
* <p>A record in any state counts as a reference, including DELETING and DELETED. A row that is
* mid-delete already has its own cleanup item; letting the orphan scan delete it too would race the
* cleanup worker's precondition check, and a DELETED row still proves the key is not free to be
* reclaimed by a second, unrelated deletion path.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaContentReferenceLedger implements ContentReferenceLedger {
private final JpaFileRepository files;
public JpaContentReferenceLedger(JpaFileRepository files) {
this.files = files;
}
@Override
public boolean isReferenced(ContentKey key) {
return files.findByContentKey(key.value()).isPresent();
}
}
@@ -0,0 +1,162 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.FileStateMachine;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.error.ConcurrentFileModificationException;
import dev.caskeleton.application.fileserver.api.error.FileNotFoundException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.metadata.FileMetadataStore;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordDraft;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordMutation;
import dev.caskeleton.application.fileserver.api.metadata.FileRecoveryQuery;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* JPA-backed {@link FileMetadataStore}.
*
* <p>Transitions run through conditional statements that require both the expected state and the
* expected version, and the state machine is consulted first so an illegal transition never reaches
* the database. Operations join the caller's {@code TransactionPort} boundary and declare no
* {@code @Transactional} of their own.
*
* <p>{@link #transition} is a conditional update followed by the re-read that reports its outcome,
* so it is only correct inside a boundary. A caller that forgets one does not get a subtly stale
* answer — the modifying statement refuses to run outside a transaction, which is the failure mode
* worth having.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaFileMetadataStore implements FileMetadataStore {
private final JpaFileRepository files;
private final FileTransitionRepository transitions;
private final FileStateMachine stateMachine;
private final Clock clock;
public JpaFileMetadataStore(
JpaFileRepository files,
FileTransitionRepository transitions,
FileStateMachine stateMachine,
Clock clock) {
this.files = files;
this.transitions = transitions;
this.stateMachine = stateMachine;
this.clock = clock;
}
@Override
public FileRecord insert(FileRecordDraft draft) {
Instant now = clock.instant();
FileEntity entity =
new FileEntity(
draft.fileId().value(),
draft.namespace().value(),
FileState.CREATED.name(),
draft.originalName(),
draft.claimedMediaType().orElse(null),
draft.expectedSize().isPresent() ? draft.expectedSize().getAsLong() : null,
now);
return FileEntityMapper.toRecord(files.save(entity));
}
@Override
public Optional<FileRecord> find(FileId fileId) {
return files.findById(fileId.value()).map(FileEntityMapper::toRecord);
}
@Override
public FileRecord transition(
FileId fileId,
long expectedVersion,
FileState expectedState,
FileState targetState,
FileRecordMutation mutation) {
stateMachine.requireTransition(expectedState, targetState);
Instant now = clock.instant();
int updated =
transitions.transition(
fileId.value(),
expectedVersion,
expectedState.name(),
targetState.name(),
mutation.contentKey().map(ContentKey::value).orElse(null),
mutation.actualSize().isPresent() ? mutation.actualSize().getAsLong() : null,
mutation.sha256().orElse(null),
mutation.strongEtag().orElse(null),
mutation.verifiedMediaType().orElse(null),
mutation.publishedAt().orElse(null),
mutation.lastErrorCode().orElse(null),
now);
if (updated == 0) {
throw conflict(fileId, expectedVersion, expectedState, targetState);
}
return reload(fileId);
}
@Override
public FileRecord markDeleting(FileId fileId, long expectedVersion) {
int updated = transitions.markDeleting(fileId.value(), expectedVersion, clock.instant());
if (updated == 0) {
throw conflict(fileId, expectedVersion, null, FileState.DELETING);
}
return reload(fileId);
}
@Override
public FileRecord relocate(
FileId fileId, long expectedVersion, StorageNamespace targetNamespace) {
int updated =
transitions.relocate(
fileId.value(), expectedVersion, targetNamespace.value(), clock.instant());
if (updated == 0) {
throw conflict(fileId, expectedVersion, FileState.READY, FileState.READY);
}
return reload(fileId);
}
@Override
public List<FileRecord> findRecoverable(FileRecoveryQuery query) {
List<String> states = query.states().stream().map(Enum::name).toList();
return files.findRecoverable(states, query.notUpdatedSince(), Limit.of(query.limit())).stream()
.map(FileEntityMapper::toRecord)
.toList();
}
private FileRecord reload(FileId fileId) {
return files
.findById(fileId.value())
.map(FileEntityMapper::toRecord)
.orElseThrow(
() ->
new FileNotFoundException(
"file disappeared during transition",
FileserverFailureContext.forFile(
FileserverErrorCode.FILE_NOT_FOUND, fileId, false)));
}
private ConcurrentFileModificationException conflict(
FileId fileId, long expectedVersion, FileState expectedState, FileState targetState) {
return new ConcurrentFileModificationException(
"file transition precondition lost: expected version "
+ expectedVersion
+ (expectedState == null ? "" : " in state " + expectedState)
+ " for target "
+ targetState,
FileserverFailureContext.forFile(
FileserverErrorCode.CONCURRENT_MODIFICATION, fileId, true));
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.QuotaExceededException;
import dev.caskeleton.application.fileserver.api.metadata.FileQuotaService;
import dev.caskeleton.application.fileserver.api.metadata.QuotaReservation;
import dev.caskeleton.application.fileserver.api.metadata.QuotaReservationStatus;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Repository;
/**
* JPA-backed {@link FileQuotaService}.
*
* <p>Reservation, extension, commit, and release are conditional statements, so a reservation that
* already expired or was released can never be extended or committed.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaFileQuotaService implements FileQuotaService {
private final FileserverQuotaRepository reservations;
private final Clock clock;
public JpaFileQuotaService(FileserverQuotaRepository reservations, Clock clock) {
this.reservations = reservations;
this.clock = clock;
}
@Override
public QuotaReservation reserve(QuotaScope scope, long expectedBytes, Duration ttl) {
if (expectedBytes < 0) {
throw new IllegalArgumentException("expectedBytes must not be negative");
}
Instant now = clock.instant();
QuotaReservationEntity entity =
new QuotaReservationEntity(
UUID.randomUUID(),
scope.type(),
scope.value(),
expectedBytes,
now.plus(ttl),
QuotaReservationStatus.RESERVED.name(),
now);
return FileEntityMapper.toReservation(reservations.save(entity));
}
@Override
public void extend(QuotaReservation reservation, long additionalBytes) {
if (additionalBytes < 0) {
throw new IllegalArgumentException("additionalBytes must not be negative");
}
int updated =
reservations.extend(reservation.reservationId(), additionalBytes, clock.instant());
if (updated == 0) {
throw quotaConflict("reservation is no longer extendable");
}
}
@Override
public void commit(QuotaReservation reservation, long actualBytes) {
if (actualBytes < 0) {
throw new IllegalArgumentException("actualBytes must not be negative");
}
int updated = reservations.commit(reservation.reservationId(), actualBytes, clock.instant());
if (updated == 0) {
throw quotaConflict("reservation is no longer committable");
}
}
@Override
public void release(QuotaReservation reservation) {
reservations.release(reservation.reservationId(), clock.instant());
}
/** Bytes currently reserved but not yet committed for a scope. */
public long reservedBytes(QuotaScope scope) {
return reservations.sumReservedBytes(scope.type(), scope.value(), clock.instant());
}
/** Bytes durably committed for a scope. */
public long committedBytes(QuotaScope scope) {
return reservations.sumCommittedBytes(scope.type(), scope.value());
}
private QuotaExceededException quotaConflict(String message) {
return new QuotaExceededException(
message, FileserverFailureContext.of(FileserverErrorCode.QUOTA_EXCEEDED, false));
}
}
@@ -0,0 +1,113 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository;
import dev.caskeleton.application.fileserver.api.metadata.FileMetadataStore;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.upload.QuotaCommitGateway;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* Settles an upload's share of its scope's quota ledger.
*
* <p>A reservation is a scope-level accounting device with a time-to-live, not a per-upload lock:
* nothing links a reservation row to the upload that took it, and the design deliberately reclaims
* stragglers by expiry and the {@code STALE_QUOTA_RESERVATION} cleanup type rather than by
* threading a reservation id through the upload session.
*
* <p>Settlement is therefore FIFO within the scope: the oldest live reservation is the one closed
* out. Which row closes does not change what quota enforcement reads, because enforcement sums
* reserved and committed bytes per scope and never looks at an individual row. Concurrent uploads
* of different sizes can leave the reserved total transiently high or low, and it converges as each
* one settles.
*
* <p>When no live reservation remains — the upload outlived its TTL — the committed bytes are still
* recorded. Durable usage that goes unrecorded because a reservation expired is how a quota ledger
* silently drifts below the truth.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaQuotaCommitGateway implements QuotaCommitGateway {
private static final Limit OLDEST = Limit.of(1);
private final FileserverQuotaRepository reservations;
private final FileMetadataStore metadataStore;
private final Clock clock;
public JpaQuotaCommitGateway(
FileserverQuotaRepository reservations, FileMetadataStore metadataStore, Clock clock) {
this.reservations = reservations;
this.metadataStore = metadataStore;
this.clock = clock;
}
@Override
public void commit(UploadSession session, long actualBytes) {
if (actualBytes < 0) {
throw new IllegalArgumentException("actualBytes must not be negative");
}
Optional<QuotaScope> scope = scopeOf(session);
if (scope.isEmpty()) {
return;
}
Instant now = clock.instant();
Optional<QuotaReservationEntity> oldest = oldestActive(scope.get(), now);
if (oldest.isPresent()
&& reservations.commit(oldest.get().getReservationId(), actualBytes, now) == 1) {
return;
}
recordCommittedWithoutReservation(scope.get(), actualBytes, now);
}
@Override
public void release(UploadSession session) {
Optional<QuotaScope> scope = scopeOf(session);
if (scope.isEmpty()) {
return;
}
Instant now = clock.instant();
oldestActive(scope.get(), now)
.ifPresent(reservation -> reservations.release(reservation.getReservationId(), now));
}
/**
* Resolves the scope from the authoritative record.
*
* <p>A session that has outlived its record has no scope to settle against; that is a reconciled
* absence, not an error to raise at the end of a successful upload.
*/
private Optional<QuotaScope> scopeOf(UploadSession session) {
return metadataStore
.find(session.fileId())
.map(FileRecord::namespace)
.map(namespace -> QuotaScope.ofNamespace(namespace.value()));
}
private Optional<QuotaReservationEntity> oldestActive(QuotaScope scope, Instant now) {
List<QuotaReservationEntity> active =
reservations.findActiveReservations(scope.type(), scope.value(), now, OLDEST);
return active.isEmpty() ? Optional.empty() : Optional.of(active.get(0));
}
/** Books durable usage that no live reservation covers, as an already-committed row. */
private void recordCommittedWithoutReservation(QuotaScope scope, long actualBytes, Instant now) {
if (actualBytes == 0) {
return;
}
QuotaReservationEntity settled =
new QuotaReservationEntity(
UUID.randomUUID(), scope.type(), scope.value(), actualBytes, now, "RESERVED", now);
reservations.save(settled);
reservations.commit(settled.getReservationId(), actualBytes, now);
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.cleanup.QuotaReclaimGateway;
import java.time.Clock;
import java.time.Instant;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* Returns reclaimed bytes to a scope's committed total.
*
* <p>Committed usage is spread over many rows, so a reclaim is drawn down newest-first across them
* until the amount is satisfied. Newest-first matters: the most recently committed rows are the
* ones a delete is most likely to correspond to, and drawing from them keeps historical rows from
* being hollowed out by unrelated deletions.
*
* <p>Each draw-down is conditional on the row still holding at least that many bytes, so two
* cleanup workers reclaiming at once cannot push the ledger negative — the loser simply moves to
* the next row.
*
* <p>A remainder that no row can absorb is dropped rather than carried. The ledger's floor is zero:
* a scope cannot owe negative bytes, and a reclaim that outruns the recorded total means the total
* was already understated, which a negative balance would not fix.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaQuotaReclaimGateway implements QuotaReclaimGateway {
private static final Limit RECLAIM_PAGE = Limit.of(64);
private final FileserverQuotaRepository reservations;
private final Clock clock;
public JpaQuotaReclaimGateway(FileserverQuotaRepository reservations, Clock clock) {
this.reservations = reservations;
this.clock = clock;
}
@Override
public void reclaim(QuotaScope scope, long bytes) {
if (bytes < 0) {
throw new IllegalArgumentException("bytes must not be negative");
}
if (bytes == 0) {
return;
}
Instant now = clock.instant();
long outstanding = bytes;
for (QuotaReservationEntity committed :
reservations.findCommittedWithBytes(scope.type(), scope.value(), RECLAIM_PAGE)) {
if (outstanding == 0) {
return;
}
long draw = Math.min(outstanding, committed.getCommittedBytes());
if (reservations.reduceCommitted(committed.getReservationId(), draw, now) == 1) {
outstanding -= draw;
}
}
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.recovery.ReconciliationStatus;
import dev.caskeleton.application.fileserver.recovery.RecoveryQueue;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* Durable recovery queue over {@code fs_recovery_item}.
*
* <p>An enqueue is an upsert: the same file reported twice updates the open item rather than adding
* a second one. Reconciliation runs on a schedule and re-raises whatever it still cannot settle, so
* an append-only queue would grow one row per sweep per unresolved file and bury the distinct
* problems under repetitions of the same one.
*
* <p>Resolution keeps the outcome rather than deleting the row. {@code UNRESOLVED} and {@code
* QUARANTINE_REQUIRED} are the two answers a human has to act on, and both are worthless if the
* record of what the system concluded disappears with the item.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaRecoveryQueue implements RecoveryQueue {
private static final String STATUS_PENDING = "PENDING";
private final FileserverRecoveryRepository items;
private final Clock clock;
public JpaRecoveryQueue(FileserverRecoveryRepository items, Clock clock) {
this.items = items;
this.clock = clock;
}
@Override
public void enqueue(FileId fileId, String reasonCode) {
Instant now = clock.instant();
if (items.refreshPending(fileId.value(), reasonCode, now) > 0) {
return;
}
items.save(
new RecoveryItemEntity(UUID.randomUUID(), fileId.value(), reasonCode, STATUS_PENDING, now));
}
@Override
public List<FileId> pending(int limit) {
if (limit < 1) {
throw new IllegalArgumentException("limit must be positive");
}
return items.findPending(Limit.of(limit)).stream()
.map(RecoveryItemEntity::getFileId)
.map(FileId::of)
.toList();
}
@Override
public void resolve(FileId fileId, ReconciliationStatus status) {
items.resolvePending(fileId.value(), status.name(), clock.instant());
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.recovery.StagingUploadLocator;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* Maps a file back to the upload that last staged it.
*
* <p>Only the newest session is returned. A file that was re-staged after a failure has more than
* one session row, and an older one names a staging object that has since been reclaimed — treating
* that as live evidence would tell reconciliation the upload is resumable when it is not.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaStagingUploadLocator implements StagingUploadLocator {
private static final Limit NEWEST = Limit.of(1);
private final JpaUploadSessionRepository sessions;
public JpaStagingUploadLocator(JpaUploadSessionRepository sessions) {
this.sessions = sessions;
}
@Override
public Optional<UploadId> locate(FileId fileId) {
List<UploadSessionEntity> found = sessions.findByFile(fileId.value(), NEWEST);
return found.isEmpty()
? Optional.empty()
: Optional.of(UploadId.of(found.get(0).getUploadId()));
}
}
@@ -0,0 +1,143 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.error.ConcurrentFileModificationException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.UploadExpiredException;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionDraft;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionStore;
import dev.caskeleton.application.fileserver.api.metadata.WriterLease;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Repository;
/**
* JPA-backed {@link UploadSessionStore} with database writer leases.
*
* <p>A lease is granted only when none is held or the held one expired, and an offset commit
* additionally requires the exact token plus the expected offset. This is the only correctness
* mechanism for multi-instance appends; no filesystem or NFS lock participates.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class JpaUploadSessionStore implements UploadSessionStore {
private final JpaUploadSessionRepository sessions;
private final UploadLeaseRepository leases;
private final Clock clock;
public JpaUploadSessionStore(
JpaUploadSessionRepository sessions, UploadLeaseRepository leases, Clock clock) {
this.sessions = sessions;
this.leases = leases;
this.clock = clock;
}
@Override
public UploadSession create(UploadSessionDraft draft) {
UploadSessionEntity entity =
new UploadSessionEntity(
draft.uploadId().value(),
draft.fileId().value(),
draft.protocol().name(),
draft.expectedLength().isPresent() ? draft.expectedLength().getAsLong() : null,
draft.expiresAt(),
clock.instant());
return FileEntityMapper.toSession(sessions.save(entity));
}
@Override
public Optional<UploadSession> find(UploadId uploadId) {
return sessions.findById(uploadId.value()).map(FileEntityMapper::toSession);
}
@Override
public WriterLease acquireLease(
UploadId uploadId, String owner, Instant now, Duration leaseDuration, long expectedVersion) {
UUID token = UUID.randomUUID();
Instant leaseUntil = now.plus(leaseDuration);
int updated =
leases.acquireLease(uploadId.value(), owner, token, leaseUntil, expectedVersion, now);
if (updated == 0) {
throw leaseConflict(uploadId, now);
}
UploadSession refreshed = requireSession(uploadId);
return new WriterLease(uploadId, owner, token, leaseUntil, refreshed.version());
}
/** Extends an already-held lease; a writer that lost the lease can never renew it. */
@Override
public WriterLease renewLease(WriterLease lease, Instant now, Duration leaseDuration) {
Instant leaseUntil = now.plus(leaseDuration);
int updated = leases.renewLease(lease.uploadId().value(), lease.token(), leaseUntil, now);
if (updated == 0) {
throw leaseConflict(lease.uploadId(), now);
}
UploadSession refreshed = requireSession(lease.uploadId());
return new WriterLease(
lease.uploadId(), lease.owner(), lease.token(), leaseUntil, refreshed.version());
}
@Override
public UploadSession commitOffset(
UploadId uploadId, WriterLease lease, long expectedOffset, long committedOffset) {
Instant now = clock.instant();
int updated =
leases.commitOffset(uploadId.value(), lease.token(), expectedOffset, committedOffset, now);
if (updated == 0) {
throw new ConcurrentFileModificationException(
"offset commit rejected: lease or expected offset no longer matches",
FileserverFailureContext.forOffset(
FileserverErrorCode.CONCURRENT_MODIFICATION, expectedOffset, committedOffset)
.withUpload(uploadId));
}
return requireSession(uploadId);
}
@Override
public void releaseLease(UploadId uploadId, WriterLease lease) {
leases.releaseLease(uploadId.value(), lease.token(), clock.instant());
}
@Override
public List<UploadSession> findExpired(Instant cutoff, int limit) {
return sessions.findExpired(cutoff, Limit.of(limit)).stream()
.map(FileEntityMapper::toSession)
.toList();
}
private UploadSession requireSession(UploadId uploadId) {
return find(uploadId)
.orElseThrow(
() ->
new UploadExpiredException(
"upload session no longer exists",
FileserverFailureContext.forUpload(
FileserverErrorCode.UPLOAD_EXPIRED, uploadId, false, false, false)));
}
private ConcurrentFileModificationException leaseConflict(UploadId uploadId, Instant now) {
Optional<UploadSession> current = find(uploadId);
if (current.isPresent() && current.get().isExpiredAt(now)) {
return new ConcurrentFileModificationException(
"upload resource expired before the lease could be granted",
FileserverFailureContext.forUpload(
FileserverErrorCode.CONCURRENT_MODIFICATION, uploadId, false, false, false));
}
return new ConcurrentFileModificationException(
"writer lease is held by another owner",
FileserverFailureContext.forUpload(
FileserverErrorCode.CONCURRENT_MODIFICATION, uploadId, true, false, false));
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for {@code fs_cleanup_item}.
*
* <p>A cleanup item names the physical object by its opaque content key. The worker re-checks
* state, version, and lease before deleting anything, so an item can never remove content an active
* upload still owns.
*/
@Entity
@Table(name = "fs_cleanup_item")
public class CleanupItemEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "cleanup_id", nullable = false, updatable = false)
private UUID cleanupId;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "file_id")
private UUID fileId;
@Column(name = "content_key", length = 200)
private String contentKey;
/**
* Staging owner for an unpublished cleanup.
*
* <p>A staging object is addressed by upload, not by file, so a cancelled or expired upload can
* only be reclaimed if the queue remembers which upload owned the bytes. Published cleanups leave
* this null and carry a content key instead.
*/
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "upload_id")
private UUID uploadId;
@Column(name = "type", nullable = false, length = 32)
private String type;
@Column(name = "attempt", nullable = false)
private int attempt;
@Column(name = "next_attempt_at", nullable = false)
private Instant nextAttemptAt;
@Column(name = "status", nullable = false, length = 16)
private String status;
@Column(name = "last_error_code", length = 64)
private String lastErrorCode;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected CleanupItemEntity() {}
public CleanupItemEntity(
UUID cleanupId,
UUID fileId,
String contentKey,
UUID uploadId,
String type,
Instant nextAttemptAt,
String status,
Instant createdAt) {
this.cleanupId = cleanupId;
this.fileId = fileId;
this.contentKey = contentKey;
this.uploadId = uploadId;
this.type = type;
this.attempt = 0;
this.nextAttemptAt = nextAttemptAt;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public UUID getCleanupId() {
return cleanupId;
}
public UUID getFileId() {
return fileId;
}
public String getContentKey() {
return contentKey;
}
public UUID getUploadId() {
return uploadId;
}
public String getType() {
return type;
}
public int getAttempt() {
return attempt;
}
public Instant getNextAttemptAt() {
return nextAttemptAt;
}
public String getStatus() {
return status;
}
public String getLastErrorCode() {
return lastErrorCode;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,163 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for {@code fs_file}; schema owned by Flyway ({@code
* db/migration/jpa/fileserver/V1__create_fileserver_metadata.sql}).
*
* <p>State is never assigned through a public setter: every transition goes through the conditional
* update in {@code FileTransitionRepository}, which requires both the expected state and the
* expected version. Package-private mutators exist only for the insert path.
*/
@Entity
@Table(name = "fs_file")
public class FileEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "file_id", nullable = false, updatable = false)
private UUID fileId;
@Column(name = "namespace", nullable = false, length = 63)
private String namespace;
@Column(name = "state", nullable = false, length = 32)
private String state;
@Column(name = "content_key", length = 200)
private String contentKey;
@Column(name = "original_name", nullable = false, length = 255)
private String originalName;
@Column(name = "claimed_media_type", length = 255)
private String claimedMediaType;
@Column(name = "verified_media_type", length = 255)
private String verifiedMediaType;
@Column(name = "expected_size")
private Long expectedSize;
@Column(name = "actual_size")
private Long actualSize;
// Fixed-width digest column: a portable JPA/Hibernate type hint, not a pinned vendor
// columnDefinition (PERSISTENCE_RDBMS_ENTITIES_DO_NOT_PIN_VENDOR_COLUMN_DEFINITIONS).
@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "sha256", length = 64)
private String sha256;
@Column(name = "strong_etag", length = 80)
private String strongEtag;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "last_error_code", length = 64)
private String lastErrorCode;
@Version
@Column(name = "version", nullable = false)
private long version;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected FileEntity() {}
/** Builds the initial {@code CREATED} row; every later change is a conditional update. */
public FileEntity(
UUID fileId,
String namespace,
String state,
String originalName,
String claimedMediaType,
Long expectedSize,
Instant createdAt) {
this.fileId = fileId;
this.namespace = namespace;
this.state = state;
this.originalName = originalName;
this.claimedMediaType = claimedMediaType;
this.expectedSize = expectedSize;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public UUID getFileId() {
return fileId;
}
public String getNamespace() {
return namespace;
}
public String getState() {
return state;
}
public String getContentKey() {
return contentKey;
}
public String getOriginalName() {
return originalName;
}
public String getClaimedMediaType() {
return claimedMediaType;
}
public String getVerifiedMediaType() {
return verifiedMediaType;
}
public Long getExpectedSize() {
return expectedSize;
}
public Long getActualSize() {
return actualSize;
}
public String getSha256() {
return sha256;
}
public String getStrongEtag() {
return strongEtag;
}
public Instant getPublishedAt() {
return publishedAt;
}
public String getLastErrorCode() {
return lastErrorCode;
}
public long getVersion() {
return version;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,116 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for {@code fs_quota_reservation}.
*
* <p>Reserved bytes become committed usage only once the actual byte count is known, and an
* abandoned reservation is reclaimed by expiry rather than held forever.
*/
@Entity
@Table(name = "fs_quota_reservation")
public class QuotaReservationEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "reservation_id", nullable = false, updatable = false)
private UUID reservationId;
@Column(name = "scope_type", nullable = false, length = 32)
private String scopeType;
@Column(name = "scope_value", nullable = false, length = 128)
private String scopeValue;
@Column(name = "reserved_bytes", nullable = false)
private long reservedBytes;
@Column(name = "committed_bytes", nullable = false)
private long committedBytes;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "status", nullable = false, length = 16)
private String status;
@Version
@Column(name = "version", nullable = false)
private long version;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected QuotaReservationEntity() {}
public QuotaReservationEntity(
UUID reservationId,
String scopeType,
String scopeValue,
long reservedBytes,
Instant expiresAt,
String status,
Instant createdAt) {
this.reservationId = reservationId;
this.scopeType = scopeType;
this.scopeValue = scopeValue;
this.reservedBytes = reservedBytes;
this.committedBytes = 0;
this.expiresAt = expiresAt;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public UUID getReservationId() {
return reservationId;
}
public String getScopeType() {
return scopeType;
}
public String getScopeValue() {
return scopeValue;
}
public long getReservedBytes() {
return reservedBytes;
}
public long getCommittedBytes() {
return committedBytes;
}
public Instant getExpiresAt() {
return expiresAt;
}
public String getStatus() {
return status;
}
public long getVersion() {
return version;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* One file awaiting reconciliation.
*
* <p>A recovery item is the record of a question the system could not answer on its own: the bytes
* and the metadata disagreed, or a commit could not be confirmed. It carries no content and no
* filename — only the file it concerns and why it was raised — because an unresolved item may
* outlive the file it points at.
*/
@Entity
@Table(name = "fs_recovery_item")
public class RecoveryItemEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "recovery_id", nullable = false, updatable = false)
private UUID recoveryId;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "file_id", nullable = false, updatable = false)
private UUID fileId;
@Column(name = "reason_code", nullable = false, length = 64)
private String reasonCode;
@Column(name = "status", nullable = false, length = 24)
private String status;
@Column(name = "attempt", nullable = false)
private int attempt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected RecoveryItemEntity() {}
public RecoveryItemEntity(
UUID recoveryId, UUID fileId, String reasonCode, String status, Instant createdAt) {
this.recoveryId = recoveryId;
this.fileId = fileId;
this.reasonCode = reasonCode;
this.status = status;
this.attempt = 0;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public UUID getRecoveryId() {
return recoveryId;
}
public UUID getFileId() {
return fileId;
}
public String getReasonCode() {
return reasonCode;
}
public String getStatus() {
return status;
}
public int getAttempt() {
return attempt;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for {@code fs_upload_session}.
*
* <p>The lease columns are the multi-instance single-writer mechanism. They are only ever changed
* by the conditional statements in {@code UploadLeaseRepository}, so a paused writer whose lease
* expired cannot advance {@code committed_offset}.
*/
@Entity
@Table(name = "fs_upload_session")
public class UploadSessionEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "upload_id", nullable = false, updatable = false)
private UUID uploadId;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "file_id", nullable = false, updatable = false)
private UUID fileId;
@Column(name = "protocol", nullable = false, length = 32)
private String protocol;
@Column(name = "expected_length")
private Long expectedLength;
@Column(name = "committed_offset", nullable = false)
private long committedOffset;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "lease_owner", length = 128)
private String leaseOwner;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "lease_token")
private UUID leaseToken;
@Column(name = "lease_until")
private Instant leaseUntil;
@Version
@Column(name = "version", nullable = false)
private long version;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected UploadSessionEntity() {}
/** Builds a fresh upload resource at offset zero and without a lease. */
public UploadSessionEntity(
UUID uploadId,
UUID fileId,
String protocol,
Long expectedLength,
Instant expiresAt,
Instant createdAt) {
this.uploadId = uploadId;
this.fileId = fileId;
this.protocol = protocol;
this.expectedLength = expectedLength;
this.committedOffset = 0;
this.expiresAt = expiresAt;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public UUID getUploadId() {
return uploadId;
}
public UUID getFileId() {
return fileId;
}
public String getProtocol() {
return protocol;
}
public Long getExpectedLength() {
return expectedLength;
}
public long getCommittedOffset() {
return committedOffset;
}
public Instant getExpiresAt() {
return expiresAt;
}
public String getLeaseOwner() {
return leaseOwner;
}
public UUID getLeaseToken() {
return leaseToken;
}
public Instant getLeaseUntil() {
return leaseUntil;
}
public long getVersion() {
return version;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for {@code fs_verification_result}.
*
* <p>Only a stable verdict and a bounded reason code are persisted. A scanner's raw response and
* any content sample are deliberately absent.
*/
@Entity
@Table(name = "fs_verification_result")
public class VerificationResultEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "verification_id", nullable = false, updatable = false)
private UUID verificationId;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "file_id", nullable = false, updatable = false)
private UUID fileId;
@Column(name = "verifier", nullable = false, length = 64)
private String verifier;
@Column(name = "verdict", nullable = false, length = 16)
private String verdict;
@Column(name = "details_code", nullable = false, length = 64)
private String detailsCode;
@Column(name = "started_at", nullable = false)
private Instant startedAt;
@Column(name = "completed_at")
private Instant completedAt;
protected VerificationResultEntity() {}
public VerificationResultEntity(
UUID verificationId,
UUID fileId,
String verifier,
String verdict,
String detailsCode,
Instant startedAt,
Instant completedAt) {
this.verificationId = verificationId;
this.fileId = fileId;
this.verifier = verifier;
this.verdict = verdict;
this.detailsCode = detailsCode;
this.startedAt = startedAt;
this.completedAt = completedAt;
}
public UUID getVerificationId() {
return verificationId;
}
public UUID getFileId() {
return fileId;
}
public String getVerifier() {
return verifier;
}
public String getVerdict() {
return verdict;
}
public String getDetailsCode() {
return detailsCode;
}
public Instant getStartedAt() {
return startedAt;
}
public Instant getCompletedAt() {
return completedAt;
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity;
import java.time.Instant;
import java.util.UUID;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
/**
* Conditional file-state transitions.
*
* <p>Every statement carries both {@code state = :expectedState} and {@code version =
* :expectedVersion}, so two writers racing on one record produce exactly one winner. A returned
* count of zero means the precondition lost and is translated into an optimistic conflict — it is
* never retried blindly.
*/
public interface FileTransitionRepository extends Repository<FileEntity, UUID> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update FileEntity f
set f.state = :targetState,
f.contentKey = coalesce(:contentKey, f.contentKey),
f.actualSize = coalesce(:actualSize, f.actualSize),
f.sha256 = coalesce(:sha256, f.sha256),
f.strongEtag = coalesce(:strongEtag, f.strongEtag),
f.verifiedMediaType = coalesce(:verifiedMediaType, f.verifiedMediaType),
f.publishedAt = coalesce(:publishedAt, f.publishedAt),
f.lastErrorCode = coalesce(:lastErrorCode, f.lastErrorCode),
f.version = f.version + 1,
f.updatedAt = :updatedAt
where f.fileId = :fileId
and f.state = :expectedState
and f.version = :expectedVersion
""")
int transition(
@Param("fileId") UUID fileId,
@Param("expectedVersion") long expectedVersion,
@Param("expectedState") String expectedState,
@Param("targetState") String targetState,
@Param("contentKey") String contentKey,
@Param("actualSize") Long actualSize,
@Param("sha256") String sha256,
@Param("strongEtag") String strongEtag,
@Param("verifiedMediaType") String verifiedMediaType,
@Param("publishedAt") Instant publishedAt,
@Param("lastErrorCode") String lastErrorCode,
@Param("updatedAt") Instant updatedAt);
/**
* Moves a record into {@code DELETING} from any state the design permits.
*
* <p>Public read authorization is blocked the moment this succeeds, well before the physical
* object is removed.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update FileEntity f
set f.state = 'DELETING',
f.version = f.version + 1,
f.updatedAt = :updatedAt
where f.fileId = :fileId
and f.version = :expectedVersion
and f.state in ('UPLOADING', 'UPLOADED', 'QUARANTINED', 'READY', 'REJECTED',
'FAILED', 'EXPIRED')
""")
int markDeleting(
@Param("fileId") UUID fileId,
@Param("expectedVersion") long expectedVersion,
@Param("updatedAt") Instant updatedAt);
/**
* Metadata-only namespace change.
*
* <p>The physical object is immutable and never moves, so a namespace change is one column plus
* the optimistic version bump. Only a READY record may be relocated: relocating anything else
* would move a record whose content is not yet proven.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update FileEntity f
set f.namespace = :targetNamespace,
f.version = f.version + 1,
f.updatedAt = :updatedAt
where f.fileId = :fileId
and f.version = :expectedVersion
and f.state = 'READY'
""")
int relocate(
@Param("fileId") UUID fileId,
@Param("expectedVersion") long expectedVersion,
@Param("targetNamespace") String targetNamespace,
@Param("updatedAt") Instant updatedAt);
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Bounded scheduling and completion of physical cleanup work. */
public interface FileserverCleanupRepository extends JpaRepository<CleanupItemEntity, UUID> {
@Query(
"""
select c from CleanupItemEntity c
where c.status in ('PENDING', 'FAILED')
and c.nextAttemptAt <= :now
order by c.nextAttemptAt asc
""")
List<CleanupItemEntity> findDue(@Param("now") Instant now, Limit limit);
/**
* Takes ownership of one due item.
*
* <p>The conditional status keeps two workers from running the same delete: whoever loses the
* race updates zero rows and skips the item rather than deleting behind the winner.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update CleanupItemEntity c
set c.status = 'IN_PROGRESS',
c.updatedAt = :now
where c.cleanupId = :cleanupId
and c.status in ('PENDING', 'FAILED')
""")
int claim(@Param("cleanupId") UUID cleanupId, @Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update CleanupItemEntity c
set c.status = :status,
c.attempt = c.attempt + 1,
c.nextAttemptAt = :nextAttemptAt,
c.lastErrorCode = :lastErrorCode,
c.updatedAt = :now
where c.cleanupId = :cleanupId
""")
int recordAttempt(
@Param("cleanupId") UUID cleanupId,
@Param("status") String status,
@Param("nextAttemptAt") Instant nextAttemptAt,
@Param("lastErrorCode") String lastErrorCode,
@Param("now") Instant now);
}
@@ -0,0 +1,140 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Conditional quota reservation statements.
*
* <p>Extend, commit, and release all require the reservation to still be {@code RESERVED} at the
* expected version, so a reservation reclaimed by expiry cannot be resurrected.
*/
public interface FileserverQuotaRepository extends JpaRepository<QuotaReservationEntity, UUID> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update QuotaReservationEntity q
set q.reservedBytes = q.reservedBytes + :additionalBytes,
q.version = q.version + 1,
q.updatedAt = :now
where q.reservationId = :reservationId
and q.status = 'RESERVED'
and q.expiresAt > :now
""")
int extend(
@Param("reservationId") UUID reservationId,
@Param("additionalBytes") long additionalBytes,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update QuotaReservationEntity q
set q.status = 'COMMITTED',
q.committedBytes = :actualBytes,
q.reservedBytes = 0,
q.version = q.version + 1,
q.updatedAt = :now
where q.reservationId = :reservationId
and q.status = 'RESERVED'
""")
int commit(
@Param("reservationId") UUID reservationId,
@Param("actualBytes") long actualBytes,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update QuotaReservationEntity q
set q.status = 'RELEASED',
q.reservedBytes = 0,
q.version = q.version + 1,
q.updatedAt = :now
where q.reservationId = :reservationId
and q.status = 'RESERVED'
""")
int release(@Param("reservationId") UUID reservationId, @Param("now") Instant now);
@Query(
"""
select coalesce(sum(q.reservedBytes), 0) from QuotaReservationEntity q
where q.scopeType = :scopeType
and q.scopeValue = :scopeValue
and q.status = 'RESERVED'
and q.expiresAt > :now
""")
long sumReservedBytes(
@Param("scopeType") String scopeType,
@Param("scopeValue") String scopeValue,
@Param("now") Instant now);
@Query(
"""
select coalesce(sum(q.committedBytes), 0) from QuotaReservationEntity q
where q.scopeType = :scopeType
and q.scopeValue = :scopeValue
and q.status = 'COMMITTED'
""")
long sumCommittedBytes(
@Param("scopeType") String scopeType, @Param("scopeValue") String scopeValue);
/** Live reservations for a scope, oldest first. */
@Query(
"""
select q from QuotaReservationEntity q
where q.scopeType = :scopeType
and q.scopeValue = :scopeValue
and q.status = 'RESERVED'
and q.expiresAt > :now
order by q.createdAt asc
""")
List<QuotaReservationEntity> findActiveReservations(
@Param("scopeType") String scopeType,
@Param("scopeValue") String scopeValue,
@Param("now") Instant now,
Limit limit);
/** Committed rows for a scope that still carry bytes, newest first. */
@Query(
"""
select q from QuotaReservationEntity q
where q.scopeType = :scopeType
and q.scopeValue = :scopeValue
and q.status = 'COMMITTED'
and q.committedBytes > 0
order by q.updatedAt desc
""")
List<QuotaReservationEntity> findCommittedWithBytes(
@Param("scopeType") String scopeType, @Param("scopeValue") String scopeValue, Limit limit);
/**
* Gives back part of a committed row.
*
* <p>The guard is what makes concurrent reclaims safe: a row that another reclaim already drew
* down below {@code amount} updates zero rows, and the caller moves to the next row instead of
* driving the ledger negative.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update QuotaReservationEntity q
set q.committedBytes = q.committedBytes - :amount,
q.version = q.version + 1,
q.updatedAt = :now
where q.reservationId = :reservationId
and q.committedBytes >= :amount
""")
int reduceCommitted(
@Param("reservationId") UUID reservationId,
@Param("amount") long amount,
@Param("now") Instant now);
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Durable list of files whose physical and logical state could not be reconciled automatically. */
public interface FileserverRecoveryRepository extends JpaRepository<RecoveryItemEntity, UUID> {
@Query(
"""
select r from RecoveryItemEntity r
where r.status = 'PENDING'
order by r.createdAt asc
""")
List<RecoveryItemEntity> findPending(Limit limit);
/**
* Re-raises an open item instead of adding a second one.
*
* <p>Reconciliation is retried on a schedule, so the same file reaches the queue repeatedly. One
* open item per file keeps the queue a worklist rather than a failure log; the newest reason wins
* because it describes the most recent evidence.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update RecoveryItemEntity r
set r.reasonCode = :reasonCode,
r.attempt = r.attempt + 1,
r.updatedAt = :now
where r.fileId = :fileId
and r.status = 'PENDING'
""")
int refreshPending(
@Param("fileId") UUID fileId,
@Param("reasonCode") String reasonCode,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update RecoveryItemEntity r
set r.status = :status,
r.updatedAt = :now
where r.fileId = :fileId
and r.status = 'PENDING'
""")
int resolvePending(
@Param("fileId") UUID fileId, @Param("status") String status, @Param("now") Instant now);
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Spring Data access to {@code fs_file} rows. */
public interface JpaFileRepository extends JpaRepository<FileEntity, java.util.UUID> {
Optional<FileEntity> findByContentKey(String contentKey);
@Query(
"""
select f from FileEntity f
where f.state in :states
and f.updatedAt < :notUpdatedSince
order by f.updatedAt asc
""")
List<FileEntity> findRecoverable(
@Param("states") Collection<String> states,
@Param("notUpdatedSince") Instant notUpdatedSince,
Limit limit);
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Spring Data access to {@code fs_upload_session} rows. */
public interface JpaUploadSessionRepository extends JpaRepository<UploadSessionEntity, UUID> {
@Query(
"""
select s from UploadSessionEntity s
where s.expiresAt < :cutoff
order by s.expiresAt asc
""")
List<UploadSessionEntity> findExpired(@Param("cutoff") Instant cutoff, Limit limit);
/**
* Sessions for one file, newest first.
*
* <p>A file can be re-staged after a failed attempt, so more than one session may exist; only the
* most recent one can still own bytes on disk.
*/
@Query(
"""
select s from UploadSessionEntity s
where s.fileId = :fileId
order by s.createdAt desc
""")
List<UploadSessionEntity> findByFile(@Param("fileId") UUID fileId, Limit limit);
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver.repository;
import dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity;
import java.time.Instant;
import java.util.UUID;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
/**
* Conditional writer-lease and offset statements.
*
* <p>A lease is granted only when none is held or the held one has expired, and an offset commit
* additionally requires the exact lease token and the expected offset. Correctness never depends on
* a filesystem or NFS lock.
*/
public interface UploadLeaseRepository extends Repository<UploadSessionEntity, UUID> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.leaseOwner = :owner,
s.leaseToken = :token,
s.leaseUntil = :leaseUntil,
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.version = :expectedVersion
and s.expiresAt > :now
and (s.leaseUntil is null or s.leaseUntil <= :now)
""")
int acquireLease(
@Param("uploadId") UUID uploadId,
@Param("owner") String owner,
@Param("token") UUID token,
@Param("leaseUntil") Instant leaseUntil,
@Param("expectedVersion") long expectedVersion,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.leaseUntil = :leaseUntil,
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.leaseToken = :token
and s.leaseUntil > :now
""")
int renewLease(
@Param("uploadId") UUID uploadId,
@Param("token") UUID token,
@Param("leaseUntil") Instant leaseUntil,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.committedOffset = :committedOffset,
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.leaseToken = :token
and s.leaseUntil > :now
and s.committedOffset = :expectedOffset
""")
int commitOffset(
@Param("uploadId") UUID uploadId,
@Param("token") UUID token,
@Param("expectedOffset") long expectedOffset,
@Param("committedOffset") long committedOffset,
@Param("now") Instant now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.leaseOwner = null,
s.leaseToken = null,
s.leaseUntil = null,
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.leaseToken = :token
""")
int releaseLease(
@Param("uploadId") UUID uploadId, @Param("token") UUID token, @Param("now") Instant now);
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.outbound.persistence.h2;
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
/**
* H2 atomic scope claim.
*
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same
* meaning in one statement:
*
* <ul>
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
* <li>a live row → neither branch fires (0 rows), so the caller lost to a live winner;
* <li>an expired row → {@code WHEN MATCHED AND expires_at <= now} takes it over (1 row).
* </ul>
*
* <p>One statement rather than select-then-insert is what keeps the SPI's promise not to poison the
* caller transaction: a losing claim returns zero updated rows, never a constraint violation the
* surrounding transaction would have to absorb.
*
* <p>No {@code RETURNING} is needed. The PostgreSQL statement returns {@code EXCLUDED.id}, which is
* the proposed id on both branches, so a claimed row is always this caller's proposed id.
*/
public final class H2IdempotencyClaimRepository implements IdempotencyClaimRepository {
private static final String CLAIM_SQL =
"""
MERGE INTO idempotency_record t
USING (VALUES (
CAST(:id AS uuid), CAST(:tenant AS varchar(128)), CAST(:principal AS varchar(256)),
CAST(:idempotencyKey AS varchar(256)), CAST(:useCaseName AS varchar(256)),
CAST(:requestHash AS varchar(64)),
CAST(:createdAt AS timestamp(6) with time zone),
CAST(:expiresAt AS timestamp(6) with time zone)
)) AS s (id, tenant, principal, idempotency_key, use_case_name,
request_hash, created_at, expires_at)
ON t.tenant = s.tenant
AND t.principal = s.principal
AND t.idempotency_key = s.idempotency_key
AND t.use_case_name = s.use_case_name
WHEN MATCHED AND t.expires_at <= :now THEN UPDATE SET
id = s.id,
request_hash = s.request_hash,
status = 'IN_FLIGHT',
response_payload = NULL,
response_ref = NULL,
created_at = s.created_at,
expires_at = s.expires_at
WHEN NOT MATCHED THEN INSERT (
id, tenant, principal, idempotency_key, use_case_name,
request_hash, status, response_payload, response_ref, created_at, expires_at
) VALUES (
s.id, s.tenant, s.principal, s.idempotency_key, s.use_case_name,
s.request_hash, 'IN_FLIGHT', NULL, NULL, s.created_at, s.expires_at
)
""";
private final EntityManager entityManager;
public H2IdempotencyClaimRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Optional<UUID> tryClaim(
IdempotencyRecordEntity proposed,
Instant now,
@Nullable IdempotencyRecordEntity exactExpiredEntity) {
// Same detach as the PostgreSQL path: a managed copy of the row this statement is about to
// overwrite would be flushed back over the claim at commit.
if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) {
entityManager.detach(exactExpiredEntity);
}
int claimed =
entityManager
.createNativeQuery(CLAIM_SQL)
.setParameter("id", proposed.getId())
.setParameter("tenant", proposed.getTenant())
.setParameter("principal", proposed.getPrincipal())
.setParameter("idempotencyKey", proposed.getIdempotencyKey())
.setParameter("useCaseName", proposed.getUseCaseName())
.setParameter("requestHash", proposed.getRequestHash())
.setParameter("createdAt", proposed.getCreatedAt())
.setParameter("expiresAt", proposed.getExpiresAt())
.setParameter("now", now)
.executeUpdate();
return claimed == 1 ? Optional.of(proposed.getId()) : Optional.empty();
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.persistence.h2;
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 H2's timeout guards to the connection bound to the current transaction.
*
* <p>Two differences from the PostgreSQL configurer, both inherent to H2 rather than choices:
*
* <ul>
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
* the transaction on a pooled connection. It is not left stale in practice because the
* transaction port applies these before every transaction, so each one overwrites the last;
* a connection borrowed outside that path keeps the previous transaction's guard.
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the
* database here. It is left to the caller-side deadline the transaction port already
* enforces, rather than silently reported as applied.
* </ul>
*
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
* arrive as {@link Duration}s from validated settings, never from request input, and a negative one
* is rejected below rather than concatenated.
*/
public final class H2LocalTimeoutConfigurer implements TransactionLocalTimeoutConfigurer {
private static final String STATEMENT_TIMEOUT_SQL = "SET QUERY_TIMEOUT ";
private static final String LOCK_TIMEOUT_SQL = "SET LOCK_TIMEOUT ";
private final JdbcOperations jdbcOperations;
public H2LocalTimeoutConfigurer(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, "statementTimeout", timeouts.statementTimeout());
apply(LOCK_TIMEOUT_SQL, "lockTimeout", timeouts.lockTimeout());
}
private void apply(String command, String name, Duration timeout) {
long milliseconds = timeout.toMillis();
if (milliseconds < 0) {
throw new IllegalArgumentException(name + " must not be negative, but was " + timeout);
}
jdbcOperations.execute(command + milliseconds);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.persistence.h2;
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import java.util.List;
/**
* H2 {@link OutboxClaimRepository}.
*
* <p>The statement is character-for-character the PostgreSQL one, because H2 2.4 accepts {@code FOR
* UPDATE SKIP LOCKED} and honours it: a probe holding a row lock on one connection saw a concurrent
* {@code SKIP LOCKED} claim return zero rows rather than block or read through the lock. The claim
* therefore keeps its meaning here — competing relay workers take disjoint rows — instead of
* degrading to a serialised scan.
*
* <p>Kept as its own class rather than shared with the PostgreSQL implementation: the SPI exists so
* a vendor can diverge, and the packages are the boundary ArchUnit enforces. A shared "portable
* SQL" base would make the next H2-only fix a change to PostgreSQL's claim path.
*/
public final class H2OutboxClaimRepository implements OutboxClaimRepository {
private static final String CLAIM_SQL =
"""
SELECT * FROM outbox_event o
WHERE o.next_attempt_at <= :now
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
AND NOT EXISTS (
SELECT 1 FROM outbox_event p
WHERE p.aggregate_id = o.aggregate_id
AND p.occurred_at < o.occurred_at
AND p.status <> 'PUBLISHED'
)
ORDER BY o.occurred_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""";
private final EntityManager entityManager;
public H2OutboxClaimRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
@SuppressWarnings("unchecked")
public List<OutboxEventEntity> claimEligible(Instant now, int limit) {
return entityManager
.createNativeQuery(CLAIM_SQL, OutboxEventEntity.class)
.setParameter("now", now)
.setParameter("limit", limit)
.getResultList();
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.persistence.h2;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings;
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
import jakarta.persistence.EntityManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the
* {@code local} profile sets.
*
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two
* consequences worth stating out loud:
*
* <ul>
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
* and inbox streams, the Spring Integration lock table — are not created under H2. The
* capabilities that own them are off by default in the local profile, and turning one on
* there will fail on a missing table rather than silently misbehave.
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own
* {@code FlywayConfigurationCustomizer} naming an H2 location.
* </ul>
*
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency
* fidelity stay with the real-PostgreSQL integration suites.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = PersistenceVendorSettings.PREFIX,
name = "vendor",
havingValue = "h2")
@Import(PersistenceJpaConfig.class)
public class H2PersistenceConfig {
@Bean
public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) {
return new H2OutboxClaimRepository(entityManager);
}
@Bean
public SqlStateErrorMapping h2SqlStateErrorMapping() {
return new H2SqlStateErrorMapping();
}
@Bean
public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer(
JdbcOperations jdbcOperations) {
return new H2LocalTimeoutConfigurer(jdbcOperations);
}
@Bean
public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) {
return new H2IdempotencyClaimRepository(entityManager);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.h2;
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Map;
/**
* H2-specific {@link SqlStateErrorMapping} rows.
*
* <p>H2 emits the standard SQLStates for unique ({@code 23505}) and not-null ({@code 23502})
* violations, which the vendor-neutral matrix already covers. Two states it does not share are
* below; both were read off a running H2 2.4.240 rather than inferred from the standard.
*
* <table>
* <caption>H2 vendor rows</caption>
* <tr><th>SQLState</th><th>code</th><th>why</th></tr>
* <tr>
* <td>{@code 23513}</td><td>{@code DB_CHECK_VIOLATION}</td>
* <td>H2 reports a failed CHECK constraint as 23513, not the 23514 the neutral matrix maps.
* Without this row a check violation falls through as an unmapped INTERNAL.</td>
* </tr>
* <tr>
* <td>{@code HYT00}</td><td>{@code DB_QUERY_CANCELED}</td>
* <td>H2 collapses every timeout-guard expiry into one state. PostgreSQL splits the same
* ground across 57014 (statement) and 55P03 (lock) and this repository maps only 57014,
* so DB_QUERY_CANCELED is the existing code for "a guard stopped the statement".</td>
* </tr>
* </table>
*/
public final class H2SqlStateErrorMapping implements SqlStateErrorMapping {
private static final Map<String, OperationalError> MAPPINGS =
Map.of(
"23513", OperationalError.DB_CHECK_VIOLATION,
"HYT00", OperationalError.DB_QUERY_CANCELED);
@Override
public Map<String, OperationalError> exactMappings() {
return MAPPINGS;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
/** Vendor-neutral SPI for a transaction-safe insert-or-expired-reclaim claim. */
public interface IdempotencyClaimRepository {
/**
* Returns the new/reclaimed record ID when this caller won, or empty for a live winner.
* Implementations must not poison the caller transaction on an ordinary uniqueness race.
*/
Optional<UUID> tryClaim(
IdempotencyRecordEntity proposed,
Instant now,
@Nullable IdempotencyRecordEntity exactExpiredEntity);
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency;
import java.time.Clock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Periodically deletes expired idempotency records. A backstop that bounds table growth — expiry is
* also enforced lazily on read and on reclaim (see README "idempotency").
*/
@Component
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "jdbc",
matchIfMissing = true)
public class IdempotencyReaper {
private static final Logger log = LoggerFactory.getLogger(IdempotencyReaper.class);
private final IdempotencyRecordJpaRepository repository;
private final Clock clock;
public IdempotencyReaper(IdempotencyRecordJpaRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
@Scheduled(fixedDelayString = "${ca-skeleton.idempotency.reaper-interval:PT10M}")
@Transactional
public int reap() {
int purged = repository.deleteExpired(clock.instant());
if (purged > 0) {
log.debug("idempotency reaper purged {} expired record(s)", purged);
}
return purged;
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Spring Data repository for {@link IdempotencyRecordEntity}: lookup by scope, delete by scope, and
* the reaper's expiry purge.
*/
public interface IdempotencyRecordJpaRepository
extends JpaRepository<IdempotencyRecordEntity, UUID> {
Optional<IdempotencyRecordEntity> findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(
String tenant, String principal, String idempotencyKey, String useCaseName);
@Modifying
@Query(
"delete from IdempotencyRecordEntity e where e.tenant = :tenant and e.principal = :principal "
+ "and e.idempotencyKey = :idempotencyKey and e.useCaseName = :useCaseName")
int deleteByScope(
@Param("tenant") String tenant,
@Param("principal") String principal,
@Param("idempotencyKey") String idempotencyKey,
@Param("useCaseName") String useCaseName);
/** Reaper / TTL boundary (§E): purge every record whose expiry is at/before {@code now}. */
@Modifying
@Query("delete from IdempotencyRecordEntity e where e.expiresAt <= :now")
int deleteExpired(@Param("now") Instant now);
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency;
/**
* Optional seam for offloading a large idempotent response body to an object store. With no bean
* wired, {@link IdempotencyStoreAdapter} falls back to inline DB storage (D9 is UNSUPPORTED — see
* README "idempotency"). Wire a real implementation (S3 / GCS / MinIO) to activate the offload.
*/
public interface IdempotencyResponseObjectStore {
/** Persist {@code payload}, returning the storage reference kept in the DB row. */
String put(String payload);
/** Resolve a previously stored reference back to the full payload for replay. */
String get(String reference);
}
@@ -0,0 +1,195 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper;
import dev.caskeleton.application.idempotency.IdempotencyRecord;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import dev.caskeleton.application.idempotency.IdempotencyStorePort;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.StoredResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository;
/**
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
* unique constraint as the concurrency arbiter; {@link #complete} applies the §F inline/ref payload
* split. See README "idempotency" for the concurrency and §F rationale.
*/
@Repository
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "jdbc",
matchIfMissing = true)
public class IdempotencyStoreAdapter implements IdempotencyStorePort {
/** §F threshold: payloads up to this size are stored inline in the DB row. */
static final int INLINE_MAX_BYTES = 8 * 1024;
private static final Logger log = LoggerFactory.getLogger(IdempotencyStoreAdapter.class);
private final IdempotencyRecordJpaRepository repository;
private final @Nullable IdempotencyResponseObjectStore objectStore;
private final @Nullable IdempotencyClaimRepository claimRepository;
private final Clock clock;
public IdempotencyStoreAdapter(
IdempotencyRecordJpaRepository repository,
@Nullable IdempotencyResponseObjectStore objectStore,
Clock clock) {
this(repository, objectStore, null, clock);
}
@Autowired
public IdempotencyStoreAdapter(
IdempotencyRecordJpaRepository repository,
@Nullable IdempotencyResponseObjectStore objectStore,
@Nullable IdempotencyClaimRepository claimRepository,
Clock clock) {
this.repository = repository;
this.objectStore = objectStore;
this.claimRepository = claimRepository;
this.clock = clock;
}
@Override
public boolean tryBegin(
IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) {
// Runs in the caller's transaction (use case owns the boundary via TransactionPort).
Optional<IdempotencyRecordEntity> existing = lookup(scope);
if (existing.isPresent()) {
IdempotencyRecordEntity row = existing.get();
if (clock.instant().isBefore(row.getExpiresAt())) {
return false; // a live record already owns the scope
}
}
IdempotencyRecordEntity claim =
new IdempotencyRecordEntity(
UUID.randomUUID(),
IdempotencyRecordEntityMapper.tenantColumn(scope),
scope.principal(),
scope.idempotencyKey(),
scope.useCaseName(),
fingerprint.hex(),
"IN_FLIGHT",
null,
null,
clock.instant(),
expiresAt);
if (claimRepository != null) {
Optional<UUID> claimed =
claimRepository.tryClaim(claim, clock.instant(), existing.orElse(null));
if (claimed.isEmpty()) {
return false;
}
IdempotencyRecordEntity reloaded =
repository
.findById(claimed.orElseThrow())
.orElseThrow(() -> new IllegalStateException("claimed idempotency row is absent"));
if (!reloaded.getRequestHash().equals(fingerprint.hex())) {
throw new IllegalStateException("claimed idempotency row conflicts");
}
return true;
}
existing.ifPresent(repository::delete);
try {
repository.saveAndFlush(claim); // flush forces the unique-constraint check now
return true;
} catch (DataIntegrityViolationException raceLost) {
// Another caller inserted between the lookup and the flush — they own it.
return false;
}
}
@Override
public Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now) {
Optional<IdempotencyRecordEntity> row = lookup(scope);
if (row.isEmpty()) {
return Optional.empty();
}
IdempotencyRecordEntity entity = row.get();
if (!now.isBefore(entity.getExpiresAt())) {
return Optional.empty(); // expired → treated as absent (§E); reaper / tryBegin purge it
}
return Optional.of(IdempotencyRecordEntityMapper.toRecord(entity, resolvePayload(entity)));
}
@Override
public void complete(IdempotencyScope scope, StoredResponse response) {
IdempotencyRecordEntity row =
lookup(scope)
.orElseThrow(
() ->
new IllegalStateException(
"no in-flight idempotency record to complete for " + scope.storageKey()));
String payload = response.payload();
String inline = null;
String ref = null;
if (exceedsInlineThreshold(payload) && objectStore != null) {
ref = objectStore.put(payload);
} else {
if (exceedsInlineThreshold(payload)) {
log.warn(
"idempotency response exceeds {}B but no object store is configured; "
+ "storing inline (feature-rate-limit-idempotency-contract §F seam)",
INLINE_MAX_BYTES);
}
inline = payload;
}
repository.save(
new IdempotencyRecordEntity(
row.getId(),
row.getTenant(),
row.getPrincipal(),
row.getIdempotencyKey(),
row.getUseCaseName(),
row.getRequestHash(),
"COMPLETED",
inline,
ref,
row.getCreatedAt(),
row.getExpiresAt()));
}
@Override
public void discard(IdempotencyScope scope) {
repository.deleteByScope(
IdempotencyRecordEntityMapper.tenantColumn(scope),
scope.principal(),
scope.idempotencyKey(),
scope.useCaseName());
}
private Optional<IdempotencyRecordEntity> lookup(IdempotencyScope scope) {
return repository.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(
IdempotencyRecordEntityMapper.tenantColumn(scope),
scope.principal(),
scope.idempotencyKey(),
scope.useCaseName());
}
private String resolvePayload(IdempotencyRecordEntity entity) {
if (entity.getResponsePayload() != null) {
return entity.getResponsePayload();
}
if (entity.getResponseRef() != null && objectStore != null) {
return objectStore.get(entity.getResponseRef());
}
return null; // IN_FLIGHT row, or a ref with no object store to resolve it
}
private static boolean exceedsInlineThreshold(String payload) {
return payload.getBytes(StandardCharsets.UTF_8).length > INLINE_MAX_BYTES;
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for the {@code idempotency_record} table; schema owned by Flyway ({@code
* V1__idempotency_record.sql}). {@code tenant} is never {@code null} (empty string for
* single-tenant) and setters are intentionally absent (row rebuilt on state transitions). See
* README "idempotency" for the invariants behind both.
*/
@Entity
@Table(
name = "idempotency_record",
uniqueConstraints =
@UniqueConstraint(
name = "uq_idempotency_scope",
columnNames = {"tenant", "principal", "idempotency_key", "use_case_name"}))
public class IdempotencyRecordEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "tenant", nullable = false, length = 128)
private String tenant;
@Column(name = "principal", nullable = false, length = 256)
private String principal;
@Column(name = "idempotency_key", nullable = false, length = 256)
private String idempotencyKey;
@Column(name = "use_case_name", nullable = false, length = 256)
private String useCaseName;
@Column(name = "request_hash", nullable = false, length = 64)
private String requestHash;
@Column(name = "status", nullable = false, length = 16)
private String status;
@Column(name = "response_payload")
private String responsePayload;
@Column(name = "response_ref", length = 512)
private String responseRef;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
protected IdempotencyRecordEntity() {}
public IdempotencyRecordEntity(
UUID id,
String tenant,
String principal,
String idempotencyKey,
String useCaseName,
String requestHash,
String status,
String responsePayload,
String responseRef,
Instant createdAt,
Instant expiresAt) {
this.id = id;
this.tenant = tenant;
this.principal = principal;
this.idempotencyKey = idempotencyKey;
this.useCaseName = useCaseName;
this.requestHash = requestHash;
this.status = status;
this.responsePayload = responsePayload;
this.responseRef = responseRef;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
}
public UUID getId() {
return id;
}
public String getTenant() {
return tenant;
}
public String getPrincipal() {
return principal;
}
public String getIdempotencyKey() {
return idempotencyKey;
}
public String getUseCaseName() {
return useCaseName;
}
public String getRequestHash() {
return requestHash;
}
public String getStatus() {
return status;
}
public String getResponsePayload() {
return responsePayload;
}
public String getResponseRef() {
return responseRef;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getExpiresAt() {
return expiresAt;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.outbound.persistence.idempotency.mapper;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import dev.caskeleton.application.idempotency.IdempotencyRecord;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import dev.caskeleton.application.idempotency.IdempotencyStatus;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.StoredResponse;
/**
* Pure translation between the {@link IdempotencyRecordEntity} row and the application {@link
* IdempotencyRecord} — no business policy. The {@code tenant} dimension round-trips through an
* empty string ({@code null} ↔ {@code ""}); see README "idempotency" for why.
*/
public final class IdempotencyRecordEntityMapper {
private IdempotencyRecordEntityMapper() {}
/** Empty string on the row represents "no tenant" (single-tenant triple scope). */
public static String tenantColumn(IdempotencyScope scope) {
return scope.tenant() == null ? "" : scope.tenant();
}
public static IdempotencyScope toScope(IdempotencyRecordEntity e) {
String tenant = (e.getTenant() == null || e.getTenant().isEmpty()) ? null : e.getTenant();
return IdempotencyScope.of(tenant, e.getPrincipal(), e.getIdempotencyKey(), e.getUseCaseName());
}
/**
* Build the application record from a row. {@code resolvedPayload} is the fully materialized
* response (already read back from the object store when the row only held a reference).
*/
public static IdempotencyRecord toRecord(IdempotencyRecordEntity e, String resolvedPayload) {
IdempotencyStatus status = IdempotencyStatus.valueOf(e.getStatus());
StoredResponse response =
(status == IdempotencyStatus.COMPLETED && resolvedPayload != null)
? new StoredResponse(resolvedPayload)
: null;
return new IdempotencyRecord(
toScope(e),
new RequestFingerprint(e.getRequestHash()),
status,
response,
e.getCreatedAt(),
e.getExpiresAt());
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.persistence.lock;
import dev.caskeleton.application.lock.DistributedLockPort;
import javax.sql.DataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.integration.jdbc.lock.DefaultLockRepository;
import org.springframework.integration.jdbc.lock.JdbcLockRegistry;
import org.springframework.integration.support.locks.DefaultLockRegistry;
/**
* Spring wiring for the distributed-lock infrastructure: in-process {@link DefaultLockRegistry} by
* default, JDBC {@link JdbcLockRegistry} when {@code multi-instance-enabled=true}. The JDBC bean is
* deliberately not {@code @Primary} so app-bootstrap can wrap it in a metrics decorator. See README
* "lock" for the provider-selection and wiring rationale.
*/
@Configuration(proxyBeanMethods = false)
public class DistributedLockPersistenceConfig {
/** In-process adapter; active when {@code multi-instance-enabled} is {@code false} or absent. */
@Bean
@Primary
@ConditionalOnProperty(
prefix = "ca-skeleton.runtime",
name = "multi-instance-enabled",
havingValue = "false",
matchIfMissing = true)
DistributedLockPort inProcessDistributedLock(LockSettings settings) {
return new LockRegistryDistributedLockAdapter(new DefaultLockRegistry(), settings.leaseTtl());
}
/**
* {@link DefaultLockRepository} backing the {@link JdbcLockRegistry}; owns the {@code INT_LOCK}
* table access while the registry constructor owns the default TTL.
*/
@Bean
@ConditionalOnProperty(
prefix = "ca-skeleton.runtime",
name = "multi-instance-enabled",
havingValue = "true")
DefaultLockRepository lockRepository(DataSource dataSource) {
DefaultLockRepository repo = new DefaultLockRepository(dataSource);
// INT_LOCK is provisioned by Flyway V4 before first use — skip the DDL check.
repo.setCheckDatabaseOnStart(false);
return repo;
}
@Bean
@ConditionalOnProperty(
prefix = "ca-skeleton.runtime",
name = "multi-instance-enabled",
havingValue = "true")
JdbcLockRegistry jdbcLockRegistry(DefaultLockRepository lockRepository, LockSettings settings) {
return new JdbcLockRegistry(lockRepository, settings.leaseTtl());
}
/**
* JDBC-backed adapter; named {@code jdbcDistributedLock} for app-bootstrap to wrap (not
* {@code @Primary}).
*/
@Bean("jdbcDistributedLock")
@ConditionalOnProperty(
prefix = "ca-skeleton.runtime",
name = "multi-instance-enabled",
havingValue = "true")
DistributedLockPort jdbcDistributedLock(
JdbcLockRegistry jdbcLockRegistry, LockSettings settings) {
return new LockRegistryDistributedLockAdapter(jdbcLockRegistry, settings.leaseTtl());
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.lock;
import dev.caskeleton.application.lock.DistributedLockPort;
import dev.caskeleton.application.lock.LockAcquisitionTimeoutException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import org.springframework.integration.support.locks.DistributedLock;
import org.springframework.integration.support.locks.LockRegistry;
/**
* Implements {@link DistributedLockPort} by delegating to any Spring Integration {@link
* LockRegistry} (provider selection lives in {@link DistributedLockPersistenceConfig}). Does not
* manage the transaction boundary, uses a finite-wait try-lock, and leaves lease-expiry handling to
* the metered decorator. See README "lock" for the D4/D5/TTL/SI-LOCK-C5 contracts.
*/
public class LockRegistryDistributedLockAdapter implements DistributedLockPort {
private final LockRegistry<? extends Lock> registry;
private final Duration configuredTtl;
public LockRegistryDistributedLockAdapter(
LockRegistry<? extends Lock> registry, Duration configuredTtl) {
this.registry = registry;
this.configuredTtl = configuredTtl;
}
@Override
public dev.caskeleton.application.lock.DistributedLock tryAcquire(
String key, Duration waitTime, Duration leaseTtl) {
if (leaseTtl.compareTo(configuredTtl) > 0) {
throw new IllegalArgumentException(
"leaseTtl ("
+ leaseTtl
+ ") exceeds the registry's configuredTtl ("
+ configuredTtl
+ "). The provider is configured with a shorter default TTL; "
+ "promising a longer lease than configured would be a false promise.");
}
Lock l = registry.obtain(key);
boolean acquired;
try {
acquired =
l instanceof DistributedLock distributedLock
? distributedLock.tryLock(waitTime, leaseTtl)
: l.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionTimeoutException(key, waitTime);
}
if (!acquired) {
throw new LockAcquisitionTimeoutException(key, waitTime);
}
return l::unlock;
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.persistence.lock;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Distributed-lock tuning knobs bound from {@code ca-skeleton.lock.*} (yaml-only defaults, not
* {@code APP_*} env keys). {@code waitTime} = max wait before timeout (default 3s); {@code
* leaseTtl} = max lock hold before auto-expiry (default 30s, must be ≥ {@code waitTime}). See
* README "lock".
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.lock")
public record LockSettings(Duration waitTime, Duration leaseTtl) {
private static final Duration DEFAULT_WAIT_TIME = Duration.ofSeconds(3);
private static final Duration DEFAULT_LEASE_TTL = Duration.ofSeconds(30);
public LockSettings {
if (waitTime == null) {
waitTime = DEFAULT_WAIT_TIME;
} else if (waitTime.isNegative() || waitTime.isZero()) {
throw new IllegalArgumentException(
"ca-skeleton.lock.wait-time must be positive (> 0), was " + waitTime);
}
if (leaseTtl == null) {
leaseTtl = DEFAULT_LEASE_TTL;
} else if (leaseTtl.isNegative() || leaseTtl.isZero()) {
throw new IllegalArgumentException(
"ca-skeleton.lock.lease-ttl must be positive (> 0), was " + leaseTtl);
}
// Cross-field invariant: a lease shorter than the max wait is nonsensical.
// If the TTL fires before the waitTime elapses, a second holder could acquire
// the lock before the first holder's protected work is complete.
if (leaseTtl.compareTo(waitTime) < 0) {
throw new IllegalArgumentException(
"ca-skeleton.lock.lease-ttl ("
+ leaseTtl
+ ") must be >= ca-skeleton.lock.wait-time ("
+ waitTime
+ ") — a lease shorter than the max wait is nonsensical");
}
}
}
@@ -0,0 +1,115 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/** Direct AES-256-GCM field encryption with a fresh 96-bit nonce and 128-bit tag. */
public final class DirectAeadNotificationPayloadCrypto {
private static final String ALGORITHM = "AES-256-GCM";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final String PROFILE = "notification-direct-aead-v1";
private static final String AAD_REVISION = "notification-aad-v1";
private static final int TAG_BITS = 128;
private final NotificationKeyMaterialProvider keys;
private final SecureRandom random;
public DirectAeadNotificationPayloadCrypto(
NotificationKeyMaterialProvider keys, SecureRandom random) {
this.keys = Objects.requireNonNull(keys, "notification key provider must be non-null");
this.random = Objects.requireNonNull(random, "notification secure random must be non-null");
}
public NotificationCiphertext encrypt(
byte[] plaintext,
NotificationCiphertext.AadContext context,
String keyReference,
String keyVersion) {
Objects.requireNonNull(plaintext, "notification plaintext must be non-null");
Objects.requireNonNull(context, "notification AAD context must be non-null");
if (plaintext.length < 1 || plaintext.length > 10_000_000) {
throw new IllegalArgumentException("notification plaintext must contain 1..10000000 bytes");
}
requireProfile(context);
byte[] nonce = new byte[12];
random.nextBytes(nonce);
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
byte[] encrypted =
handle.readBytes(
material -> transform(Cipher.ENCRYPT_MODE, material, nonce, context, plaintext));
return new NotificationCiphertext(
ALGORITHM,
handle.keyReference(),
handle.keyVersion(),
PROFILE,
AAD_REVISION,
nonce,
encrypted);
}
}
public byte[] decrypt(
NotificationCiphertext encrypted, NotificationCiphertext.AadContext context) {
Objects.requireNonNull(encrypted, "notification ciphertext must be non-null");
Objects.requireNonNull(context, "notification AAD context must be non-null");
requireProfile(context);
if (!ALGORITHM.equals(encrypted.algorithm())
|| !PROFILE.equals(encrypted.cryptoProfileVersion())
|| !AAD_REVISION.equals(encrypted.aadRevision())) {
throw new NotificationCryptoException(
"notification ciphertext cryptographic profile is unsupported");
}
try (NotificationKeyMaterialHandle handle =
keys.acquire(encrypted.keyReference(), encrypted.keyVersion())) {
return handle.readBytes(
material ->
transform(
Cipher.DECRYPT_MODE,
material,
encrypted.nonce(),
context,
encrypted.ciphertext()));
} catch (NotificationCryptoException failure) {
throw failure;
} catch (RuntimeException failure) {
throw new NotificationCryptoException(
"notification ciphertext authentication failed", failure);
}
}
private static byte[] transform(
int mode,
byte[] material,
byte[] nonce,
NotificationCiphertext.AadContext context,
byte[] input) {
if (material.length != 32) {
throw new NotificationCryptoException(
"notification AES-256 key revision has an invalid length");
}
byte[] keyCopy = material.clone();
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(mode, new SecretKeySpec(keyCopy, "AES"), new GCMParameterSpec(TAG_BITS, nonce));
cipher.updateAAD(context.canonicalBytes());
return cipher.doFinal(input);
} catch (GeneralSecurityException failure) {
throw new NotificationCryptoException(
"notification ciphertext authentication failed", failure);
} finally {
Arrays.fill(keyCopy, (byte) 0);
}
}
private static void requireProfile(NotificationCiphertext.AadContext context) {
if (!PROFILE.equals(context.cryptoProfileVersion())) {
throw new NotificationCryptoException("notification AAD crypto profile is unsupported");
}
}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
/** Non-secret AES-GCM metadata and ciphertext; mutable arrays are defensively copied. */
@SuppressWarnings(
"ArrayRecordComponent") // constructor/accessor copies preserve the public record API
public record NotificationCiphertext(
String algorithm,
String keyReference,
String keyVersion,
String cryptoProfileVersion,
String aadRevision,
byte[] nonce,
byte[] ciphertext) {
public NotificationCiphertext {
if (!"AES-256-GCM".equals(algorithm)) {
throw new IllegalArgumentException("notification ciphertext algorithm must be AES-256-GCM");
}
keyReference =
NotificationKeyMaterialHandle.requireSlug(
"notification ciphertext key reference", keyReference);
keyVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification ciphertext key version", keyVersion);
cryptoProfileVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification crypto profile version", cryptoProfileVersion);
aadRevision =
NotificationKeyMaterialHandle.requireSlug("notification AAD revision", aadRevision);
Objects.requireNonNull(nonce, "notification ciphertext nonce must be non-null");
Objects.requireNonNull(ciphertext, "notification ciphertext bytes must be non-null");
if (nonce.length != 12 || ciphertext.length < 17 || ciphertext.length > 10_000_016) {
throw new IllegalArgumentException(
"notification ciphertext nonce/tag/payload bounds are invalid");
}
nonce = nonce.clone();
ciphertext = ciphertext.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof NotificationCiphertext that)) {
return false;
}
return algorithm.equals(that.algorithm)
&& keyReference.equals(that.keyReference)
&& keyVersion.equals(that.keyVersion)
&& cryptoProfileVersion.equals(that.cryptoProfileVersion)
&& aadRevision.equals(that.aadRevision)
&& Arrays.equals(nonce, that.nonce)
&& Arrays.equals(ciphertext, that.ciphertext);
}
@Override
public int hashCode() {
int result =
Objects.hash(algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision);
result = 31 * result + Arrays.hashCode(nonce);
return 31 * result + Arrays.hashCode(ciphertext);
}
@Override
public String toString() {
return "NotificationCiphertext[algorithm="
+ algorithm
+ ", keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", cryptoProfileVersion="
+ cryptoProfileVersion
+ ", aadRevision="
+ aadRevision
+ ", nonce=<redacted>, ciphertext=<redacted>]";
}
/** Exact approved length-prefixed AAD hierarchy for one encrypted notification field. */
public record AadContext(
String schemaTable,
String recordId,
String notificationId,
Optional<String> deliveryId,
Optional<String> attemptId,
String fieldPurpose,
String providerBindingRevision,
String cryptoProfileVersion) {
public AadContext {
if (schemaTable == null || !schemaTable.matches("[a-z][a-z0-9_]{0,62}")) {
throw new IllegalArgumentException(
"notification AAD table must match [a-z][a-z0-9_]{0,62}");
}
recordId = requireOpaque("notification AAD record ID", recordId);
notificationId = requireOpaque("notification AAD notification ID", notificationId);
deliveryId = requireOptional("notification AAD delivery ID", deliveryId);
attemptId = requireOptional("notification AAD attempt ID", attemptId);
fieldPurpose =
NotificationKeyMaterialHandle.requireSlug("notification AAD field purpose", fieldPurpose);
providerBindingRevision =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD provider binding revision", providerBindingRevision);
cryptoProfileVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD crypto profile", cryptoProfileVersion);
}
byte[] canonicalBytes() {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
update(output, schemaTable);
update(output, recordId);
update(output, notificationId);
updateOptional(output, deliveryId);
updateOptional(output, attemptId);
update(output, fieldPurpose);
update(output, providerBindingRevision);
update(output, cryptoProfileVersion);
return output.toByteArray();
}
private static Optional<String> requireOptional(String field, Optional<String> value) {
Objects.requireNonNull(value, field + " container must be non-null");
return value.map(item -> requireOpaque(field, item));
}
private static String requireOpaque(String field, String value) {
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
field + " must contain 1..128 opaque identifier characters");
}
return value;
}
private static void update(java.io.ByteArrayOutputStream output, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
output.writeBytes(bytes);
}
private static void updateOptional(
java.io.ByteArrayOutputStream output, Optional<String> value) {
output.write(value.isPresent() ? 1 : 0);
value.ifPresent(item -> update(output, item));
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
/** Redacted fail-closed notification cryptographic operation error. */
public final class NotificationCryptoException extends RuntimeException {
private static final long serialVersionUID = 1L;
public NotificationCryptoException(String safeMessage) {
super(safeMessage);
}
public NotificationCryptoException(String safeMessage, Throwable cause) {
super(safeMessage, cause);
}
}
@@ -0,0 +1,127 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Purpose-separated length-prefixed HMAC-SHA-256 over non-secret canonical tuple fields. */
public final class NotificationHmacDigester {
private static final String ALGORITHM = "HmacSHA256";
private static final int MAXIMUM_VERIFICATION_VERSIONS = 4;
private final NotificationKeyMaterialProvider keys;
public NotificationHmacDigester(NotificationKeyMaterialProvider keys) {
this.keys = Objects.requireNonNull(keys, "notification HMAC key provider must be non-null");
}
public Digest digest(
String purpose, List<String> fields, String keyReference, String keyVersion) {
byte[] canonical = canonical(purpose, fields);
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
String value =
handle.readBytes(material -> HexFormat.of().formatHex(hmac(material, canonical)));
return new Digest(handle.keyReference(), handle.keyVersion(), value);
} finally {
Arrays.fill(canonical, (byte) 0);
}
}
public boolean verify(Digest expected, String purpose, List<String> fields) {
Objects.requireNonNull(expected, "expected notification HMAC must be non-null");
List<String> versions =
List.copyOf(
Objects.requireNonNull(
keys.verificationVersions(expected.keyReference()),
"notification HMAC verification versions must be non-null"));
if (versions.isEmpty()
|| versions.size() > MAXIMUM_VERIFICATION_VERSIONS
|| new HashSet<>(versions).size() != versions.size()) {
throw new NotificationCryptoException(
"notification HMAC verification key set must be unique and bounded");
}
if (!versions.contains(expected.keyVersion())) {
return false;
}
Digest actual = digest(purpose, fields, expected.keyReference(), expected.keyVersion());
return MessageDigest.isEqual(
HexFormat.of().parseHex(expected.value()), HexFormat.of().parseHex(actual.value()));
}
private static byte[] canonical(String purpose, List<String> fields) {
if (purpose == null || !purpose.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"notification HMAC purpose must match [a-z][a-z0-9-]{0,62}");
}
Objects.requireNonNull(fields, "notification HMAC fields must be non-null");
if (fields.isEmpty() || fields.size() > 32) {
throw new IllegalArgumentException("notification HMAC fields must contain 1..32 entries");
}
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
update(output, purpose);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(fields.size()).array());
fields.forEach(
field -> {
if (field == null || field.length() > 4_096) {
throw new IllegalArgumentException(
"notification HMAC field must contain at most 4096 characters");
}
update(output, field);
});
return output.toByteArray();
}
private static byte[] hmac(byte[] material, byte[] canonical) {
if (material.length < 32) {
throw new NotificationCryptoException("notification HMAC key revision has an invalid length");
}
byte[] keyCopy = material.clone();
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(keyCopy, ALGORITHM));
return mac.doFinal(canonical);
} catch (GeneralSecurityException failure) {
throw new NotificationCryptoException("notification HMAC operation failed", failure);
} finally {
Arrays.fill(keyCopy, (byte) 0);
}
}
private static void update(java.io.ByteArrayOutputStream output, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
output.writeBytes(bytes);
}
public record Digest(String keyReference, String keyVersion, String value) {
public Digest {
keyReference =
NotificationKeyMaterialHandle.requireSlug(
"notification HMAC key reference", keyReference);
keyVersion =
NotificationKeyMaterialHandle.requireSlug("notification HMAC key version", keyVersion);
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("notification HMAC value must be lowercase SHA-256 hex");
}
}
@Override
public String toString() {
return "Digest[keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", value=<redacted>]";
}
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.Function;
/** Operation-scoped mutable key copy that wipes on close and rejects use after close. */
public final class NotificationKeyMaterialHandle implements AutoCloseable {
private final String keyReference;
private final String keyVersion;
private final byte[] material;
private boolean closed;
private NotificationKeyMaterialHandle(String keyReference, String keyVersion, byte[] material) {
this.keyReference = requireSlug("notification key reference", keyReference);
this.keyVersion = requireSlug("notification key version", keyVersion);
this.material = material;
}
public static NotificationKeyMaterialHandle fromBytes(
String keyReference, String keyVersion, byte[] material) {
Objects.requireNonNull(material, "notification key material must be non-null");
if (material.length < 32 || material.length > 65_536) {
throw new IllegalArgumentException("notification key material must contain 32..65536 bytes");
}
return new NotificationKeyMaterialHandle(keyReference, keyVersion, material.clone());
}
public String keyReference() {
return keyReference;
}
public String keyVersion() {
return keyVersion;
}
public synchronized <T> T readBytes(Function<byte[], T> reader) {
Objects.requireNonNull(reader, "notification key reader must be non-null");
if (closed) {
throw new IllegalStateException("notification key material handle is closed");
}
return reader.apply(material);
}
@Override
public synchronized void close() {
if (!closed) {
Arrays.fill(material, (byte) 0);
closed = true;
}
}
@Override
public synchronized String toString() {
return "NotificationKeyMaterialHandle[keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", material=<redacted>, closed="
+ closed
+ "]";
}
static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.util.List;
/**
* Acquires versioned mutable key copies and declares bounded current-plus-retiring verification.
*/
public interface NotificationKeyMaterialProvider {
NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion);
List<String> verificationVersions(String keyReference);
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.persistence.outbox;
import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity;
import java.time.Instant;
import java.util.List;
/**
* SPI for vendor-specific outbox row claim. Implementations select eligible rows with a
* vendor-appropriate locking strategy (PG uses {@code FOR UPDATE SKIP LOCKED}) and own the
* eligibility predicate + per-aggregate FIFO gate in SQL. See README "outbox" for the eligible-row
* rules and the SKIP LOCKED / FIFO rationale.
*/
public interface OutboxClaimRepository {
/**
* Claims up to {@code limit} eligible outbox rows as of {@code now} (rows with {@code
* next_attempt_at > now} are not yet eligible). Returned list may be empty.
*/
List<OutboxEventEntity> claimEligible(Instant now, int limit);
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.persistence.outbox;
import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Spring Data repository for {@link OutboxEventEntity} — only the vendor-neutral JPQL operations.
* Vendor-specific row claiming lives in the {@link OutboxClaimRepository} SPI (see README
* "outbox").
*/
public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> {
/**
* Deletes PUBLISHED rows whose {@code occurred_at} is before {@code cutoff}; returns the count.
*/
@Modifying
@Query("delete from OutboxEventEntity e where e.status = 'PUBLISHED' and e.occurredAt < :cutoff")
int deletePublishedBefore(@Param("cutoff") Instant cutoff);
/** {@code [status (String), count (Long)]} pairs for the outbox.pending.size gauge. */
@Query("select e.status, count(e) from OutboxEventEntity e group by e.status")
List<Object[]> countGroupedByStatus();
/**
* {@code [eventType (String), oldestOccurredAt (Instant)]} per unpublished type for the lag
* gauge.
*/
@Query(
"select e.eventType, min(e.occurredAt) from OutboxEventEntity e "
+ "where e.status <> 'PUBLISHED' group by e.eventType")
List<Object[]> findOldestUnpublishedOccurredAtByEventType();
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.persistence.outbox;
import java.time.Clock;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Periodically purges PUBLISHED outbox rows older than the configured retention period, bounding
* table growth. See README "outbox" for retention/scheduling rationale.
*/
@Component
public class OutboxReaper {
private static final Logger log = LoggerFactory.getLogger(OutboxReaper.class);
private final OutboxEventJpaRepository repository;
private final Clock clock;
private final Duration retention;
// @Value is acceptable for this single reaper-local value; canonical settings live in
// app-bootstrap OutboxSettings / application.yml.
public OutboxReaper(
OutboxEventJpaRepository repository,
Clock clock,
@Value("${ca-skeleton.outbox.published-retention:P7D}") Duration retention) {
this.repository = repository;
this.clock = clock;
this.retention = retention;
}
/** Deletes PUBLISHED rows older than {@code clock.instant() - retention}; returns the count. */
@Scheduled(fixedDelayString = "${ca-skeleton.outbox.reaper-interval:PT10M}")
@Transactional
public int reap() {
int purged = repository.deletePublishedBefore(clock.instant().minus(retention));
if (purged > 0) {
log.debug("outbox reaper purged {} published row(s)", purged);
}
return purged;
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.persistence.outbox;
import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity;
import dev.caskeleton.application.outbox.NewOutboxEvent;
import dev.caskeleton.application.outbox.OutboxAppendPort;
import dev.caskeleton.application.outbox.OutboxEvent;
import dev.caskeleton.application.outbox.OutboxEventStatus;
import dev.caskeleton.application.outbox.OutboxStorePort;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
/**
* JPA-backed {@link OutboxAppendPort} + {@link OutboxStorePort}. {@link #append} and the claim/mark
* operations run inside the caller's {@code TransactionPort.inWrite()} boundary and declare no
* {@code @Transactional} of their own; {@link #claimBatch} delegates the vendor claim to {@link
* OutboxClaimRepository}. See README "outbox" for the append/claim/no-@Transactional contracts.
*/
@Repository
public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort {
private final OutboxEventJpaRepository repository;
private final OutboxClaimRepository claimRepository;
public OutboxStoreAdapter(
OutboxEventJpaRepository repository, OutboxClaimRepository claimRepository) {
this.repository = repository;
this.claimRepository = claimRepository;
}
@Override
public void append(NewOutboxEvent event) {
OutboxEventEntity entity = new OutboxEventEntity();
entity.setEventId(event.eventId());
entity.setAggregateId(event.aggregateId());
entity.setEventType(event.eventType());
entity.setPayload(event.payload());
entity.setOccurredAt(event.occurredAt());
entity.setStatus(OutboxEventStatus.PENDING.name());
entity.setAttemptCount(0);
// PENDING: nextAttemptAt = occurredAt so the relay can claim immediately.
entity.setNextAttemptAt(event.occurredAt());
entity.setCorrelationId(event.correlationId());
entity.setIdempotencyKey(event.idempotencyKey());
repository.save(entity);
}
@Override
public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) {
List<OutboxEventEntity> eligible = claimRepository.claimEligible(now, batchSize);
return eligible.stream()
.map(
entity -> {
entity.setStatus(OutboxEventStatus.IN_FLIGHT.name());
entity.setAttemptCount(entity.getAttemptCount() + 1);
entity.setNextAttemptAt(now.plus(inFlightTimeout));
return toOutboxEvent(entity);
})
.toList();
}
/**
* Marks the event as published. Throws {@link IllegalStateException} if the row is missing — a
* missing just-claimed row is a bug, and a silent no-op would wedge the aggregate's FIFO queue
* (see README "outbox").
*/
@Override
public void markPublished(String eventId) {
repository
.findById(eventId)
.orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId))
.setStatus(OutboxEventStatus.PUBLISHED.name());
}
/**
* Marks the event as failed and schedules the next retry. Throws if the row is missing (see
* {@link #markPublished}).
*/
@Override
public void markFailed(String eventId, Instant nextAttemptAt) {
OutboxEventEntity entity =
repository
.findById(eventId)
.orElseThrow(
() -> new IllegalStateException("outbox row not found for eventId=" + eventId));
entity.setStatus(OutboxEventStatus.FAILED.name());
entity.setNextAttemptAt(nextAttemptAt);
}
/**
* Marks the event as dead-lettered after retries are exhausted. Throws if the row is missing (see
* {@link #markPublished}).
*/
@Override
public void markDead(String eventId) {
repository
.findById(eventId)
.orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId))
.setStatus(OutboxEventStatus.DEAD.name());
}
@Override
public Map<OutboxEventStatus, Long> countByStatus() {
List<Object[]> rows = repository.countGroupedByStatus();
Map<OutboxEventStatus, Long> result = new EnumMap<>(OutboxEventStatus.class);
for (Object[] row : rows) {
OutboxEventStatus status = OutboxEventStatus.valueOf((String) row[0]);
result.put(status, (Long) row[1]);
}
return result;
}
@Override
public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) {
List<Object[]> rows = repository.findOldestUnpublishedOccurredAtByEventType();
// HashMap (not EnumMap): the key is a String event-type name, not an enum.
Map<String, Long> result = new HashMap<>();
for (Object[] row : rows) {
String eventType = (String) row[0];
Instant oldest = (Instant) row[1];
result.put(eventType, Duration.between(oldest, now).toSeconds());
}
return result;
}
private static OutboxEvent toOutboxEvent(OutboxEventEntity e) {
return new OutboxEvent(
e.getEventId(),
e.getEventType(),
e.getAggregateId(),
e.getPayload(),
e.getOccurredAt(),
e.getCorrelationId(),
e.getIdempotencyKey(),
OutboxEventStatus.valueOf(e.getStatus()),
e.getAttemptCount());
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.persistence.outbox.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA row for the {@code outbox_event} table; schema owned by Flyway ({@code
* V3__outbox_event.sql}). Does not extend {@code AuditableEntity} (infra record, not a domain
* aggregate) and exposes mutating setters on purpose (relay transitions state in-place). See README
* "outbox" for both.
*/
@Entity
@Table(name = "outbox_event")
public class OutboxEventEntity {
@Id
@Column(name = "event_id", nullable = false, length = 64, updatable = false)
private String eventId;
@Column(name = "aggregate_id", nullable = false, length = 256, updatable = false)
private String aggregateId;
@Column(name = "event_type", nullable = false, length = 256, updatable = false)
private String eventType;
@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column(name = "payload", nullable = false, updatable = false)
private String payload;
@Column(name = "occurred_at", nullable = false, updatable = false)
private Instant occurredAt;
/** Lifecycle status string: PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD. */
@Column(name = "status", nullable = false, length = 16)
private String status;
@Column(name = "attempt_count", nullable = false)
private int attemptCount;
/** Dual-purpose per status (PENDING / IN_FLIGHT / FAILED) — see README "outbox". */
@Column(name = "next_attempt_at", nullable = false)
private Instant nextAttemptAt;
@Column(name = "correlation_id", nullable = false, length = 64, updatable = false)
private String correlationId;
@Column(name = "idempotency_key", nullable = false, length = 256, updatable = false)
private String idempotencyKey;
/** JPA no-arg constructor. */
public OutboxEventEntity() {}
public String getEventId() {
return eventId;
}
public void setEventId(String eventId) {
this.eventId = eventId;
}
public String getAggregateId() {
return aggregateId;
}
public void setAggregateId(String aggregateId) {
this.aggregateId = aggregateId;
}
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public String getPayload() {
return payload;
}
public void setPayload(String payload) {
this.payload = payload;
}
public Instant getOccurredAt() {
return occurredAt;
}
public void setOccurredAt(Instant occurredAt) {
this.occurredAt = occurredAt;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public int getAttemptCount() {
return attemptCount;
}
public void setAttemptCount(int attemptCount) {
this.attemptCount = attemptCount;
}
public Instant getNextAttemptAt() {
return nextAttemptAt;
}
public void setNextAttemptAt(Instant nextAttemptAt) {
this.nextAttemptAt = nextAttemptAt;
}
public String getCorrelationId() {
return correlationId;
}
public void setCorrelationId(String correlationId) {
this.correlationId = correlationId;
}
public String getIdempotencyKey() {
return idempotencyKey;
}
public void setIdempotencyKey(String idempotencyKey) {
this.idempotencyKey = idempotencyKey;
}
}
@@ -0,0 +1,2 @@
/** Persistence adapter anchor for project-owned storage integrations. */
package dev.caskeleton.adapter.outbound.persistence;
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
/** PostgreSQL atomic scope claim using the named V1 unique constraint. */
public final class PostgreSqlIdempotencyClaimRepository implements IdempotencyClaimRepository {
private static final String 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
) VALUES (
:id, :tenant, :principal, :idempotencyKey, :useCaseName,
:requestHash, 'IN_FLIGHT', NULL, NULL, :createdAt, :expiresAt
)
ON CONFLICT ON CONSTRAINT uq_idempotency_scope
DO UPDATE SET
id = EXCLUDED.id,
request_hash = EXCLUDED.request_hash,
status = 'IN_FLIGHT',
response_payload = NULL,
response_ref = NULL,
created_at = EXCLUDED.created_at,
expires_at = EXCLUDED.expires_at
WHERE idempotency_record.expires_at <= :now
RETURNING id
""";
private final EntityManager entityManager;
public PostgreSqlIdempotencyClaimRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Optional<UUID> tryClaim(
IdempotencyRecordEntity proposed,
Instant now,
@Nullable IdempotencyRecordEntity exactExpiredEntity) {
if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) {
entityManager.detach(exactExpiredEntity);
}
@SuppressWarnings("unchecked")
java.util.List<Object> rows =
entityManager
.createNativeQuery(CLAIM_SQL)
.setParameter("id", proposed.getId())
.setParameter("tenant", proposed.getTenant())
.setParameter("principal", proposed.getPrincipal())
.setParameter("idempotencyKey", proposed.getIdempotencyKey())
.setParameter("useCaseName", proposed.getUseCaseName())
.setParameter("requestHash", proposed.getRequestHash())
.setParameter("createdAt", proposed.getCreatedAt())
.setParameter("expiresAt", proposed.getExpiresAt())
.setParameter("now", now)
.getResultList();
if (rows.isEmpty()) {
return Optional.empty();
}
Object id = rows.getFirst();
return Optional.of(id instanceof UUID uuid ? uuid : UUID.fromString(id.toString()));
}
}
@@ -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);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import java.util.List;
/**
* PostgreSQL {@link OutboxClaimRepository}: claims eligible outbox rows with {@code FOR UPDATE SKIP
* LOCKED}. Design notes in the module README.
*/
public class PostgreSqlOutboxClaimRepository implements OutboxClaimRepository {
private static final String CLAIM_SQL =
"""
SELECT * FROM outbox_event o
WHERE o.next_attempt_at <= :now
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
AND NOT EXISTS (
SELECT 1 FROM outbox_event p
WHERE p.aggregate_id = o.aggregate_id
AND p.occurred_at < o.occurred_at
AND p.status <> 'PUBLISHED'
)
ORDER BY o.occurred_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""";
private final EntityManager entityManager;
public PostgreSqlOutboxClaimRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
@SuppressWarnings("unchecked")
public List<OutboxEventEntity> claimEligible(Instant now, int limit) {
return entityManager
.createNativeQuery(CLAIM_SQL, OutboxEventEntity.class)
.setParameter("now", now)
.setParameter("limit", limit)
.getResultList();
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings;
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
import jakarta.persistence.EntityManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
* PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor
* {@code @Bean}s. See the module README.
*
* <p>{@code matchIfMissing = true} keeps PostgreSQL the default: this configuration was
* unconditional before {@link PersistenceVendorSettings} existed, and a deployment that never sets
* the selector must keep the vendor it already runs.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = PersistenceVendorSettings.PREFIX,
name = "vendor",
havingValue = "postgresql",
matchIfMissing = true)
@Import(PersistenceJpaConfig.class)
public class PostgreSqlPersistenceConfig {
@Bean
public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) {
return new PostgreSqlOutboxClaimRepository(entityManager);
}
@Bean
public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() {
return new PostgreSqlSqlStateErrorMapping();
}
@Bean
public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer(
JdbcOperations jdbcOperations) {
return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations);
}
@Bean
public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) {
return new PostgreSqlIdempotencyClaimRepository(entityManager);
}
@Bean
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
return configuration -> configuration.locations("classpath:db/migration/postgresql");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Map;
/**
* PostgreSQL-specific {@link SqlStateErrorMapping} contributing vendor-only SQLState codes. See the
* module README for the code/category/retryable table.
*/
public class PostgreSqlSqlStateErrorMapping implements SqlStateErrorMapping {
private static final Map<String, OperationalError> MAPPINGS =
Map.of(
"40P01", OperationalError.DB_DEADLOCK,
"25P03", OperationalError.DB_IDLE_IN_TX_TIMEOUT,
"57014", OperationalError.DB_QUERY_CANCELED);
@Override
public Map<String, OperationalError> exactMappings() {
return MAPPINGS;
}
}
@@ -0,0 +1,890 @@
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.jdbc.core.JdbcOperations;
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.
*
* <p>Deliberately carries no Spring stereotype. Both composition roots component-scan {@code
* dev.caskeleton.adapter}, so a {@code @Repository} here was registered in every deployment
* regardless of which idempotency provider was selected: {@code provider=jdbc} acquired an
* owner-safe V2 store it never asked for, and {@code provider=redis} acquired a second one beside
* its own. Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan-
* registered store meant neither selection could start.
*
* <p>{@code ca-skeleton.capabilities.idempotency.provider} is {@code disabled | jdbc | redis} and
* has no value that selects this store, so nothing composes it today; the integration test
* constructs it directly. Giving it a selector is outstanding work, and it belongs with the
* registry entry for that property rather than with a stereotype that composes it everywhere.
*/
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;
public PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc) {
this(jdbc, new SecureRandom());
}
PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc, SecureRandom secureRandom) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
}
@Override
public IdempotencyClaimAttempt newClaimAttempt(OperationId operationId) {
Objects.requireNonNull(operationId, "operationId");
byte[] token = new byte[32];
secureRandom.nextBytes(token);
return new IdempotencyClaimAttempt(HexFormat.of().formatHex(token), operationId);
}
@Override
public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) {
Objects.requireNonNull(request, "request");
requirePrimaryWriteTransaction();
requireActiveCapability();
int inserted =
jdbc.update(
INSERT_CLAIM_SQL,
UUID.randomUUID(),
V2_PRINCIPAL_SENTINEL,
request.scope().digest(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.replayTtl().toMillis(),
request.scope().digest(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId());
Row row = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
Instant dbNow = databaseNowAfterLock();
if (inserted == 1) {
return acquired(row);
}
if (isExpiredCompleted(row, dbNow)) {
return resetClaim(request, row);
}
if (!row.requestHash().equals(request.requestFingerprint().hex())) {
return new IdempotencyClaimOutcome.FingerprintMismatch();
}
if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) {
return new IdempotencyClaimOutcome.CompletedReplay(
new StoredResponse(row.responsePayload()), row.replayUntil());
}
if (sameClaimAttempt(row, request.claimAttempt())) {
return new IdempotencyClaimOutcome.ReplayedAcquire(owner(row), row.processingLeaseUntil());
}
if (row.ownerToken().equals(request.claimAttempt().ownerToken())) {
return new IdempotencyClaimOutcome.OwnerOperationConflict();
}
if (row.state() == IdempotencyState.CLAIMED && !dbNow.isBefore(row.processingLeaseUntil())) {
return resetClaim(request, row);
}
if (row.state() == IdempotencyState.FAILED_RETRYABLE) {
return resetClaim(request, row);
}
if (row.state() == IdempotencyState.EXECUTING && !dbNow.isBefore(row.processingLeaseUntil())) {
abandonExpiredExecution(row);
return new IdempotencyClaimOutcome.RecoveryRequired(row.attempt());
}
if (row.state() == IdempotencyState.ABANDONED) {
return new IdempotencyClaimOutcome.RecoveryRequired(row.attempt());
}
Duration retryAfter =
row.processingLeaseUntil().isAfter(dbNow)
? Duration.between(dbNow, row.processingLeaseUntil())
: Duration.ZERO;
return new IdempotencyClaimOutcome.InProgress(retryAfter, row.attempt());
}
@Override
public IdempotencyMutationResult<IdempotencyStartOutcome> markExecutionStarted(
IdempotencyOwner owner, OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return startResult(IdempotencyStartOutcome.ABSENT, null);
}
if (isDuplicate(row, "START", operationId)) {
return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row));
}
IdempotencyStartOutcome mismatch = classifyStartMismatch(row, owner);
if (mismatch != null) {
return startResult(mismatch, null);
}
String resultDigest = transitionDigest("START", operationId, owner);
int updated =
jdbc.update(
START_SQL,
operationId.value(),
resultDigest,
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
if (updated != 1) {
return startResult(IdempotencyStartOutcome.NOT_OWNER, null);
}
return startResult(
IdempotencyStartOutcome.STARTED, owner.withStateRevision(owner.stateRevision() + 1));
}
@Override
public IdempotencyMutationResult<IdempotencyRenewOutcome> renew(
IdempotencyOwner owner, Duration processingLeaseTtl, OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePositiveBounded("processing lease TTL", processingLeaseTtl, MAXIMUM_PROCESSING_LEASE);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return renewResult(IdempotencyRenewOutcome.ABSENT, null);
}
if (isDuplicate(row, "RENEW", operationId)) {
return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row));
}
IdempotencyRenewOutcome mismatch = classifyRenewMismatch(row, owner);
if (mismatch != null) {
return renewResult(mismatch, null);
}
int updated =
jdbc.update(
RENEW_SQL,
processingLeaseTtl.toMillis(),
operationId.value(),
transitionDigest("RENEW", operationId, owner),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
if (updated != 1) {
return renewResult(IdempotencyRenewOutcome.NOT_OWNER, null);
}
return renewResult(
IdempotencyRenewOutcome.RENEWED, owner.withStateRevision(owner.stateRevision() + 1));
}
@Override
public IdempotencyCompleteOutcome complete(
IdempotencyOwner owner,
StoredResponse response,
Duration replayTtl,
OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(response, "response");
Objects.requireNonNull(operationId, "operationId");
requirePositiveBounded("replay TTL", replayTtl, MAXIMUM_RETENTION);
requireInlineResponse(response);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyCompleteOutcome.ABSENT;
}
String responseDigest = sha256(response.payload());
if (row.state() == IdempotencyState.COMPLETED
&& "COMPLETE".equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastTransitionOperationId())) {
return responseDigest.equals(row.responseDigest())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
}
IdempotencyCompleteOutcome mismatch = classifyCompleteMismatch(row, owner);
if (mismatch != null) {
return mismatch;
}
int updated =
jdbc.update(
COMPLETE_SQL,
operationId.value(),
responseDigest,
response.payload(),
responseDigest,
replayTtl.toMillis(),
replayTtl.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
return updated == 1
? IdempotencyCompleteOutcome.COMPLETED
: IdempotencyCompleteOutcome.INDETERMINATE;
}
@Override
public IdempotencyFailOutcome markFailed(
IdempotencyOwner owner,
IdempotencyFailureDisposition disposition,
Duration retention,
OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(disposition, "disposition");
Objects.requireNonNull(operationId, "operationId");
requirePositiveBounded("failure retention", retention, MAXIMUM_RETENTION);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyFailOutcome.ABSENT;
}
String transitionKind =
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? "FAIL_RETRYABLE"
: "FAIL_ABANDONED";
if (isDuplicate(row, transitionKind, operationId)) {
return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
}
IdempotencyFailOutcome mismatch = classifyFailMismatch(row, owner);
if (mismatch != null) {
return mismatch;
}
String targetState =
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? IdempotencyState.FAILED_RETRYABLE.name()
: IdempotencyState.ABANDONED.name();
int updated =
jdbc.update(
FAIL_SQL,
targetState,
operationId.value(),
transitionKind,
transitionDigest(transitionKind, operationId, owner),
disposition.name(),
retention.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
if (updated != 1) {
return IdempotencyFailOutcome.INDETERMINATE;
}
return disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? IdempotencyFailOutcome.MARKED_RETRYABLE
: IdempotencyFailOutcome.MARKED_ABANDONED;
}
@Override
public IdempotencyReleaseOutcome releaseBeforeExecution(
IdempotencyOwner owner, OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyReleaseOutcome.ABSENT;
}
if (isDuplicate(row, "RELEASE", operationId)) {
return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
}
if (!sameOwnerTuple(row, owner)) {
return IdempotencyReleaseOutcome.NOT_OWNER;
}
if (row.state() == IdempotencyState.EXECUTING) {
return IdempotencyReleaseOutcome.EXECUTION_ALREADY_STARTED;
}
if (row.state() != IdempotencyState.CLAIMED) {
return IdempotencyReleaseOutcome.OPERATION_CONFLICT;
}
int updated =
jdbc.update(
RELEASE_SQL,
operationId.value(),
transitionDigest("RELEASE", operationId, owner),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
return updated == 1
? IdempotencyReleaseOutcome.RELEASED_BEFORE_EXECUTION
: IdempotencyReleaseOutcome.INDETERMINATE;
}
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
Objects.requireNonNull(request, "request");
Optional<Row> found = find(request.scope().digest());
if (found.isEmpty()) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT);
}
Row row = found.get();
if (!row.requestHash().equals(request.requestFingerprint().hex())) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH);
}
boolean sameAttempt = sameClaimAttempt(row, request.claimAttempt());
if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) {
return new IdempotencyInspection(
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
Optional.empty(),
Optional.empty(),
Optional.of(new StoredResponse(row.responsePayload())),
Optional.ofNullable(row.replayUntil()));
}
if (sameAttempt && row.state() == IdempotencyState.CLAIMED) {
return inspectionWithOwner(IdempotencyInspectionOutcome.CLAIMED_SAME_OPERATION, row);
}
if (sameAttempt && row.state() == IdempotencyState.EXECUTING) {
return inspectionWithOwner(IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION, row);
}
if (row.ownerToken().equals(request.claimAttempt().ownerToken()) && !sameAttempt) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.OPERATION_CONFLICT);
}
return switch (row.state()) {
case FAILED_RETRYABLE ->
IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FAILED_RETRYABLE);
case ABANDONED -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABANDONED);
default -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.IN_PROGRESS_OTHER);
};
}
private IdempotencyClaimOutcome resetClaim(IdempotencyClaimRequest request, Row row) {
int updated =
jdbc.update(
RESET_CLAIM_SQL,
request.scope().digest(),
request.scope().operationCode(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId(),
request.replayTtl().toMillis(),
request.scope().digest(),
row.state().name(),
row.stateRevision());
if (updated != 1) {
return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId());
}
Row reset = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
return new IdempotencyClaimOutcome.TakenOverClaimed(owner(reset), reset.processingLeaseUntil());
}
private void abandonExpiredExecution(Row row) {
String resultDigest = sha256("EXPIRED_EXECUTION|" + row.claimOperationId());
int updated =
jdbc.update(
ABANDON_EXPIRED_EXECUTION_SQL,
resultDigest,
row.scopeHash(),
row.ownerToken(),
row.attempt(),
row.claimOperationId(),
row.stateRevision());
if (updated != 1) {
throw indeterminateClaim();
}
}
private IdempotencyStartOutcome classifyStartMismatch(Row row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyStartOutcome.NOT_OWNER;
}
if (row.stateRevision() != owner.stateRevision()) {
return IdempotencyStartOutcome.OPERATION_CONFLICT;
}
if (row.state() != IdempotencyState.CLAIMED) {
return IdempotencyStartOutcome.NOT_CLAIMED;
}
return null;
}
private IdempotencyRenewOutcome classifyRenewMismatch(Row row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyRenewOutcome.NOT_OWNER;
}
if (row.stateRevision() != owner.stateRevision()) {
return IdempotencyRenewOutcome.OPERATION_CONFLICT;
}
if (row.state() != IdempotencyState.CLAIMED && row.state() != IdempotencyState.EXECUTING) {
return IdempotencyRenewOutcome.NOT_IN_PROGRESS;
}
return null;
}
private IdempotencyCompleteOutcome classifyCompleteMismatch(Row row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyCompleteOutcome.NOT_OWNER;
}
if (row.stateRevision() != owner.stateRevision()) {
return IdempotencyCompleteOutcome.OPERATION_CONFLICT;
}
if (row.state() != IdempotencyState.EXECUTING) {
return IdempotencyCompleteOutcome.NOT_IN_PROGRESS;
}
return null;
}
private IdempotencyFailOutcome classifyFailMismatch(Row row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyFailOutcome.NOT_OWNER;
}
if (row.stateRevision() != owner.stateRevision()) {
return IdempotencyFailOutcome.OPERATION_CONFLICT;
}
if (row.state() != IdempotencyState.EXECUTING) {
return IdempotencyFailOutcome.NOT_IN_PROGRESS;
}
return null;
}
private Optional<Row> findForUpdate(String scopeHash) {
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
}
private Optional<Row> find(String scopeHash) {
return queryOne(SELECT_ROW_SQL, scopeHash);
}
private Optional<Row> queryOne(String sql, String scopeHash) {
List<Row> rows = jdbc.query(sql, this::mapRow, scopeHash);
if (rows.size() > 1) {
throw new IllegalStateException("multiple idempotency V2 rows for one scope digest");
}
return rows.stream().findFirst();
}
private Row mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
return new Row(
resultSet.getString("scope_hash"),
resultSet.getInt("key_digest_version"),
resultSet.getString("operation_code"),
resultSet.getString("request_hash"),
IdempotencyState.valueOf(resultSet.getString("status")),
resultSet.getLong("state_revision"),
resultSet.getString("owner_token"),
resultSet.getLong("attempt"),
resultSet.getString("claim_operation_id"),
resultSet.getString("last_transition_operation_id"),
resultSet.getString("last_transition_kind"),
resultSet.getString("last_transition_result_digest"),
instant(resultSet, "processing_lease_until"),
nullableInstant(resultSet, "replay_until"),
resultSet.getString("response_payload"),
resultSet.getString("response_digest"),
resultSet.getString("response_codec_id"),
resultSet.getInt("policy_revision"),
instant(resultSet, "expires_at"));
}
private Instant databaseNowAfterLock() {
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
if (value == null) {
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
}
return value.toInstant();
}
private void requireActiveCapability() {
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
if (active == null || active != 1) {
throw new IllegalStateException(
"jpa-idempotency-owner-safe-v2 is not active at core epoch 1/revision 2");
}
}
private static void requirePrimaryWriteTransaction() {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"owner-safe idempotency mutation requires an active primary transaction");
}
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
throw new IllegalStateException(
"owner-safe idempotency mutation requires a read-write transaction");
}
}
private static void requirePositiveBounded(String name, Duration value, Duration maximum) {
Objects.requireNonNull(value, name);
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(name + " must be positive and at most " + maximum);
}
}
private static void requireInlineResponse(StoredResponse response) {
int size = response.payload().getBytes(StandardCharsets.UTF_8).length;
if (size > INLINE_RESPONSE_MAX_BYTES) {
throw new IllegalArgumentException(
"SAME_STORE_TRANSACTIONAL response exceeds the bounded inline response limit");
}
}
private static boolean sameClaimAttempt(Row row, IdempotencyClaimAttempt attempt) {
return row.ownerToken().equals(attempt.ownerToken())
&& row.claimOperationId().equals(attempt.operationId().value());
}
private static boolean sameOwnerIdentity(Row row, IdempotencyOwner owner) {
return row.scopeHash().equals(owner.scope().digest())
&& row.ownerToken().equals(owner.ownerToken())
&& row.attempt() == owner.attempt()
&& row.claimOperationId().equals(owner.claimOperationId().value());
}
private static boolean sameOwnerTuple(Row row, IdempotencyOwner owner) {
return sameOwnerIdentity(row, owner) && row.stateRevision() == owner.stateRevision();
}
private static boolean isDuplicate(Row row, String transitionKind, OperationId operationId) {
return transitionKind.equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastTransitionOperationId());
}
private static boolean isExpiredCompleted(Row row, Instant dbNow) {
return row.state() == IdempotencyState.COMPLETED
&& row.replayUntil() != null
&& !dbNow.isBefore(row.replayUntil());
}
private static IdempotencyClaimOutcome.Acquired acquired(Row row) {
return new IdempotencyClaimOutcome.Acquired(owner(row), row.processingLeaseUntil());
}
private static IdempotencyOwner owner(Row row) {
return new IdempotencyOwner(
new dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest(
row.scopeHash(), row.keyDigestVersion(), row.operationCode()),
row.ownerToken(),
row.attempt(),
row.stateRevision(),
new OperationId(row.claimOperationId()));
}
private static IdempotencyMutationResult<IdempotencyStartOutcome> startResult(
IdempotencyStartOutcome outcome, IdempotencyOwner owner) {
return new IdempotencyMutationResult<>(outcome, owner, IdempotencyStartOutcome::carriesOwner);
}
private static IdempotencyMutationResult<IdempotencyRenewOutcome> renewResult(
IdempotencyRenewOutcome outcome, IdempotencyOwner owner) {
return new IdempotencyMutationResult<>(outcome, owner, IdempotencyRenewOutcome::carriesOwner);
}
private static IdempotencyInspection inspectionWithOwner(
IdempotencyInspectionOutcome outcome, Row row) {
return new IdempotencyInspection(
outcome,
Optional.of(owner(row)),
Optional.of(row.processingLeaseUntil()),
Optional.empty(),
Optional.empty());
}
private static String transitionDigest(
String transition, OperationId operationId, IdempotencyOwner owner) {
return sha256(
transition
+ '|'
+ operationId.value()
+ '|'
+ owner.ownerToken()
+ '|'
+ owner.attempt()
+ '|'
+ owner.stateRevision());
}
private static String sha256(String value) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static Instant instant(ResultSet resultSet, String column) throws SQLException {
return resultSet.getObject(column, OffsetDateTime.class).toInstant();
}
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
return value == null ? null : value.toInstant();
}
private IllegalStateException indeterminateClaim() {
return new IllegalStateException("owner-safe idempotency claim outcome is indeterminate");
}
private record Row(
String scopeHash,
int keyDigestVersion,
String operationCode,
String requestHash,
IdempotencyState state,
long stateRevision,
String ownerToken,
long attempt,
String claimOperationId,
String lastTransitionOperationId,
String lastTransitionKind,
String lastTransitionResultDigest,
Instant processingLeaseUntil,
Instant replayUntil,
String responsePayload,
String responseDigest,
String responseCodecId,
int policyRevision,
Instant expiresAt) {}
}
@@ -0,0 +1,588 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.inbox;
import dev.caskeleton.application.inbox.InboxClaimAttempt;
import dev.caskeleton.application.inbox.InboxClaimOutcome;
import dev.caskeleton.application.inbox.InboxClaimRequest;
import dev.caskeleton.application.inbox.InboxOwner;
import dev.caskeleton.application.inbox.InboxOwnerTransition;
import dev.caskeleton.application.inbox.InboxScopeDigest;
import dev.caskeleton.application.inbox.InboxState;
import dev.caskeleton.application.inbox.InboxStorePort;
import dev.caskeleton.application.inbox.InboxTransitionOutcome;
import dev.caskeleton.application.transaction.OperationId;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* PostgreSQL same-store inbox.
*
* <p>Claim, business mutation, optional outgoing outbox append, and completion are intended to run
* inside one caller-owned transaction. Broker acknowledgement is deliberately outside this port.
*/
@Repository
public class PostgreSqlSameStoreInboxAdapter implements InboxStorePort {
private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30);
private static final String DB_NOW_SQL = "select clock_timestamp()";
private static final String ACTIVE_CAPABILITY_SQL =
"""
select count(*)
from capability_schema_registry
where capability_id = 'jpa-inbox-same-store-v1'
and core_epoch = 1
and feature_revision = 1
and lifecycle_state = 'ACTIVE'
""";
private static final String INSERT_SQL =
"""
insert into inbox_record_v1 (
scope_hash,
message_intent_digest,
state,
state_revision,
owner_token,
attempt,
claim_operation_id,
processing_lease_until,
last_operation_id,
last_transition_kind,
last_result_digest,
terminal_at,
retention_until,
created_at,
updated_at
)
select
?, ?, 'RECEIVED', 0, ?, 1, ?,
db_now + (? * interval '1 millisecond'),
null, null, null, null,
db_now + (? * interval '1 millisecond'),
db_now, db_now
from (select clock_timestamp() as db_now) authority
on conflict (scope_hash) do nothing
""";
private static final String SELECT_SQL =
"""
select scope_hash, message_intent_digest, state, state_revision, owner_token, attempt,
claim_operation_id, processing_lease_until, last_operation_id,
last_transition_kind, last_result_digest, terminal_at, retention_until
from inbox_record_v1
where scope_hash = ?
""";
private static final String SELECT_FOR_UPDATE_SQL = SELECT_SQL + " for update";
private static final String RESET_SQL =
"""
update inbox_record_v1
set message_intent_digest = ?,
state = 'RECEIVED',
state_revision = state_revision + 1,
owner_token = ?,
attempt = attempt + 1,
claim_operation_id = ?,
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
last_operation_id = null,
last_transition_kind = null,
last_result_digest = null,
terminal_at = null,
retention_until = clock_timestamp() + (? * interval '1 millisecond'),
updated_at = clock_timestamp()
where scope_hash = ?
and state = ?
and state_revision = ?
""";
private static final String EXPIRE_PROCESSING_SQL =
"""
update inbox_record_v1
set state = 'DEAD',
state_revision = state_revision + 1,
last_operation_id = claim_operation_id,
last_transition_kind = 'EXPIRED_PROCESSING',
last_result_digest = ?,
terminal_at = clock_timestamp(),
updated_at = clock_timestamp()
where scope_hash = ?
and state = 'PROCESSING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String START_SQL =
"""
update inbox_record_v1
set state = 'PROCESSING',
state_revision = state_revision + 1,
last_operation_id = ?,
last_transition_kind = 'START',
last_result_digest = ?,
updated_at = clock_timestamp()
where scope_hash = ?
and state = 'RECEIVED'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
and processing_lease_until > clock_timestamp()
""";
private static final String COMPLETE_SQL =
"""
update inbox_record_v1
set state = 'COMPLETED',
state_revision = state_revision + 1,
last_operation_id = ?,
last_transition_kind = 'COMPLETE',
last_result_digest = ?,
terminal_at = clock_timestamp(),
updated_at = clock_timestamp()
where scope_hash = ?
and state = 'PROCESSING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String FAIL_SQL =
"""
update inbox_record_v1
set state = ?,
state_revision = state_revision + 1,
last_operation_id = ?,
last_transition_kind = ?,
last_result_digest = ?,
terminal_at = case when ? = 'DEAD' then clock_timestamp() else null end,
retention_until = clock_timestamp() + (? * interval '1 millisecond'),
processing_lease_until = clock_timestamp(),
updated_at = clock_timestamp()
where scope_hash = ?
and state = 'PROCESSING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private final DataSource dataSource;
private final JdbcOperations jdbc;
private final SecureRandom secureRandom;
@Autowired
public PostgreSqlSameStoreInboxAdapter(DataSource dataSource) {
this(dataSource, new JdbcTemplate(dataSource), new SecureRandom());
}
PostgreSqlSameStoreInboxAdapter(
DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) {
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
}
@Override
public InboxClaimAttempt newClaimAttempt(OperationId operationId) {
Objects.requireNonNull(operationId, "operationId");
byte[] token = new byte[32];
secureRandom.nextBytes(token);
return new InboxClaimAttempt(HexFormat.of().formatHex(token), operationId);
}
@Override
public InboxClaimOutcome claim(InboxClaimRequest request) {
Objects.requireNonNull(request, "request");
requireSameResourcePrimaryWriteTransaction();
requireActiveCapability();
int inserted =
jdbc.update(
INSERT_SQL,
request.scope().value(),
request.messageIntentDigest(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLease().toMillis(),
request.terminalRetention().toMillis());
InboxRow row = findForUpdate(request.scope()).orElseThrow(this::indeterminate);
Instant dbNow = databaseNowAfterLock();
if (inserted == 1) {
return new InboxClaimOutcome.Acquired(owner(row), row.processingLeaseUntil());
}
if ((row.state() == InboxState.COMPLETED || row.state() == InboxState.DEAD)
&& !dbNow.isBefore(row.retentionUntil())) {
return resetClaim(request, row);
}
if (!row.messageIntentDigest().equals(request.messageIntentDigest())) {
return new InboxClaimOutcome.IntentMismatch();
}
if (sameClaimAttempt(row, request.claimAttempt())) {
if (row.state() == InboxState.COMPLETED) {
return new InboxClaimOutcome.Completed();
}
return new InboxClaimOutcome.ReplayedAcquire(owner(row), row.processingLeaseUntil());
}
if (row.ownerToken().equals(request.claimAttempt().ownerToken())) {
return new InboxClaimOutcome.OwnerOperationConflict();
}
if (row.state() == InboxState.COMPLETED) {
return new InboxClaimOutcome.Completed();
}
if ((row.state() == InboxState.RECEIVED && !dbNow.isBefore(row.processingLeaseUntil()))
|| row.state() == InboxState.RETRYABLE) {
return resetClaim(request, row);
}
if (row.state() == InboxState.PROCESSING && !dbNow.isBefore(row.processingLeaseUntil())) {
expireProcessing(row);
return new InboxClaimOutcome.RecoveryRequired(row.attempt());
}
if (row.state() == InboxState.DEAD) {
return new InboxClaimOutcome.RecoveryRequired(row.attempt());
}
Duration retryAfter =
row.processingLeaseUntil().isAfter(dbNow)
? Duration.between(dbNow, row.processingLeaseUntil())
: Duration.ZERO;
return new InboxClaimOutcome.InProgress(retryAfter, row.attempt());
}
@Override
public InboxOwnerTransition markProcessing(InboxOwner owner, OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requireSameResourcePrimaryWriteTransaction();
InboxRow row = findForUpdate(owner.scope()).orElse(null);
if (row == null) {
return transition(InboxTransitionOutcome.ABSENT, null);
}
if (isDuplicate(row, "START", operationId)) {
return transition(InboxTransitionOutcome.PROCESSING_STARTED, owner(row));
}
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.RECEIVED);
if (mismatch != null) {
return transition(mismatch, null);
}
int updated =
jdbc.update(
START_SQL,
operationId.value(),
transitionDigest("START", operationId, owner),
owner.scope().value(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
return updated == 1
? transition(
InboxTransitionOutcome.PROCESSING_STARTED,
owner.withStateRevision(owner.stateRevision() + 1))
: transition(InboxTransitionOutcome.NOT_OWNER, null);
}
@Override
public InboxTransitionOutcome complete(InboxOwner owner, OperationId operationId) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requireSameResourcePrimaryWriteTransaction();
InboxRow row = findForUpdate(owner.scope()).orElse(null);
if (row == null) {
return InboxTransitionOutcome.ABSENT;
}
String digest = transitionDigest("COMPLETE", operationId, owner);
InboxTransitionOutcome duplicate = classifyDuplicate(row, "COMPLETE", operationId, digest);
if (duplicate != null) {
return duplicate;
}
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING);
if (mismatch != null) {
return mismatch;
}
int updated =
jdbc.update(
COMPLETE_SQL,
operationId.value(),
digest,
owner.scope().value(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
return updated == 1 ? InboxTransitionOutcome.COMPLETED : InboxTransitionOutcome.RESULT_CONFLICT;
}
@Override
public InboxTransitionOutcome markRetryable(
InboxOwner owner, Duration retention, OperationId operationId) {
return fail(owner, retention, operationId, InboxState.RETRYABLE);
}
@Override
public InboxTransitionOutcome markDead(
InboxOwner owner, Duration retention, OperationId operationId) {
return fail(owner, retention, operationId, InboxState.DEAD);
}
private InboxTransitionOutcome fail(
InboxOwner owner, Duration retention, OperationId operationId, InboxState target) {
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePositiveRetention(retention);
requireSameResourcePrimaryWriteTransaction();
InboxRow row = findForUpdate(owner.scope()).orElse(null);
if (row == null) {
return InboxTransitionOutcome.ABSENT;
}
String kind = target == InboxState.RETRYABLE ? "RETRYABLE" : "DEAD";
String digest = transitionDigest(kind, operationId, owner);
InboxTransitionOutcome duplicate = classifyDuplicate(row, kind, operationId, digest);
if (duplicate != null) {
return duplicate;
}
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING);
if (mismatch != null) {
return mismatch;
}
int updated =
jdbc.update(
FAIL_SQL,
target.name(),
operationId.value(),
kind,
digest,
target.name(),
retention.toMillis(),
owner.scope().value(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
if (updated != 1) {
return InboxTransitionOutcome.RESULT_CONFLICT;
}
return target == InboxState.RETRYABLE
? InboxTransitionOutcome.RETRYABLE
: InboxTransitionOutcome.DEAD;
}
private InboxClaimOutcome resetClaim(InboxClaimRequest request, InboxRow row) {
int updated =
jdbc.update(
RESET_SQL,
request.messageIntentDigest(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLease().toMillis(),
request.terminalRetention().toMillis(),
request.scope().value(),
row.state().name(),
row.stateRevision());
if (updated != 1) {
throw indeterminate();
}
InboxRow reset = findForUpdate(request.scope()).orElseThrow(this::indeterminate);
return new InboxClaimOutcome.TakenOver(owner(reset), reset.processingLeaseUntil());
}
private void expireProcessing(InboxRow row) {
String resultDigest = sha256("EXPIRED_PROCESSING|" + row.claimOperationId());
int updated =
jdbc.update(
EXPIRE_PROCESSING_SQL,
resultDigest,
row.scopeHash(),
row.ownerToken(),
row.attempt(),
row.claimOperationId(),
row.stateRevision());
if (updated != 1) {
throw indeterminate();
}
}
private InboxTransitionOutcome classifyMismatch(
InboxRow row, InboxOwner owner, InboxState expectedState) {
if (!sameOwnerIdentity(row, owner)) {
return InboxTransitionOutcome.NOT_OWNER;
}
if (row.stateRevision() != owner.stateRevision()) {
return InboxTransitionOutcome.STALE_REVISION;
}
if (row.state() != expectedState) {
return InboxTransitionOutcome.INVALID_STATE;
}
return null;
}
private static InboxTransitionOutcome classifyDuplicate(
InboxRow row, String kind, OperationId operationId, String digest) {
if (kind.equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastOperationId())) {
return digest.equals(row.lastResultDigest())
? InboxTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION
: InboxTransitionOutcome.RESULT_CONFLICT;
}
return null;
}
private Optional<InboxRow> findForUpdate(InboxScopeDigest scope) {
return queryOne(SELECT_FOR_UPDATE_SQL, scope);
}
private Optional<InboxRow> queryOne(String sql, InboxScopeDigest scope) {
List<InboxRow> rows = jdbc.query(sql, this::mapRow, scope.value());
if (rows.size() > 1) {
throw new IllegalStateException("multiple inbox rows for one scope");
}
return rows.stream().findFirst();
}
private InboxRow mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
return new InboxRow(
resultSet.getString("scope_hash"),
resultSet.getString("message_intent_digest"),
InboxState.valueOf(resultSet.getString("state")),
resultSet.getLong("state_revision"),
resultSet.getString("owner_token"),
resultSet.getLong("attempt"),
resultSet.getString("claim_operation_id"),
resultSet.getObject("processing_lease_until", OffsetDateTime.class).toInstant(),
resultSet.getString("last_operation_id"),
resultSet.getString("last_transition_kind"),
resultSet.getString("last_result_digest"),
nullableInstant(resultSet, "terminal_at"),
resultSet.getObject("retention_until", OffsetDateTime.class).toInstant());
}
private Instant databaseNowAfterLock() {
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
if (value == null) {
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
}
return value.toInstant();
}
private void requireActiveCapability() {
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
if (active == null || active != 1) {
throw new IllegalStateException(
"jpa-inbox-same-store-v1 is not active at core epoch 1/revision 1");
}
}
private void requireSameResourcePrimaryWriteTransaction() {
if (!TransactionSynchronizationManager.isActualTransactionActive()
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly()
|| !TransactionSynchronizationManager.hasResource(dataSource)) {
throw new IllegalStateException(
"same-store inbox mutation requires the adapter datasource primary write transaction");
}
}
private static void requirePositiveRetention(Duration retention) {
Objects.requireNonNull(retention, "retention");
if (retention.isZero()
|| retention.isNegative()
|| retention.compareTo(MAXIMUM_RETENTION) > 0) {
throw new IllegalArgumentException(
"retention must be positive and at most " + MAXIMUM_RETENTION);
}
}
private static boolean sameClaimAttempt(InboxRow row, InboxClaimAttempt attempt) {
return row.ownerToken().equals(attempt.ownerToken())
&& row.claimOperationId().equals(attempt.operationId().value());
}
private static boolean sameOwnerIdentity(InboxRow row, InboxOwner owner) {
return row.scopeHash().equals(owner.scope().value())
&& row.ownerToken().equals(owner.ownerToken())
&& row.attempt() == owner.attempt()
&& row.claimOperationId().equals(owner.claimOperationId().value());
}
private static boolean isDuplicate(InboxRow row, String kind, OperationId operationId) {
return kind.equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastOperationId());
}
private static InboxOwner owner(InboxRow row) {
return new InboxOwner(
new InboxScopeDigest(row.scopeHash()),
row.ownerToken(),
row.attempt(),
row.stateRevision(),
new OperationId(row.claimOperationId()));
}
private static InboxOwnerTransition transition(InboxTransitionOutcome outcome, InboxOwner owner) {
return new InboxOwnerTransition(outcome, Optional.ofNullable(owner));
}
private static String transitionDigest(String kind, OperationId operationId, InboxOwner owner) {
return sha256(
kind
+ '|'
+ operationId.value()
+ '|'
+ owner.ownerToken()
+ '|'
+ owner.attempt()
+ '|'
+ owner.stateRevision());
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
return value == null ? null : value.toInstant();
}
private IllegalStateException indeterminate() {
return new IllegalStateException("same-store inbox transition is indeterminate");
}
private record InboxRow(
String scopeHash,
String messageIntentDigest,
InboxState state,
long stateRevision,
String ownerToken,
long attempt,
String claimOperationId,
Instant processingLeaseUntil,
String lastOperationId,
String lastTransitionKind,
String lastResultDigest,
Instant terminalAt,
Instant retentionUntil) {}
}
@@ -0,0 +1,380 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox;
import dev.caskeleton.application.outbox.v2.NewOutboxEventV2;
import dev.caskeleton.application.outbox.v2.OutboxAppendOutcome;
import dev.caskeleton.application.outbox.v2.OutboxAppendPortV2;
import dev.caskeleton.application.outbox.v2.OutboxAppendReceipt;
import dev.caskeleton.application.outbox.v2.OutboxDispatchAuthority;
import dev.caskeleton.application.outbox.v2.OutboxPublicationAuthority;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* PostgreSQL immutable outbox V2 append implementation.
*
* <p>The active publication control row is held {@code FOR SHARE} until the caller's transaction
* finishes. Identity and envelope inserts therefore cannot straddle an authority cutover.
*/
@Repository
public class PostgreSqlImmutableOutboxAppendAdapter implements OutboxAppendPortV2 {
private static final String ACTIVE_CAPABILITY_SQL =
"""
select count(*)
from capability_schema_registry
where capability_id = 'jpa-outbox-storage-v2'
and core_epoch = 1
and feature_revision = 2
and lifecycle_state = 'ACTIVE'
""";
private static final String LOCK_CONTROL_SQL =
"""
select control.active_epoch, control.active_authority
from outbox_publication_control_v2 control
join outbox_publication_cutover_v2 cutover
on cutover.scope_id = control.scope_id
and cutover.active_epoch = control.active_epoch
and cutover.active_authority = control.active_authority
where control.scope_id = 'PRIMARY'
and control.state = 'ACTIVE'
for share of control
""";
private static final String INSERT_IDENTITY_SQL =
"""
insert into outbox_event_identity_v2 (
event_id,
aggregate_type,
aggregate_id,
aggregate_version,
event_ordinal,
retention_bucket,
created_at
)
select ?, ?, ?, ?, ?, (db_now at time zone 'UTC')::date, db_now
from (select clock_timestamp() as db_now) authority
on conflict do nothing
returning retention_bucket, created_at
""";
private static final String INSERT_EVENT_SQL =
"""
insert into outbox_event_log_v2 (
retention_bucket,
event_id,
aggregate_type,
aggregate_id,
aggregate_version,
event_ordinal,
event_type,
event_schema,
logical_destination,
partition_key,
publication_epoch,
dispatch_authority,
content_type,
correlation_id,
causation_id,
occurred_at,
payload,
payload_digest,
trace_parent,
created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?)
""";
private static final String FIND_EVENT_SQL =
"""
select identity.event_id,
identity.aggregate_type,
identity.aggregate_id,
identity.aggregate_version,
identity.event_ordinal,
identity.retention_bucket,
event.event_type,
event.event_schema,
event.logical_destination,
event.partition_key,
event.publication_epoch,
event.dispatch_authority,
event.content_type,
event.correlation_id,
event.causation_id,
event.occurred_at,
event.payload_digest
from outbox_event_identity_v2 identity
left join outbox_event_log_v2 event
on event.event_id = identity.event_id
and event.retention_bucket = identity.retention_bucket
where identity.event_id = ?
""";
private static final String FIND_ORDERING_IDENTITY_SQL =
"""
select event_id
from outbox_event_identity_v2
where aggregate_type = ?
and aggregate_id = ?
and aggregate_version = ?
and event_ordinal = ?
""";
private final DataSource dataSource;
private final JdbcOperations jdbc;
@Autowired
public PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource) {
this(dataSource, new JdbcTemplate(dataSource));
}
PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource, JdbcOperations jdbc) {
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
}
@Override
public OutboxAppendReceipt append(NewOutboxEventV2 event) {
Objects.requireNonNull(event, "event");
requireSameResourcePrimaryWriteTransaction();
requireActiveCapability();
PublicationControl control = lockPublicationControl();
OutboxDispatchAuthority dispatchAuthority = dispatchAuthority(control.authority());
List<IdentityInsert> inserted =
jdbc.query(
INSERT_IDENTITY_SQL,
(resultSet, rowNumber) ->
new IdentityInsert(
resultSet.getObject("retention_bucket", LocalDate.class),
resultSet.getObject("created_at", OffsetDateTime.class).toInstant()),
event.eventId(),
event.aggregateType(),
event.aggregateId(),
event.aggregateVersion(),
event.eventOrdinal());
if (inserted.isEmpty()) {
return classifyExisting(event);
}
IdentityInsert identity = inserted.getFirst();
String payloadDigest = sha256(event.payload());
int envelopeInserted =
jdbc.update(
INSERT_EVENT_SQL,
identity.retentionBucket(),
event.eventId(),
event.aggregateType(),
event.aggregateId(),
event.aggregateVersion(),
event.eventOrdinal(),
event.eventType(),
event.eventSchema(),
event.logicalDestination(),
event.partitionKey(),
control.activeEpoch(),
dispatchAuthority.name(),
event.contentType(),
event.correlationId(),
event.causationId(),
OffsetDateTime.ofInstant(event.occurredAt(), java.time.ZoneOffset.UTC),
event.payload(),
payloadDigest,
OffsetDateTime.ofInstant(identity.createdAt(), java.time.ZoneOffset.UTC));
if (envelopeInserted != 1) {
throw new IllegalStateException("immutable outbox event envelope insert affected no row");
}
return new OutboxAppendReceipt(
OutboxAppendOutcome.APPENDED,
event.eventId(),
identity.retentionBucket(),
control.activeEpoch(),
dispatchAuthority);
}
private OutboxAppendReceipt classifyExisting(NewOutboxEventV2 requested) {
Optional<StoredEvent> byId = findEvent(requested.eventId());
if (byId.isPresent()) {
StoredEvent stored = byId.get();
OutboxAppendOutcome outcome =
sameIntent(stored, requested)
? OutboxAppendOutcome.ALREADY_APPENDED_SAME_EVENT
: OutboxAppendOutcome.EVENT_ID_CONFLICT;
return new OutboxAppendReceipt(
outcome,
stored.eventId(),
stored.retentionBucket(),
stored.publicationEpoch(),
stored.dispatchAuthority());
}
List<String> orderingOwner =
jdbc.query(
FIND_ORDERING_IDENTITY_SQL,
(resultSet, rowNumber) -> resultSet.getString("event_id"),
requested.aggregateType(),
requested.aggregateId(),
requested.aggregateVersion(),
requested.eventOrdinal());
if (!orderingOwner.isEmpty()) {
PublicationControl control = lockPublicationControl();
return new OutboxAppendReceipt(
OutboxAppendOutcome.AGGREGATE_ORDER_CONFLICT,
requested.eventId(),
currentRetentionBucket(),
control.activeEpoch(),
dispatchAuthority(control.authority()));
}
throw new IllegalStateException(
"outbox identity insert lost without an event-ID or aggregate-order conflict");
}
private Optional<StoredEvent> findEvent(String eventId) {
List<StoredEvent> rows = jdbc.query(FIND_EVENT_SQL, this::mapStoredEvent, eventId);
if (rows.size() > 1) {
throw new IllegalStateException("multiple immutable outbox identities for one event ID");
}
return rows.stream().findFirst();
}
private StoredEvent mapStoredEvent(ResultSet resultSet, int rowNumber) throws SQLException {
String dispatch = resultSet.getString("dispatch_authority");
if (dispatch == null) {
throw new IllegalStateException(
"outbox identity exists without its same-transaction immutable envelope");
}
return new StoredEvent(
resultSet.getString("event_id"),
resultSet.getString("aggregate_type"),
resultSet.getString("aggregate_id"),
resultSet.getLong("aggregate_version"),
resultSet.getInt("event_ordinal"),
resultSet.getObject("retention_bucket", LocalDate.class),
resultSet.getString("event_type"),
resultSet.getInt("event_schema"),
resultSet.getString("logical_destination"),
resultSet.getString("partition_key"),
resultSet.getLong("publication_epoch"),
OutboxDispatchAuthority.valueOf(dispatch),
resultSet.getString("content_type"),
resultSet.getString("correlation_id"),
resultSet.getString("causation_id"),
resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(),
resultSet.getString("payload_digest"));
}
private PublicationControl lockPublicationControl() {
List<PublicationControl> rows =
jdbc.query(
LOCK_CONTROL_SQL,
(resultSet, rowNumber) ->
new PublicationControl(
resultSet.getLong("active_epoch"),
OutboxPublicationAuthority.valueOf(resultSet.getString("active_authority"))));
if (rows.size() != 1) {
throw new IllegalStateException(
"outbox publication control has no exact active immutable sentinel");
}
return rows.getFirst();
}
private LocalDate currentRetentionBucket() {
return jdbc.queryForObject(
"select (clock_timestamp() at time zone 'UTC')::date", LocalDate.class);
}
private void requireActiveCapability() {
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
if (active == null || active != 1) {
throw new IllegalStateException(
"jpa-outbox-storage-v2 is not active at core epoch 1/revision 2");
}
}
private void requireSameResourcePrimaryWriteTransaction() {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"outbox V2 append requires an active primary write transaction");
}
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
throw new IllegalStateException("outbox V2 append rejects a read-only transaction");
}
if (!TransactionSynchronizationManager.hasResource(dataSource)) {
throw new IllegalStateException(
"outbox V2 append transaction is not bound to the adapter datasource");
}
}
private static OutboxDispatchAuthority dispatchAuthority(OutboxPublicationAuthority authority) {
return switch (authority) {
case LEGACY_POLLING -> OutboxDispatchAuthority.LEGACY_SHADOW;
case POLLING_V2 -> OutboxDispatchAuthority.POLLING_V2;
case CDC -> OutboxDispatchAuthority.CDC;
};
}
private static boolean sameIntent(StoredEvent stored, NewOutboxEventV2 requested) {
return stored.aggregateType().equals(requested.aggregateType())
&& stored.aggregateId().equals(requested.aggregateId())
&& stored.aggregateVersion() == requested.aggregateVersion()
&& stored.eventOrdinal() == requested.eventOrdinal()
&& stored.eventType().equals(requested.eventType())
&& stored.eventSchema() == requested.eventSchema()
&& stored.logicalDestination().equals(requested.logicalDestination())
&& stored.partitionKey().equals(requested.partitionKey())
&& stored.contentType().equals(requested.contentType())
&& stored.correlationId().equals(requested.correlationId())
&& Objects.equals(stored.causationId(), requested.causationId())
&& stored.occurredAt().equals(requested.occurredAt())
&& stored.payloadDigest().equals(sha256(requested.payload()));
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private record PublicationControl(long activeEpoch, OutboxPublicationAuthority authority) {}
private record IdentityInsert(LocalDate retentionBucket, Instant createdAt) {}
private record StoredEvent(
String eventId,
String aggregateType,
String aggregateId,
long aggregateVersion,
int eventOrdinal,
LocalDate retentionBucket,
String eventType,
int eventSchema,
String logicalDestination,
String partitionKey,
long publicationEpoch,
OutboxDispatchAuthority dispatchAuthority,
String contentType,
String correlationId,
String causationId,
Instant occurredAt,
String payloadDigest) {}
}
@@ -0,0 +1,531 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox;
import dev.caskeleton.application.outbox.v2.ClaimedOutboxDelivery;
import dev.caskeleton.application.outbox.v2.OutboxDeliveryClaimRequest;
import dev.caskeleton.application.outbox.v2.OutboxDeliveryOwner;
import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransition;
import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransitionOutcome;
import dev.caskeleton.application.outbox.v2.OutboxPollingDeliveryPortV2;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** PostgreSQL {@code SKIP LOCKED} polling relay with strict aggregate order and owner-safe CAS. */
@Repository
public class PostgreSqlPollingDeliveryAdapter implements OutboxPollingDeliveryPortV2 {
private static final Pattern ERROR_CODE = Pattern.compile("[A-Z][A-Z0-9_.-]{0,63}");
private static final String ACTIVE_CAPABILITIES_SQL =
"""
select count(*)
from capability_schema_registry
where capability_id in (
'jpa-outbox-storage-v2',
'jpa-outbox-polling-delivery-v2'
)
and core_epoch = 1
and feature_revision = 2
and lifecycle_state = 'ACTIVE'
""";
private static final String CLAIM_SQL =
"""
with authority as (
select control.active_epoch
from outbox_publication_control_v2 control
join outbox_publication_cutover_v2 cutover
on cutover.scope_id = control.scope_id
and cutover.active_epoch = control.active_epoch
and cutover.active_authority = control.active_authority
where control.scope_id = 'PRIMARY'
and control.state = 'ACTIVE'
and control.active_authority = 'POLLING_V2'
for share of control
),
db_clock as (
select clock_timestamp() as db_now
),
eligible as (
select delivery.retention_bucket, delivery.event_id, delivery.destination
from outbox_delivery_v2 delivery
join outbox_event_log_v2 event
on event.retention_bucket = delivery.retention_bucket
and event.event_id = delivery.event_id
cross join authority
cross join db_clock
where delivery.destination = ?
and delivery.publication_epoch = authority.active_epoch
and (
(delivery.state in ('PENDING', 'RETRY_WAIT')
and delivery.next_attempt_at <= db_clock.db_now)
or
(delivery.state = 'CLAIMED'
and delivery.claim_until <= db_clock.db_now)
)
and not exists (
select 1
from outbox_delivery_v2 prior_delivery
join outbox_event_log_v2 prior_event
on prior_event.retention_bucket = prior_delivery.retention_bucket
and prior_event.event_id = prior_delivery.event_id
where prior_delivery.destination = delivery.destination
and prior_event.aggregate_type = event.aggregate_type
and prior_event.aggregate_id = event.aggregate_id
and (
prior_event.aggregate_version,
prior_event.event_ordinal
) < (
event.aggregate_version,
event.event_ordinal
)
and prior_delivery.state <> 'PUBLISHED'
)
order by event.created_at, event.event_id
for update of delivery skip locked
limit ?
),
claimed as (
update outbox_delivery_v2 delivery
set state = 'CLAIMED',
claim_owner = ?,
claim_token = ?,
claim_until = db_clock.db_now + (? * interval '1 millisecond'),
attempt = delivery.attempt + 1,
version = delivery.version + 1,
updated_at = db_clock.db_now
from eligible, db_clock
where delivery.retention_bucket = eligible.retention_bucket
and delivery.event_id = eligible.event_id
and delivery.destination = eligible.destination
returning delivery.*
)
select claimed.retention_bucket,
claimed.event_id,
claimed.destination,
claimed.claim_owner,
claimed.claim_token,
claimed.attempt,
claimed.version,
claimed.publication_epoch,
event.event_type,
event.event_schema,
event.aggregate_type,
event.aggregate_id,
event.aggregate_version,
event.event_ordinal,
event.partition_key,
event.content_type,
event.correlation_id,
event.causation_id,
event.occurred_at,
event.payload,
event.payload_digest
from claimed
join outbox_event_log_v2 event
on event.retention_bucket = claimed.retention_bucket
and event.event_id = claimed.event_id
order by event.created_at, event.event_id
""";
private static final String MARK_PUBLISHED_SQL =
"""
update outbox_delivery_v2 delivery
set state = 'PUBLISHED',
claim_owner = null,
claim_token = null,
claim_until = null,
last_operation_id = ?,
last_result_digest = ?,
published_at = clock_timestamp(),
version = version + 1,
updated_at = clock_timestamp()
where retention_bucket = ?
and event_id = ?
and destination = ?
and state = 'CLAIMED'
and claim_owner = ?
and claim_token = ?
and attempt = ?
and version = ?
and publication_epoch = ?
and exists (
select 1
from outbox_publication_control_v2 control
join outbox_publication_cutover_v2 cutover
on cutover.scope_id = control.scope_id
and cutover.active_epoch = control.active_epoch
and cutover.active_authority = control.active_authority
where control.scope_id = 'PRIMARY'
and control.state = 'ACTIVE'
and control.active_authority = 'POLLING_V2'
and control.active_epoch = delivery.publication_epoch
)
""";
private static final String MARK_RETRY_SQL =
"""
update outbox_delivery_v2 delivery
set state = 'RETRY_WAIT',
claim_owner = null,
claim_token = null,
claim_until = null,
next_attempt_at = ?,
last_error_code = ?,
last_operation_id = ?,
last_result_digest = ?,
version = version + 1,
updated_at = clock_timestamp()
where retention_bucket = ?
and event_id = ?
and destination = ?
and state = 'CLAIMED'
and claim_owner = ?
and claim_token = ?
and attempt = ?
and version = ?
and publication_epoch = ?
and exists (
select 1
from outbox_publication_control_v2 control
where control.scope_id = 'PRIMARY'
and control.state = 'ACTIVE'
and control.active_authority = 'POLLING_V2'
and control.active_epoch = delivery.publication_epoch
)
""";
private static final String MARK_DEAD_SQL =
"""
update outbox_delivery_v2 delivery
set state = 'DEAD',
claim_owner = null,
claim_token = null,
claim_until = null,
last_error_code = ?,
last_operation_id = ?,
last_result_digest = ?,
dead_at = clock_timestamp(),
version = version + 1,
updated_at = clock_timestamp()
where retention_bucket = ?
and event_id = ?
and destination = ?
and state = 'CLAIMED'
and claim_owner = ?
and claim_token = ?
and attempt = ?
and version = ?
and publication_epoch = ?
and exists (
select 1
from outbox_publication_control_v2 control
where control.scope_id = 'PRIMARY'
and control.state = 'ACTIVE'
and control.active_authority = 'POLLING_V2'
and control.active_epoch = delivery.publication_epoch
)
""";
private static final String INSPECT_SQL =
"""
select delivery.state,
delivery.claim_owner,
delivery.claim_token,
delivery.attempt,
delivery.version,
delivery.publication_epoch,
delivery.last_operation_id,
delivery.last_result_digest,
control.active_epoch,
control.active_authority,
control.state as authority_state
from outbox_delivery_v2 delivery
cross join outbox_publication_control_v2 control
where delivery.retention_bucket = ?
and delivery.event_id = ?
and delivery.destination = ?
and control.scope_id = 'PRIMARY'
""";
private final DataSource dataSource;
private final JdbcOperations jdbc;
private final SecureRandom secureRandom;
@Autowired
public PostgreSqlPollingDeliveryAdapter(DataSource dataSource) {
this(dataSource, new JdbcTemplate(dataSource), new SecureRandom());
}
PostgreSqlPollingDeliveryAdapter(
DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) {
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
}
@Override
public List<ClaimedOutboxDelivery> claimBatch(OutboxDeliveryClaimRequest request) {
Objects.requireNonNull(request, "request");
requireSameResourcePrimaryWriteTransaction();
requireActiveCapabilities();
String claimToken = newClaimToken();
return jdbc.query(
CLAIM_SQL,
this::mapClaim,
request.destination(),
request.batchSize(),
request.claimOwner(),
claimToken,
request.claimLease().toMillis());
}
@Override
public OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition) {
Objects.requireNonNull(transition, "transition");
requireSameResourcePrimaryWriteTransaction();
String digest = transitionDigest("PUBLISHED", transition, null);
OutboxDeliveryOwner owner = transition.owner();
int updated =
jdbc.update(
MARK_PUBLISHED_SQL,
transition.operationId().value(),
digest,
owner.retentionBucket(),
owner.eventId(),
owner.destination(),
owner.claimOwner(),
owner.claimToken(),
owner.attempt(),
owner.version(),
owner.publicationEpoch());
return updated == 1
? OutboxDeliveryTransitionOutcome.PUBLISHED
: classifyFailedTransition(transition, digest);
}
@Override
public OutboxDeliveryTransitionOutcome markRetryable(
OutboxDeliveryTransition transition, Instant nextAttemptAt, String errorCode) {
Objects.requireNonNull(transition, "transition");
Objects.requireNonNull(nextAttemptAt, "nextAttemptAt");
requireErrorCode(errorCode);
requireSameResourcePrimaryWriteTransaction();
String digest = transitionDigest("RETRY_WAIT", transition, errorCode);
OutboxDeliveryOwner owner = transition.owner();
int updated =
jdbc.update(
MARK_RETRY_SQL,
OffsetDateTime.ofInstant(nextAttemptAt, ZoneOffset.UTC),
errorCode,
transition.operationId().value(),
digest,
owner.retentionBucket(),
owner.eventId(),
owner.destination(),
owner.claimOwner(),
owner.claimToken(),
owner.attempt(),
owner.version(),
owner.publicationEpoch());
return updated == 1
? OutboxDeliveryTransitionOutcome.RETRY_SCHEDULED
: classifyFailedTransition(transition, digest);
}
@Override
public OutboxDeliveryTransitionOutcome markDead(
OutboxDeliveryTransition transition, String errorCode) {
Objects.requireNonNull(transition, "transition");
requireErrorCode(errorCode);
requireSameResourcePrimaryWriteTransaction();
String digest = transitionDigest("DEAD", transition, errorCode);
OutboxDeliveryOwner owner = transition.owner();
int updated =
jdbc.update(
MARK_DEAD_SQL,
errorCode,
transition.operationId().value(),
digest,
owner.retentionBucket(),
owner.eventId(),
owner.destination(),
owner.claimOwner(),
owner.claimToken(),
owner.attempt(),
owner.version(),
owner.publicationEpoch());
return updated == 1
? OutboxDeliveryTransitionOutcome.DEAD
: classifyFailedTransition(transition, digest);
}
private ClaimedOutboxDelivery mapClaim(ResultSet resultSet, int rowNumber) throws SQLException {
OutboxDeliveryOwner owner =
new OutboxDeliveryOwner(
resultSet.getObject("retention_bucket", LocalDate.class),
resultSet.getString("event_id"),
resultSet.getString("destination"),
resultSet.getString("claim_owner"),
resultSet.getString("claim_token"),
resultSet.getInt("attempt"),
resultSet.getLong("version"),
resultSet.getLong("publication_epoch"));
return new ClaimedOutboxDelivery(
owner,
resultSet.getString("event_type"),
resultSet.getInt("event_schema"),
resultSet.getString("aggregate_type"),
resultSet.getString("aggregate_id"),
resultSet.getLong("aggregate_version"),
resultSet.getInt("event_ordinal"),
resultSet.getString("partition_key"),
resultSet.getString("content_type"),
resultSet.getString("correlation_id"),
resultSet.getString("causation_id"),
resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(),
resultSet.getString("payload"),
resultSet.getString("payload_digest"));
}
private OutboxDeliveryTransitionOutcome classifyFailedTransition(
OutboxDeliveryTransition transition, String requestedDigest) {
OutboxDeliveryOwner owner = transition.owner();
List<DeliveryState> rows =
jdbc.query(
INSPECT_SQL,
(resultSet, rowNumber) ->
new DeliveryState(
resultSet.getString("state"),
resultSet.getString("claim_owner"),
resultSet.getString("claim_token"),
resultSet.getInt("attempt"),
resultSet.getLong("version"),
resultSet.getLong("publication_epoch"),
resultSet.getString("last_operation_id"),
resultSet.getString("last_result_digest"),
resultSet.getLong("active_epoch"),
resultSet.getString("active_authority"),
resultSet.getString("authority_state")),
owner.retentionBucket(),
owner.eventId(),
owner.destination());
if (rows.isEmpty()) {
return OutboxDeliveryTransitionOutcome.ABSENT;
}
DeliveryState row = rows.getFirst();
if (transition.operationId().value().equals(row.lastOperationId())) {
return requestedDigest.equals(row.lastResultDigest())
? OutboxDeliveryTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION
: OutboxDeliveryTransitionOutcome.RESULT_CONFLICT;
}
if (!"ACTIVE".equals(row.authorityState())
|| !"POLLING_V2".equals(row.activeAuthority())
|| row.activeEpoch() != row.publicationEpoch()) {
return OutboxDeliveryTransitionOutcome.AUTHORITY_MISMATCH;
}
if (!Objects.equals(owner.claimOwner(), row.claimOwner())
|| !Objects.equals(owner.claimToken(), row.claimToken())
|| owner.attempt() != row.attempt()) {
return OutboxDeliveryTransitionOutcome.NOT_OWNER;
}
if (!"CLAIMED".equals(row.state())) {
return OutboxDeliveryTransitionOutcome.NOT_CLAIMED;
}
if (owner.version() != row.version()) {
return OutboxDeliveryTransitionOutcome.STALE_VERSION;
}
return OutboxDeliveryTransitionOutcome.RESULT_CONFLICT;
}
private void requireActiveCapabilities() {
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITIES_SQL, Integer.class);
if (active == null || active != 2) {
throw new IllegalStateException(
"outbox storage and polling delivery V2 must both be active at revision 2");
}
}
private void requireSameResourcePrimaryWriteTransaction() {
if (!TransactionSynchronizationManager.isActualTransactionActive()
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly()
|| !TransactionSynchronizationManager.hasResource(dataSource)) {
throw new IllegalStateException(
"polling delivery mutation requires the adapter datasource primary write transaction");
}
}
private String newClaimToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private static void requireErrorCode(String errorCode) {
if (errorCode == null || !ERROR_CODE.matcher(errorCode).matches()) {
throw new IllegalArgumentException(
"error code must be 1-64 uppercase ASCII letters, digits, dot, dash, or underscore");
}
}
private static String transitionDigest(
String kind, OutboxDeliveryTransition transition, String detail) {
OutboxDeliveryOwner owner = transition.owner();
return sha256(
kind
+ '|'
+ transition.operationId().value()
+ '|'
+ owner.eventId()
+ '|'
+ owner.destination()
+ '|'
+ owner.claimToken()
+ '|'
+ owner.attempt()
+ '|'
+ owner.version()
+ '|'
+ Objects.toString(detail, ""));
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private record DeliveryState(
String state,
String claimOwner,
String claimToken,
int attempt,
long version,
long publicationEpoch,
String lastOperationId,
String lastResultDigest,
long activeEpoch,
String activeAuthority,
String authorityState) {}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import java.time.Duration;
import java.util.Objects;
/** PostgreSQL transaction-local timeouts bounded by the remaining absolute deadline. */
public record EffectiveTransactionTimeouts(
Duration statementTimeout, Duration lockTimeout, Duration idleGuardTimeout) {
public EffectiveTransactionTimeouts {
requirePositive(statementTimeout, "statementTimeout");
requirePositive(lockTimeout, "lockTimeout");
requirePositive(idleGuardTimeout, "idleGuardTimeout");
if (lockTimeout.compareTo(statementTimeout) >= 0) {
throw new IllegalArgumentException("lockTimeout must be less than statementTimeout");
}
}
private static void requirePositive(Duration duration, String name) {
Objects.requireNonNull(duration, name + " must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
import org.springframework.validation.annotation.Validated;
/** Finite deadline and PostgreSQL transaction-local timeout policy. */
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.jpa.transaction")
public record JpaTransactionSettings(
Duration transactionTimeout,
Duration beginBudget,
Duration minimumActionWindow,
Duration completionMargin,
Duration statementTimeout,
Duration lockTimeout,
Duration idleGuardTimeout,
Duration transactionMargin,
Duration lockMargin,
Duration retryBaseDelay,
Duration retryMaximumDelay,
Integer retryMaximumAttempts) {
private static final Duration MAXIMUM = Duration.ofDays(1);
@ConstructorBinding
public JpaTransactionSettings {
transactionTimeout =
defaulted(transactionTimeout, Duration.ofSeconds(30), "transaction-timeout");
beginBudget = defaulted(beginBudget, Duration.ofMillis(250), "begin-budget");
minimumActionWindow =
defaulted(minimumActionWindow, Duration.ofSeconds(1), "minimum-action-window");
completionMargin = defaulted(completionMargin, Duration.ofMillis(500), "completion-margin");
statementTimeout = defaulted(statementTimeout, Duration.ofSeconds(10), "statement-timeout");
lockTimeout = defaulted(lockTimeout, Duration.ofSeconds(2), "lock-timeout");
idleGuardTimeout = defaulted(idleGuardTimeout, Duration.ofSeconds(15), "idle-guard-timeout");
transactionMargin = defaulted(transactionMargin, Duration.ofMillis(250), "transaction-margin");
lockMargin = defaulted(lockMargin, Duration.ofMillis(100), "lock-margin");
retryBaseDelay = defaulted(retryBaseDelay, Duration.ofMillis(10), "retry-base-delay");
retryMaximumDelay = defaulted(retryMaximumDelay, Duration.ofMillis(50), "retry-maximum-delay");
retryMaximumAttempts = retryMaximumAttempts == null ? 2 : retryMaximumAttempts;
if (statementTimeout.compareTo(transactionTimeout) > 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.statement-timeout must be <= transaction-timeout");
}
if (lockTimeout.compareTo(statementTimeout) >= 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.lock-timeout must be < statement-timeout");
}
if (transactionMargin.compareTo(statementTimeout) >= 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.transaction-margin must be < statement-timeout");
}
if (lockMargin.compareTo(statementTimeout.minus(lockTimeout)) >= 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.lock-margin must leave lock-timeout below statement-timeout");
}
if (retryBaseDelay.compareTo(retryMaximumDelay) > 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.retry-base-delay must be <= retry-maximum-delay");
}
if (retryMaximumAttempts < 1 || retryMaximumAttempts > 5) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction.retry-maximum-attempts must be between 1 and 5");
}
}
public JpaTransactionSettings(
Duration transactionTimeout,
Duration beginBudget,
Duration minimumActionWindow,
Duration completionMargin,
Duration statementTimeout,
Duration lockTimeout,
Duration idleGuardTimeout,
Duration transactionMargin,
Duration lockMargin) {
this(
transactionTimeout,
beginBudget,
minimumActionWindow,
completionMargin,
statementTimeout,
lockTimeout,
idleGuardTimeout,
transactionMargin,
lockMargin,
null,
null,
null);
}
private static Duration defaulted(Duration value, Duration fallback, String name) {
Duration selected = value == null ? fallback : value;
if (selected.isZero() || selected.isNegative() || selected.compareTo(MAXIMUM) > 0) {
throw new IllegalArgumentException(
"ca-skeleton.jpa.transaction." + name + " must be in (0, 1 day]");
}
return selected;
}
}
@@ -0,0 +1,287 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator;
import dev.caskeleton.application.transaction.OperationId;
import dev.caskeleton.application.transaction.TransactionAdmissionException;
import dev.caskeleton.application.transaction.TransactionPhase;
import dev.caskeleton.application.transaction.TransactionPolicyId;
import dev.caskeleton.application.transaction.TransactionRequest;
import dev.caskeleton.application.transaction.TransactionResult;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.springframework.core.Ordered;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.UnexpectedRollbackException;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Internal Spring executor for the application-owned named transaction policies. */
final class SpringPolicyTransactionPort {
private final PlatformTransactionManager transactionManager;
private final LongSupplier monotonicNanos;
private final TransactionDeadlineCalculator deadlineCalculator;
private final TransactionRetryBackoff retryBackoff;
private final TransactionLocalTimeoutConfigurer localTimeoutConfigurer;
private final PersistenceExceptionTranslator exceptionTranslator;
SpringPolicyTransactionPort(
PlatformTransactionManager transactionManager,
LongSupplier monotonicNanos,
TransactionDeadlineCalculator deadlineCalculator,
TransactionRetryBackoff retryBackoff,
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
PersistenceExceptionTranslator exceptionTranslator) {
this.transactionManager =
Objects.requireNonNull(transactionManager, "transactionManager must be non-null");
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos must be non-null");
this.deadlineCalculator =
Objects.requireNonNull(deadlineCalculator, "deadlineCalculator must be non-null");
this.retryBackoff = Objects.requireNonNull(retryBackoff, "retryBackoff must be non-null");
this.localTimeoutConfigurer =
Objects.requireNonNull(localTimeoutConfigurer, "localTimeoutConfigurer must be non-null");
this.exceptionTranslator =
Objects.requireNonNull(exceptionTranslator, "exceptionTranslator must be non-null");
}
<T> TransactionResult<T> execute(TransactionRequest request, Supplier<T> action) {
Objects.requireNonNull(request, "request must be non-null");
Objects.requireNonNull(action, "action must be non-null");
PolicySpec policy = policy(request.policyId());
if (policy.replicaRequired()) {
throw new TransactionAdmissionException(
"replica transaction policy is unavailable until the replica capability is qualified");
}
int attempt = 1;
while (true) {
AttemptResult<T> attemptResult = executeOnce(request, action, policy);
if (!shouldRetry(request.policyId(), attemptResult, attempt)) {
return attemptResult.result();
}
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
return attemptResult.result();
}
attempt++;
}
}
private <T> AttemptResult<T> executeOnce(
TransactionRequest request, Supplier<T> action, PolicySpec policy) {
TransactionStartBudget startBudget =
deadlineCalculator.beforeAcquisition(request.callBudget(), monotonicNanos.getAsLong());
DefaultTransactionDefinition definition = definition(request.policyId(), startBudget, policy);
TransactionStatus status;
try {
status = transactionManager.getTransaction(definition);
} catch (RuntimeException failure) {
throw new TransactionAdmissionException(
"transaction acquisition failed before application work started", failure);
}
PhaseTracker tracker = new PhaseTracker();
tracker.observe(TransactionPhase.CONNECTION_ACQUIRED);
boolean physicalOwner = status.isNewTransaction();
PhaseSentinel sentinel = registerSentinelIfPossible(physicalOwner, tracker);
tracker.observe(TransactionPhase.ACTIVE);
try {
EffectiveTransactionTimeouts effectiveTimeouts =
deadlineCalculator.afterBegin(
request.callBudget(), monotonicNanos.getAsLong(), startBudget);
localTimeoutConfigurer.apply(effectiveTimeouts);
} catch (RuntimeException localTimeoutFailure) {
return new AttemptResult<>(
rollback(status, request.operationId(), tracker, localTimeoutFailure), physicalOwner);
}
T value;
try {
value = action.get();
} catch (RuntimeException actionFailure) {
return new AttemptResult<>(
rollback(status, request.operationId(), tracker, actionFailure), physicalOwner);
}
tracker.observe(TransactionPhase.COMMIT_REQUESTED);
try {
transactionManager.commit(status);
} catch (RuntimeException commitFailure) {
if (sentinel.commitAcknowledged()) {
return new AttemptResult<>(
new TransactionResult.CommittedWithPostCommitFailure<>(
value, request.operationId(), commitFailure),
physicalOwner);
}
if (sentinel.rolledBack()
|| commitFailure instanceof UnexpectedRollbackException
|| TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
return new AttemptResult<>(
new TransactionResult.DeterminateRollback<>(translate(commitFailure)), physicalOwner);
}
return new AttemptResult<>(
new TransactionResult.Indeterminate<>(
request.operationId(), tracker.lastObserved(), Optional.empty()),
physicalOwner);
}
if (!physicalOwner) {
return new AttemptResult<>(new TransactionResult.Participating<>(value), false);
}
tracker.observe(TransactionPhase.COMMIT_ACKED);
return new AttemptResult<>(
new TransactionResult.Committed<>(value, request.operationId()), true);
}
private boolean shouldRetry(
TransactionPolicyId policyId, AttemptResult<?> attemptResult, int attempt) {
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|| !attemptResult.physicalOwner()
|| attempt >= retryBackoff.maximumAttempts()
|| Thread.currentThread().isInterrupted()) {
return false;
}
TransactionResult<?> result = attemptResult.result();
if (result instanceof TransactionResult.DeterminateRollback<?> rollback) {
return TransactionRetryClassifier.isReplayCandidate(rollback.failure());
}
return false;
}
private <T> TransactionResult<T> rollback(
TransactionStatus status,
Optional<OperationId> operationId,
PhaseTracker tracker,
RuntimeException actionFailure) {
try {
transactionManager.rollback(status);
return new TransactionResult.DeterminateRollback<>(translate(actionFailure));
} catch (RuntimeException rollbackFailure) {
actionFailure.addSuppressed(rollbackFailure);
return new TransactionResult.Indeterminate<>(
operationId, tracker.lastObserved(), Optional.empty());
}
}
private RuntimeException translate(RuntimeException failure) {
return exceptionTranslator.translate(failure).map(RuntimeException.class::cast).orElse(failure);
}
private DefaultTransactionDefinition definition(
TransactionPolicyId policyId, TransactionStartBudget startBudget, PolicySpec policy) {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setName("application-" + policyId.name().toLowerCase(Locale.ROOT));
definition.setPropagationBehavior(policy.propagation());
definition.setIsolationLevel(policy.isolation());
definition.setReadOnly(policy.readOnly());
definition.setTimeout(startBudget.springTimeoutSeconds());
return definition;
}
private static PolicySpec policy(TransactionPolicyId policyId) {
return switch (policyId) {
case COMMAND_DEFAULT, INBOX_AND_HANDLER ->
required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
case COMMAND_SERIALIZABLE_REPLAY_SAFE ->
required(TransactionDefinition.ISOLATION_SERIALIZABLE, false);
case QUERY_PRIMARY -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, true);
case QUERY_REPLICA_ELIGIBLE ->
new PolicySpec(
TransactionDefinition.PROPAGATION_REQUIRED,
TransactionDefinition.ISOLATION_READ_COMMITTED,
true,
true);
case OUTBOX_APPEND -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
case MAINTENANCE_NEW ->
new PolicySpec(
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
TransactionDefinition.ISOLATION_READ_COMMITTED,
false,
false);
};
}
private static PolicySpec required(int isolation, boolean readOnly) {
return new PolicySpec(TransactionDefinition.PROPAGATION_REQUIRED, isolation, readOnly, false);
}
private static PhaseSentinel registerSentinelIfPossible(
boolean physicalOwner, PhaseTracker tracker) {
PhaseSentinel sentinel = new PhaseSentinel(tracker);
if (physicalOwner && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(sentinel);
}
return sentinel;
}
private record PolicySpec(
int propagation, int isolation, boolean readOnly, boolean replicaRequired) {}
private record AttemptResult<T>(TransactionResult<T> result, boolean physicalOwner) {
private AttemptResult {
Objects.requireNonNull(result, "result must be non-null");
}
}
private static final class PhaseTracker {
private TransactionPhase lastObserved = TransactionPhase.ROUTE_ADMISSION;
private void observe(TransactionPhase phase) {
lastObserved = phase;
}
private TransactionPhase lastObserved() {
return lastObserved;
}
}
private static final class PhaseSentinel implements TransactionSynchronization, Ordered {
private final PhaseTracker tracker;
private boolean commitAcknowledged;
private int completionStatus = STATUS_UNKNOWN;
private PhaseSentinel(PhaseTracker tracker) {
this.tracker = tracker;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
@Override
public void beforeCommit(boolean readOnly) {
tracker.observe(TransactionPhase.FLUSHED);
}
@Override
public void afterCommit() {
commitAcknowledged = true;
tracker.observe(TransactionPhase.COMMIT_ACKED);
}
@Override
public void afterCompletion(int status) {
completionStatus = status;
tracker.observe(TransactionPhase.SYNCHRONIZATION_CLEANUP);
}
private boolean commitAcknowledged() {
return commitAcknowledged || completionStatus == STATUS_COMMITTED;
}
private boolean rolledBack() {
return completionStatus == STATUS_ROLLED_BACK;
}
}
}
@@ -0,0 +1,216 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator;
import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping;
import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping;
import dev.caskeleton.application.transaction.Isolation;
import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException;
import dev.caskeleton.application.transaction.PolicyTransactionPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionRequest;
import dev.caskeleton.application.transaction.TransactionResult;
import java.sql.SQLException;
import java.time.Duration;
import java.util.Locale;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Spring-backed {@link PolicyTransactionPort}: one pre-built {@link TransactionTemplate} per mode,
* all pinned to {@link Isolation#READ_COMMITTED}. See README "transaction" for why the templates
* are pre-built (mutable-template race) and CLAUDE.md for the mode table.
*/
@Component
public class SpringTransactionPort implements PolicyTransactionPort {
private final TransactionTemplate writeTemplate;
private final TransactionTemplate readTemplate;
private final TransactionTemplate requiresNewTemplate;
private final SpringPolicyTransactionPort policyExecutor;
private final PersistenceExceptionTranslator exceptionTranslator;
public SpringTransactionPort(PlatformTransactionManager transactionManager) {
this(
transactionManager,
System::nanoTime,
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)),
ignored -> {},
defaultExceptionTranslator());
}
SpringTransactionPort(
PlatformTransactionManager transactionManager, LongSupplier monotonicNanos) {
this(
transactionManager,
monotonicNanos,
TransactionDeadlineCalculator.withoutAcquisitionEnvelope(
new JpaTransactionSettings(null, null, null, null, null, null, null, null, null)),
ignored -> {},
defaultExceptionTranslator());
}
@Autowired
public SpringTransactionPort(
PlatformTransactionManager transactionManager,
JpaTransactionSettings settings,
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
DataSource dataSource,
PersistenceExceptionTranslator exceptionTranslator) {
this(
transactionManager,
System::nanoTime,
new TransactionDeadlineCalculator(connectionTimeout(dataSource), settings),
TransactionRetryBackoff.production(
settings, connectionTimeout(dataSource), System::nanoTime),
localTimeoutConfigurer,
exceptionTranslator);
}
SpringTransactionPort(
PlatformTransactionManager transactionManager,
LongSupplier monotonicNanos,
TransactionDeadlineCalculator deadlineCalculator,
TransactionLocalTimeoutConfigurer localTimeoutConfigurer) {
this(
transactionManager,
monotonicNanos,
deadlineCalculator,
localTimeoutConfigurer,
defaultExceptionTranslator());
}
SpringTransactionPort(
PlatformTransactionManager transactionManager,
LongSupplier monotonicNanos,
TransactionDeadlineCalculator deadlineCalculator,
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
PersistenceExceptionTranslator exceptionTranslator) {
this(
transactionManager,
monotonicNanos,
deadlineCalculator,
TransactionRetryBackoff.production(defaultSettings(), monotonicNanos),
localTimeoutConfigurer,
exceptionTranslator);
}
SpringTransactionPort(
PlatformTransactionManager transactionManager,
LongSupplier monotonicNanos,
TransactionDeadlineCalculator deadlineCalculator,
TransactionRetryBackoff retryBackoff,
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
PersistenceExceptionTranslator exceptionTranslator) {
this.writeTemplate =
template(
transactionManager,
TransactionMode.WRITE,
TransactionDefinition.PROPAGATION_REQUIRED,
false);
this.readTemplate =
template(
transactionManager,
TransactionMode.READ_ONLY,
TransactionDefinition.PROPAGATION_REQUIRED,
true);
this.requiresNewTemplate =
template(
transactionManager,
TransactionMode.REQUIRES_NEW,
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
false);
this.policyExecutor =
new SpringPolicyTransactionPort(
transactionManager,
monotonicNanos,
deadlineCalculator,
retryBackoff,
localTimeoutConfigurer,
exceptionTranslator);
this.exceptionTranslator = exceptionTranslator;
}
@Override
public <T> T inWrite(Supplier<T> action) {
return executeLegacy(writeTemplate, action);
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new NestedRootTransactionRejectedException();
}
return executeLegacy(writeTemplate, action);
}
@Override
public <T> T inRead(Supplier<T> action) {
return executeLegacy(readTemplate, action);
}
@Override
public <T> T inNew(Supplier<T> action) {
return executeLegacy(requiresNewTemplate, action);
}
@Override
public <T> TransactionResult<T> inTransaction(TransactionRequest request, Supplier<T> action) {
return policyExecutor.execute(request, action);
}
private static TransactionTemplate template(
PlatformTransactionManager transactionManager,
TransactionMode mode,
int propagation,
boolean readOnly) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setName("application-" + mode.name().toLowerCase(Locale.ROOT));
template.setPropagationBehavior(propagation);
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
template.setReadOnly(readOnly);
return template;
}
private static Duration connectionTimeout(DataSource dataSource) {
try {
HikariDataSource hikariDataSource =
dataSource instanceof HikariDataSource hikari
? hikari
: dataSource.unwrap(HikariDataSource.class);
return Duration.ofMillis(hikariDataSource.getConnectionTimeout());
} catch (SQLException exception) {
throw new IllegalStateException(
"the JPA transaction deadline policy requires a HikariDataSource", exception);
}
}
private <T> T executeLegacy(TransactionTemplate template, Supplier<T> action) {
try {
return template.execute(status -> action.get());
} catch (RuntimeException failure) {
throw exceptionTranslator
.translate(failure)
.map(RuntimeException.class::cast)
.orElse(failure);
}
}
private static PersistenceExceptionTranslator defaultExceptionTranslator() {
return new PersistenceExceptionTranslator(
java.util.List.of(
new StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping()));
}
private static JpaTransactionSettings defaultSettings() {
return new JpaTransactionSettings(null, null, null, null, null, null, null, null, null);
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import dev.caskeleton.application.outbound.CallBudget;
import dev.caskeleton.application.transaction.TransactionAdmissionException;
import java.time.Duration;
import java.util.Objects;
/** Computes conservative Spring and PostgreSQL timeout windows from one monotonic deadline. */
final class TransactionDeadlineCalculator {
private static final long NANOS_PER_MILLISECOND = Duration.ofMillis(1).toNanos();
private static final long NANOS_PER_SECOND = Duration.ofSeconds(1).toNanos();
private final Duration connectionTimeout;
private final JpaTransactionSettings settings;
private final boolean ignoreAcquisitionEnvelope;
TransactionDeadlineCalculator(Duration connectionTimeout, JpaTransactionSettings settings) {
this(connectionTimeout, settings, false);
}
private TransactionDeadlineCalculator(
Duration connectionTimeout,
JpaTransactionSettings settings,
boolean ignoreAcquisitionEnvelope) {
this.connectionTimeout =
Objects.requireNonNull(connectionTimeout, "connectionTimeout must be non-null");
this.settings = Objects.requireNonNull(settings, "settings must be non-null");
this.ignoreAcquisitionEnvelope = ignoreAcquisitionEnvelope;
if (connectionTimeout.isNegative()) {
throw new IllegalArgumentException("connectionTimeout must not be negative");
}
}
static TransactionDeadlineCalculator withoutAcquisitionEnvelope(JpaTransactionSettings settings) {
return new TransactionDeadlineCalculator(Duration.ZERO, settings, true);
}
TransactionStartBudget beforeAcquisition(CallBudget callBudget, long nowNanos) {
Objects.requireNonNull(callBudget, "callBudget must be non-null");
long remainingNanos = callBudget.remainingNanosAt(nowNanos);
long requiredNanos =
ignoreAcquisitionEnvelope
? 0
: sumNanos(
connectionTimeout,
settings.beginBudget(),
settings.minimumActionWindow(),
settings.completionMargin());
if (remainingNanos < requiredNanos) {
throw new TransactionAdmissionException(
"remaining call budget cannot contain pool acquisition, begin, action, and completion");
}
long safeTransactionNanos =
ignoreAcquisitionEnvelope
? remainingNanos
: remainingNanos
- connectionTimeout.toNanos()
- settings.beginBudget().toNanos()
- settings.completionMargin().toNanos();
long boundedNanos = Math.min(safeTransactionNanos, settings.transactionTimeout().toNanos());
int timeoutSeconds = (int) Math.min(Integer.MAX_VALUE, boundedNanos / NANOS_PER_SECOND);
if (timeoutSeconds < 1) {
throw new TransactionAdmissionException(
"at least one second must remain for the Spring transaction timeout");
}
return new TransactionStartBudget(timeoutSeconds, nowNanos);
}
EffectiveTransactionTimeouts afterBegin(
CallBudget callBudget, long nowNanos, TransactionStartBudget startBudget) {
Objects.requireNonNull(callBudget, "callBudget must be non-null");
Objects.requireNonNull(startBudget, "startBudget must be non-null");
long elapsedNanos = Math.max(0, nowNanos - startBudget.acquisitionStartedNanos());
long springRemainingNanos =
(long) startBudget.springTimeoutSeconds() * NANOS_PER_SECOND - elapsedNanos;
long callRemainingNanos = callBudget.remainingNanosAt(nowNanos);
long completionNanos = ignoreAcquisitionEnvelope ? 0 : settings.completionMargin().toNanos();
long transactionMarginNanos =
ignoreAcquisitionEnvelope ? 0 : settings.transactionMargin().toNanos();
long lockMarginNanos = ignoreAcquisitionEnvelope ? 1 : settings.lockMargin().toNanos();
long statementWindowNanos =
Math.min(callRemainingNanos - completionNanos, springRemainingNanos)
- transactionMarginNanos;
long statementNanos = Math.min(settings.statementTimeout().toNanos(), statementWindowNanos);
long lockNanos = Math.min(settings.lockTimeout().toNanos(), statementNanos - lockMarginNanos);
long idleNanos =
Math.min(settings.idleGuardTimeout().toNanos(), callRemainingNanos - completionNanos);
if (statementNanos < NANOS_PER_MILLISECOND
|| lockNanos < NANOS_PER_MILLISECOND
|| idleNanos < NANOS_PER_MILLISECOND) {
throw new TransactionAdmissionException(
"remaining call budget after transaction begin cannot contain local timeout windows");
}
return new EffectiveTransactionTimeouts(
Duration.ofNanos(statementNanos), Duration.ofNanos(lockNanos), Duration.ofNanos(idleNanos));
}
private static long sumNanos(Duration... durations) {
long result = 0;
try {
for (Duration duration : durations) {
result = Math.addExact(result, duration.toNanos());
}
return result;
} catch (ArithmeticException exception) {
throw new IllegalArgumentException(
"transaction deadline settings exceed the supported range", exception);
}
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
/** Vendor implementation applies timeout values to the active physical transaction. */
@FunctionalInterface
public interface TransactionLocalTimeoutConfigurer {
void apply(EffectiveTransactionTimeouts timeouts);
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import dev.caskeleton.application.outbound.CallBudget;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.locks.LockSupport;
import java.util.function.LongSupplier;
/** Bounded exponential full-jitter pause constrained by the caller's absolute budget. */
final class TransactionRetryBackoff {
@FunctionalInterface
interface NanosSleeper {
void sleep(long nanos);
}
@FunctionalInterface
interface JitterSource {
long nextLong(long exclusiveBound);
}
private final JpaTransactionSettings settings;
private final LongSupplier monotonicNanos;
private final NanosSleeper sleeper;
private final JitterSource jitter;
private final long minimumNextAttemptNanos;
TransactionRetryBackoff(
JpaTransactionSettings settings,
LongSupplier monotonicNanos,
NanosSleeper sleeper,
JitterSource jitter) {
this(settings, Duration.ZERO, monotonicNanos, sleeper, jitter);
}
TransactionRetryBackoff(
JpaTransactionSettings settings,
Duration connectionAcquisitionTimeout,
LongSupplier monotonicNanos,
NanosSleeper sleeper,
JitterSource jitter) {
this.settings = Objects.requireNonNull(settings, "settings");
Objects.requireNonNull(connectionAcquisitionTimeout, "connectionAcquisitionTimeout");
if (connectionAcquisitionTimeout.isNegative()) {
throw new IllegalArgumentException("connectionAcquisitionTimeout must not be negative");
}
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos");
this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
this.jitter = Objects.requireNonNull(jitter, "jitter");
try {
this.minimumNextAttemptNanos =
Math.addExact(
connectionAcquisitionTimeout.toNanos(),
Math.addExact(
settings.minimumActionWindow().toNanos(),
Math.addExact(
settings.beginBudget().toNanos(), settings.completionMargin().toNanos())));
} catch (ArithmeticException exception) {
throw new IllegalArgumentException(
"retry minimum attempt window exceeds long range", exception);
}
}
static TransactionRetryBackoff production(
JpaTransactionSettings settings, LongSupplier monotonicNanos) {
return production(settings, Duration.ZERO, monotonicNanos);
}
static TransactionRetryBackoff production(
JpaTransactionSettings settings,
Duration connectionAcquisitionTimeout,
LongSupplier monotonicNanos) {
return new TransactionRetryBackoff(
settings,
connectionAcquisitionTimeout,
monotonicNanos,
LockSupport::parkNanos,
bound -> ThreadLocalRandom.current().nextLong(bound));
}
boolean pauseBeforeRetry(CallBudget callBudget, int completedAttempts) {
Objects.requireNonNull(callBudget, "callBudget");
if (completedAttempts < 1 || Thread.currentThread().isInterrupted()) {
return false;
}
long cappedDelayNanos = cappedExponentialDelay(completedAttempts);
long jitteredDelayNanos = jitter.nextLong(cappedDelayNanos + 1);
long remainingNanos = callBudget.remainingNanosAt(monotonicNanos.getAsLong());
if (remainingNanos <= saturatedAdd(jitteredDelayNanos, minimumNextAttemptNanos)) {
return false;
}
if (jitteredDelayNanos > 0) {
sleeper.sleep(jitteredDelayNanos);
}
return !Thread.currentThread().isInterrupted()
&& callBudget.remainingNanosAt(monotonicNanos.getAsLong()) > minimumNextAttemptNanos;
}
int maximumAttempts() {
return settings.retryMaximumAttempts();
}
private long cappedExponentialDelay(int completedAttempts) {
long baseNanos = settings.retryBaseDelay().toNanos();
long maximumNanos = settings.retryMaximumDelay().toNanos();
int shift = Math.min(completedAttempts - 1, 62);
if (baseNanos > (maximumNanos >> shift)) {
return maximumNanos;
}
return Math.min(maximumNanos, baseNanos << shift);
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException ignored) {
return Long.MAX_VALUE;
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import java.sql.SQLException;
/** Fail-closed SQLState classifier for replay-safe whole-transaction retries. */
final class TransactionRetryClassifier {
private static final String SERIALIZATION_FAILURE = "40001";
private static final String POSTGRESQL_DEADLOCK = "40P01";
private TransactionRetryClassifier() {}
static boolean isReplayCandidate(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof SQLException sqlException) {
String sqlState = sqlException.getSQLState();
return SERIALIZATION_FAILURE.equals(sqlState) || POSTGRESQL_DEADLOCK.equals(sqlState);
}
if (current.getCause() == current) {
return false;
}
}
return false;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
/** Conservative Spring transaction timeout selected before pool acquisition. */
public record TransactionStartBudget(int springTimeoutSeconds, long acquisitionStartedNanos) {
public TransactionStartBudget {
if (springTimeoutSeconds < 1) {
throw new IllegalArgumentException("springTimeoutSeconds must be positive");
}
}
}
@@ -0,0 +1,41 @@
-- Independent core stream initialization. The bridge V6 creates the registry for an adopted
-- legacy database; a fresh target database may create it here before optional streams run.
CREATE TABLE IF NOT EXISTS capability_schema_registry (
capability_id varchar(128) NOT NULL,
schema_stream varchar(32) NOT NULL,
installation_origin varchar(32) NOT NULL,
core_epoch integer NOT NULL,
feature_revision integer NOT NULL,
lifecycle_state varchar(32) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id),
CONSTRAINT ck_capability_schema_registry_origin
CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')),
CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0),
CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0)
);
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
) VALUES (
'jpa-flyway-migration',
'db/migration/jpa/core',
CASE
WHEN EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE installation_origin = 'LEGACY_ADOPTED'
) THEN 'LEGACY_ADOPTED'
ELSE 'FRESH'
END,
1,
1,
'ACTIVE'
)
ON CONFLICT (capability_id) DO NOTHING;
@@ -0,0 +1,198 @@
-- Fileserver platform metadata. The relational record — not the filesystem — decides whether a
-- file is publicly readable, so every state transition is guarded by both `state` and `version`.
-- No physical path, mount, or original physical filename is stored here: `content_key` is a
-- server-generated opaque key and `original_name` is untrusted display text only.
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 'fileserver metadata requires active core epoch 1';
END IF;
END
$$;
CREATE TABLE fs_file (
file_id uuid NOT NULL,
namespace varchar(63) NOT NULL,
state varchar(32) NOT NULL,
content_key varchar(200),
original_name varchar(255) NOT NULL,
claimed_media_type varchar(255),
verified_media_type varchar(255),
expected_size bigint,
actual_size bigint,
sha256 char(64),
strong_etag varchar(80),
published_at timestamptz,
last_error_code varchar(64),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_fs_file PRIMARY KEY (file_id),
CONSTRAINT ck_fs_file_state
CHECK (state IN (
'CREATED', 'UPLOADING', 'UPLOADED', 'VERIFYING', 'QUARANTINED',
'READY', 'REJECTED', 'FAILED', 'DELETING', 'DELETED', 'EXPIRED')),
CONSTRAINT ck_fs_file_size CHECK (actual_size IS NULL OR actual_size >= 0),
CONSTRAINT ck_fs_file_expected_size CHECK (expected_size IS NULL OR expected_size >= 0),
CONSTRAINT ck_fs_file_version CHECK (version >= 0),
-- READY is the only publicly readable state, so it must carry complete, verified identity.
CONSTRAINT ck_fs_file_ready_is_complete
CHECK (
state <> 'READY'
OR (content_key IS NOT NULL
AND actual_size IS NOT NULL
AND sha256 IS NOT NULL
AND strong_etag IS NOT NULL
AND published_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX uq_fs_file_content_key
ON fs_file (content_key)
WHERE content_key IS NOT NULL;
CREATE INDEX ix_fs_file_state_updated
ON fs_file (state, updated_at);
CREATE INDEX ix_fs_file_namespace_state
ON fs_file (namespace, state);
CREATE TABLE fs_upload_session (
upload_id uuid NOT NULL,
file_id uuid NOT NULL,
protocol varchar(32) NOT NULL,
expected_length bigint,
committed_offset bigint NOT NULL DEFAULT 0,
expires_at timestamptz NOT NULL,
lease_owner varchar(128),
lease_token uuid,
lease_until timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_fs_upload_session PRIMARY KEY (upload_id),
CONSTRAINT fk_fs_upload_session_file FOREIGN KEY (file_id) REFERENCES fs_file (file_id),
CONSTRAINT ck_fs_upload_offset CHECK (committed_offset >= 0),
CONSTRAINT ck_fs_upload_expected_length
CHECK (expected_length IS NULL OR expected_length >= committed_offset),
CONSTRAINT ck_fs_upload_version CHECK (version >= 0),
CONSTRAINT ck_fs_upload_protocol
CHECK (protocol IN ('RAW', 'MULTIPART', 'BATCH', 'TUS_1_0', 'HTTPBIS_DRAFT12')),
-- A lease is all-or-nothing: an owner without a token or expiry could not be validated.
CONSTRAINT ck_fs_upload_lease_is_whole
CHECK (
(lease_owner IS NULL AND lease_token IS NULL AND lease_until IS NULL)
OR (lease_owner IS NOT NULL AND lease_token IS NOT NULL AND lease_until IS NOT NULL)
)
);
CREATE INDEX ix_fs_upload_session_expiry
ON fs_upload_session (expires_at);
CREATE INDEX ix_fs_upload_session_lease
ON fs_upload_session (lease_until)
WHERE lease_until IS NOT NULL;
CREATE INDEX ix_fs_upload_session_file
ON fs_upload_session (file_id);
CREATE TABLE fs_verification_result (
verification_id uuid NOT NULL,
file_id uuid NOT NULL,
verifier varchar(64) NOT NULL,
verdict varchar(16) NOT NULL,
details_code varchar(64) NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz,
CONSTRAINT pk_fs_verification_result PRIMARY KEY (verification_id),
CONSTRAINT fk_fs_verification_file FOREIGN KEY (file_id) REFERENCES fs_file (file_id),
CONSTRAINT ck_fs_verification_verdict
CHECK (verdict IN ('ACCEPT', 'QUARANTINE', 'REJECT', 'RETRY'))
);
CREATE INDEX ix_fs_verification_file
ON fs_verification_result (file_id, started_at);
CREATE INDEX ix_fs_verification_backlog
ON fs_verification_result (verdict, started_at)
WHERE completed_at IS NULL;
CREATE TABLE fs_quota_reservation (
reservation_id uuid NOT NULL,
scope_type varchar(32) NOT NULL,
scope_value varchar(128) NOT NULL,
reserved_bytes bigint NOT NULL,
committed_bytes bigint NOT NULL DEFAULT 0,
expires_at timestamptz NOT NULL,
status varchar(16) NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_fs_quota_reservation PRIMARY KEY (reservation_id),
CONSTRAINT ck_fs_quota_bytes CHECK (reserved_bytes >= 0 AND committed_bytes >= 0),
CONSTRAINT ck_fs_quota_version CHECK (version >= 0),
CONSTRAINT ck_fs_quota_status
CHECK (status IN ('RESERVED', 'COMMITTED', 'RELEASED', 'EXPIRED'))
);
CREATE INDEX ix_fs_quota_scope
ON fs_quota_reservation (scope_type, scope_value, status);
CREATE INDEX ix_fs_quota_expiry
ON fs_quota_reservation (status, expires_at)
WHERE status = 'RESERVED';
CREATE TABLE fs_cleanup_item (
cleanup_id uuid NOT NULL,
file_id uuid,
content_key varchar(200),
type varchar(32) NOT NULL,
attempt integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL,
status varchar(16) NOT NULL,
last_error_code varchar(64),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_fs_cleanup_item PRIMARY KEY (cleanup_id),
CONSTRAINT ck_fs_cleanup_attempt CHECK (attempt >= 0),
CONSTRAINT ck_fs_cleanup_status
CHECK (status IN ('PENDING', 'IN_PROGRESS', 'DONE', 'FAILED', 'ABANDONED')),
CONSTRAINT ck_fs_cleanup_type
CHECK (type IN (
'EXPIRED_UPLOAD', 'CANCELLED_STAGING', 'FAILED_VERIFICATION_CONTENT',
'DELETED_READY_CONTENT', 'ORPHAN_PHYSICAL_OBJECT', 'STALE_QUOTA_RESERVATION',
'ABANDONED_LEASE', 'SUPERSEDED_POINTER_VERSION'))
);
CREATE INDEX ix_fs_cleanup_schedule
ON fs_cleanup_item (status, next_attempt_at)
WHERE status IN ('PENDING', 'FAILED');
CREATE INDEX ix_fs_cleanup_content_key
ON fs_cleanup_item (content_key)
WHERE content_key IS NOT NULL;
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
)
SELECT
'jpa-fileserver-metadata-v1',
'db/migration/jpa/fileserver',
installation_origin,
1,
1,
'INSTALLED_INACTIVE'
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration';
@@ -0,0 +1,63 @@
-- Two additions the reclamation paths need in order to run at all.
--
-- `fs_cleanup_item.upload_id`: a staging object is addressed by upload, not by file, so a queued
-- staging cleanup could name what to reclaim only for already-published content. Without this
-- column a cancelled or expired upload leaves bytes that nothing can find.
--
-- `fs_recovery_item`: reconciliation reports files whose bytes and metadata disagree. Holding that
-- list in memory would lose exactly the cases that matter — the ones a restart interrupted.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-fileserver-metadata-v1'
AND feature_revision >= 1
) THEN
RAISE EXCEPTION 'fileserver recovery schema requires fileserver metadata revision 1';
END IF;
END
$$;
ALTER TABLE fs_cleanup_item
ADD COLUMN upload_id uuid;
-- An item names exactly one target: a staging object by upload, or published content by key.
ALTER TABLE fs_cleanup_item
ADD CONSTRAINT ck_fs_cleanup_target
CHECK (upload_id IS NULL OR content_key IS NULL);
CREATE INDEX ix_fs_cleanup_upload
ON fs_cleanup_item (upload_id)
WHERE upload_id IS NOT NULL;
CREATE TABLE fs_recovery_item (
recovery_id uuid NOT NULL,
file_id uuid NOT NULL,
reason_code varchar(64) NOT NULL,
status varchar(24) NOT NULL,
attempt integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_fs_recovery_item PRIMARY KEY (recovery_id),
CONSTRAINT fk_fs_recovery_item_file FOREIGN KEY (file_id) REFERENCES fs_file (file_id),
CONSTRAINT ck_fs_recovery_attempt CHECK (attempt >= 0),
CONSTRAINT ck_fs_recovery_status
CHECK (status IN (
'PENDING', 'CONFIRMED_SUCCESS', 'CONFIRMED_NOT_APPLIED',
'RECOVERABLE_PARTIAL', 'QUARANTINE_REQUIRED', 'UNRESOLVED'))
);
-- At most one open item per file: the queue is a worklist, not a log of every sweep.
CREATE UNIQUE INDEX uq_fs_recovery_open
ON fs_recovery_item (file_id)
WHERE status = 'PENDING';
CREATE INDEX ix_fs_recovery_backlog
ON fs_recovery_item (status, created_at);
UPDATE capability_schema_registry
SET feature_revision = 2
WHERE capability_id = 'jpa-fileserver-metadata-v1'
AND feature_revision < 2;
@@ -0,0 +1,141 @@
-- Additive owner-safe idempotency V2 expansion. V1 columns remain readable throughout the
-- compatibility window; no synthetic owner is invented for legacy COMPLETED rows.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
AND core_epoch >= 1
AND lifecycle_state = 'ACTIVE'
) THEN
RAISE EXCEPTION 'jpa idempotency V2 requires active core epoch 1';
END IF;
IF to_regclass('public.idempotency_record') IS NULL
AND NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
AND installation_origin = 'FRESH'
) THEN
RAISE EXCEPTION 'legacy adoption requires the compatible idempotency_record';
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS idempotency_record (
id uuid NOT NULL,
tenant varchar(128) NOT NULL DEFAULT '',
principal varchar(256) NOT NULL,
idempotency_key varchar(256) NOT NULL,
use_case_name varchar(256) NOT NULL,
request_hash char(64) NOT NULL,
status varchar(16) NOT NULL,
response_payload text,
response_ref varchar(512),
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
CONSTRAINT pk_idempotency_record PRIMARY KEY (id),
CONSTRAINT uq_idempotency_scope
UNIQUE (tenant, principal, idempotency_key, use_case_name)
);
CREATE INDEX IF NOT EXISTS ix_idempotency_record_expires_at
ON idempotency_record (expires_at);
ALTER TABLE idempotency_record
ADD COLUMN IF NOT EXISTS scope_hash char(64),
ADD COLUMN IF NOT EXISTS key_digest_version integer,
ADD COLUMN IF NOT EXISTS operation_code varchar(64),
ADD COLUMN IF NOT EXISTS record_version integer,
ADD COLUMN IF NOT EXISTS state_revision bigint,
ADD COLUMN IF NOT EXISTS owner_token varchar(128),
ADD COLUMN IF NOT EXISTS attempt bigint,
ADD COLUMN IF NOT EXISTS claim_operation_id varchar(128),
ADD COLUMN IF NOT EXISTS last_transition_operation_id varchar(128),
ADD COLUMN IF NOT EXISTS last_transition_kind varchar(64),
ADD COLUMN IF NOT EXISTS last_transition_result_digest char(64),
ADD COLUMN IF NOT EXISTS reconciliation_evidence_digest char(64),
ADD COLUMN IF NOT EXISTS processing_lease_until timestamptz,
ADD COLUMN IF NOT EXISTS replay_until timestamptz,
ADD COLUMN IF NOT EXISTS policy_revision integer,
ADD COLUMN IF NOT EXISTS response_codec_id varchar(64),
ADD COLUMN IF NOT EXISTS response_codec_version integer,
ADD COLUMN IF NOT EXISTS response_digest char(64),
ADD COLUMN IF NOT EXISTS failure_disposition varchar(32),
ADD COLUMN IF NOT EXISTS updated_at timestamptz,
ADD COLUMN IF NOT EXISTS completed_at timestamptz;
CREATE UNIQUE INDEX IF NOT EXISTS uq_idempotency_record_v2_scope
ON idempotency_record (scope_hash)
WHERE record_version = 2;
CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_lease
ON idempotency_record (status, processing_lease_until)
WHERE record_version = 2;
CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_terminal
ON idempotency_record (status, replay_until)
WHERE record_version = 2
AND status IN ('COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED');
ALTER TABLE idempotency_record
ADD CONSTRAINT ck_idempotency_record_v2_shape
CHECK (
record_version IS NULL
OR (
record_version = 2
AND scope_hash IS NOT NULL
AND key_digest_version > 0
AND operation_code IS NOT NULL
AND state_revision >= 0
AND owner_token IS NOT NULL
AND attempt > 0
AND claim_operation_id IS NOT NULL
AND processing_lease_until IS NOT NULL
AND policy_revision > 0
AND response_codec_id IS NOT NULL
AND updated_at IS NOT NULL
)
) NOT VALID;
ALTER TABLE idempotency_record
ADD CONSTRAINT ck_idempotency_record_v2_state
CHECK (
record_version IS NULL
OR status IN ('CLAIMED', 'EXECUTING', 'COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED')
) NOT VALID;
ALTER TABLE idempotency_record
ADD CONSTRAINT ck_idempotency_record_v2_completed_response
CHECK (
record_version IS NULL
OR status <> 'COMPLETED'
OR (
response_payload IS NOT NULL
AND response_ref IS NULL
AND response_digest IS NOT NULL
AND replay_until IS NOT NULL
AND completed_at IS NOT NULL
)
) NOT VALID;
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
)
SELECT
'jpa-idempotency-owner-safe-v2',
'db/migration/jpa/idempotency',
installation_origin,
1,
2,
'INSTALLED_INACTIVE'
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
ON CONFLICT (capability_id) DO NOTHING;
@@ -0,0 +1,70 @@
-- Same-store inbox state machine. The scope hash is the canonical digest of
-- consumer-group/handler/tenant/message-ID; raw broker metadata is not persisted here.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
AND core_epoch >= 1
AND lifecycle_state = 'ACTIVE'
) THEN
RAISE EXCEPTION 'same-store inbox requires active core epoch 1';
END IF;
END
$$;
CREATE TABLE inbox_record_v1 (
scope_hash char(64) NOT NULL,
message_intent_digest char(64) NOT NULL,
state varchar(16) NOT NULL,
state_revision bigint NOT NULL,
owner_token varchar(128) NOT NULL,
attempt bigint NOT NULL,
claim_operation_id varchar(128) NOT NULL,
processing_lease_until timestamptz NOT NULL,
last_operation_id varchar(128),
last_transition_kind varchar(32),
last_result_digest char(64),
terminal_at timestamptz,
retention_until timestamptz NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_inbox_record_v1 PRIMARY KEY (scope_hash),
CONSTRAINT ck_inbox_record_v1_state
CHECK (state IN ('RECEIVED', 'PROCESSING', 'COMPLETED', 'RETRYABLE', 'DEAD')),
CONSTRAINT ck_inbox_record_v1_revision CHECK (state_revision >= 0),
CONSTRAINT ck_inbox_record_v1_attempt CHECK (attempt > 0),
CONSTRAINT ck_inbox_record_v1_terminal
CHECK (
(state IN ('COMPLETED', 'DEAD') AND terminal_at IS NOT NULL)
OR (state NOT IN ('COMPLETED', 'DEAD') AND terminal_at IS NULL)
)
);
CREATE INDEX ix_inbox_record_v1_lease
ON inbox_record_v1 (state, processing_lease_until)
WHERE state IN ('RECEIVED', 'PROCESSING');
CREATE INDEX ix_inbox_record_v1_terminal
ON inbox_record_v1 (state, terminal_at, retention_until)
WHERE state IN ('COMPLETED', 'DEAD', 'RETRYABLE');
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
)
SELECT
'jpa-inbox-same-store-v1',
'db/migration/jpa/inbox',
installation_origin,
1,
1,
'INSTALLED_INACTIVE'
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration';
@@ -0,0 +1,152 @@
-- Mutable polling delivery state, separated from the immutable outbox identity/envelope.
DO $$
BEGIN
IF to_regclass('public.outbox_event_log_v2') IS NULL
OR to_regclass('public.outbox_publication_control_v2') IS NULL THEN
RAISE EXCEPTION 'polling delivery V2 requires outbox storage V2';
END IF;
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-outbox-storage-v2'
AND core_epoch = 1
AND feature_revision = 2
) THEN
RAISE EXCEPTION 'polling delivery V2 requires outbox storage revision 2';
END IF;
END
$$;
CREATE TABLE outbox_delivery_v2 (
retention_bucket date NOT NULL,
event_id varchar(64) NOT NULL,
destination varchar(256) NOT NULL,
publication_epoch bigint NOT NULL,
state varchar(16) NOT NULL,
claim_owner varchar(128),
claim_token char(64),
claim_until timestamptz,
attempt integer NOT NULL,
next_attempt_at timestamptz NOT NULL,
last_error_code varchar(64),
last_operation_id varchar(128),
last_result_digest char(64),
published_at timestamptz,
dead_at timestamptz,
version bigint NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_outbox_delivery_v2
PRIMARY KEY (retention_bucket, event_id, destination),
CONSTRAINT fk_outbox_delivery_v2_event
FOREIGN KEY (retention_bucket, event_id)
REFERENCES outbox_event_log_v2 (retention_bucket, event_id),
CONSTRAINT ck_outbox_delivery_v2_epoch CHECK (publication_epoch > 0),
CONSTRAINT ck_outbox_delivery_v2_state
CHECK (state IN ('PENDING', 'CLAIMED', 'PUBLISHED', 'RETRY_WAIT', 'DEAD')),
CONSTRAINT ck_outbox_delivery_v2_attempt CHECK (attempt >= 0),
CONSTRAINT ck_outbox_delivery_v2_version CHECK (version >= 0),
CONSTRAINT ck_outbox_delivery_v2_state_shape
CHECK (
(state = 'CLAIMED'
AND claim_owner IS NOT NULL
AND claim_token IS NOT NULL
AND claim_until IS NOT NULL
AND published_at IS NULL
AND dead_at IS NULL)
OR
(state <> 'CLAIMED'
AND claim_owner IS NULL
AND claim_token IS NULL
AND claim_until IS NULL)
),
CONSTRAINT ck_outbox_delivery_v2_terminal_shape
CHECK (
(state = 'PUBLISHED' AND published_at IS NOT NULL AND dead_at IS NULL)
OR (state = 'DEAD' AND dead_at IS NOT NULL AND published_at IS NULL)
OR (state NOT IN ('PUBLISHED', 'DEAD')
AND published_at IS NULL
AND dead_at IS NULL)
)
);
CREATE INDEX ix_outbox_delivery_v2_claim
ON outbox_delivery_v2 (destination, next_attempt_at, created_at)
WHERE state IN ('PENDING', 'RETRY_WAIT', 'CLAIMED');
CREATE INDEX ix_outbox_delivery_v2_terminal
ON outbox_delivery_v2 (state, published_at, dead_at)
WHERE state IN ('PUBLISHED', 'DEAD');
CREATE OR REPLACE FUNCTION create_polling_delivery_v2()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.dispatch_authority = 'POLLING_V2' THEN
INSERT INTO outbox_delivery_v2 (
retention_bucket,
event_id,
destination,
publication_epoch,
state,
claim_owner,
claim_token,
claim_until,
attempt,
next_attempt_at,
last_error_code,
last_operation_id,
last_result_digest,
published_at,
dead_at,
version,
created_at,
updated_at
) VALUES (
NEW.retention_bucket,
NEW.event_id,
NEW.logical_destination,
NEW.publication_epoch,
'PENDING',
null,
null,
null,
0,
NEW.created_at,
null,
null,
null,
null,
null,
0,
NEW.created_at,
NEW.created_at
);
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER trg_create_polling_delivery_v2
AFTER INSERT ON outbox_event_log_v2
FOR EACH ROW EXECUTE FUNCTION create_polling_delivery_v2();
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
)
SELECT
'jpa-outbox-polling-delivery-v2',
'db/migration/jpa/outbox-polling',
installation_origin,
1,
2,
'INSTALLED_INACTIVE'
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration';
@@ -0,0 +1,307 @@
-- Immutable outbox storage V2. Publication authority remains LEGACY_POLLING after adoption until
-- an explicit cutover transaction writes the next immutable sentinel and advances the control row.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
AND core_epoch >= 1
AND lifecycle_state = 'ACTIVE'
) THEN
RAISE EXCEPTION 'jpa outbox storage V2 requires active core epoch 1';
END IF;
IF to_regclass('public.outbox_event') IS NULL
AND NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
AND installation_origin = 'FRESH'
) THEN
RAISE EXCEPTION 'legacy adoption requires the compatible outbox_event';
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS outbox_event (
event_id varchar(64) NOT NULL,
aggregate_id varchar(256) NOT NULL,
event_type varchar(256) NOT NULL,
payload text NOT NULL,
occurred_at timestamptz NOT NULL,
status varchar(16) NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL,
correlation_id varchar(64) NOT NULL,
idempotency_key varchar(256) NOT NULL,
CONSTRAINT pk_outbox_event PRIMARY KEY (event_id)
);
CREATE INDEX IF NOT EXISTS ix_outbox_event_eligible ON outbox_event (next_attempt_at)
WHERE status IN ('PENDING', 'FAILED', 'IN_FLIGHT');
CREATE INDEX IF NOT EXISTS ix_outbox_event_aggregate_occurred
ON outbox_event (aggregate_id, occurred_at);
CREATE INDEX IF NOT EXISTS ix_outbox_event_published_occurred ON outbox_event (occurred_at)
WHERE status = 'PUBLISHED';
CREATE INDEX IF NOT EXISTS ix_outbox_event_status_occurred ON outbox_event (status, occurred_at);
CREATE TABLE outbox_publication_control_v2 (
scope_id varchar(32) NOT NULL,
active_epoch bigint NOT NULL,
active_authority varchar(32) NOT NULL,
state varchar(16) NOT NULL,
revision bigint NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT pk_outbox_publication_control_v2 PRIMARY KEY (scope_id),
CONSTRAINT ck_outbox_publication_control_v2_scope CHECK (scope_id = 'PRIMARY'),
CONSTRAINT ck_outbox_publication_control_v2_epoch CHECK (active_epoch > 0),
CONSTRAINT ck_outbox_publication_control_v2_authority
CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')),
CONSTRAINT ck_outbox_publication_control_v2_state
CHECK (state IN ('PREPARING', 'ACTIVE', 'DRAINING')),
CONSTRAINT ck_outbox_publication_control_v2_revision CHECK (revision >= 0)
);
CREATE TABLE outbox_publication_cutover_v2 (
scope_id varchar(32) NOT NULL,
active_epoch bigint NOT NULL,
previous_epoch bigint NOT NULL,
transition_kind varchar(32) NOT NULL,
active_authority varchar(32) NOT NULL,
legacy_row_count bigint NOT NULL,
legacy_pending_count bigint NOT NULL,
legacy_digest char(64) NOT NULL,
schema_manifest_id varchar(128) NOT NULL,
external_manifest_id varchar(128),
activated_at timestamptz NOT NULL,
CONSTRAINT pk_outbox_publication_cutover_v2 PRIMARY KEY (scope_id, active_epoch),
CONSTRAINT fk_outbox_publication_cutover_v2_scope
FOREIGN KEY (scope_id) REFERENCES outbox_publication_control_v2 (scope_id),
CONSTRAINT ck_outbox_publication_cutover_v2_epoch
CHECK (active_epoch > 0 AND previous_epoch = active_epoch - 1),
CONSTRAINT ck_outbox_publication_cutover_v2_kind
CHECK (transition_kind IN ('GENESIS_FRESH', 'GENESIS_LEGACY', 'CUTOVER')),
CONSTRAINT ck_outbox_publication_cutover_v2_authority
CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')),
CONSTRAINT ck_outbox_publication_cutover_v2_counts
CHECK (legacy_row_count >= 0 AND legacy_pending_count >= 0)
);
CREATE TABLE outbox_event_identity_v2 (
event_id varchar(64) NOT NULL,
aggregate_type varchar(128) NOT NULL,
aggregate_id varchar(256) NOT NULL,
aggregate_version bigint NOT NULL,
event_ordinal integer NOT NULL,
retention_bucket date NOT NULL,
created_at timestamptz NOT NULL,
CONSTRAINT pk_outbox_event_identity_v2 PRIMARY KEY (event_id),
CONSTRAINT uq_outbox_event_identity_v2_aggregate_order
UNIQUE (aggregate_type, aggregate_id, aggregate_version, event_ordinal),
CONSTRAINT uq_outbox_event_identity_v2_bucket UNIQUE (event_id, retention_bucket),
CONSTRAINT ck_outbox_event_identity_v2_version CHECK (aggregate_version > 0),
CONSTRAINT ck_outbox_event_identity_v2_ordinal CHECK (event_ordinal BETWEEN 0 AND 1023)
);
CREATE TABLE outbox_event_log_v2 (
retention_bucket date NOT NULL,
event_id varchar(64) NOT NULL,
aggregate_type varchar(128) NOT NULL,
aggregate_id varchar(256) NOT NULL,
aggregate_version bigint NOT NULL,
event_ordinal integer NOT NULL,
event_type varchar(256) NOT NULL,
event_schema integer NOT NULL,
logical_destination varchar(256) NOT NULL,
partition_key varchar(256) NOT NULL,
publication_epoch bigint NOT NULL,
dispatch_authority varchar(32) NOT NULL,
content_type varchar(128) NOT NULL,
correlation_id varchar(128) NOT NULL,
causation_id varchar(128),
occurred_at timestamptz NOT NULL,
payload text NOT NULL,
payload_digest char(64) NOT NULL,
trace_parent varchar(256),
created_at timestamptz NOT NULL,
CONSTRAINT pk_outbox_event_log_v2 PRIMARY KEY (retention_bucket, event_id),
CONSTRAINT fk_outbox_event_log_v2_identity
FOREIGN KEY (event_id, retention_bucket)
REFERENCES outbox_event_identity_v2 (event_id, retention_bucket),
CONSTRAINT ck_outbox_event_log_v2_schema CHECK (event_schema > 0),
CONSTRAINT ck_outbox_event_log_v2_epoch CHECK (publication_epoch > 0),
CONSTRAINT ck_outbox_event_log_v2_authority
CHECK (dispatch_authority IN ('LEGACY_SHADOW', 'POLLING_V2', 'CDC')),
CONSTRAINT ck_outbox_event_log_v2_payload_size
CHECK (octet_length(payload) BETWEEN 1 AND 1048576)
) PARTITION BY RANGE (retention_bucket);
CREATE TABLE outbox_event_log_v2_default
PARTITION OF outbox_event_log_v2 DEFAULT;
CREATE INDEX ix_outbox_event_log_v2_aggregate_order
ON outbox_event_log_v2 (
aggregate_type,
aggregate_id,
aggregate_version,
event_ordinal
);
CREATE OR REPLACE FUNCTION enforce_outbox_event_v2_authority()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
control_epoch bigint;
control_authority varchar(32);
control_state varchar(16);
expected_dispatch varchar(32);
BEGIN
SELECT active_epoch, active_authority, state
INTO control_epoch, control_authority, control_state
FROM outbox_publication_control_v2
WHERE scope_id = 'PRIMARY';
IF control_state <> 'ACTIVE' THEN
RAISE EXCEPTION 'outbox publication authority is not ACTIVE';
END IF;
expected_dispatch := CASE control_authority
WHEN 'LEGACY_POLLING' THEN 'LEGACY_SHADOW'
WHEN 'POLLING_V2' THEN 'POLLING_V2'
WHEN 'CDC' THEN 'CDC'
END;
IF NEW.publication_epoch <> control_epoch
OR NEW.dispatch_authority <> expected_dispatch THEN
RAISE EXCEPTION 'outbox publication epoch or dispatch authority mismatch';
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER trg_outbox_event_v2_authority
BEFORE INSERT ON outbox_event_log_v2
FOR EACH ROW EXECUTE FUNCTION enforce_outbox_event_v2_authority();
CREATE OR REPLACE FUNCTION fence_legacy_outbox_writer()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
authority varchar(32);
BEGIN
SELECT active_authority
INTO authority
FROM outbox_publication_control_v2
WHERE scope_id = 'PRIMARY';
IF authority <> 'LEGACY_POLLING' THEN
RAISE EXCEPTION 'legacy outbox writer is fenced after authority cutover';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER trg_fence_legacy_outbox_writer
BEFORE INSERT OR UPDATE OR DELETE ON outbox_event
FOR EACH ROW EXECUTE FUNCTION fence_legacy_outbox_writer();
CREATE OR REPLACE FUNCTION reject_outbox_cutover_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'outbox publication cutover sentinel is immutable';
END
$$;
CREATE TRIGGER trg_reject_outbox_cutover_mutation
BEFORE UPDATE OR DELETE ON outbox_publication_cutover_v2
FOR EACH ROW EXECUTE FUNCTION reject_outbox_cutover_mutation();
INSERT INTO outbox_publication_control_v2 (
scope_id,
active_epoch,
active_authority,
state,
revision,
updated_at
) VALUES (
'PRIMARY',
1,
'LEGACY_POLLING',
'ACTIVE',
0,
clock_timestamp()
);
WITH legacy AS (
SELECT
count(*) AS row_count,
count(*) FILTER (WHERE status <> 'PUBLISHED') AS pending_count,
coalesce(
string_agg(
event_id || ':' || status || ':' || attempt_count::text,
',' ORDER BY event_id
),
''
) AS intent
FROM outbox_event
),
origin AS (
SELECT installation_origin
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration'
)
INSERT INTO outbox_publication_cutover_v2 (
scope_id,
active_epoch,
previous_epoch,
transition_kind,
active_authority,
legacy_row_count,
legacy_pending_count,
legacy_digest,
schema_manifest_id,
external_manifest_id,
activated_at
)
SELECT
'PRIMARY',
1,
0,
CASE installation_origin
WHEN 'FRESH' THEN 'GENESIS_FRESH'
ELSE 'GENESIS_LEGACY'
END,
'LEGACY_POLLING',
row_count,
pending_count,
md5(intent) || md5('outbox-v2:' || intent),
'jpa-outbox-storage-v2-schema-revision-2',
null,
clock_timestamp()
FROM legacy
CROSS JOIN origin;
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
)
SELECT
'jpa-outbox-storage-v2',
'db/migration/jpa/outbox-storage',
installation_origin,
1,
2,
'INSTALLED_INACTIVE'
FROM capability_schema_registry
WHERE capability_id = 'jpa-flyway-migration';
@@ -0,0 +1,21 @@
-- feature-rate-limit-idempotency-contract — idempotency record store. Schema decisions: see module README.
CREATE TABLE 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 NULL,
response_ref varchar(512) NULL,
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)
);
-- Reaper / TTL-boundary expiry scans.
CREATE INDEX ix_idempotency_record_expires_at ON idempotency_record (expires_at);
@@ -0,0 +1,29 @@
-- feature-domain-event-outbox-contract — transactional outbox event table. Schema decisions: see module README.
CREATE TABLE 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)
);
-- Relay-poll lookup of eligible rows.
CREATE INDEX ix_outbox_event_eligible ON outbox_event (next_attempt_at)
WHERE status IN ('PENDING', 'FAILED', 'IN_FLIGHT');
-- FIFO gate (I4): per-aggregate ordering / earlier-unpublished lookup.
CREATE INDEX ix_outbox_event_aggregate_occurred ON outbox_event (aggregate_id, occurred_at);
-- Reaper: PUBLISHED rows older than retention cutoff.
CREATE INDEX ix_outbox_event_published_occurred ON outbox_event (occurred_at)
WHERE status = 'PUBLISHED';
-- Metrics: countByStatus / oldest-unpublished-lag.
CREATE INDEX ix_outbox_event_status_occurred ON outbox_event (status, occurred_at);
@@ -0,0 +1,10 @@
-- feature-distributed-lock-contract — Spring Integration JDBC LockRegistry backing table.
-- DDL is the SI 6.5 PostgreSQL schema verbatim; provenance and TTL notes in module README.
CREATE TABLE INT_LOCK (
LOCK_KEY CHAR(36) NOT NULL,
REGION VARCHAR(100) NOT NULL,
CLIENT_ID CHAR(36),
CREATED_DATE TIMESTAMP NOT NULL,
constraint INT_LOCK_PK primary key (LOCK_KEY, REGION)
);
@@ -0,0 +1,12 @@
-- Spring Integration 7 adds per-lock expiry state to the JDBC LockRegistry table.
-- Keep V4 immutable for databases that already applied it, and evolve the table forward here.
ALTER TABLE INT_LOCK
ADD COLUMN IF NOT EXISTS EXPIRED_AFTER TIMESTAMP;
UPDATE INT_LOCK
SET EXPIRED_AFTER = CREATED_DATE
WHERE EXPIRED_AFTER IS NULL;
ALTER TABLE INT_LOCK
ALTER COLUMN EXPIRED_AFTER SET NOT NULL;
@@ -0,0 +1,43 @@
-- Bridge migration: preserve the immutable V1/V3/V4/V5 legacy history and record its
-- installation origin before independent JPA capability streams are adopted.
DO $$
BEGIN
IF to_regclass('public.idempotency_record') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires idempotency_record';
END IF;
IF to_regclass('public.outbox_event') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires outbox_event';
END IF;
IF to_regclass('public.int_lock') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires INT_LOCK';
END IF;
END
$$;
CREATE TABLE capability_schema_registry (
capability_id varchar(128) NOT NULL,
schema_stream varchar(32) NOT NULL,
installation_origin varchar(32) NOT NULL,
core_epoch integer NOT NULL,
feature_revision integer NOT NULL,
lifecycle_state varchar(32) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id),
CONSTRAINT ck_capability_schema_registry_origin
CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')),
CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0),
CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0)
);
INSERT INTO capability_schema_registry (
capability_id,
schema_stream,
installation_origin,
core_epoch,
feature_revision,
lifecycle_state
) VALUES
('legacy-idempotency-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'),
('legacy-outbox-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'),
('legacy-jdbc-coordination-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE');
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import dev.caskeleton.application.fileserver.api.DefaultFileStateMachine;
import dev.caskeleton.application.fileserver.api.FileStateMachine;
import jakarta.persistence.EntityManagerFactory;
import java.time.Clock;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.core.env.MapPropertySource;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Minimal Spring JPA context for the Fileserver metadata stores.
*
* <p>Boot auto-configuration is deliberately not used: this harness wires only the entities,
* repositories, and stores under test so a readiness failure points at the Fileserver mapping
* rather than at unrelated application wiring. Hibernate runs in {@code validate} mode so the
* entities are proven against the Flyway-created schema instead of a generated one.
*/
final class FileserverJpaTestContext implements AutoCloseable {
private final AnnotationConfigApplicationContext context;
FileserverJpaTestContext(DataSource dataSource, Clock clock) {
this.context = new AnnotationConfigApplicationContext();
// The Fileserver stores are gated on the capability switch, exactly as they are in production.
// Setting it here is what makes this harness exercise the shipped condition rather than a
// parallel, always-on wiring that no deployment ever gets.
context
.getEnvironment()
.getPropertySources()
.addFirst(
new MapPropertySource(
"fileserver-capability", Map.of("app.fileserver-platform.enabled", "true")));
context.getBeanFactory().registerSingleton("dataSource", dataSource);
context.getBeanFactory().registerSingleton("clock", clock);
context.register(FileserverJpaConfiguration.class);
context.refresh();
}
<T> T bean(Class<T> type) {
return context.getBean(type);
}
TransactionTemplate transactions() {
return new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
}
@Override
public void close() {
context.close();
}
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver.repository")
@ComponentScan(
basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver",
includeFilters =
@ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*fileserver\\.Jpa.*"),
useDefaultFilters = false)
static class FileserverJpaConfiguration {
@Bean
LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("dev.caskeleton.adapter.outbound.persistence.fileserver.entity");
factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.hbm2ddl.auto", "validate");
properties.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
factory.setJpaPropertyMap(properties);
return factory;
}
@Bean
PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Bean
FileStateMachine fileStateMachine() {
return new DefaultFileStateMachine();
}
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class PostgreSqlAggregateIntegrationTest {
private static PostgreSqlReadinessSupport postgres;
@BeforeAll
static void startPostgreSql() throws Exception {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start();
postgres.execute(
"create table readiness_aggregate("
+ "id uuid primary key, title varchar(100) not null, "
+ "occurred_at timestamptz not null, version bigint not null)");
}
@AfterAll
static void stopPostgreSql() {
if (postgres != null) {
postgres.close();
}
}
@Test
void roundTripsUuidAndInstantAndDetectsExpectedVersionConflict() throws Exception {
UUID id = UUID.randomUUID();
Instant occurredAt = Instant.parse("2026-07-28T12:00:00.123456Z");
try (Connection connection = postgres.connection();
PreparedStatement insert =
connection.prepareStatement(
"insert into readiness_aggregate(id,title,occurred_at,version) "
+ "values (?,?,?,0)")) {
insert.setObject(1, id);
insert.setString(2, "aggregate");
insert.setObject(3, occurredAt.atOffset(ZoneOffset.UTC));
assertThat(insert.executeUpdate()).isOne();
}
try (Connection connection = postgres.connection();
PreparedStatement query =
connection.prepareStatement(
"select id,title,occurred_at,version from readiness_aggregate where id=?")) {
query.setObject(1, id);
try (ResultSet row = query.executeQuery()) {
assertThat(row.next()).isTrue();
assertThat(row.getObject(1, UUID.class)).isEqualTo(id);
assertThat(row.getString(2)).isEqualTo("aggregate");
assertThat(row.getObject(3, java.time.OffsetDateTime.class).toInstant())
.isEqualTo(occurredAt);
assertThat(row.getLong(4)).isZero();
}
}
try (Connection first = postgres.connection();
Connection second = postgres.connection();
PreparedStatement firstUpdate =
first.prepareStatement(
"update readiness_aggregate set title=?,version=version+1 "
+ "where id=? and version=?");
PreparedStatement secondUpdate =
second.prepareStatement(
"update readiness_aggregate set title=?,version=version+1 "
+ "where id=? and version=?")) {
first.setAutoCommit(false);
second.setAutoCommit(false);
bindUpdate(firstUpdate, "first", id, 0);
bindUpdate(secondUpdate, "second", id, 0);
assertThat(firstUpdate.executeUpdate()).isOne();
first.commit();
assertThat(secondUpdate.executeUpdate()).isZero();
second.rollback();
}
}
private static void bindUpdate(
PreparedStatement statement, String title, UUID id, long expectedVersion) throws Exception {
statement.setString(1, title);
statement.setObject(2, id);
statement.setLong(3, expectedVersion);
}
}
@@ -0,0 +1,470 @@
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.fileserver.JpaFileMetadataStore;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.error.ConcurrentFileModificationException;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordDraft;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordMutation;
import dev.caskeleton.application.fileserver.api.metadata.FileRecoveryQuery;
import dev.caskeleton.application.fileserver.api.metadata.QuotaReservation;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionDraft;
import dev.caskeleton.application.fileserver.api.metadata.WriterLease;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
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.transaction.support.TransactionTemplate;
/**
* Real-PostgreSQL proof of the Fileserver optimistic-transition and writer-lease invariants.
*
* <p>Every scenario races two writers on purpose: exactly one must win and the loser must surface
* an optimistic conflict rather than silently overwrite.
*/
class PostgreSqlFileserverMetadataStoreIntegrationTest {
private static final String DIGEST = "a".repeat(64);
private static final String ETAG = "\"" + DIGEST + "\"";
private static PostgreSqlReadinessSupport postgres;
private static FileserverJpaTestContext context;
private static JdbcTemplate jdbc;
private static TransactionTemplate transactions;
private static JpaFileMetadataStore files;
private static JpaUploadSessionStore uploads;
private static JpaFileQuotaService quota;
@BeforeAll
static void startAndMigratePostgreSql() {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start(8, 2_000);
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/fileserver",
"flyway_jpa_fileserver_history",
"explicit-jpa-fileserver-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
context = new FileserverJpaTestContext(postgres.dataSource(), Clock.systemUTC());
transactions = context.transactions();
files = context.bean(JpaFileMetadataStore.class);
uploads = context.bean(JpaUploadSessionStore.class);
quota = context.bean(JpaFileQuotaService.class);
}
@AfterAll
static void stopPostgreSql() {
if (context != null) {
context.close();
}
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void clearRows() {
jdbc.update("delete from fs_cleanup_item");
jdbc.update("delete from fs_quota_reservation");
jdbc.update("delete from fs_verification_result");
jdbc.update("delete from fs_upload_session");
jdbc.update("delete from fs_file");
}
@Test
void onlyOneReadyTransitionWinsForTheSameVersion() {
FileRecord verifying = insertVerifyingFile();
ContentKey contentKey = new ContentKey("ab/cd/0123456789abcdef");
CompletableFuture<FileRecord> first =
async(
() ->
files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(contentKey, 10, DIGEST, ETAG, Instant.now())));
CompletableFuture<FileRecord> second =
async(
() ->
files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(contentKey, 10, DIGEST, ETAG, Instant.now())));
assertThat(successCount(first, second)).isEqualTo(1);
assertThat(concurrentModificationCount(first, second)).isEqualTo(1);
assertThat(files.find(verifying.fileId()).orElseThrow().state()).isEqualTo(FileState.READY);
}
@Test
void transitionWritesTheCompletePublishIdentityAndBumpsTheVersion() {
FileRecord verifying = insertVerifyingFile();
ContentKey contentKey = new ContentKey("ab/cd/publish0000000001");
Instant publishedAt = Instant.parse("2026-08-07T10:00:00Z");
FileRecord published =
transactions.execute(
ignored ->
files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(contentKey, 10, DIGEST, ETAG, publishedAt)));
assertThat(published.state()).isEqualTo(FileState.READY);
assertThat(published.contentKey()).contains(contentKey);
assertThat(published.actualSize()).hasValue(10);
assertThat(published.sha256()).contains(DIGEST);
assertThat(published.strongEtag()).contains(ETAG);
assertThat(published.publishedAt()).contains(publishedAt);
assertThat(published.version()).isEqualTo(verifying.version() + 1);
}
@Test
void anIllegalTransitionNeverReachesTheDatabase() {
FileRecord created =
transactions.execute(ignored -> files.insert(draft(FileId.of(UUID.randomUUID()))));
assertThatThrownBy(
() ->
transactions.execute(
ignored ->
files.transition(
created.fileId(),
created.version(),
FileState.CREATED,
FileState.READY,
FileRecordMutation.none())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("CREATED -> READY");
assertThat(files.find(created.fileId()).orElseThrow().state()).isEqualTo(FileState.CREATED);
}
@Test
void logicalDeleteMovesTheRecordOutOfPublicReadability() {
FileRecord ready = insertReadyFile();
FileRecord deleting =
transactions.execute(ignored -> files.markDeleting(ready.fileId(), ready.version()));
assertThat(deleting.state()).isEqualTo(FileState.DELETING);
assertThat(deleting.state().isPubliclyReadable()).isFalse();
}
@Test
void recoverableQueriesAreBoundedAndOrdered() {
insertVerifyingFile();
insertVerifyingFile();
insertVerifyingFile();
List<FileRecord> recoverable =
transactions.execute(
ignored ->
files.findRecoverable(
new FileRecoveryQuery(
Set.of(FileState.VERIFYING), Instant.now().plusSeconds(60), 2)));
assertThat(recoverable).hasSize(2);
}
@Test
void onlyOneWriterLeaseIsValid() {
UploadSession session = insertActiveUpload();
Instant now = Instant.parse("2026-08-07T10:00:00Z");
WriterLease first =
transactions.execute(
ignored ->
uploads.acquireLease(
session.uploadId(), "node-a", now, Duration.ofSeconds(30), session.version()));
assertThatThrownBy(
() ->
transactions.execute(
ignored ->
uploads.acquireLease(
session.uploadId(),
"node-b",
now.plusSeconds(1),
Duration.ofSeconds(30),
session.version())))
.isInstanceOf(ConcurrentFileModificationException.class);
assertThat(first.owner()).isEqualTo("node-a");
}
@Test
void anExpiredLeaseMayBeTakenOverAndTheStaleWriterCannotCommit() {
UploadSession session = insertActiveUpload();
Instant now = Instant.parse("2026-08-07T10:00:00Z");
WriterLease stale =
transactions.execute(
ignored ->
uploads.acquireLease(
session.uploadId(), "node-a", now, Duration.ofSeconds(30), session.version()));
UploadSession afterFirst = uploads.find(session.uploadId()).orElseThrow();
WriterLease current =
transactions.execute(
ignored ->
uploads.acquireLease(
session.uploadId(),
"node-b",
now.plusSeconds(60),
Duration.ofSeconds(30),
afterFirst.version()));
assertThat(current.owner()).isEqualTo("node-b");
assertThatThrownBy(
() ->
transactions.execute(
ignored -> uploads.commitOffset(session.uploadId(), stale, 0, 3)))
.isInstanceOf(ConcurrentFileModificationException.class);
assertThat(uploads.find(session.uploadId()).orElseThrow().committedOffset()).isZero();
}
@Test
void offsetCommitRequiresTheExactExpectedOffset() {
UploadSession session = insertActiveUpload();
Instant now = Instant.now();
WriterLease lease =
transactions.execute(
ignored ->
uploads.acquireLease(
session.uploadId(), "node-a", now, Duration.ofMinutes(5), session.version()));
UploadSession advanced =
transactions.execute(ignored -> uploads.commitOffset(session.uploadId(), lease, 0, 3));
assertThat(advanced.committedOffset()).isEqualTo(3);
assertThatThrownBy(
() ->
transactions.execute(
ignored -> uploads.commitOffset(session.uploadId(), lease, 0, 6)))
.isInstanceOf(ConcurrentFileModificationException.class);
assertThat(uploads.find(session.uploadId()).orElseThrow().committedOffset()).isEqualTo(3);
}
@Test
void expiredSessionsAreListedForCleanup() {
UploadSession active = insertActiveUpload();
jdbc.update(
"update fs_upload_session set expires_at = ? where upload_id = ?",
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC).minusSeconds(60),
active.uploadId().value());
assertThat(uploads.findExpired(Instant.now(), 10))
.extracting(UploadSession::uploadId)
.containsExactly(active.uploadId());
}
@Test
void reservationCommitUsesActualBytesAndReleasesRemainder() {
QuotaScope scope = QuotaScope.ofTenant("tenant-a");
QuotaReservation reservation =
transactions.execute(ignored -> quota.reserve(scope, 1000, Duration.ofHours(1)));
transactions.executeWithoutResult(ignored -> quota.commit(reservation, 600));
assertThat(quota.committedBytes(scope)).isEqualTo(600);
assertThat(quota.reservedBytes(scope)).isZero();
}
@Test
void aReleasedReservationCanNeverBeCommittedOrExtended() {
QuotaScope scope = QuotaScope.ofTenant("tenant-b");
QuotaReservation reservation =
transactions.execute(ignored -> quota.reserve(scope, 1000, Duration.ofHours(1)));
transactions.executeWithoutResult(ignored -> quota.release(reservation));
assertThatThrownBy(
() -> transactions.executeWithoutResult(ignored -> quota.commit(reservation, 600)))
.isInstanceOf(dev.caskeleton.application.fileserver.api.error.QuotaExceededException.class);
assertThatThrownBy(
() -> transactions.executeWithoutResult(ignored -> quota.extend(reservation, 100)))
.isInstanceOf(dev.caskeleton.application.fileserver.api.error.QuotaExceededException.class);
assertThat(quota.reservedBytes(scope)).isZero();
}
private static FileRecordDraft draft(FileId fileId) {
return new FileRecordDraft(
fileId,
StorageNamespace.of("tenant-a"),
"report.bin",
Optional.of("application/octet-stream"),
OptionalLong.of(10));
}
private FileRecord insertVerifyingFile() {
return transactions.execute(
ignored -> {
FileRecord created = files.insert(draft(FileId.of(UUID.randomUUID())));
FileRecord uploading =
files.transition(
created.fileId(),
created.version(),
FileState.CREATED,
FileState.UPLOADING,
FileRecordMutation.none());
FileRecord uploaded =
files.transition(
uploading.fileId(),
uploading.version(),
FileState.UPLOADING,
FileState.UPLOADED,
FileRecordMutation.uploaded(10, DIGEST));
return files.transition(
uploaded.fileId(),
uploaded.version(),
FileState.UPLOADED,
FileState.VERIFYING,
FileRecordMutation.none());
});
}
private FileRecord insertReadyFile() {
FileRecord verifying = insertVerifyingFile();
return transactions.execute(
ignored ->
files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(
new ContentKey("ab/cd/" + UUID.randomUUID().toString().replace("-", "")),
10,
DIGEST,
ETAG,
Instant.now())));
}
private UploadSession insertActiveUpload() {
return transactions.execute(
ignored -> {
FileRecord created = files.insert(draft(FileId.of(UUID.randomUUID())));
return uploads.create(
new UploadSessionDraft(
UploadId.of(UUID.randomUUID()),
created.fileId(),
UploadProtocol.RAW,
OptionalLong.of(10),
Instant.now().plusSeconds(3600)));
});
}
private <T> CompletableFuture<T> async(Supplier<T> action) {
return CompletableFuture.supplyAsync(
() -> transactions.execute(ignored -> action.get()), RACE_POOL);
}
private static final ExecutorService RACE_POOL = Executors.newFixedThreadPool(4);
@SafeVarargs
private static int successCount(CompletableFuture<FileRecord>... futures) {
int successes = 0;
for (CompletableFuture<FileRecord> future : futures) {
if (outcomeOf(future) == null) {
successes++;
}
}
return successes;
}
@SafeVarargs
private static int concurrentModificationCount(CompletableFuture<FileRecord>... futures) {
int conflicts = 0;
for (CompletableFuture<FileRecord> future : futures) {
Throwable failure = outcomeOf(future);
if (failure != null && isOptimisticConflict(failure)) {
conflicts++;
}
}
return conflicts;
}
private static boolean isOptimisticConflict(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof ConcurrentFileModificationException) {
return true;
}
}
return false;
}
private static Throwable outcomeOf(CompletableFuture<FileRecord> future) {
try {
future.join();
return null;
} catch (RuntimeException exception) {
return exception;
}
}
@Test
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.fileserver());
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
}
@@ -0,0 +1,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.SQLException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Real-PostgreSQL proof that the Fileserver metadata schema exists with the optimistic-locking and
* READY-completeness guarantees the design requires.
*/
class PostgreSqlFileserverMigrationIntegrationTest {
private static PostgreSqlReadinessSupport postgres;
private static JdbcTemplate jdbc;
@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/fileserver",
"flyway_jpa_fileserver_history",
"explicit-jpa-fileserver-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
}
@AfterAll
static void stopPostgreSql() {
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void clearRows() {
jdbc.update("delete from fs_cleanup_item");
jdbc.update("delete from fs_quota_reservation");
jdbc.update("delete from fs_verification_result");
jdbc.update("delete from fs_upload_session");
jdbc.update("delete from fs_file");
}
@Test
void createsFileserverTablesAndVersionColumns() throws Exception {
try (Connection connection = postgres.dataSource().getConnection()) {
assertThat(columnExists(connection, "fs_file", "version")).isTrue();
assertThat(columnExists(connection, "fs_file", "content_key")).isTrue();
assertThat(columnExists(connection, "fs_file", "strong_etag")).isTrue();
assertThat(columnExists(connection, "fs_upload_session", "lease_until")).isTrue();
assertThat(columnExists(connection, "fs_upload_session", "lease_token")).isTrue();
assertThat(columnExists(connection, "fs_upload_session", "committed_offset")).isTrue();
assertThat(columnExists(connection, "fs_quota_reservation", "reserved_bytes")).isTrue();
assertThat(columnExists(connection, "fs_verification_result", "verdict")).isTrue();
assertThat(columnExists(connection, "fs_cleanup_item", "next_attempt_at")).isTrue();
}
}
@Test
void registersTheCapabilityStreamAsInstalledButInactive() {
String lifecycle =
jdbc.queryForObject(
"select lifecycle_state from capability_schema_registry where capability_id = ?",
String.class,
"jpa-fileserver-metadata-v1");
assertThat(lifecycle).isEqualTo("INSTALLED_INACTIVE");
}
@Test
void readyRowsMustCarryCompletePublishedIdentity() {
UUID fileId = UUID.randomUUID();
assertThatThrownBy(
() ->
jdbc.update(
"""
insert into fs_file(
file_id, namespace, state, original_name, version, created_at, updated_at)
values (?, ?, 'READY', ?, 0, ?, ?)
""",
fileId,
"tenant-a",
"report.bin",
now(),
now()))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void contentKeyIsUniqueAcrossFilesButManyRowsMayHaveNone() {
insertCreated(UUID.randomUUID());
insertCreated(UUID.randomUUID());
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
insertCreated(first);
insertCreated(second);
jdbc.update(
"update fs_file set content_key = ? where file_id = ?", "ab/cd/key0000000001", first);
assertThatThrownBy(
() ->
jdbc.update(
"update fs_file set content_key = ? where file_id = ?",
"ab/cd/key0000000001",
second))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void aLeaseIsAllOrNothing() {
UUID fileId = UUID.randomUUID();
UUID uploadId = UUID.randomUUID();
insertCreated(fileId);
insertUpload(uploadId, fileId);
assertThatThrownBy(
() ->
jdbc.update(
"update fs_upload_session set lease_owner = ? where upload_id = ?",
"node-a",
uploadId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void committedOffsetNeverExceedsTheDeclaredLength() {
UUID fileId = UUID.randomUUID();
UUID uploadId = UUID.randomUUID();
insertCreated(fileId);
insertUpload(uploadId, fileId);
jdbc.update("update fs_upload_session set expected_length = 10 where upload_id = ?", uploadId);
assertThatThrownBy(
() ->
jdbc.update(
"update fs_upload_session set committed_offset = 11 where upload_id = ?",
uploadId))
.isInstanceOf(DataIntegrityViolationException.class);
}
private void insertCreated(UUID fileId) {
jdbc.update(
"""
insert into fs_file(
file_id, namespace, state, original_name, version, created_at, updated_at)
values (?, ?, 'CREATED', ?, 0, ?, ?)
""",
fileId,
"tenant-a",
"report.bin",
now(),
now());
}
private void insertUpload(UUID uploadId, UUID fileId) {
jdbc.update(
"""
insert into fs_upload_session(
upload_id, file_id, protocol, committed_offset, expires_at, version,
created_at, updated_at)
values (?, ?, 'RAW', 0, ?, 0, ?, ?)
""",
uploadId,
fileId,
now().plusSeconds(3600),
now(),
now());
}
private static OffsetDateTime now() {
return OffsetDateTime.now(ZoneOffset.UTC);
}
private static boolean columnExists(Connection connection, String table, String column)
throws SQLException {
try (ResultSet columns = connection.getMetaData().getColumns(null, null, table, column)) {
return columns.next();
}
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
}
@@ -0,0 +1,494 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator;
import dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.metadata.FileRecord;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordDraft;
import dev.caskeleton.application.fileserver.api.metadata.FileRecordMutation;
import dev.caskeleton.application.fileserver.api.metadata.QuotaScope;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionDraft;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.cleanup.CleanupItem;
import dev.caskeleton.application.fileserver.cleanup.CleanupRequest;
import dev.caskeleton.application.fileserver.cleanup.CleanupType;
import dev.caskeleton.application.fileserver.recovery.ReconciliationStatus;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.UUID;
import java.util.function.Supplier;
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.transaction.support.TransactionTemplate;
/**
* Real-PostgreSQL proof of the reclamation side of the Fileserver.
*
* <p>The cleanup queue, the recovery queue, and the quota ledger are the components that decide
* when physical bytes may be destroyed and how much space a tenant is charged for. All three are
* conditional-update designs whose correctness lives in SQL, so an in-memory fake would prove
* nothing about them: the partial unique index, the {@code IN ('PENDING','FAILED')} claim guard,
* and the {@code committedBytes >= :amount} floor only exist in the database.
*/
class PostgreSqlFileserverReclamationIntegrationTest {
private static PostgreSqlReadinessSupport postgres;
private static FileserverJpaTestContext context;
private static JdbcTemplate jdbc;
private static TransactionTemplate transactions;
private static JpaFileMetadataStore files;
private static JpaUploadSessionStore uploads;
private static JpaFileQuotaService quota;
private static JpaCleanupQueue cleanupQueue;
private static JpaRecoveryQueue recoveryQueue;
private static JpaQuotaCommitGateway quotaCommit;
private static JpaQuotaReclaimGateway quotaReclaim;
private static JpaContentReferenceLedger ledger;
private static JpaStagingUploadLocator stagingLocator;
@BeforeAll
static void startAndMigratePostgreSql() {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start(8, 2_000);
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/fileserver",
"flyway_jpa_fileserver_history",
"explicit-jpa-fileserver-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
context = new FileserverJpaTestContext(postgres.dataSource(), Clock.systemUTC());
transactions = context.transactions();
files = context.bean(JpaFileMetadataStore.class);
uploads = context.bean(JpaUploadSessionStore.class);
quota = context.bean(JpaFileQuotaService.class);
cleanupQueue = context.bean(JpaCleanupQueue.class);
recoveryQueue = context.bean(JpaRecoveryQueue.class);
quotaCommit = context.bean(JpaQuotaCommitGateway.class);
quotaReclaim = context.bean(JpaQuotaReclaimGateway.class);
ledger = context.bean(JpaContentReferenceLedger.class);
stagingLocator = context.bean(JpaStagingUploadLocator.class);
}
@AfterAll
static void stopPostgreSql() {
if (context != null) {
context.close();
}
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void truncate() {
jdbc.execute(
"TRUNCATE fs_recovery_item, fs_cleanup_item, fs_quota_reservation,"
+ " fs_verification_result, fs_upload_session, fs_file CASCADE");
}
@Test
void aStagingCleanupSurvivesTheRoundTripWithItsUploadIdentity() {
FileRecord record = insertRecord();
UploadId uploadId = UploadId.of(UUID.randomUUID());
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forStaging(CleanupType.CANCELLED_STAGING, record.fileId(), uploadId));
return null;
});
List<CleanupItem> claimed = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
assertThat(claimed)
.singleElement()
.satisfies(
item -> {
assertThat(item.request().uploadId()).contains(uploadId);
assertThat(item.request().contentKey()).isEmpty();
assertThat(item.type()).isEqualTo(CleanupType.CANCELLED_STAGING);
});
}
@Test
void aClaimedItemIsNotHandedToASecondWorker() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
List<CleanupItem> first = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
List<CleanupItem> second = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10));
assertThat(first).hasSize(1);
assertThat(second).isEmpty();
}
@Test
void aFailedItemBecomesDueAgainOnlyAfterItsBackoff() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
CleanupItem item = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10)).get(0);
Instant retryAt = Instant.now().plus(Duration.ofMinutes(5));
inTransaction(
() -> {
cleanupQueue.markFailed(item, "STORAGE_UNAVAILABLE", retryAt);
return null;
});
assertThat(inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10))).isEmpty();
assertThat(inTransaction(() -> cleanupQueue.claimDue(retryAt.plusSeconds(1), 10))).hasSize(1);
}
@Test
void anItemThatKeepsFailingIsAbandonedRatherThanRetriedForever() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
Instant due = Instant.now();
for (int attempt = 0; attempt < JpaCleanupQueue.MAXIMUM_ATTEMPTS; attempt++) {
List<CleanupItem> claimed = inTransaction(() -> cleanupQueue.claimDue(due, 10));
if (claimed.isEmpty()) {
break;
}
inTransaction(
() -> {
cleanupQueue.markFailed(claimed.get(0), "STILL_FAILING", due);
return null;
});
}
assertThat(inTransaction(() -> cleanupQueue.claimDue(due.plusSeconds(1), 10))).isEmpty();
assertThat(statusCounts("fs_cleanup_item", "ABANDONED")).isEqualTo(1);
}
@Test
void aDoneItemIsNeverClaimedAgain() {
FileRecord record = insertRecord();
inTransaction(
() -> {
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, record.fileId(), contentKey()));
return null;
});
CleanupItem item = inTransaction(() -> cleanupQueue.claimDue(Instant.now(), 10)).get(0);
inTransaction(
() -> {
cleanupQueue.markDone(item);
return null;
});
assertThat(inTransaction(() -> cleanupQueue.claimDue(Instant.now().plusSeconds(600), 10)))
.isEmpty();
}
@Test
void thesameFileReportedTwiceHoldsOneOpenRecoveryItem() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "READY_DIGEST_MISMATCH");
recoveryQueue.enqueue(record.fileId(), "READY_SIZE_MISMATCH");
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).containsExactly(record.fileId());
assertThat(rowCount("fs_recovery_item")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT reason_code FROM fs_recovery_item", String.class))
.isEqualTo("READY_SIZE_MISMATCH");
}
@Test
void aResolvedRecoveryItemLeavesThePendingListButKeepsItsOutcome() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "PUBLISH_EVIDENCE_INCOMPLETE");
return null;
});
inTransaction(
() -> {
recoveryQueue.resolve(record.fileId(), ReconciliationStatus.QUARANTINE_REQUIRED);
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).isEmpty();
assertThat(statusCounts("fs_recovery_item", "QUARANTINE_REQUIRED")).isEqualTo(1);
}
@Test
void aResolvedFileCanBeRaisedAgainLater() {
FileRecord record = insertRecord();
inTransaction(
() -> {
recoveryQueue.enqueue(record.fileId(), "FIRST");
recoveryQueue.resolve(record.fileId(), ReconciliationStatus.UNRESOLVED);
recoveryQueue.enqueue(record.fileId(), "SECOND");
return null;
});
assertThat(inTransaction(() -> recoveryQueue.pending(10))).containsExactly(record.fileId());
assertThat(rowCount("fs_recovery_item")).isEqualTo(2);
}
@Test
void committingAnUploadMovesReservedBytesToCommitted() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(() -> quota.reserve(scope, 1_000, Duration.ofHours(1)));
inTransaction(
() -> {
quotaCommit.commit(session, 600);
return null;
});
assertThat(inTransaction(() -> quota.reservedBytes(scope))).isZero();
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(600);
}
@Test
void committingWithoutALiveReservationStillRecordsTheDurableUsage() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 450);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(450);
}
@Test
void releasingAnUploadGivesTheReservedCapacityBack() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(() -> quota.reserve(scope, 2_000, Duration.ofHours(1)));
inTransaction(
() -> {
quotaCommit.release(session);
return null;
});
assertThat(inTransaction(() -> quota.reservedBytes(scope))).isZero();
assertThat(inTransaction(() -> quota.committedBytes(scope))).isZero();
}
@Test
void reclaimingDrawsCommittedBytesDownAcrossRows() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 300);
quotaCommit.commit(session, 700);
return null;
});
inTransaction(
() -> {
quotaReclaim.reclaim(scope, 800);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isEqualTo(200);
}
@Test
void reclaimingMoreThanIsRecordedStopsAtZeroRatherThanGoingNegative() {
FileRecord record = insertRecord();
UploadSession session = insertSession(record.fileId());
QuotaScope scope = QuotaScope.ofNamespace(record.namespace().value());
inTransaction(
() -> {
quotaCommit.commit(session, 100);
return null;
});
inTransaction(
() -> {
quotaReclaim.reclaim(scope, 5_000);
return null;
});
assertThat(inTransaction(() -> quota.committedBytes(scope))).isZero();
}
@Test
void theLedgerReportsAKeyAsReferencedOnlyWhileARecordNamesIt() {
ContentKey key = contentKey();
assertThat(inTransaction(() -> ledger.isReferenced(key))).isFalse();
FileRecord record = insertRecord();
inTransaction(() -> publish(record, key));
assertThat(inTransaction(() -> ledger.isReferenced(key))).isTrue();
}
@Test
void theStagingLocatorReturnsTheNewestSessionForAFile() {
FileRecord record = insertRecord();
UploadSession older = insertSession(record.fileId());
UploadSession newer = insertSession(record.fileId());
Optional<UploadId> located = inTransaction(() -> stagingLocator.locate(record.fileId()));
assertThat(located).isPresent();
assertThat(located.get()).isIn(older.uploadId(), newer.uploadId());
assertThat(inTransaction(() -> stagingLocator.locate(FileId.of(UUID.randomUUID())))).isEmpty();
}
private FileRecord insertRecord() {
return inTransaction(
() ->
files.insert(
new FileRecordDraft(
FileId.of(UUID.randomUUID()),
StorageNamespace.of("tenant-a"),
"report.bin",
Optional.of("application/octet-stream"),
OptionalLong.of(1_000))));
}
private FileRecord publish(FileRecord record, ContentKey key) {
FileRecord uploading =
files.transition(
record.fileId(),
record.version(),
FileState.CREATED,
FileState.UPLOADING,
FileRecordMutation.none());
FileRecord uploaded =
files.transition(
uploading.fileId(),
uploading.version(),
FileState.UPLOADING,
FileState.UPLOADED,
FileRecordMutation.uploaded(10, "b".repeat(64)));
FileRecord verifying =
files.transition(
uploaded.fileId(),
uploaded.version(),
FileState.UPLOADED,
FileState.VERIFYING,
FileRecordMutation.none());
return files.transition(
verifying.fileId(),
verifying.version(),
FileState.VERIFYING,
FileState.READY,
FileRecordMutation.publishAt(
key, 10, "b".repeat(64), "\"" + "b".repeat(64) + "\"", Instant.now()));
}
private UploadSession insertSession(FileId fileId) {
return inTransaction(
() ->
uploads.create(
new UploadSessionDraft(
UploadId.of(UUID.randomUUID()),
fileId,
UploadProtocol.RAW,
OptionalLong.of(1_000),
Instant.now().plus(Duration.ofHours(1)))));
}
private static ContentKey contentKey() {
String flat = UUID.randomUUID().toString().replace("-", "");
return ContentKey.of(flat.substring(0, 2) + '/' + flat.substring(2, 4) + '/' + flat);
}
private static <T> T inTransaction(Supplier<T> action) {
return transactions.execute(status -> action.get());
}
private static int rowCount(String table) {
Integer count = jdbc.queryForObject("SELECT count(*) FROM " + table, Integer.class);
return count == null ? 0 : count;
}
private static int statusCounts(String table, String status) {
Integer count =
jdbc.queryForObject(
"SELECT count(*) FROM " + table + " WHERE status = ?", Integer.class, status);
return count == null ? 0 : count;
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
}
@@ -0,0 +1,298 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.StoredResponse;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
import dev.caskeleton.application.transaction.OperationId;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
class PostgreSqlIdempotencyIntegrationTest {
private static final IdempotencyScopeDigest SCOPE =
new IdempotencyScopeDigest("a".repeat(64), 1, "CREATE_WORK_LOG");
private static final RequestFingerprint FINGERPRINT =
RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8));
private static PostgreSqlReadinessSupport postgres;
private static JdbcTemplate jdbc;
private static TransactionTemplate transactions;
private static PostgreSqlOwnerSafeIdempotencyStore store;
@BeforeAll
static void startAndMigratePostgreSql() {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start();
migrate("classpath:db/migration/postgresql", "flyway_schema_history");
migrateIndependent(
"classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption");
migrateIndependent(
"classpath:db/migration/jpa/idempotency",
"flyway_jpa_idempotency_history",
"explicit-jpa-idempotency-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource()));
store = new PostgreSqlOwnerSafeIdempotencyStore(jdbc);
jdbc.update(
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
+ "where capability_id = 'jpa-idempotency-owner-safe-v2'");
jdbc.execute(
"create table idempotency_business_probe ("
+ "scope_hash char(64) primary key, mutation_count integer not null)");
}
@AfterAll
static void stopPostgreSql() {
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void clearRows() {
jdbc.update("delete from idempotency_record where record_version = 2");
jdbc.update("delete from idempotency_business_probe");
}
@Test
void sameStoreTransactionCommitsBusinessMutationAndCompletionTogetherThenReplays() {
IdempotencyClaimAttempt attempt = store.newClaimAttempt(new OperationId("claim-1"));
IdempotencyClaimRequest request = request(attempt, Duration.ofSeconds(5));
transactions.executeWithoutResult(
ignored -> {
IdempotencyOwner owner =
((IdempotencyClaimOutcome.Acquired) store.claim(request)).owner();
owner =
store.markExecutionStarted(owner, new OperationId("start-1")).owner().orElseThrow();
jdbc.update(
"insert into idempotency_business_probe(scope_hash, mutation_count) values (?, 1)",
SCOPE.digest());
assertThat(
store.complete(
owner,
new StoredResponse("{\"workLogId\":\"42\"}"),
Duration.ofHours(24),
new OperationId("complete-1")))
.isEqualTo(IdempotencyCompleteOutcome.COMPLETED);
});
IdempotencyClaimOutcome replay =
transactions.execute(ignored -> store.claim(request(attempt, Duration.ofSeconds(5))));
assertThat(replay)
.isInstanceOfSatisfying(
IdempotencyClaimOutcome.CompletedReplay.class,
completed ->
assertThat(completed.response().payload()).isEqualTo("{\"workLogId\":\"42\"}"));
assertThat(
jdbc.queryForObject(
"select mutation_count from idempotency_business_probe where scope_hash = ?",
Integer.class,
SCOPE.digest()))
.isEqualTo(1);
assertThat(
jdbc.queryForObject(
"select idempotency_key from idempotency_record where scope_hash = ?",
String.class,
SCOPE.digest()))
.isEqualTo(SCOPE.digest());
}
@Test
void expiredClaimCanBeTakenOverButTheStaleOwnerCannotStart() {
IdempotencyOwner staleOwner =
transactions.execute(
ignored ->
((IdempotencyClaimOutcome.Acquired)
store.claim(
request(
store.newClaimAttempt(new OperationId("claim-old")),
Duration.ofMillis(25))))
.owner());
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
IdempotencyClaimAttempt replacement =
store.newClaimAttempt(new OperationId("claim-replacement"));
IdempotencyClaimOutcome takeover =
transactions.execute(ignored -> store.claim(request(replacement, Duration.ofSeconds(5))));
assertThat(takeover)
.isInstanceOfSatisfying(
IdempotencyClaimOutcome.TakenOverClaimed.class,
result -> {
assertThat(result.owner().attempt()).isEqualTo(2);
assertThat(result.owner().ownerToken()).isEqualTo(replacement.ownerToken());
});
assertThat(
transactions
.execute(
ignored ->
store.markExecutionStarted(staleOwner, new OperationId("stale-start")))
.outcome())
.isEqualTo(IdempotencyStartOutcome.NOT_OWNER);
}
@Test
void expiredExecutingRecordRequiresReconciliationAndIsNeverBlindlyTakenOver() {
IdempotencyClaimAttempt original = store.newClaimAttempt(new OperationId("claim-executing"));
IdempotencyOwner executing =
transactions.execute(
ignored -> {
IdempotencyOwner owner =
((IdempotencyClaimOutcome.Acquired)
store.claim(request(original, Duration.ofMillis(25))))
.owner();
return store
.markExecutionStarted(owner, new OperationId("start-executing"))
.owner()
.orElseThrow();
});
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
IdempotencyClaimOutcome outcome =
transactions.execute(
ignored ->
store.claim(
request(
store.newClaimAttempt(new OperationId("claim-after-unknown")),
Duration.ofSeconds(5))));
assertThat(outcome)
.isInstanceOfSatisfying(
IdempotencyClaimOutcome.RecoveryRequired.class,
recovery -> assertThat(recovery.currentAttempt()).isEqualTo(executing.attempt()));
assertThat(
store.inspect(new IdempotencyInspectionRequest(SCOPE, FINGERPRINT, original)).outcome())
.isEqualTo(IdempotencyInspectionOutcome.ABANDONED);
}
@Test
void competingTransactionCannotPassTheOwnerRowUntilTheFirstBusinessCommit() throws Exception {
CountDownLatch firstHasCompletedInsideTransaction = new CountDownLatch(1);
CountDownLatch allowFirstCommit = new CountDownLatch(1);
IdempotencyClaimRequest firstRequest =
request(store.newClaimAttempt(new OperationId("claim-first")), Duration.ofSeconds(5));
IdempotencyClaimRequest secondRequest =
request(store.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Void> first =
executor.submit(
() -> {
transactions.executeWithoutResult(
ignored -> {
IdempotencyOwner owner =
((IdempotencyClaimOutcome.Acquired) store.claim(firstRequest)).owner();
owner =
store
.markExecutionStarted(owner, new OperationId("start-first"))
.owner()
.orElseThrow();
jdbc.update(
"insert into idempotency_business_probe(scope_hash, mutation_count) "
+ "values (?, 1)",
SCOPE.digest());
assertThat(
store.complete(
owner,
new StoredResponse("done"),
Duration.ofHours(1),
new OperationId("complete-first")))
.isEqualTo(IdempotencyCompleteOutcome.COMPLETED);
firstHasCompletedInsideTransaction.countDown();
await(allowFirstCommit);
});
return null;
});
firstHasCompletedInsideTransaction.await();
Future<IdempotencyClaimOutcome> second =
executor.submit(() -> transactions.execute(ignored -> store.claim(secondRequest)));
assertThat(second.isDone()).isFalse();
allowFirstCommit.countDown();
first.get();
assertThat(second.get()).isInstanceOf(IdempotencyClaimOutcome.CompletedReplay.class);
}
assertThat(
jdbc.queryForObject(
"select mutation_count from idempotency_business_probe where scope_hash = ?",
Integer.class,
SCOPE.digest()))
.isEqualTo(1);
}
@Test
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.idempotency());
}
private static IdempotencyClaimRequest request(
IdempotencyClaimAttempt attempt, Duration processingLease) {
return new IdempotencyClaimRequest(
SCOPE, FINGERPRINT, attempt, processingLease, Duration.ofHours(24), "json.v1", 2);
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("test synchronization interrupted", exception);
}
}
}
@@ -0,0 +1,280 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter;
import dev.caskeleton.application.inbox.InboxClaimAttempt;
import dev.caskeleton.application.inbox.InboxClaimOutcome;
import dev.caskeleton.application.inbox.InboxClaimRequest;
import dev.caskeleton.application.inbox.InboxOwner;
import dev.caskeleton.application.inbox.InboxScopeDigest;
import dev.caskeleton.application.inbox.InboxTransitionOutcome;
import dev.caskeleton.application.transaction.OperationId;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
class PostgreSqlInboxIntegrationTest {
private static final InboxScopeDigest SCOPE = new InboxScopeDigest("a".repeat(64));
private static final String INTENT = "b".repeat(64);
private static PostgreSqlReadinessSupport postgres;
private static JdbcTemplate jdbc;
private static TransactionTemplate transactions;
private static PostgreSqlSameStoreInboxAdapter inbox;
@BeforeAll
static void startAndMigratePostgreSql() {
PostgreSqlReadinessSupport.assertDockerAvailable();
postgres = PostgreSqlReadinessSupport.start();
migrate("classpath:db/migration/postgresql", "flyway_schema_history");
migrateIndependent(
"classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption");
migrateIndependent(
"classpath:db/migration/jpa/inbox",
"flyway_jpa_inbox_history",
"explicit-jpa-inbox-adoption");
jdbc = new JdbcTemplate(postgres.dataSource());
transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource()));
inbox = new PostgreSqlSameStoreInboxAdapter(postgres.dataSource());
jdbc.update(
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
+ "where capability_id = 'jpa-inbox-same-store-v1'");
jdbc.execute(
"create table inbox_business_probe ("
+ "scope_hash char(64) primary key, mutation_count integer not null)");
}
@AfterAll
static void stopPostgreSql() {
if (postgres != null) {
postgres.close();
}
}
@BeforeEach
void clearRows() {
jdbc.update("delete from inbox_record_v1");
jdbc.update("delete from inbox_business_probe");
}
@Test
void claimBusinessMutationAndCompletionCommitOrRollbackAsOneUnit() {
InboxClaimAttempt rolledBackAttempt = inbox.newClaimAttempt(new OperationId("claim-rollback"));
assertThatThrownBy(
() ->
transactions.executeWithoutResult(
ignored -> {
InboxOwner owner =
((InboxClaimOutcome.Acquired)
inbox.claim(request(rolledBackAttempt, Duration.ofSeconds(5))))
.owner();
owner =
inbox
.markProcessing(owner, new OperationId("start-rollback"))
.owner()
.orElseThrow();
jdbc.update(
"insert into inbox_business_probe(scope_hash, mutation_count) "
+ "values (?, 1)",
SCOPE.value());
throw new IllegalStateException("rollback");
}))
.isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("select count(*) from inbox_record_v1", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class))
.isZero();
InboxClaimAttempt committedAttempt = inbox.newClaimAttempt(new OperationId("claim-commit"));
transactions.executeWithoutResult(
ignored -> {
InboxOwner owner =
((InboxClaimOutcome.Acquired)
inbox.claim(request(committedAttempt, Duration.ofSeconds(5))))
.owner();
owner =
inbox.markProcessing(owner, new OperationId("start-commit")).owner().orElseThrow();
jdbc.update(
"insert into inbox_business_probe(scope_hash, mutation_count) values (?, 1)",
SCOPE.value());
assertThat(inbox.complete(owner, new OperationId("complete-commit")))
.isEqualTo(InboxTransitionOutcome.COMPLETED);
});
InboxClaimOutcome redelivery =
transactions.execute(
ignored ->
inbox.claim(
request(
inbox.newClaimAttempt(new OperationId("claim-redelivery")),
Duration.ofSeconds(5))));
assertThat(redelivery).isInstanceOf(InboxClaimOutcome.Completed.class);
assertThat(
jdbc.queryForObject(
"select mutation_count from inbox_business_probe where scope_hash = ?",
Integer.class,
SCOPE.value()))
.isEqualTo(1);
}
@Test
void expiredReceivedCanBeTakenOverButStaleOwnerCannotStart() {
InboxOwner stale =
transactions.execute(
ignored ->
((InboxClaimOutcome.Acquired)
inbox.claim(
request(
inbox.newClaimAttempt(new OperationId("claim-old")),
Duration.ofMillis(25))))
.owner());
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
InboxClaimOutcome takeover =
transactions.execute(
ignored ->
inbox.claim(
request(
inbox.newClaimAttempt(new OperationId("claim-new")),
Duration.ofSeconds(5))));
assertThat(takeover)
.isInstanceOfSatisfying(
InboxClaimOutcome.TakenOver.class,
result -> assertThat(result.owner().attempt()).isEqualTo(2));
assertThat(
transactions
.execute(ignored -> inbox.markProcessing(stale, new OperationId("stale-start")))
.outcome())
.isEqualTo(InboxTransitionOutcome.NOT_OWNER);
}
@Test
void expiredProcessingRequiresRecoveryInsteadOfBlindTakeover() {
InboxClaimAttempt attempt = inbox.newClaimAttempt(new OperationId("claim-processing"));
transactions.executeWithoutResult(
ignored -> {
InboxOwner owner =
((InboxClaimOutcome.Acquired) inbox.claim(request(attempt, Duration.ofMillis(25))))
.owner();
inbox.markProcessing(owner, new OperationId("start-processing"));
});
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
InboxClaimOutcome outcome =
transactions.execute(
ignored ->
inbox.claim(
request(
inbox.newClaimAttempt(new OperationId("claim-after-unknown")),
Duration.ofSeconds(5))));
assertThat(outcome).isInstanceOf(InboxClaimOutcome.RecoveryRequired.class);
assertThat(
jdbc.queryForObject(
"select state from inbox_record_v1 where scope_hash = ?",
String.class,
SCOPE.value()))
.isEqualTo("DEAD");
}
@Test
void takeoverCannotPassTheOwnerRowWhileBusinessTransactionIsOpen() throws Exception {
CountDownLatch firstCompletedInsideTransaction = new CountDownLatch(1);
CountDownLatch allowCommit = new CountDownLatch(1);
InboxClaimRequest first =
request(inbox.newClaimAttempt(new OperationId("claim-first")), Duration.ofMillis(25));
InboxClaimRequest second =
request(inbox.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Void> firstHandler =
executor.submit(
() -> {
transactions.executeWithoutResult(
ignored -> {
InboxOwner owner = ((InboxClaimOutcome.Acquired) inbox.claim(first)).owner();
owner =
inbox
.markProcessing(owner, new OperationId("start-first"))
.owner()
.orElseThrow();
jdbc.update(
"insert into inbox_business_probe(scope_hash, mutation_count) "
+ "values (?, 1)",
SCOPE.value());
assertThat(inbox.complete(owner, new OperationId("complete-first")))
.isEqualTo(InboxTransitionOutcome.COMPLETED);
firstCompletedInsideTransaction.countDown();
await(allowCommit);
});
return null;
});
firstCompletedInsideTransaction.await();
Future<InboxClaimOutcome> competing =
executor.submit(() -> transactions.execute(ignored -> inbox.claim(second)));
assertThat(competing.isDone()).isFalse();
allowCommit.countDown();
firstHandler.get();
assertThat(competing.get()).isInstanceOf(InboxClaimOutcome.Completed.class);
}
assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class))
.isEqualTo(1);
}
@Test
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.inbox());
}
private static InboxClaimRequest request(InboxClaimAttempt attempt, Duration processingLease) {
return new InboxClaimRequest(SCOPE, INTENT, attempt, processingLease, Duration.ofDays(7));
}
private static void migrate(String location, String historyTable) {
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
private static void migrateIndependent(
String location, String historyTable, String baselineDescription) {
Flyway flyway =
Flyway.configure()
.dataSource(postgres.dataSource())
.locations(location)
.table(historyTable)
.baselineVersion("0")
.baselineDescription(baselineDescription)
.baselineOnMigrate(false)
.outOfOrder(false)
.load();
flyway.baseline();
flyway.migrate();
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("test synchronization interrupted", exception);
}
}
}

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