chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -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=
|
||||
+16
@@ -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();
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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 {}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+120
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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();
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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());
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+162
@@ -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));
|
||||
}
|
||||
}
|
||||
+97
@@ -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));
|
||||
}
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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());
|
||||
}
|
||||
}
|
||||
+40
@@ -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()));
|
||||
}
|
||||
}
|
||||
+143
@@ -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));
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+163
@@ -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;
|
||||
}
|
||||
}
|
||||
+116
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
+132
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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);
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
+140
@@ -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);
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
+96
@@ -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();
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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();
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+195
@@ -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;
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
+72
@@ -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());
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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>]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+37
@@ -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();
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
@@ -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());
|
||||
}
|
||||
}
|
||||
+137
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/** Persistence adapter anchor for project-owned storage integrations. */
|
||||
package dev.caskeleton.adapter.outbound.persistence;
|
||||
+70
@@ -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()));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts;
|
||||
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/** Applies finite PostgreSQL timeout guards to the current transaction only. */
|
||||
public final class PostgreSqlLocalTimeoutConfigurer implements TransactionLocalTimeoutConfigurer {
|
||||
|
||||
private static final String STATEMENT_TIMEOUT_SQL =
|
||||
"select set_config('statement_timeout', ?, true)";
|
||||
private static final String LOCK_TIMEOUT_SQL = "select set_config('lock_timeout', ?, true)";
|
||||
private static final String IDLE_TIMEOUT_SQL =
|
||||
"select set_config('idle_in_transaction_session_timeout', ?, true)";
|
||||
|
||||
private final JdbcOperations jdbcOperations;
|
||||
|
||||
public PostgreSqlLocalTimeoutConfigurer(JdbcOperations jdbcOperations) {
|
||||
this.jdbcOperations = Objects.requireNonNull(jdbcOperations, "jdbcOperations must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(EffectiveTransactionTimeouts timeouts) {
|
||||
Objects.requireNonNull(timeouts, "timeouts must be non-null");
|
||||
apply(STATEMENT_TIMEOUT_SQL, timeouts.statementTimeout());
|
||||
apply(LOCK_TIMEOUT_SQL, timeouts.lockTimeout());
|
||||
apply(IDLE_TIMEOUT_SQL, timeouts.idleGuardTimeout());
|
||||
}
|
||||
|
||||
private void apply(String sql, Duration timeout) {
|
||||
String value = timeout.toMillis() + "ms";
|
||||
jdbcOperations.queryForObject(sql, String.class, value);
|
||||
}
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
}
|
||||
+59
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+890
@@ -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) {}
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.inbox;
|
||||
|
||||
import dev.caskeleton.application.inbox.InboxClaimAttempt;
|
||||
import dev.caskeleton.application.inbox.InboxClaimOutcome;
|
||||
import dev.caskeleton.application.inbox.InboxClaimRequest;
|
||||
import dev.caskeleton.application.inbox.InboxOwner;
|
||||
import dev.caskeleton.application.inbox.InboxOwnerTransition;
|
||||
import dev.caskeleton.application.inbox.InboxScopeDigest;
|
||||
import dev.caskeleton.application.inbox.InboxState;
|
||||
import dev.caskeleton.application.inbox.InboxStorePort;
|
||||
import dev.caskeleton.application.inbox.InboxTransitionOutcome;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* PostgreSQL same-store inbox.
|
||||
*
|
||||
* <p>Claim, business mutation, optional outgoing outbox append, and completion are intended to run
|
||||
* inside one caller-owned transaction. Broker acknowledgement is deliberately outside this port.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgreSqlSameStoreInboxAdapter implements InboxStorePort {
|
||||
|
||||
private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30);
|
||||
private static final String DB_NOW_SQL = "select clock_timestamp()";
|
||||
|
||||
private static final String ACTIVE_CAPABILITY_SQL =
|
||||
"""
|
||||
select count(*)
|
||||
from capability_schema_registry
|
||||
where capability_id = 'jpa-inbox-same-store-v1'
|
||||
and core_epoch = 1
|
||||
and feature_revision = 1
|
||||
and lifecycle_state = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String INSERT_SQL =
|
||||
"""
|
||||
insert into inbox_record_v1 (
|
||||
scope_hash,
|
||||
message_intent_digest,
|
||||
state,
|
||||
state_revision,
|
||||
owner_token,
|
||||
attempt,
|
||||
claim_operation_id,
|
||||
processing_lease_until,
|
||||
last_operation_id,
|
||||
last_transition_kind,
|
||||
last_result_digest,
|
||||
terminal_at,
|
||||
retention_until,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
select
|
||||
?, ?, 'RECEIVED', 0, ?, 1, ?,
|
||||
db_now + (? * interval '1 millisecond'),
|
||||
null, null, null, null,
|
||||
db_now + (? * interval '1 millisecond'),
|
||||
db_now, db_now
|
||||
from (select clock_timestamp() as db_now) authority
|
||||
on conflict (scope_hash) do nothing
|
||||
""";
|
||||
|
||||
private static final String SELECT_SQL =
|
||||
"""
|
||||
select scope_hash, message_intent_digest, state, state_revision, owner_token, attempt,
|
||||
claim_operation_id, processing_lease_until, last_operation_id,
|
||||
last_transition_kind, last_result_digest, terminal_at, retention_until
|
||||
from inbox_record_v1
|
||||
where scope_hash = ?
|
||||
""";
|
||||
|
||||
private static final String SELECT_FOR_UPDATE_SQL = SELECT_SQL + " for update";
|
||||
|
||||
private static final String RESET_SQL =
|
||||
"""
|
||||
update inbox_record_v1
|
||||
set message_intent_digest = ?,
|
||||
state = 'RECEIVED',
|
||||
state_revision = state_revision + 1,
|
||||
owner_token = ?,
|
||||
attempt = attempt + 1,
|
||||
claim_operation_id = ?,
|
||||
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
last_operation_id = null,
|
||||
last_transition_kind = null,
|
||||
last_result_digest = null,
|
||||
terminal_at = null,
|
||||
retention_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and state = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String EXPIRE_PROCESSING_SQL =
|
||||
"""
|
||||
update inbox_record_v1
|
||||
set state = 'DEAD',
|
||||
state_revision = state_revision + 1,
|
||||
last_operation_id = claim_operation_id,
|
||||
last_transition_kind = 'EXPIRED_PROCESSING',
|
||||
last_result_digest = ?,
|
||||
terminal_at = clock_timestamp(),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and state = 'PROCESSING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String START_SQL =
|
||||
"""
|
||||
update inbox_record_v1
|
||||
set state = 'PROCESSING',
|
||||
state_revision = state_revision + 1,
|
||||
last_operation_id = ?,
|
||||
last_transition_kind = 'START',
|
||||
last_result_digest = ?,
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and state = 'RECEIVED'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
and processing_lease_until > clock_timestamp()
|
||||
""";
|
||||
|
||||
private static final String COMPLETE_SQL =
|
||||
"""
|
||||
update inbox_record_v1
|
||||
set state = 'COMPLETED',
|
||||
state_revision = state_revision + 1,
|
||||
last_operation_id = ?,
|
||||
last_transition_kind = 'COMPLETE',
|
||||
last_result_digest = ?,
|
||||
terminal_at = clock_timestamp(),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and state = 'PROCESSING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private static final String FAIL_SQL =
|
||||
"""
|
||||
update inbox_record_v1
|
||||
set state = ?,
|
||||
state_revision = state_revision + 1,
|
||||
last_operation_id = ?,
|
||||
last_transition_kind = ?,
|
||||
last_result_digest = ?,
|
||||
terminal_at = case when ? = 'DEAD' then clock_timestamp() else null end,
|
||||
retention_until = clock_timestamp() + (? * interval '1 millisecond'),
|
||||
processing_lease_until = clock_timestamp(),
|
||||
updated_at = clock_timestamp()
|
||||
where scope_hash = ?
|
||||
and state = 'PROCESSING'
|
||||
and owner_token = ?
|
||||
and attempt = ?
|
||||
and claim_operation_id = ?
|
||||
and state_revision = ?
|
||||
""";
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final JdbcOperations jdbc;
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
@Autowired
|
||||
public PostgreSqlSameStoreInboxAdapter(DataSource dataSource) {
|
||||
this(dataSource, new JdbcTemplate(dataSource), new SecureRandom());
|
||||
}
|
||||
|
||||
PostgreSqlSameStoreInboxAdapter(
|
||||
DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxClaimAttempt newClaimAttempt(OperationId operationId) {
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
byte[] token = new byte[32];
|
||||
secureRandom.nextBytes(token);
|
||||
return new InboxClaimAttempt(HexFormat.of().formatHex(token), operationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxClaimOutcome claim(InboxClaimRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
requireActiveCapability();
|
||||
int inserted =
|
||||
jdbc.update(
|
||||
INSERT_SQL,
|
||||
request.scope().value(),
|
||||
request.messageIntentDigest(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLease().toMillis(),
|
||||
request.terminalRetention().toMillis());
|
||||
InboxRow row = findForUpdate(request.scope()).orElseThrow(this::indeterminate);
|
||||
Instant dbNow = databaseNowAfterLock();
|
||||
if (inserted == 1) {
|
||||
return new InboxClaimOutcome.Acquired(owner(row), row.processingLeaseUntil());
|
||||
}
|
||||
if ((row.state() == InboxState.COMPLETED || row.state() == InboxState.DEAD)
|
||||
&& !dbNow.isBefore(row.retentionUntil())) {
|
||||
return resetClaim(request, row);
|
||||
}
|
||||
if (!row.messageIntentDigest().equals(request.messageIntentDigest())) {
|
||||
return new InboxClaimOutcome.IntentMismatch();
|
||||
}
|
||||
if (sameClaimAttempt(row, request.claimAttempt())) {
|
||||
if (row.state() == InboxState.COMPLETED) {
|
||||
return new InboxClaimOutcome.Completed();
|
||||
}
|
||||
return new InboxClaimOutcome.ReplayedAcquire(owner(row), row.processingLeaseUntil());
|
||||
}
|
||||
if (row.ownerToken().equals(request.claimAttempt().ownerToken())) {
|
||||
return new InboxClaimOutcome.OwnerOperationConflict();
|
||||
}
|
||||
if (row.state() == InboxState.COMPLETED) {
|
||||
return new InboxClaimOutcome.Completed();
|
||||
}
|
||||
if ((row.state() == InboxState.RECEIVED && !dbNow.isBefore(row.processingLeaseUntil()))
|
||||
|| row.state() == InboxState.RETRYABLE) {
|
||||
return resetClaim(request, row);
|
||||
}
|
||||
if (row.state() == InboxState.PROCESSING && !dbNow.isBefore(row.processingLeaseUntil())) {
|
||||
expireProcessing(row);
|
||||
return new InboxClaimOutcome.RecoveryRequired(row.attempt());
|
||||
}
|
||||
if (row.state() == InboxState.DEAD) {
|
||||
return new InboxClaimOutcome.RecoveryRequired(row.attempt());
|
||||
}
|
||||
Duration retryAfter =
|
||||
row.processingLeaseUntil().isAfter(dbNow)
|
||||
? Duration.between(dbNow, row.processingLeaseUntil())
|
||||
: Duration.ZERO;
|
||||
return new InboxClaimOutcome.InProgress(retryAfter, row.attempt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxOwnerTransition markProcessing(InboxOwner owner, OperationId operationId) {
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
InboxRow row = findForUpdate(owner.scope()).orElse(null);
|
||||
if (row == null) {
|
||||
return transition(InboxTransitionOutcome.ABSENT, null);
|
||||
}
|
||||
if (isDuplicate(row, "START", operationId)) {
|
||||
return transition(InboxTransitionOutcome.PROCESSING_STARTED, owner(row));
|
||||
}
|
||||
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.RECEIVED);
|
||||
if (mismatch != null) {
|
||||
return transition(mismatch, null);
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
START_SQL,
|
||||
operationId.value(),
|
||||
transitionDigest("START", operationId, owner),
|
||||
owner.scope().value(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
return updated == 1
|
||||
? transition(
|
||||
InboxTransitionOutcome.PROCESSING_STARTED,
|
||||
owner.withStateRevision(owner.stateRevision() + 1))
|
||||
: transition(InboxTransitionOutcome.NOT_OWNER, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxTransitionOutcome complete(InboxOwner owner, OperationId operationId) {
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
InboxRow row = findForUpdate(owner.scope()).orElse(null);
|
||||
if (row == null) {
|
||||
return InboxTransitionOutcome.ABSENT;
|
||||
}
|
||||
String digest = transitionDigest("COMPLETE", operationId, owner);
|
||||
InboxTransitionOutcome duplicate = classifyDuplicate(row, "COMPLETE", operationId, digest);
|
||||
if (duplicate != null) {
|
||||
return duplicate;
|
||||
}
|
||||
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING);
|
||||
if (mismatch != null) {
|
||||
return mismatch;
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
COMPLETE_SQL,
|
||||
operationId.value(),
|
||||
digest,
|
||||
owner.scope().value(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
return updated == 1 ? InboxTransitionOutcome.COMPLETED : InboxTransitionOutcome.RESULT_CONFLICT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxTransitionOutcome markRetryable(
|
||||
InboxOwner owner, Duration retention, OperationId operationId) {
|
||||
return fail(owner, retention, operationId, InboxState.RETRYABLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InboxTransitionOutcome markDead(
|
||||
InboxOwner owner, Duration retention, OperationId operationId) {
|
||||
return fail(owner, retention, operationId, InboxState.DEAD);
|
||||
}
|
||||
|
||||
private InboxTransitionOutcome fail(
|
||||
InboxOwner owner, Duration retention, OperationId operationId, InboxState target) {
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(operationId, "operationId");
|
||||
requirePositiveRetention(retention);
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
InboxRow row = findForUpdate(owner.scope()).orElse(null);
|
||||
if (row == null) {
|
||||
return InboxTransitionOutcome.ABSENT;
|
||||
}
|
||||
String kind = target == InboxState.RETRYABLE ? "RETRYABLE" : "DEAD";
|
||||
String digest = transitionDigest(kind, operationId, owner);
|
||||
InboxTransitionOutcome duplicate = classifyDuplicate(row, kind, operationId, digest);
|
||||
if (duplicate != null) {
|
||||
return duplicate;
|
||||
}
|
||||
InboxTransitionOutcome mismatch = classifyMismatch(row, owner, InboxState.PROCESSING);
|
||||
if (mismatch != null) {
|
||||
return mismatch;
|
||||
}
|
||||
int updated =
|
||||
jdbc.update(
|
||||
FAIL_SQL,
|
||||
target.name(),
|
||||
operationId.value(),
|
||||
kind,
|
||||
digest,
|
||||
target.name(),
|
||||
retention.toMillis(),
|
||||
owner.scope().value(),
|
||||
owner.ownerToken(),
|
||||
owner.attempt(),
|
||||
owner.claimOperationId().value(),
|
||||
owner.stateRevision());
|
||||
if (updated != 1) {
|
||||
return InboxTransitionOutcome.RESULT_CONFLICT;
|
||||
}
|
||||
return target == InboxState.RETRYABLE
|
||||
? InboxTransitionOutcome.RETRYABLE
|
||||
: InboxTransitionOutcome.DEAD;
|
||||
}
|
||||
|
||||
private InboxClaimOutcome resetClaim(InboxClaimRequest request, InboxRow row) {
|
||||
int updated =
|
||||
jdbc.update(
|
||||
RESET_SQL,
|
||||
request.messageIntentDigest(),
|
||||
request.claimAttempt().ownerToken(),
|
||||
request.claimAttempt().operationId().value(),
|
||||
request.processingLease().toMillis(),
|
||||
request.terminalRetention().toMillis(),
|
||||
request.scope().value(),
|
||||
row.state().name(),
|
||||
row.stateRevision());
|
||||
if (updated != 1) {
|
||||
throw indeterminate();
|
||||
}
|
||||
InboxRow reset = findForUpdate(request.scope()).orElseThrow(this::indeterminate);
|
||||
return new InboxClaimOutcome.TakenOver(owner(reset), reset.processingLeaseUntil());
|
||||
}
|
||||
|
||||
private void expireProcessing(InboxRow row) {
|
||||
String resultDigest = sha256("EXPIRED_PROCESSING|" + row.claimOperationId());
|
||||
int updated =
|
||||
jdbc.update(
|
||||
EXPIRE_PROCESSING_SQL,
|
||||
resultDigest,
|
||||
row.scopeHash(),
|
||||
row.ownerToken(),
|
||||
row.attempt(),
|
||||
row.claimOperationId(),
|
||||
row.stateRevision());
|
||||
if (updated != 1) {
|
||||
throw indeterminate();
|
||||
}
|
||||
}
|
||||
|
||||
private InboxTransitionOutcome classifyMismatch(
|
||||
InboxRow row, InboxOwner owner, InboxState expectedState) {
|
||||
if (!sameOwnerIdentity(row, owner)) {
|
||||
return InboxTransitionOutcome.NOT_OWNER;
|
||||
}
|
||||
if (row.stateRevision() != owner.stateRevision()) {
|
||||
return InboxTransitionOutcome.STALE_REVISION;
|
||||
}
|
||||
if (row.state() != expectedState) {
|
||||
return InboxTransitionOutcome.INVALID_STATE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static InboxTransitionOutcome classifyDuplicate(
|
||||
InboxRow row, String kind, OperationId operationId, String digest) {
|
||||
if (kind.equals(row.lastTransitionKind())
|
||||
&& operationId.value().equals(row.lastOperationId())) {
|
||||
return digest.equals(row.lastResultDigest())
|
||||
? InboxTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION
|
||||
: InboxTransitionOutcome.RESULT_CONFLICT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Optional<InboxRow> findForUpdate(InboxScopeDigest scope) {
|
||||
return queryOne(SELECT_FOR_UPDATE_SQL, scope);
|
||||
}
|
||||
|
||||
private Optional<InboxRow> queryOne(String sql, InboxScopeDigest scope) {
|
||||
List<InboxRow> rows = jdbc.query(sql, this::mapRow, scope.value());
|
||||
if (rows.size() > 1) {
|
||||
throw new IllegalStateException("multiple inbox rows for one scope");
|
||||
}
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
private InboxRow mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
|
||||
return new InboxRow(
|
||||
resultSet.getString("scope_hash"),
|
||||
resultSet.getString("message_intent_digest"),
|
||||
InboxState.valueOf(resultSet.getString("state")),
|
||||
resultSet.getLong("state_revision"),
|
||||
resultSet.getString("owner_token"),
|
||||
resultSet.getLong("attempt"),
|
||||
resultSet.getString("claim_operation_id"),
|
||||
resultSet.getObject("processing_lease_until", OffsetDateTime.class).toInstant(),
|
||||
resultSet.getString("last_operation_id"),
|
||||
resultSet.getString("last_transition_kind"),
|
||||
resultSet.getString("last_result_digest"),
|
||||
nullableInstant(resultSet, "terminal_at"),
|
||||
resultSet.getObject("retention_until", OffsetDateTime.class).toInstant());
|
||||
}
|
||||
|
||||
private Instant databaseNowAfterLock() {
|
||||
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
|
||||
}
|
||||
return value.toInstant();
|
||||
}
|
||||
|
||||
private void requireActiveCapability() {
|
||||
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
|
||||
if (active == null || active != 1) {
|
||||
throw new IllegalStateException(
|
||||
"jpa-inbox-same-store-v1 is not active at core epoch 1/revision 1");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameResourcePrimaryWriteTransaction() {
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()
|
||||
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly()
|
||||
|| !TransactionSynchronizationManager.hasResource(dataSource)) {
|
||||
throw new IllegalStateException(
|
||||
"same-store inbox mutation requires the adapter datasource primary write transaction");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositiveRetention(Duration retention) {
|
||||
Objects.requireNonNull(retention, "retention");
|
||||
if (retention.isZero()
|
||||
|| retention.isNegative()
|
||||
|| retention.compareTo(MAXIMUM_RETENTION) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"retention must be positive and at most " + MAXIMUM_RETENTION);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameClaimAttempt(InboxRow row, InboxClaimAttempt attempt) {
|
||||
return row.ownerToken().equals(attempt.ownerToken())
|
||||
&& row.claimOperationId().equals(attempt.operationId().value());
|
||||
}
|
||||
|
||||
private static boolean sameOwnerIdentity(InboxRow row, InboxOwner owner) {
|
||||
return row.scopeHash().equals(owner.scope().value())
|
||||
&& row.ownerToken().equals(owner.ownerToken())
|
||||
&& row.attempt() == owner.attempt()
|
||||
&& row.claimOperationId().equals(owner.claimOperationId().value());
|
||||
}
|
||||
|
||||
private static boolean isDuplicate(InboxRow row, String kind, OperationId operationId) {
|
||||
return kind.equals(row.lastTransitionKind())
|
||||
&& operationId.value().equals(row.lastOperationId());
|
||||
}
|
||||
|
||||
private static InboxOwner owner(InboxRow row) {
|
||||
return new InboxOwner(
|
||||
new InboxScopeDigest(row.scopeHash()),
|
||||
row.ownerToken(),
|
||||
row.attempt(),
|
||||
row.stateRevision(),
|
||||
new OperationId(row.claimOperationId()));
|
||||
}
|
||||
|
||||
private static InboxOwnerTransition transition(InboxTransitionOutcome outcome, InboxOwner owner) {
|
||||
return new InboxOwnerTransition(outcome, Optional.ofNullable(owner));
|
||||
}
|
||||
|
||||
private static String transitionDigest(String kind, OperationId operationId, InboxOwner owner) {
|
||||
return sha256(
|
||||
kind
|
||||
+ '|'
|
||||
+ operationId.value()
|
||||
+ '|'
|
||||
+ owner.ownerToken()
|
||||
+ '|'
|
||||
+ owner.attempt()
|
||||
+ '|'
|
||||
+ owner.stateRevision());
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
|
||||
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
|
||||
return value == null ? null : value.toInstant();
|
||||
}
|
||||
|
||||
private IllegalStateException indeterminate() {
|
||||
return new IllegalStateException("same-store inbox transition is indeterminate");
|
||||
}
|
||||
|
||||
private record InboxRow(
|
||||
String scopeHash,
|
||||
String messageIntentDigest,
|
||||
InboxState state,
|
||||
long stateRevision,
|
||||
String ownerToken,
|
||||
long attempt,
|
||||
String claimOperationId,
|
||||
Instant processingLeaseUntil,
|
||||
String lastOperationId,
|
||||
String lastTransitionKind,
|
||||
String lastResultDigest,
|
||||
Instant terminalAt,
|
||||
Instant retentionUntil) {}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.v2.NewOutboxEventV2;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxAppendOutcome;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxAppendPortV2;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxAppendReceipt;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxDispatchAuthority;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxPublicationAuthority;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* PostgreSQL immutable outbox V2 append implementation.
|
||||
*
|
||||
* <p>The active publication control row is held {@code FOR SHARE} until the caller's transaction
|
||||
* finishes. Identity and envelope inserts therefore cannot straddle an authority cutover.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgreSqlImmutableOutboxAppendAdapter implements OutboxAppendPortV2 {
|
||||
|
||||
private static final String ACTIVE_CAPABILITY_SQL =
|
||||
"""
|
||||
select count(*)
|
||||
from capability_schema_registry
|
||||
where capability_id = 'jpa-outbox-storage-v2'
|
||||
and core_epoch = 1
|
||||
and feature_revision = 2
|
||||
and lifecycle_state = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String LOCK_CONTROL_SQL =
|
||||
"""
|
||||
select control.active_epoch, control.active_authority
|
||||
from outbox_publication_control_v2 control
|
||||
join outbox_publication_cutover_v2 cutover
|
||||
on cutover.scope_id = control.scope_id
|
||||
and cutover.active_epoch = control.active_epoch
|
||||
and cutover.active_authority = control.active_authority
|
||||
where control.scope_id = 'PRIMARY'
|
||||
and control.state = 'ACTIVE'
|
||||
for share of control
|
||||
""";
|
||||
|
||||
private static final String INSERT_IDENTITY_SQL =
|
||||
"""
|
||||
insert into outbox_event_identity_v2 (
|
||||
event_id,
|
||||
aggregate_type,
|
||||
aggregate_id,
|
||||
aggregate_version,
|
||||
event_ordinal,
|
||||
retention_bucket,
|
||||
created_at
|
||||
)
|
||||
select ?, ?, ?, ?, ?, (db_now at time zone 'UTC')::date, db_now
|
||||
from (select clock_timestamp() as db_now) authority
|
||||
on conflict do nothing
|
||||
returning retention_bucket, created_at
|
||||
""";
|
||||
|
||||
private static final String INSERT_EVENT_SQL =
|
||||
"""
|
||||
insert into outbox_event_log_v2 (
|
||||
retention_bucket,
|
||||
event_id,
|
||||
aggregate_type,
|
||||
aggregate_id,
|
||||
aggregate_version,
|
||||
event_ordinal,
|
||||
event_type,
|
||||
event_schema,
|
||||
logical_destination,
|
||||
partition_key,
|
||||
publication_epoch,
|
||||
dispatch_authority,
|
||||
content_type,
|
||||
correlation_id,
|
||||
causation_id,
|
||||
occurred_at,
|
||||
payload,
|
||||
payload_digest,
|
||||
trace_parent,
|
||||
created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, ?)
|
||||
""";
|
||||
|
||||
private static final String FIND_EVENT_SQL =
|
||||
"""
|
||||
select identity.event_id,
|
||||
identity.aggregate_type,
|
||||
identity.aggregate_id,
|
||||
identity.aggregate_version,
|
||||
identity.event_ordinal,
|
||||
identity.retention_bucket,
|
||||
event.event_type,
|
||||
event.event_schema,
|
||||
event.logical_destination,
|
||||
event.partition_key,
|
||||
event.publication_epoch,
|
||||
event.dispatch_authority,
|
||||
event.content_type,
|
||||
event.correlation_id,
|
||||
event.causation_id,
|
||||
event.occurred_at,
|
||||
event.payload_digest
|
||||
from outbox_event_identity_v2 identity
|
||||
left join outbox_event_log_v2 event
|
||||
on event.event_id = identity.event_id
|
||||
and event.retention_bucket = identity.retention_bucket
|
||||
where identity.event_id = ?
|
||||
""";
|
||||
|
||||
private static final String FIND_ORDERING_IDENTITY_SQL =
|
||||
"""
|
||||
select event_id
|
||||
from outbox_event_identity_v2
|
||||
where aggregate_type = ?
|
||||
and aggregate_id = ?
|
||||
and aggregate_version = ?
|
||||
and event_ordinal = ?
|
||||
""";
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final JdbcOperations jdbc;
|
||||
|
||||
@Autowired
|
||||
public PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource) {
|
||||
this(dataSource, new JdbcTemplate(dataSource));
|
||||
}
|
||||
|
||||
PostgreSqlImmutableOutboxAppendAdapter(DataSource dataSource, JdbcOperations jdbc) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboxAppendReceipt append(NewOutboxEventV2 event) {
|
||||
Objects.requireNonNull(event, "event");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
requireActiveCapability();
|
||||
PublicationControl control = lockPublicationControl();
|
||||
OutboxDispatchAuthority dispatchAuthority = dispatchAuthority(control.authority());
|
||||
|
||||
List<IdentityInsert> inserted =
|
||||
jdbc.query(
|
||||
INSERT_IDENTITY_SQL,
|
||||
(resultSet, rowNumber) ->
|
||||
new IdentityInsert(
|
||||
resultSet.getObject("retention_bucket", LocalDate.class),
|
||||
resultSet.getObject("created_at", OffsetDateTime.class).toInstant()),
|
||||
event.eventId(),
|
||||
event.aggregateType(),
|
||||
event.aggregateId(),
|
||||
event.aggregateVersion(),
|
||||
event.eventOrdinal());
|
||||
|
||||
if (inserted.isEmpty()) {
|
||||
return classifyExisting(event);
|
||||
}
|
||||
IdentityInsert identity = inserted.getFirst();
|
||||
String payloadDigest = sha256(event.payload());
|
||||
int envelopeInserted =
|
||||
jdbc.update(
|
||||
INSERT_EVENT_SQL,
|
||||
identity.retentionBucket(),
|
||||
event.eventId(),
|
||||
event.aggregateType(),
|
||||
event.aggregateId(),
|
||||
event.aggregateVersion(),
|
||||
event.eventOrdinal(),
|
||||
event.eventType(),
|
||||
event.eventSchema(),
|
||||
event.logicalDestination(),
|
||||
event.partitionKey(),
|
||||
control.activeEpoch(),
|
||||
dispatchAuthority.name(),
|
||||
event.contentType(),
|
||||
event.correlationId(),
|
||||
event.causationId(),
|
||||
OffsetDateTime.ofInstant(event.occurredAt(), java.time.ZoneOffset.UTC),
|
||||
event.payload(),
|
||||
payloadDigest,
|
||||
OffsetDateTime.ofInstant(identity.createdAt(), java.time.ZoneOffset.UTC));
|
||||
if (envelopeInserted != 1) {
|
||||
throw new IllegalStateException("immutable outbox event envelope insert affected no row");
|
||||
}
|
||||
return new OutboxAppendReceipt(
|
||||
OutboxAppendOutcome.APPENDED,
|
||||
event.eventId(),
|
||||
identity.retentionBucket(),
|
||||
control.activeEpoch(),
|
||||
dispatchAuthority);
|
||||
}
|
||||
|
||||
private OutboxAppendReceipt classifyExisting(NewOutboxEventV2 requested) {
|
||||
Optional<StoredEvent> byId = findEvent(requested.eventId());
|
||||
if (byId.isPresent()) {
|
||||
StoredEvent stored = byId.get();
|
||||
OutboxAppendOutcome outcome =
|
||||
sameIntent(stored, requested)
|
||||
? OutboxAppendOutcome.ALREADY_APPENDED_SAME_EVENT
|
||||
: OutboxAppendOutcome.EVENT_ID_CONFLICT;
|
||||
return new OutboxAppendReceipt(
|
||||
outcome,
|
||||
stored.eventId(),
|
||||
stored.retentionBucket(),
|
||||
stored.publicationEpoch(),
|
||||
stored.dispatchAuthority());
|
||||
}
|
||||
|
||||
List<String> orderingOwner =
|
||||
jdbc.query(
|
||||
FIND_ORDERING_IDENTITY_SQL,
|
||||
(resultSet, rowNumber) -> resultSet.getString("event_id"),
|
||||
requested.aggregateType(),
|
||||
requested.aggregateId(),
|
||||
requested.aggregateVersion(),
|
||||
requested.eventOrdinal());
|
||||
if (!orderingOwner.isEmpty()) {
|
||||
PublicationControl control = lockPublicationControl();
|
||||
return new OutboxAppendReceipt(
|
||||
OutboxAppendOutcome.AGGREGATE_ORDER_CONFLICT,
|
||||
requested.eventId(),
|
||||
currentRetentionBucket(),
|
||||
control.activeEpoch(),
|
||||
dispatchAuthority(control.authority()));
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"outbox identity insert lost without an event-ID or aggregate-order conflict");
|
||||
}
|
||||
|
||||
private Optional<StoredEvent> findEvent(String eventId) {
|
||||
List<StoredEvent> rows = jdbc.query(FIND_EVENT_SQL, this::mapStoredEvent, eventId);
|
||||
if (rows.size() > 1) {
|
||||
throw new IllegalStateException("multiple immutable outbox identities for one event ID");
|
||||
}
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
private StoredEvent mapStoredEvent(ResultSet resultSet, int rowNumber) throws SQLException {
|
||||
String dispatch = resultSet.getString("dispatch_authority");
|
||||
if (dispatch == null) {
|
||||
throw new IllegalStateException(
|
||||
"outbox identity exists without its same-transaction immutable envelope");
|
||||
}
|
||||
return new StoredEvent(
|
||||
resultSet.getString("event_id"),
|
||||
resultSet.getString("aggregate_type"),
|
||||
resultSet.getString("aggregate_id"),
|
||||
resultSet.getLong("aggregate_version"),
|
||||
resultSet.getInt("event_ordinal"),
|
||||
resultSet.getObject("retention_bucket", LocalDate.class),
|
||||
resultSet.getString("event_type"),
|
||||
resultSet.getInt("event_schema"),
|
||||
resultSet.getString("logical_destination"),
|
||||
resultSet.getString("partition_key"),
|
||||
resultSet.getLong("publication_epoch"),
|
||||
OutboxDispatchAuthority.valueOf(dispatch),
|
||||
resultSet.getString("content_type"),
|
||||
resultSet.getString("correlation_id"),
|
||||
resultSet.getString("causation_id"),
|
||||
resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(),
|
||||
resultSet.getString("payload_digest"));
|
||||
}
|
||||
|
||||
private PublicationControl lockPublicationControl() {
|
||||
List<PublicationControl> rows =
|
||||
jdbc.query(
|
||||
LOCK_CONTROL_SQL,
|
||||
(resultSet, rowNumber) ->
|
||||
new PublicationControl(
|
||||
resultSet.getLong("active_epoch"),
|
||||
OutboxPublicationAuthority.valueOf(resultSet.getString("active_authority"))));
|
||||
if (rows.size() != 1) {
|
||||
throw new IllegalStateException(
|
||||
"outbox publication control has no exact active immutable sentinel");
|
||||
}
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
private LocalDate currentRetentionBucket() {
|
||||
return jdbc.queryForObject(
|
||||
"select (clock_timestamp() at time zone 'UTC')::date", LocalDate.class);
|
||||
}
|
||||
|
||||
private void requireActiveCapability() {
|
||||
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITY_SQL, Integer.class);
|
||||
if (active == null || active != 1) {
|
||||
throw new IllegalStateException(
|
||||
"jpa-outbox-storage-v2 is not active at core epoch 1/revision 2");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameResourcePrimaryWriteTransaction() {
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
throw new IllegalStateException(
|
||||
"outbox V2 append requires an active primary write transaction");
|
||||
}
|
||||
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
|
||||
throw new IllegalStateException("outbox V2 append rejects a read-only transaction");
|
||||
}
|
||||
if (!TransactionSynchronizationManager.hasResource(dataSource)) {
|
||||
throw new IllegalStateException(
|
||||
"outbox V2 append transaction is not bound to the adapter datasource");
|
||||
}
|
||||
}
|
||||
|
||||
private static OutboxDispatchAuthority dispatchAuthority(OutboxPublicationAuthority authority) {
|
||||
return switch (authority) {
|
||||
case LEGACY_POLLING -> OutboxDispatchAuthority.LEGACY_SHADOW;
|
||||
case POLLING_V2 -> OutboxDispatchAuthority.POLLING_V2;
|
||||
case CDC -> OutboxDispatchAuthority.CDC;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean sameIntent(StoredEvent stored, NewOutboxEventV2 requested) {
|
||||
return stored.aggregateType().equals(requested.aggregateType())
|
||||
&& stored.aggregateId().equals(requested.aggregateId())
|
||||
&& stored.aggregateVersion() == requested.aggregateVersion()
|
||||
&& stored.eventOrdinal() == requested.eventOrdinal()
|
||||
&& stored.eventType().equals(requested.eventType())
|
||||
&& stored.eventSchema() == requested.eventSchema()
|
||||
&& stored.logicalDestination().equals(requested.logicalDestination())
|
||||
&& stored.partitionKey().equals(requested.partitionKey())
|
||||
&& stored.contentType().equals(requested.contentType())
|
||||
&& stored.correlationId().equals(requested.correlationId())
|
||||
&& Objects.equals(stored.causationId(), requested.causationId())
|
||||
&& stored.occurredAt().equals(requested.occurredAt())
|
||||
&& stored.payloadDigest().equals(sha256(requested.payload()));
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record PublicationControl(long activeEpoch, OutboxPublicationAuthority authority) {}
|
||||
|
||||
private record IdentityInsert(LocalDate retentionBucket, Instant createdAt) {}
|
||||
|
||||
private record StoredEvent(
|
||||
String eventId,
|
||||
String aggregateType,
|
||||
String aggregateId,
|
||||
long aggregateVersion,
|
||||
int eventOrdinal,
|
||||
LocalDate retentionBucket,
|
||||
String eventType,
|
||||
int eventSchema,
|
||||
String logicalDestination,
|
||||
String partitionKey,
|
||||
long publicationEpoch,
|
||||
OutboxDispatchAuthority dispatchAuthority,
|
||||
String contentType,
|
||||
String correlationId,
|
||||
String causationId,
|
||||
Instant occurredAt,
|
||||
String payloadDigest) {}
|
||||
}
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.postgresql.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.v2.ClaimedOutboxDelivery;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxDeliveryClaimRequest;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxDeliveryOwner;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransition;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxDeliveryTransitionOutcome;
|
||||
import dev.caskeleton.application.outbox.v2.OutboxPollingDeliveryPortV2;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/** PostgreSQL {@code SKIP LOCKED} polling relay with strict aggregate order and owner-safe CAS. */
|
||||
@Repository
|
||||
public class PostgreSqlPollingDeliveryAdapter implements OutboxPollingDeliveryPortV2 {
|
||||
|
||||
private static final Pattern ERROR_CODE = Pattern.compile("[A-Z][A-Z0-9_.-]{0,63}");
|
||||
|
||||
private static final String ACTIVE_CAPABILITIES_SQL =
|
||||
"""
|
||||
select count(*)
|
||||
from capability_schema_registry
|
||||
where capability_id in (
|
||||
'jpa-outbox-storage-v2',
|
||||
'jpa-outbox-polling-delivery-v2'
|
||||
)
|
||||
and core_epoch = 1
|
||||
and feature_revision = 2
|
||||
and lifecycle_state = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String CLAIM_SQL =
|
||||
"""
|
||||
with authority as (
|
||||
select control.active_epoch
|
||||
from outbox_publication_control_v2 control
|
||||
join outbox_publication_cutover_v2 cutover
|
||||
on cutover.scope_id = control.scope_id
|
||||
and cutover.active_epoch = control.active_epoch
|
||||
and cutover.active_authority = control.active_authority
|
||||
where control.scope_id = 'PRIMARY'
|
||||
and control.state = 'ACTIVE'
|
||||
and control.active_authority = 'POLLING_V2'
|
||||
for share of control
|
||||
),
|
||||
db_clock as (
|
||||
select clock_timestamp() as db_now
|
||||
),
|
||||
eligible as (
|
||||
select delivery.retention_bucket, delivery.event_id, delivery.destination
|
||||
from outbox_delivery_v2 delivery
|
||||
join outbox_event_log_v2 event
|
||||
on event.retention_bucket = delivery.retention_bucket
|
||||
and event.event_id = delivery.event_id
|
||||
cross join authority
|
||||
cross join db_clock
|
||||
where delivery.destination = ?
|
||||
and delivery.publication_epoch = authority.active_epoch
|
||||
and (
|
||||
(delivery.state in ('PENDING', 'RETRY_WAIT')
|
||||
and delivery.next_attempt_at <= db_clock.db_now)
|
||||
or
|
||||
(delivery.state = 'CLAIMED'
|
||||
and delivery.claim_until <= db_clock.db_now)
|
||||
)
|
||||
and not exists (
|
||||
select 1
|
||||
from outbox_delivery_v2 prior_delivery
|
||||
join outbox_event_log_v2 prior_event
|
||||
on prior_event.retention_bucket = prior_delivery.retention_bucket
|
||||
and prior_event.event_id = prior_delivery.event_id
|
||||
where prior_delivery.destination = delivery.destination
|
||||
and prior_event.aggregate_type = event.aggregate_type
|
||||
and prior_event.aggregate_id = event.aggregate_id
|
||||
and (
|
||||
prior_event.aggregate_version,
|
||||
prior_event.event_ordinal
|
||||
) < (
|
||||
event.aggregate_version,
|
||||
event.event_ordinal
|
||||
)
|
||||
and prior_delivery.state <> 'PUBLISHED'
|
||||
)
|
||||
order by event.created_at, event.event_id
|
||||
for update of delivery skip locked
|
||||
limit ?
|
||||
),
|
||||
claimed as (
|
||||
update outbox_delivery_v2 delivery
|
||||
set state = 'CLAIMED',
|
||||
claim_owner = ?,
|
||||
claim_token = ?,
|
||||
claim_until = db_clock.db_now + (? * interval '1 millisecond'),
|
||||
attempt = delivery.attempt + 1,
|
||||
version = delivery.version + 1,
|
||||
updated_at = db_clock.db_now
|
||||
from eligible, db_clock
|
||||
where delivery.retention_bucket = eligible.retention_bucket
|
||||
and delivery.event_id = eligible.event_id
|
||||
and delivery.destination = eligible.destination
|
||||
returning delivery.*
|
||||
)
|
||||
select claimed.retention_bucket,
|
||||
claimed.event_id,
|
||||
claimed.destination,
|
||||
claimed.claim_owner,
|
||||
claimed.claim_token,
|
||||
claimed.attempt,
|
||||
claimed.version,
|
||||
claimed.publication_epoch,
|
||||
event.event_type,
|
||||
event.event_schema,
|
||||
event.aggregate_type,
|
||||
event.aggregate_id,
|
||||
event.aggregate_version,
|
||||
event.event_ordinal,
|
||||
event.partition_key,
|
||||
event.content_type,
|
||||
event.correlation_id,
|
||||
event.causation_id,
|
||||
event.occurred_at,
|
||||
event.payload,
|
||||
event.payload_digest
|
||||
from claimed
|
||||
join outbox_event_log_v2 event
|
||||
on event.retention_bucket = claimed.retention_bucket
|
||||
and event.event_id = claimed.event_id
|
||||
order by event.created_at, event.event_id
|
||||
""";
|
||||
|
||||
private static final String MARK_PUBLISHED_SQL =
|
||||
"""
|
||||
update outbox_delivery_v2 delivery
|
||||
set state = 'PUBLISHED',
|
||||
claim_owner = null,
|
||||
claim_token = null,
|
||||
claim_until = null,
|
||||
last_operation_id = ?,
|
||||
last_result_digest = ?,
|
||||
published_at = clock_timestamp(),
|
||||
version = version + 1,
|
||||
updated_at = clock_timestamp()
|
||||
where retention_bucket = ?
|
||||
and event_id = ?
|
||||
and destination = ?
|
||||
and state = 'CLAIMED'
|
||||
and claim_owner = ?
|
||||
and claim_token = ?
|
||||
and attempt = ?
|
||||
and version = ?
|
||||
and publication_epoch = ?
|
||||
and exists (
|
||||
select 1
|
||||
from outbox_publication_control_v2 control
|
||||
join outbox_publication_cutover_v2 cutover
|
||||
on cutover.scope_id = control.scope_id
|
||||
and cutover.active_epoch = control.active_epoch
|
||||
and cutover.active_authority = control.active_authority
|
||||
where control.scope_id = 'PRIMARY'
|
||||
and control.state = 'ACTIVE'
|
||||
and control.active_authority = 'POLLING_V2'
|
||||
and control.active_epoch = delivery.publication_epoch
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String MARK_RETRY_SQL =
|
||||
"""
|
||||
update outbox_delivery_v2 delivery
|
||||
set state = 'RETRY_WAIT',
|
||||
claim_owner = null,
|
||||
claim_token = null,
|
||||
claim_until = null,
|
||||
next_attempt_at = ?,
|
||||
last_error_code = ?,
|
||||
last_operation_id = ?,
|
||||
last_result_digest = ?,
|
||||
version = version + 1,
|
||||
updated_at = clock_timestamp()
|
||||
where retention_bucket = ?
|
||||
and event_id = ?
|
||||
and destination = ?
|
||||
and state = 'CLAIMED'
|
||||
and claim_owner = ?
|
||||
and claim_token = ?
|
||||
and attempt = ?
|
||||
and version = ?
|
||||
and publication_epoch = ?
|
||||
and exists (
|
||||
select 1
|
||||
from outbox_publication_control_v2 control
|
||||
where control.scope_id = 'PRIMARY'
|
||||
and control.state = 'ACTIVE'
|
||||
and control.active_authority = 'POLLING_V2'
|
||||
and control.active_epoch = delivery.publication_epoch
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String MARK_DEAD_SQL =
|
||||
"""
|
||||
update outbox_delivery_v2 delivery
|
||||
set state = 'DEAD',
|
||||
claim_owner = null,
|
||||
claim_token = null,
|
||||
claim_until = null,
|
||||
last_error_code = ?,
|
||||
last_operation_id = ?,
|
||||
last_result_digest = ?,
|
||||
dead_at = clock_timestamp(),
|
||||
version = version + 1,
|
||||
updated_at = clock_timestamp()
|
||||
where retention_bucket = ?
|
||||
and event_id = ?
|
||||
and destination = ?
|
||||
and state = 'CLAIMED'
|
||||
and claim_owner = ?
|
||||
and claim_token = ?
|
||||
and attempt = ?
|
||||
and version = ?
|
||||
and publication_epoch = ?
|
||||
and exists (
|
||||
select 1
|
||||
from outbox_publication_control_v2 control
|
||||
where control.scope_id = 'PRIMARY'
|
||||
and control.state = 'ACTIVE'
|
||||
and control.active_authority = 'POLLING_V2'
|
||||
and control.active_epoch = delivery.publication_epoch
|
||||
)
|
||||
""";
|
||||
|
||||
private static final String INSPECT_SQL =
|
||||
"""
|
||||
select delivery.state,
|
||||
delivery.claim_owner,
|
||||
delivery.claim_token,
|
||||
delivery.attempt,
|
||||
delivery.version,
|
||||
delivery.publication_epoch,
|
||||
delivery.last_operation_id,
|
||||
delivery.last_result_digest,
|
||||
control.active_epoch,
|
||||
control.active_authority,
|
||||
control.state as authority_state
|
||||
from outbox_delivery_v2 delivery
|
||||
cross join outbox_publication_control_v2 control
|
||||
where delivery.retention_bucket = ?
|
||||
and delivery.event_id = ?
|
||||
and delivery.destination = ?
|
||||
and control.scope_id = 'PRIMARY'
|
||||
""";
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final JdbcOperations jdbc;
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
@Autowired
|
||||
public PostgreSqlPollingDeliveryAdapter(DataSource dataSource) {
|
||||
this(dataSource, new JdbcTemplate(dataSource), new SecureRandom());
|
||||
}
|
||||
|
||||
PostgreSqlPollingDeliveryAdapter(
|
||||
DataSource dataSource, JdbcOperations jdbc, SecureRandom secureRandom) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ClaimedOutboxDelivery> claimBatch(OutboxDeliveryClaimRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
requireActiveCapabilities();
|
||||
String claimToken = newClaimToken();
|
||||
return jdbc.query(
|
||||
CLAIM_SQL,
|
||||
this::mapClaim,
|
||||
request.destination(),
|
||||
request.batchSize(),
|
||||
request.claimOwner(),
|
||||
claimToken,
|
||||
request.claimLease().toMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboxDeliveryTransitionOutcome markPublished(OutboxDeliveryTransition transition) {
|
||||
Objects.requireNonNull(transition, "transition");
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
String digest = transitionDigest("PUBLISHED", transition, null);
|
||||
OutboxDeliveryOwner owner = transition.owner();
|
||||
int updated =
|
||||
jdbc.update(
|
||||
MARK_PUBLISHED_SQL,
|
||||
transition.operationId().value(),
|
||||
digest,
|
||||
owner.retentionBucket(),
|
||||
owner.eventId(),
|
||||
owner.destination(),
|
||||
owner.claimOwner(),
|
||||
owner.claimToken(),
|
||||
owner.attempt(),
|
||||
owner.version(),
|
||||
owner.publicationEpoch());
|
||||
return updated == 1
|
||||
? OutboxDeliveryTransitionOutcome.PUBLISHED
|
||||
: classifyFailedTransition(transition, digest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboxDeliveryTransitionOutcome markRetryable(
|
||||
OutboxDeliveryTransition transition, Instant nextAttemptAt, String errorCode) {
|
||||
Objects.requireNonNull(transition, "transition");
|
||||
Objects.requireNonNull(nextAttemptAt, "nextAttemptAt");
|
||||
requireErrorCode(errorCode);
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
String digest = transitionDigest("RETRY_WAIT", transition, errorCode);
|
||||
OutboxDeliveryOwner owner = transition.owner();
|
||||
int updated =
|
||||
jdbc.update(
|
||||
MARK_RETRY_SQL,
|
||||
OffsetDateTime.ofInstant(nextAttemptAt, ZoneOffset.UTC),
|
||||
errorCode,
|
||||
transition.operationId().value(),
|
||||
digest,
|
||||
owner.retentionBucket(),
|
||||
owner.eventId(),
|
||||
owner.destination(),
|
||||
owner.claimOwner(),
|
||||
owner.claimToken(),
|
||||
owner.attempt(),
|
||||
owner.version(),
|
||||
owner.publicationEpoch());
|
||||
return updated == 1
|
||||
? OutboxDeliveryTransitionOutcome.RETRY_SCHEDULED
|
||||
: classifyFailedTransition(transition, digest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboxDeliveryTransitionOutcome markDead(
|
||||
OutboxDeliveryTransition transition, String errorCode) {
|
||||
Objects.requireNonNull(transition, "transition");
|
||||
requireErrorCode(errorCode);
|
||||
requireSameResourcePrimaryWriteTransaction();
|
||||
String digest = transitionDigest("DEAD", transition, errorCode);
|
||||
OutboxDeliveryOwner owner = transition.owner();
|
||||
int updated =
|
||||
jdbc.update(
|
||||
MARK_DEAD_SQL,
|
||||
errorCode,
|
||||
transition.operationId().value(),
|
||||
digest,
|
||||
owner.retentionBucket(),
|
||||
owner.eventId(),
|
||||
owner.destination(),
|
||||
owner.claimOwner(),
|
||||
owner.claimToken(),
|
||||
owner.attempt(),
|
||||
owner.version(),
|
||||
owner.publicationEpoch());
|
||||
return updated == 1
|
||||
? OutboxDeliveryTransitionOutcome.DEAD
|
||||
: classifyFailedTransition(transition, digest);
|
||||
}
|
||||
|
||||
private ClaimedOutboxDelivery mapClaim(ResultSet resultSet, int rowNumber) throws SQLException {
|
||||
OutboxDeliveryOwner owner =
|
||||
new OutboxDeliveryOwner(
|
||||
resultSet.getObject("retention_bucket", LocalDate.class),
|
||||
resultSet.getString("event_id"),
|
||||
resultSet.getString("destination"),
|
||||
resultSet.getString("claim_owner"),
|
||||
resultSet.getString("claim_token"),
|
||||
resultSet.getInt("attempt"),
|
||||
resultSet.getLong("version"),
|
||||
resultSet.getLong("publication_epoch"));
|
||||
return new ClaimedOutboxDelivery(
|
||||
owner,
|
||||
resultSet.getString("event_type"),
|
||||
resultSet.getInt("event_schema"),
|
||||
resultSet.getString("aggregate_type"),
|
||||
resultSet.getString("aggregate_id"),
|
||||
resultSet.getLong("aggregate_version"),
|
||||
resultSet.getInt("event_ordinal"),
|
||||
resultSet.getString("partition_key"),
|
||||
resultSet.getString("content_type"),
|
||||
resultSet.getString("correlation_id"),
|
||||
resultSet.getString("causation_id"),
|
||||
resultSet.getObject("occurred_at", OffsetDateTime.class).toInstant(),
|
||||
resultSet.getString("payload"),
|
||||
resultSet.getString("payload_digest"));
|
||||
}
|
||||
|
||||
private OutboxDeliveryTransitionOutcome classifyFailedTransition(
|
||||
OutboxDeliveryTransition transition, String requestedDigest) {
|
||||
OutboxDeliveryOwner owner = transition.owner();
|
||||
List<DeliveryState> rows =
|
||||
jdbc.query(
|
||||
INSPECT_SQL,
|
||||
(resultSet, rowNumber) ->
|
||||
new DeliveryState(
|
||||
resultSet.getString("state"),
|
||||
resultSet.getString("claim_owner"),
|
||||
resultSet.getString("claim_token"),
|
||||
resultSet.getInt("attempt"),
|
||||
resultSet.getLong("version"),
|
||||
resultSet.getLong("publication_epoch"),
|
||||
resultSet.getString("last_operation_id"),
|
||||
resultSet.getString("last_result_digest"),
|
||||
resultSet.getLong("active_epoch"),
|
||||
resultSet.getString("active_authority"),
|
||||
resultSet.getString("authority_state")),
|
||||
owner.retentionBucket(),
|
||||
owner.eventId(),
|
||||
owner.destination());
|
||||
if (rows.isEmpty()) {
|
||||
return OutboxDeliveryTransitionOutcome.ABSENT;
|
||||
}
|
||||
DeliveryState row = rows.getFirst();
|
||||
if (transition.operationId().value().equals(row.lastOperationId())) {
|
||||
return requestedDigest.equals(row.lastResultDigest())
|
||||
? OutboxDeliveryTransitionOutcome.ALREADY_APPLIED_SAME_OPERATION
|
||||
: OutboxDeliveryTransitionOutcome.RESULT_CONFLICT;
|
||||
}
|
||||
if (!"ACTIVE".equals(row.authorityState())
|
||||
|| !"POLLING_V2".equals(row.activeAuthority())
|
||||
|| row.activeEpoch() != row.publicationEpoch()) {
|
||||
return OutboxDeliveryTransitionOutcome.AUTHORITY_MISMATCH;
|
||||
}
|
||||
if (!Objects.equals(owner.claimOwner(), row.claimOwner())
|
||||
|| !Objects.equals(owner.claimToken(), row.claimToken())
|
||||
|| owner.attempt() != row.attempt()) {
|
||||
return OutboxDeliveryTransitionOutcome.NOT_OWNER;
|
||||
}
|
||||
if (!"CLAIMED".equals(row.state())) {
|
||||
return OutboxDeliveryTransitionOutcome.NOT_CLAIMED;
|
||||
}
|
||||
if (owner.version() != row.version()) {
|
||||
return OutboxDeliveryTransitionOutcome.STALE_VERSION;
|
||||
}
|
||||
return OutboxDeliveryTransitionOutcome.RESULT_CONFLICT;
|
||||
}
|
||||
|
||||
private void requireActiveCapabilities() {
|
||||
Integer active = jdbc.queryForObject(ACTIVE_CAPABILITIES_SQL, Integer.class);
|
||||
if (active == null || active != 2) {
|
||||
throw new IllegalStateException(
|
||||
"outbox storage and polling delivery V2 must both be active at revision 2");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameResourcePrimaryWriteTransaction() {
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()
|
||||
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly()
|
||||
|| !TransactionSynchronizationManager.hasResource(dataSource)) {
|
||||
throw new IllegalStateException(
|
||||
"polling delivery mutation requires the adapter datasource primary write transaction");
|
||||
}
|
||||
}
|
||||
|
||||
private String newClaimToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private static void requireErrorCode(String errorCode) {
|
||||
if (errorCode == null || !ERROR_CODE.matcher(errorCode).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"error code must be 1-64 uppercase ASCII letters, digits, dot, dash, or underscore");
|
||||
}
|
||||
}
|
||||
|
||||
private static String transitionDigest(
|
||||
String kind, OutboxDeliveryTransition transition, String detail) {
|
||||
OutboxDeliveryOwner owner = transition.owner();
|
||||
return sha256(
|
||||
kind
|
||||
+ '|'
|
||||
+ transition.operationId().value()
|
||||
+ '|'
|
||||
+ owner.eventId()
|
||||
+ '|'
|
||||
+ owner.destination()
|
||||
+ '|'
|
||||
+ owner.claimToken()
|
||||
+ '|'
|
||||
+ owner.attempt()
|
||||
+ '|'
|
||||
+ owner.version()
|
||||
+ '|'
|
||||
+ Objects.toString(detail, ""));
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record DeliveryState(
|
||||
String state,
|
||||
String claimOwner,
|
||||
String claimToken,
|
||||
int attempt,
|
||||
long version,
|
||||
long publicationEpoch,
|
||||
String lastOperationId,
|
||||
String lastResultDigest,
|
||||
long activeEpoch,
|
||||
String activeAuthority,
|
||||
String authorityState) {}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** PostgreSQL transaction-local timeouts bounded by the remaining absolute deadline. */
|
||||
public record EffectiveTransactionTimeouts(
|
||||
Duration statementTimeout, Duration lockTimeout, Duration idleGuardTimeout) {
|
||||
|
||||
public EffectiveTransactionTimeouts {
|
||||
requirePositive(statementTimeout, "statementTimeout");
|
||||
requirePositive(lockTimeout, "lockTimeout");
|
||||
requirePositive(idleGuardTimeout, "idleGuardTimeout");
|
||||
if (lockTimeout.compareTo(statementTimeout) >= 0) {
|
||||
throw new IllegalArgumentException("lockTimeout must be less than statementTimeout");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositive(Duration duration, String name) {
|
||||
Objects.requireNonNull(duration, name + " must be non-null");
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/** Finite deadline and PostgreSQL transaction-local timeout policy. */
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.jpa.transaction")
|
||||
public record JpaTransactionSettings(
|
||||
Duration transactionTimeout,
|
||||
Duration beginBudget,
|
||||
Duration minimumActionWindow,
|
||||
Duration completionMargin,
|
||||
Duration statementTimeout,
|
||||
Duration lockTimeout,
|
||||
Duration idleGuardTimeout,
|
||||
Duration transactionMargin,
|
||||
Duration lockMargin,
|
||||
Duration retryBaseDelay,
|
||||
Duration retryMaximumDelay,
|
||||
Integer retryMaximumAttempts) {
|
||||
|
||||
private static final Duration MAXIMUM = Duration.ofDays(1);
|
||||
|
||||
@ConstructorBinding
|
||||
public JpaTransactionSettings {
|
||||
transactionTimeout =
|
||||
defaulted(transactionTimeout, Duration.ofSeconds(30), "transaction-timeout");
|
||||
beginBudget = defaulted(beginBudget, Duration.ofMillis(250), "begin-budget");
|
||||
minimumActionWindow =
|
||||
defaulted(minimumActionWindow, Duration.ofSeconds(1), "minimum-action-window");
|
||||
completionMargin = defaulted(completionMargin, Duration.ofMillis(500), "completion-margin");
|
||||
statementTimeout = defaulted(statementTimeout, Duration.ofSeconds(10), "statement-timeout");
|
||||
lockTimeout = defaulted(lockTimeout, Duration.ofSeconds(2), "lock-timeout");
|
||||
idleGuardTimeout = defaulted(idleGuardTimeout, Duration.ofSeconds(15), "idle-guard-timeout");
|
||||
transactionMargin = defaulted(transactionMargin, Duration.ofMillis(250), "transaction-margin");
|
||||
lockMargin = defaulted(lockMargin, Duration.ofMillis(100), "lock-margin");
|
||||
retryBaseDelay = defaulted(retryBaseDelay, Duration.ofMillis(10), "retry-base-delay");
|
||||
retryMaximumDelay = defaulted(retryMaximumDelay, Duration.ofMillis(50), "retry-maximum-delay");
|
||||
retryMaximumAttempts = retryMaximumAttempts == null ? 2 : retryMaximumAttempts;
|
||||
|
||||
if (statementTimeout.compareTo(transactionTimeout) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.statement-timeout must be <= transaction-timeout");
|
||||
}
|
||||
if (lockTimeout.compareTo(statementTimeout) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.lock-timeout must be < statement-timeout");
|
||||
}
|
||||
if (transactionMargin.compareTo(statementTimeout) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.transaction-margin must be < statement-timeout");
|
||||
}
|
||||
if (lockMargin.compareTo(statementTimeout.minus(lockTimeout)) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.lock-margin must leave lock-timeout below statement-timeout");
|
||||
}
|
||||
if (retryBaseDelay.compareTo(retryMaximumDelay) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.retry-base-delay must be <= retry-maximum-delay");
|
||||
}
|
||||
if (retryMaximumAttempts < 1 || retryMaximumAttempts > 5) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction.retry-maximum-attempts must be between 1 and 5");
|
||||
}
|
||||
}
|
||||
|
||||
public JpaTransactionSettings(
|
||||
Duration transactionTimeout,
|
||||
Duration beginBudget,
|
||||
Duration minimumActionWindow,
|
||||
Duration completionMargin,
|
||||
Duration statementTimeout,
|
||||
Duration lockTimeout,
|
||||
Duration idleGuardTimeout,
|
||||
Duration transactionMargin,
|
||||
Duration lockMargin) {
|
||||
this(
|
||||
transactionTimeout,
|
||||
beginBudget,
|
||||
minimumActionWindow,
|
||||
completionMargin,
|
||||
statementTimeout,
|
||||
lockTimeout,
|
||||
idleGuardTimeout,
|
||||
transactionMargin,
|
||||
lockMargin,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private static Duration defaulted(Duration value, Duration fallback, String name) {
|
||||
Duration selected = value == null ? fallback : value;
|
||||
if (selected.isZero() || selected.isNegative() || selected.compareTo(MAXIMUM) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.jpa.transaction." + name + " must be in (0, 1 day]");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
import dev.caskeleton.application.transaction.TransactionAdmissionException;
|
||||
import dev.caskeleton.application.transaction.TransactionPhase;
|
||||
import dev.caskeleton.application.transaction.TransactionPolicyId;
|
||||
import dev.caskeleton.application.transaction.TransactionRequest;
|
||||
import dev.caskeleton.application.transaction.TransactionResult;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.UnexpectedRollbackException;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/** Internal Spring executor for the application-owned named transaction policies. */
|
||||
final class SpringPolicyTransactionPort {
|
||||
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final LongSupplier monotonicNanos;
|
||||
private final TransactionDeadlineCalculator deadlineCalculator;
|
||||
private final TransactionRetryBackoff retryBackoff;
|
||||
private final TransactionLocalTimeoutConfigurer localTimeoutConfigurer;
|
||||
private final PersistenceExceptionTranslator exceptionTranslator;
|
||||
|
||||
SpringPolicyTransactionPort(
|
||||
PlatformTransactionManager transactionManager,
|
||||
LongSupplier monotonicNanos,
|
||||
TransactionDeadlineCalculator deadlineCalculator,
|
||||
TransactionRetryBackoff retryBackoff,
|
||||
TransactionLocalTimeoutConfigurer localTimeoutConfigurer,
|
||||
PersistenceExceptionTranslator exceptionTranslator) {
|
||||
this.transactionManager =
|
||||
Objects.requireNonNull(transactionManager, "transactionManager must be non-null");
|
||||
this.monotonicNanos = Objects.requireNonNull(monotonicNanos, "monotonicNanos must be non-null");
|
||||
this.deadlineCalculator =
|
||||
Objects.requireNonNull(deadlineCalculator, "deadlineCalculator must be non-null");
|
||||
this.retryBackoff = Objects.requireNonNull(retryBackoff, "retryBackoff must be non-null");
|
||||
this.localTimeoutConfigurer =
|
||||
Objects.requireNonNull(localTimeoutConfigurer, "localTimeoutConfigurer must be non-null");
|
||||
this.exceptionTranslator =
|
||||
Objects.requireNonNull(exceptionTranslator, "exceptionTranslator must be non-null");
|
||||
}
|
||||
|
||||
<T> TransactionResult<T> execute(TransactionRequest request, Supplier<T> action) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
Objects.requireNonNull(action, "action must be non-null");
|
||||
|
||||
PolicySpec policy = policy(request.policyId());
|
||||
if (policy.replicaRequired()) {
|
||||
throw new TransactionAdmissionException(
|
||||
"replica transaction policy is unavailable until the replica capability is qualified");
|
||||
}
|
||||
|
||||
int attempt = 1;
|
||||
while (true) {
|
||||
AttemptResult<T> attemptResult = executeOnce(request, action, policy);
|
||||
if (!shouldRetry(request.policyId(), attemptResult, attempt)) {
|
||||
return attemptResult.result();
|
||||
}
|
||||
if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
|
||||
return attemptResult.result();
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> AttemptResult<T> executeOnce(
|
||||
TransactionRequest request, Supplier<T> action, PolicySpec policy) {
|
||||
TransactionStartBudget startBudget =
|
||||
deadlineCalculator.beforeAcquisition(request.callBudget(), monotonicNanos.getAsLong());
|
||||
DefaultTransactionDefinition definition = definition(request.policyId(), startBudget, policy);
|
||||
TransactionStatus status;
|
||||
try {
|
||||
status = transactionManager.getTransaction(definition);
|
||||
} catch (RuntimeException failure) {
|
||||
throw new TransactionAdmissionException(
|
||||
"transaction acquisition failed before application work started", failure);
|
||||
}
|
||||
|
||||
PhaseTracker tracker = new PhaseTracker();
|
||||
tracker.observe(TransactionPhase.CONNECTION_ACQUIRED);
|
||||
boolean physicalOwner = status.isNewTransaction();
|
||||
PhaseSentinel sentinel = registerSentinelIfPossible(physicalOwner, tracker);
|
||||
tracker.observe(TransactionPhase.ACTIVE);
|
||||
|
||||
try {
|
||||
EffectiveTransactionTimeouts effectiveTimeouts =
|
||||
deadlineCalculator.afterBegin(
|
||||
request.callBudget(), monotonicNanos.getAsLong(), startBudget);
|
||||
localTimeoutConfigurer.apply(effectiveTimeouts);
|
||||
} catch (RuntimeException localTimeoutFailure) {
|
||||
return new AttemptResult<>(
|
||||
rollback(status, request.operationId(), tracker, localTimeoutFailure), physicalOwner);
|
||||
}
|
||||
|
||||
T value;
|
||||
try {
|
||||
value = action.get();
|
||||
} catch (RuntimeException actionFailure) {
|
||||
return new AttemptResult<>(
|
||||
rollback(status, request.operationId(), tracker, actionFailure), physicalOwner);
|
||||
}
|
||||
|
||||
tracker.observe(TransactionPhase.COMMIT_REQUESTED);
|
||||
try {
|
||||
transactionManager.commit(status);
|
||||
} catch (RuntimeException commitFailure) {
|
||||
if (sentinel.commitAcknowledged()) {
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.CommittedWithPostCommitFailure<>(
|
||||
value, request.operationId(), commitFailure),
|
||||
physicalOwner);
|
||||
}
|
||||
if (sentinel.rolledBack()
|
||||
|| commitFailure instanceof UnexpectedRollbackException
|
||||
|| TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.DeterminateRollback<>(translate(commitFailure)), physicalOwner);
|
||||
}
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.Indeterminate<>(
|
||||
request.operationId(), tracker.lastObserved(), Optional.empty()),
|
||||
physicalOwner);
|
||||
}
|
||||
|
||||
if (!physicalOwner) {
|
||||
return new AttemptResult<>(new TransactionResult.Participating<>(value), false);
|
||||
}
|
||||
tracker.observe(TransactionPhase.COMMIT_ACKED);
|
||||
return new AttemptResult<>(
|
||||
new TransactionResult.Committed<>(value, request.operationId()), true);
|
||||
}
|
||||
|
||||
private boolean shouldRetry(
|
||||
TransactionPolicyId policyId, AttemptResult<?> attemptResult, int attempt) {
|
||||
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|
||||
|| !attemptResult.physicalOwner()
|
||||
|| attempt >= retryBackoff.maximumAttempts()
|
||||
|| Thread.currentThread().isInterrupted()) {
|
||||
return false;
|
||||
}
|
||||
TransactionResult<?> result = attemptResult.result();
|
||||
if (result instanceof TransactionResult.DeterminateRollback<?> rollback) {
|
||||
return TransactionRetryClassifier.isReplayCandidate(rollback.failure());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private <T> TransactionResult<T> rollback(
|
||||
TransactionStatus status,
|
||||
Optional<OperationId> operationId,
|
||||
PhaseTracker tracker,
|
||||
RuntimeException actionFailure) {
|
||||
try {
|
||||
transactionManager.rollback(status);
|
||||
return new TransactionResult.DeterminateRollback<>(translate(actionFailure));
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
actionFailure.addSuppressed(rollbackFailure);
|
||||
return new TransactionResult.Indeterminate<>(
|
||||
operationId, tracker.lastObserved(), Optional.empty());
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeException translate(RuntimeException failure) {
|
||||
return exceptionTranslator.translate(failure).map(RuntimeException.class::cast).orElse(failure);
|
||||
}
|
||||
|
||||
private DefaultTransactionDefinition definition(
|
||||
TransactionPolicyId policyId, TransactionStartBudget startBudget, PolicySpec policy) {
|
||||
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
|
||||
definition.setName("application-" + policyId.name().toLowerCase(Locale.ROOT));
|
||||
definition.setPropagationBehavior(policy.propagation());
|
||||
definition.setIsolationLevel(policy.isolation());
|
||||
definition.setReadOnly(policy.readOnly());
|
||||
definition.setTimeout(startBudget.springTimeoutSeconds());
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static PolicySpec policy(TransactionPolicyId policyId) {
|
||||
return switch (policyId) {
|
||||
case COMMAND_DEFAULT, INBOX_AND_HANDLER ->
|
||||
required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
|
||||
case COMMAND_SERIALIZABLE_REPLAY_SAFE ->
|
||||
required(TransactionDefinition.ISOLATION_SERIALIZABLE, false);
|
||||
case QUERY_PRIMARY -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, true);
|
||||
case QUERY_REPLICA_ELIGIBLE ->
|
||||
new PolicySpec(
|
||||
TransactionDefinition.PROPAGATION_REQUIRED,
|
||||
TransactionDefinition.ISOLATION_READ_COMMITTED,
|
||||
true,
|
||||
true);
|
||||
case OUTBOX_APPEND -> required(TransactionDefinition.ISOLATION_READ_COMMITTED, false);
|
||||
case MAINTENANCE_NEW ->
|
||||
new PolicySpec(
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
|
||||
TransactionDefinition.ISOLATION_READ_COMMITTED,
|
||||
false,
|
||||
false);
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicySpec required(int isolation, boolean readOnly) {
|
||||
return new PolicySpec(TransactionDefinition.PROPAGATION_REQUIRED, isolation, readOnly, false);
|
||||
}
|
||||
|
||||
private static PhaseSentinel registerSentinelIfPossible(
|
||||
boolean physicalOwner, PhaseTracker tracker) {
|
||||
PhaseSentinel sentinel = new PhaseSentinel(tracker);
|
||||
if (physicalOwner && TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(sentinel);
|
||||
}
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
private record PolicySpec(
|
||||
int propagation, int isolation, boolean readOnly, boolean replicaRequired) {}
|
||||
|
||||
private record AttemptResult<T>(TransactionResult<T> result, boolean physicalOwner) {
|
||||
|
||||
private AttemptResult {
|
||||
Objects.requireNonNull(result, "result must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PhaseTracker {
|
||||
|
||||
private TransactionPhase lastObserved = TransactionPhase.ROUTE_ADMISSION;
|
||||
|
||||
private void observe(TransactionPhase phase) {
|
||||
lastObserved = phase;
|
||||
}
|
||||
|
||||
private TransactionPhase lastObserved() {
|
||||
return lastObserved;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PhaseSentinel implements TransactionSynchronization, Ordered {
|
||||
|
||||
private final PhaseTracker tracker;
|
||||
private boolean commitAcknowledged;
|
||||
private int completionStatus = STATUS_UNKNOWN;
|
||||
|
||||
private PhaseSentinel(PhaseTracker tracker) {
|
||||
this.tracker = tracker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeCommit(boolean readOnly) {
|
||||
tracker.observe(TransactionPhase.FLUSHED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
commitAcknowledged = true;
|
||||
tracker.observe(TransactionPhase.COMMIT_ACKED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
completionStatus = status;
|
||||
tracker.observe(TransactionPhase.SYNCHRONIZATION_CLEANUP);
|
||||
}
|
||||
|
||||
private boolean commitAcknowledged() {
|
||||
return commitAcknowledged || completionStatus == STATUS_COMMITTED;
|
||||
}
|
||||
|
||||
private boolean rolledBack() {
|
||||
return completionStatus == STATUS_ROLLED_BACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import dev.caskeleton.application.transaction.TransactionAdmissionException;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Computes conservative Spring and PostgreSQL timeout windows from one monotonic deadline. */
|
||||
final class TransactionDeadlineCalculator {
|
||||
|
||||
private static final long NANOS_PER_MILLISECOND = Duration.ofMillis(1).toNanos();
|
||||
private static final long NANOS_PER_SECOND = Duration.ofSeconds(1).toNanos();
|
||||
|
||||
private final Duration connectionTimeout;
|
||||
private final JpaTransactionSettings settings;
|
||||
private final boolean ignoreAcquisitionEnvelope;
|
||||
|
||||
TransactionDeadlineCalculator(Duration connectionTimeout, JpaTransactionSettings settings) {
|
||||
this(connectionTimeout, settings, false);
|
||||
}
|
||||
|
||||
private TransactionDeadlineCalculator(
|
||||
Duration connectionTimeout,
|
||||
JpaTransactionSettings settings,
|
||||
boolean ignoreAcquisitionEnvelope) {
|
||||
this.connectionTimeout =
|
||||
Objects.requireNonNull(connectionTimeout, "connectionTimeout must be non-null");
|
||||
this.settings = Objects.requireNonNull(settings, "settings must be non-null");
|
||||
this.ignoreAcquisitionEnvelope = ignoreAcquisitionEnvelope;
|
||||
if (connectionTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("connectionTimeout must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
static TransactionDeadlineCalculator withoutAcquisitionEnvelope(JpaTransactionSettings settings) {
|
||||
return new TransactionDeadlineCalculator(Duration.ZERO, settings, true);
|
||||
}
|
||||
|
||||
TransactionStartBudget beforeAcquisition(CallBudget callBudget, long nowNanos) {
|
||||
Objects.requireNonNull(callBudget, "callBudget must be non-null");
|
||||
long remainingNanos = callBudget.remainingNanosAt(nowNanos);
|
||||
long requiredNanos =
|
||||
ignoreAcquisitionEnvelope
|
||||
? 0
|
||||
: sumNanos(
|
||||
connectionTimeout,
|
||||
settings.beginBudget(),
|
||||
settings.minimumActionWindow(),
|
||||
settings.completionMargin());
|
||||
if (remainingNanos < requiredNanos) {
|
||||
throw new TransactionAdmissionException(
|
||||
"remaining call budget cannot contain pool acquisition, begin, action, and completion");
|
||||
}
|
||||
|
||||
long safeTransactionNanos =
|
||||
ignoreAcquisitionEnvelope
|
||||
? remainingNanos
|
||||
: remainingNanos
|
||||
- connectionTimeout.toNanos()
|
||||
- settings.beginBudget().toNanos()
|
||||
- settings.completionMargin().toNanos();
|
||||
long boundedNanos = Math.min(safeTransactionNanos, settings.transactionTimeout().toNanos());
|
||||
int timeoutSeconds = (int) Math.min(Integer.MAX_VALUE, boundedNanos / NANOS_PER_SECOND);
|
||||
if (timeoutSeconds < 1) {
|
||||
throw new TransactionAdmissionException(
|
||||
"at least one second must remain for the Spring transaction timeout");
|
||||
}
|
||||
return new TransactionStartBudget(timeoutSeconds, nowNanos);
|
||||
}
|
||||
|
||||
EffectiveTransactionTimeouts afterBegin(
|
||||
CallBudget callBudget, long nowNanos, TransactionStartBudget startBudget) {
|
||||
Objects.requireNonNull(callBudget, "callBudget must be non-null");
|
||||
Objects.requireNonNull(startBudget, "startBudget must be non-null");
|
||||
long elapsedNanos = Math.max(0, nowNanos - startBudget.acquisitionStartedNanos());
|
||||
long springRemainingNanos =
|
||||
(long) startBudget.springTimeoutSeconds() * NANOS_PER_SECOND - elapsedNanos;
|
||||
long callRemainingNanos = callBudget.remainingNanosAt(nowNanos);
|
||||
long completionNanos = ignoreAcquisitionEnvelope ? 0 : settings.completionMargin().toNanos();
|
||||
long transactionMarginNanos =
|
||||
ignoreAcquisitionEnvelope ? 0 : settings.transactionMargin().toNanos();
|
||||
long lockMarginNanos = ignoreAcquisitionEnvelope ? 1 : settings.lockMargin().toNanos();
|
||||
|
||||
long statementWindowNanos =
|
||||
Math.min(callRemainingNanos - completionNanos, springRemainingNanos)
|
||||
- transactionMarginNanos;
|
||||
long statementNanos = Math.min(settings.statementTimeout().toNanos(), statementWindowNanos);
|
||||
long lockNanos = Math.min(settings.lockTimeout().toNanos(), statementNanos - lockMarginNanos);
|
||||
long idleNanos =
|
||||
Math.min(settings.idleGuardTimeout().toNanos(), callRemainingNanos - completionNanos);
|
||||
if (statementNanos < NANOS_PER_MILLISECOND
|
||||
|| lockNanos < NANOS_PER_MILLISECOND
|
||||
|| idleNanos < NANOS_PER_MILLISECOND) {
|
||||
throw new TransactionAdmissionException(
|
||||
"remaining call budget after transaction begin cannot contain local timeout windows");
|
||||
}
|
||||
|
||||
return new EffectiveTransactionTimeouts(
|
||||
Duration.ofNanos(statementNanos), Duration.ofNanos(lockNanos), Duration.ofNanos(idleNanos));
|
||||
}
|
||||
|
||||
private static long sumNanos(Duration... durations) {
|
||||
long result = 0;
|
||||
try {
|
||||
for (Duration duration : durations) {
|
||||
result = Math.addExact(result, duration.toNanos());
|
||||
}
|
||||
return result;
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"transaction deadline settings exceed the supported range", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
/** Vendor implementation applies timeout values to the active physical transaction. */
|
||||
@FunctionalInterface
|
||||
public interface TransactionLocalTimeoutConfigurer {
|
||||
|
||||
void apply(EffectiveTransactionTimeouts timeouts);
|
||||
}
|
||||
+121
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
import java.sql.SQLException;
|
||||
|
||||
/** Fail-closed SQLState classifier for replay-safe whole-transaction retries. */
|
||||
final class TransactionRetryClassifier {
|
||||
|
||||
private static final String SERIALIZATION_FAILURE = "40001";
|
||||
private static final String POSTGRESQL_DEADLOCK = "40P01";
|
||||
|
||||
private TransactionRetryClassifier() {}
|
||||
|
||||
static boolean isReplayCandidate(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = current.getCause()) {
|
||||
if (current instanceof SQLException sqlException) {
|
||||
String sqlState = sqlException.getSQLState();
|
||||
return SERIALIZATION_FAILURE.equals(sqlState) || POSTGRESQL_DEADLOCK.equals(sqlState);
|
||||
}
|
||||
if (current.getCause() == current) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.transaction;
|
||||
|
||||
/** Conservative Spring transaction timeout selected before pool acquisition. */
|
||||
public record TransactionStartBudget(int springTimeoutSeconds, long acquisitionStartedNanos) {
|
||||
|
||||
public TransactionStartBudget {
|
||||
if (springTimeoutSeconds < 1) {
|
||||
throw new IllegalArgumentException("springTimeoutSeconds must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
-- Independent core stream initialization. The bridge V6 creates the registry for an adopted
|
||||
-- legacy database; a fresh target database may create it here before optional streams run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_schema_registry (
|
||||
capability_id varchar(128) NOT NULL,
|
||||
schema_stream varchar(32) NOT NULL,
|
||||
installation_origin varchar(32) NOT NULL,
|
||||
core_epoch integer NOT NULL,
|
||||
feature_revision integer NOT NULL,
|
||||
lifecycle_state varchar(32) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id),
|
||||
CONSTRAINT ck_capability_schema_registry_origin
|
||||
CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')),
|
||||
CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0),
|
||||
CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0)
|
||||
);
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
) VALUES (
|
||||
'jpa-flyway-migration',
|
||||
'db/migration/jpa/core',
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE installation_origin = 'LEGACY_ADOPTED'
|
||||
) THEN 'LEGACY_ADOPTED'
|
||||
ELSE 'FRESH'
|
||||
END,
|
||||
1,
|
||||
1,
|
||||
'ACTIVE'
|
||||
)
|
||||
ON CONFLICT (capability_id) DO NOTHING;
|
||||
+198
@@ -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';
|
||||
+63
@@ -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;
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
-- Additive owner-safe idempotency V2 expansion. V1 columns remain readable throughout the
|
||||
-- compatibility window; no synthetic owner is invented for legacy COMPLETED rows.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
AND core_epoch >= 1
|
||||
AND lifecycle_state = 'ACTIVE'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'jpa idempotency V2 requires active core epoch 1';
|
||||
END IF;
|
||||
IF to_regclass('public.idempotency_record') IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
AND installation_origin = 'FRESH'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'legacy adoption requires the compatible idempotency_record';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency_record (
|
||||
id uuid NOT NULL,
|
||||
tenant varchar(128) NOT NULL DEFAULT '',
|
||||
principal varchar(256) NOT NULL,
|
||||
idempotency_key varchar(256) NOT NULL,
|
||||
use_case_name varchar(256) NOT NULL,
|
||||
request_hash char(64) NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
response_payload text,
|
||||
response_ref varchar(512),
|
||||
created_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_idempotency_record PRIMARY KEY (id),
|
||||
CONSTRAINT uq_idempotency_scope
|
||||
UNIQUE (tenant, principal, idempotency_key, use_case_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_idempotency_record_expires_at
|
||||
ON idempotency_record (expires_at);
|
||||
|
||||
ALTER TABLE idempotency_record
|
||||
ADD COLUMN IF NOT EXISTS scope_hash char(64),
|
||||
ADD COLUMN IF NOT EXISTS key_digest_version integer,
|
||||
ADD COLUMN IF NOT EXISTS operation_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS record_version integer,
|
||||
ADD COLUMN IF NOT EXISTS state_revision bigint,
|
||||
ADD COLUMN IF NOT EXISTS owner_token varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS attempt bigint,
|
||||
ADD COLUMN IF NOT EXISTS claim_operation_id varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS last_transition_operation_id varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS last_transition_kind varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS last_transition_result_digest char(64),
|
||||
ADD COLUMN IF NOT EXISTS reconciliation_evidence_digest char(64),
|
||||
ADD COLUMN IF NOT EXISTS processing_lease_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS replay_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS policy_revision integer,
|
||||
ADD COLUMN IF NOT EXISTS response_codec_id varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS response_codec_version integer,
|
||||
ADD COLUMN IF NOT EXISTS response_digest char(64),
|
||||
ADD COLUMN IF NOT EXISTS failure_disposition varchar(32),
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_idempotency_record_v2_scope
|
||||
ON idempotency_record (scope_hash)
|
||||
WHERE record_version = 2;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_lease
|
||||
ON idempotency_record (status, processing_lease_until)
|
||||
WHERE record_version = 2;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_idempotency_record_v2_terminal
|
||||
ON idempotency_record (status, replay_until)
|
||||
WHERE record_version = 2
|
||||
AND status IN ('COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED');
|
||||
|
||||
ALTER TABLE idempotency_record
|
||||
ADD CONSTRAINT ck_idempotency_record_v2_shape
|
||||
CHECK (
|
||||
record_version IS NULL
|
||||
OR (
|
||||
record_version = 2
|
||||
AND scope_hash IS NOT NULL
|
||||
AND key_digest_version > 0
|
||||
AND operation_code IS NOT NULL
|
||||
AND state_revision >= 0
|
||||
AND owner_token IS NOT NULL
|
||||
AND attempt > 0
|
||||
AND claim_operation_id IS NOT NULL
|
||||
AND processing_lease_until IS NOT NULL
|
||||
AND policy_revision > 0
|
||||
AND response_codec_id IS NOT NULL
|
||||
AND updated_at IS NOT NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE idempotency_record
|
||||
ADD CONSTRAINT ck_idempotency_record_v2_state
|
||||
CHECK (
|
||||
record_version IS NULL
|
||||
OR status IN ('CLAIMED', 'EXECUTING', 'COMPLETED', 'FAILED_RETRYABLE', 'ABANDONED')
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE idempotency_record
|
||||
ADD CONSTRAINT ck_idempotency_record_v2_completed_response
|
||||
CHECK (
|
||||
record_version IS NULL
|
||||
OR status <> 'COMPLETED'
|
||||
OR (
|
||||
response_payload IS NOT NULL
|
||||
AND response_ref IS NULL
|
||||
AND response_digest IS NOT NULL
|
||||
AND replay_until IS NOT NULL
|
||||
AND completed_at IS NOT NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
)
|
||||
SELECT
|
||||
'jpa-idempotency-owner-safe-v2',
|
||||
'db/migration/jpa/idempotency',
|
||||
installation_origin,
|
||||
1,
|
||||
2,
|
||||
'INSTALLED_INACTIVE'
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
ON CONFLICT (capability_id) DO NOTHING;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
-- Same-store inbox state machine. The scope hash is the canonical digest of
|
||||
-- consumer-group/handler/tenant/message-ID; raw broker metadata is not persisted here.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
AND core_epoch >= 1
|
||||
AND lifecycle_state = 'ACTIVE'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'same-store inbox requires active core epoch 1';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE inbox_record_v1 (
|
||||
scope_hash char(64) NOT NULL,
|
||||
message_intent_digest char(64) NOT NULL,
|
||||
state varchar(16) NOT NULL,
|
||||
state_revision bigint NOT NULL,
|
||||
owner_token varchar(128) NOT NULL,
|
||||
attempt bigint NOT NULL,
|
||||
claim_operation_id varchar(128) NOT NULL,
|
||||
processing_lease_until timestamptz NOT NULL,
|
||||
last_operation_id varchar(128),
|
||||
last_transition_kind varchar(32),
|
||||
last_result_digest char(64),
|
||||
terminal_at timestamptz,
|
||||
retention_until timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_inbox_record_v1 PRIMARY KEY (scope_hash),
|
||||
CONSTRAINT ck_inbox_record_v1_state
|
||||
CHECK (state IN ('RECEIVED', 'PROCESSING', 'COMPLETED', 'RETRYABLE', 'DEAD')),
|
||||
CONSTRAINT ck_inbox_record_v1_revision CHECK (state_revision >= 0),
|
||||
CONSTRAINT ck_inbox_record_v1_attempt CHECK (attempt > 0),
|
||||
CONSTRAINT ck_inbox_record_v1_terminal
|
||||
CHECK (
|
||||
(state IN ('COMPLETED', 'DEAD') AND terminal_at IS NOT NULL)
|
||||
OR (state NOT IN ('COMPLETED', 'DEAD') AND terminal_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_inbox_record_v1_lease
|
||||
ON inbox_record_v1 (state, processing_lease_until)
|
||||
WHERE state IN ('RECEIVED', 'PROCESSING');
|
||||
|
||||
CREATE INDEX ix_inbox_record_v1_terminal
|
||||
ON inbox_record_v1 (state, terminal_at, retention_until)
|
||||
WHERE state IN ('COMPLETED', 'DEAD', 'RETRYABLE');
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
)
|
||||
SELECT
|
||||
'jpa-inbox-same-store-v1',
|
||||
'db/migration/jpa/inbox',
|
||||
installation_origin,
|
||||
1,
|
||||
1,
|
||||
'INSTALLED_INACTIVE'
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration';
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
-- Mutable polling delivery state, separated from the immutable outbox identity/envelope.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.outbox_event_log_v2') IS NULL
|
||||
OR to_regclass('public.outbox_publication_control_v2') IS NULL THEN
|
||||
RAISE EXCEPTION 'polling delivery V2 requires outbox storage V2';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-outbox-storage-v2'
|
||||
AND core_epoch = 1
|
||||
AND feature_revision = 2
|
||||
) THEN
|
||||
RAISE EXCEPTION 'polling delivery V2 requires outbox storage revision 2';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE outbox_delivery_v2 (
|
||||
retention_bucket date NOT NULL,
|
||||
event_id varchar(64) NOT NULL,
|
||||
destination varchar(256) NOT NULL,
|
||||
publication_epoch bigint NOT NULL,
|
||||
state varchar(16) NOT NULL,
|
||||
claim_owner varchar(128),
|
||||
claim_token char(64),
|
||||
claim_until timestamptz,
|
||||
attempt integer NOT NULL,
|
||||
next_attempt_at timestamptz NOT NULL,
|
||||
last_error_code varchar(64),
|
||||
last_operation_id varchar(128),
|
||||
last_result_digest char(64),
|
||||
published_at timestamptz,
|
||||
dead_at timestamptz,
|
||||
version bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_outbox_delivery_v2
|
||||
PRIMARY KEY (retention_bucket, event_id, destination),
|
||||
CONSTRAINT fk_outbox_delivery_v2_event
|
||||
FOREIGN KEY (retention_bucket, event_id)
|
||||
REFERENCES outbox_event_log_v2 (retention_bucket, event_id),
|
||||
CONSTRAINT ck_outbox_delivery_v2_epoch CHECK (publication_epoch > 0),
|
||||
CONSTRAINT ck_outbox_delivery_v2_state
|
||||
CHECK (state IN ('PENDING', 'CLAIMED', 'PUBLISHED', 'RETRY_WAIT', 'DEAD')),
|
||||
CONSTRAINT ck_outbox_delivery_v2_attempt CHECK (attempt >= 0),
|
||||
CONSTRAINT ck_outbox_delivery_v2_version CHECK (version >= 0),
|
||||
CONSTRAINT ck_outbox_delivery_v2_state_shape
|
||||
CHECK (
|
||||
(state = 'CLAIMED'
|
||||
AND claim_owner IS NOT NULL
|
||||
AND claim_token IS NOT NULL
|
||||
AND claim_until IS NOT NULL
|
||||
AND published_at IS NULL
|
||||
AND dead_at IS NULL)
|
||||
OR
|
||||
(state <> 'CLAIMED'
|
||||
AND claim_owner IS NULL
|
||||
AND claim_token IS NULL
|
||||
AND claim_until IS NULL)
|
||||
),
|
||||
CONSTRAINT ck_outbox_delivery_v2_terminal_shape
|
||||
CHECK (
|
||||
(state = 'PUBLISHED' AND published_at IS NOT NULL AND dead_at IS NULL)
|
||||
OR (state = 'DEAD' AND dead_at IS NOT NULL AND published_at IS NULL)
|
||||
OR (state NOT IN ('PUBLISHED', 'DEAD')
|
||||
AND published_at IS NULL
|
||||
AND dead_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_outbox_delivery_v2_claim
|
||||
ON outbox_delivery_v2 (destination, next_attempt_at, created_at)
|
||||
WHERE state IN ('PENDING', 'RETRY_WAIT', 'CLAIMED');
|
||||
|
||||
CREATE INDEX ix_outbox_delivery_v2_terminal
|
||||
ON outbox_delivery_v2 (state, published_at, dead_at)
|
||||
WHERE state IN ('PUBLISHED', 'DEAD');
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_polling_delivery_v2()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.dispatch_authority = 'POLLING_V2' THEN
|
||||
INSERT INTO outbox_delivery_v2 (
|
||||
retention_bucket,
|
||||
event_id,
|
||||
destination,
|
||||
publication_epoch,
|
||||
state,
|
||||
claim_owner,
|
||||
claim_token,
|
||||
claim_until,
|
||||
attempt,
|
||||
next_attempt_at,
|
||||
last_error_code,
|
||||
last_operation_id,
|
||||
last_result_digest,
|
||||
published_at,
|
||||
dead_at,
|
||||
version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
NEW.retention_bucket,
|
||||
NEW.event_id,
|
||||
NEW.logical_destination,
|
||||
NEW.publication_epoch,
|
||||
'PENDING',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
NEW.created_at,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
NEW.created_at,
|
||||
NEW.created_at
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_create_polling_delivery_v2
|
||||
AFTER INSERT ON outbox_event_log_v2
|
||||
FOR EACH ROW EXECUTE FUNCTION create_polling_delivery_v2();
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
)
|
||||
SELECT
|
||||
'jpa-outbox-polling-delivery-v2',
|
||||
'db/migration/jpa/outbox-polling',
|
||||
installation_origin,
|
||||
1,
|
||||
2,
|
||||
'INSTALLED_INACTIVE'
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration';
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
-- Immutable outbox storage V2. Publication authority remains LEGACY_POLLING after adoption until
|
||||
-- an explicit cutover transaction writes the next immutable sentinel and advances the control row.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
AND core_epoch >= 1
|
||||
AND lifecycle_state = 'ACTIVE'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'jpa outbox storage V2 requires active core epoch 1';
|
||||
END IF;
|
||||
IF to_regclass('public.outbox_event') IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
AND installation_origin = 'FRESH'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'legacy adoption requires the compatible outbox_event';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox_event (
|
||||
event_id varchar(64) NOT NULL,
|
||||
aggregate_id varchar(256) NOT NULL,
|
||||
event_type varchar(256) NOT NULL,
|
||||
payload text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL,
|
||||
correlation_id varchar(64) NOT NULL,
|
||||
idempotency_key varchar(256) NOT NULL,
|
||||
CONSTRAINT pk_outbox_event PRIMARY KEY (event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_outbox_event_eligible ON outbox_event (next_attempt_at)
|
||||
WHERE status IN ('PENDING', 'FAILED', 'IN_FLIGHT');
|
||||
CREATE INDEX IF NOT EXISTS ix_outbox_event_aggregate_occurred
|
||||
ON outbox_event (aggregate_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_outbox_event_published_occurred ON outbox_event (occurred_at)
|
||||
WHERE status = 'PUBLISHED';
|
||||
CREATE INDEX IF NOT EXISTS ix_outbox_event_status_occurred ON outbox_event (status, occurred_at);
|
||||
|
||||
CREATE TABLE outbox_publication_control_v2 (
|
||||
scope_id varchar(32) NOT NULL,
|
||||
active_epoch bigint NOT NULL,
|
||||
active_authority varchar(32) NOT NULL,
|
||||
state varchar(16) NOT NULL,
|
||||
revision bigint NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_outbox_publication_control_v2 PRIMARY KEY (scope_id),
|
||||
CONSTRAINT ck_outbox_publication_control_v2_scope CHECK (scope_id = 'PRIMARY'),
|
||||
CONSTRAINT ck_outbox_publication_control_v2_epoch CHECK (active_epoch > 0),
|
||||
CONSTRAINT ck_outbox_publication_control_v2_authority
|
||||
CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')),
|
||||
CONSTRAINT ck_outbox_publication_control_v2_state
|
||||
CHECK (state IN ('PREPARING', 'ACTIVE', 'DRAINING')),
|
||||
CONSTRAINT ck_outbox_publication_control_v2_revision CHECK (revision >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE outbox_publication_cutover_v2 (
|
||||
scope_id varchar(32) NOT NULL,
|
||||
active_epoch bigint NOT NULL,
|
||||
previous_epoch bigint NOT NULL,
|
||||
transition_kind varchar(32) NOT NULL,
|
||||
active_authority varchar(32) NOT NULL,
|
||||
legacy_row_count bigint NOT NULL,
|
||||
legacy_pending_count bigint NOT NULL,
|
||||
legacy_digest char(64) NOT NULL,
|
||||
schema_manifest_id varchar(128) NOT NULL,
|
||||
external_manifest_id varchar(128),
|
||||
activated_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_outbox_publication_cutover_v2 PRIMARY KEY (scope_id, active_epoch),
|
||||
CONSTRAINT fk_outbox_publication_cutover_v2_scope
|
||||
FOREIGN KEY (scope_id) REFERENCES outbox_publication_control_v2 (scope_id),
|
||||
CONSTRAINT ck_outbox_publication_cutover_v2_epoch
|
||||
CHECK (active_epoch > 0 AND previous_epoch = active_epoch - 1),
|
||||
CONSTRAINT ck_outbox_publication_cutover_v2_kind
|
||||
CHECK (transition_kind IN ('GENESIS_FRESH', 'GENESIS_LEGACY', 'CUTOVER')),
|
||||
CONSTRAINT ck_outbox_publication_cutover_v2_authority
|
||||
CHECK (active_authority IN ('LEGACY_POLLING', 'POLLING_V2', 'CDC')),
|
||||
CONSTRAINT ck_outbox_publication_cutover_v2_counts
|
||||
CHECK (legacy_row_count >= 0 AND legacy_pending_count >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE outbox_event_identity_v2 (
|
||||
event_id varchar(64) NOT NULL,
|
||||
aggregate_type varchar(128) NOT NULL,
|
||||
aggregate_id varchar(256) NOT NULL,
|
||||
aggregate_version bigint NOT NULL,
|
||||
event_ordinal integer NOT NULL,
|
||||
retention_bucket date NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_outbox_event_identity_v2 PRIMARY KEY (event_id),
|
||||
CONSTRAINT uq_outbox_event_identity_v2_aggregate_order
|
||||
UNIQUE (aggregate_type, aggregate_id, aggregate_version, event_ordinal),
|
||||
CONSTRAINT uq_outbox_event_identity_v2_bucket UNIQUE (event_id, retention_bucket),
|
||||
CONSTRAINT ck_outbox_event_identity_v2_version CHECK (aggregate_version > 0),
|
||||
CONSTRAINT ck_outbox_event_identity_v2_ordinal CHECK (event_ordinal BETWEEN 0 AND 1023)
|
||||
);
|
||||
|
||||
CREATE TABLE outbox_event_log_v2 (
|
||||
retention_bucket date NOT NULL,
|
||||
event_id varchar(64) NOT NULL,
|
||||
aggregate_type varchar(128) NOT NULL,
|
||||
aggregate_id varchar(256) NOT NULL,
|
||||
aggregate_version bigint NOT NULL,
|
||||
event_ordinal integer NOT NULL,
|
||||
event_type varchar(256) NOT NULL,
|
||||
event_schema integer NOT NULL,
|
||||
logical_destination varchar(256) NOT NULL,
|
||||
partition_key varchar(256) NOT NULL,
|
||||
publication_epoch bigint NOT NULL,
|
||||
dispatch_authority varchar(32) NOT NULL,
|
||||
content_type varchar(128) NOT NULL,
|
||||
correlation_id varchar(128) NOT NULL,
|
||||
causation_id varchar(128),
|
||||
occurred_at timestamptz NOT NULL,
|
||||
payload text NOT NULL,
|
||||
payload_digest char(64) NOT NULL,
|
||||
trace_parent varchar(256),
|
||||
created_at timestamptz NOT NULL,
|
||||
CONSTRAINT pk_outbox_event_log_v2 PRIMARY KEY (retention_bucket, event_id),
|
||||
CONSTRAINT fk_outbox_event_log_v2_identity
|
||||
FOREIGN KEY (event_id, retention_bucket)
|
||||
REFERENCES outbox_event_identity_v2 (event_id, retention_bucket),
|
||||
CONSTRAINT ck_outbox_event_log_v2_schema CHECK (event_schema > 0),
|
||||
CONSTRAINT ck_outbox_event_log_v2_epoch CHECK (publication_epoch > 0),
|
||||
CONSTRAINT ck_outbox_event_log_v2_authority
|
||||
CHECK (dispatch_authority IN ('LEGACY_SHADOW', 'POLLING_V2', 'CDC')),
|
||||
CONSTRAINT ck_outbox_event_log_v2_payload_size
|
||||
CHECK (octet_length(payload) BETWEEN 1 AND 1048576)
|
||||
) PARTITION BY RANGE (retention_bucket);
|
||||
|
||||
CREATE TABLE outbox_event_log_v2_default
|
||||
PARTITION OF outbox_event_log_v2 DEFAULT;
|
||||
|
||||
CREATE INDEX ix_outbox_event_log_v2_aggregate_order
|
||||
ON outbox_event_log_v2 (
|
||||
aggregate_type,
|
||||
aggregate_id,
|
||||
aggregate_version,
|
||||
event_ordinal
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_outbox_event_v2_authority()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
control_epoch bigint;
|
||||
control_authority varchar(32);
|
||||
control_state varchar(16);
|
||||
expected_dispatch varchar(32);
|
||||
BEGIN
|
||||
SELECT active_epoch, active_authority, state
|
||||
INTO control_epoch, control_authority, control_state
|
||||
FROM outbox_publication_control_v2
|
||||
WHERE scope_id = 'PRIMARY';
|
||||
|
||||
IF control_state <> 'ACTIVE' THEN
|
||||
RAISE EXCEPTION 'outbox publication authority is not ACTIVE';
|
||||
END IF;
|
||||
|
||||
expected_dispatch := CASE control_authority
|
||||
WHEN 'LEGACY_POLLING' THEN 'LEGACY_SHADOW'
|
||||
WHEN 'POLLING_V2' THEN 'POLLING_V2'
|
||||
WHEN 'CDC' THEN 'CDC'
|
||||
END;
|
||||
|
||||
IF NEW.publication_epoch <> control_epoch
|
||||
OR NEW.dispatch_authority <> expected_dispatch THEN
|
||||
RAISE EXCEPTION 'outbox publication epoch or dispatch authority mismatch';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_outbox_event_v2_authority
|
||||
BEFORE INSERT ON outbox_event_log_v2
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_outbox_event_v2_authority();
|
||||
|
||||
CREATE OR REPLACE FUNCTION fence_legacy_outbox_writer()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
authority varchar(32);
|
||||
BEGIN
|
||||
SELECT active_authority
|
||||
INTO authority
|
||||
FROM outbox_publication_control_v2
|
||||
WHERE scope_id = 'PRIMARY';
|
||||
IF authority <> 'LEGACY_POLLING' THEN
|
||||
RAISE EXCEPTION 'legacy outbox writer is fenced after authority cutover';
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_fence_legacy_outbox_writer
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON outbox_event
|
||||
FOR EACH ROW EXECUTE FUNCTION fence_legacy_outbox_writer();
|
||||
|
||||
CREATE OR REPLACE FUNCTION reject_outbox_cutover_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'outbox publication cutover sentinel is immutable';
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_reject_outbox_cutover_mutation
|
||||
BEFORE UPDATE OR DELETE ON outbox_publication_cutover_v2
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_outbox_cutover_mutation();
|
||||
|
||||
INSERT INTO outbox_publication_control_v2 (
|
||||
scope_id,
|
||||
active_epoch,
|
||||
active_authority,
|
||||
state,
|
||||
revision,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'PRIMARY',
|
||||
1,
|
||||
'LEGACY_POLLING',
|
||||
'ACTIVE',
|
||||
0,
|
||||
clock_timestamp()
|
||||
);
|
||||
|
||||
WITH legacy AS (
|
||||
SELECT
|
||||
count(*) AS row_count,
|
||||
count(*) FILTER (WHERE status <> 'PUBLISHED') AS pending_count,
|
||||
coalesce(
|
||||
string_agg(
|
||||
event_id || ':' || status || ':' || attempt_count::text,
|
||||
',' ORDER BY event_id
|
||||
),
|
||||
''
|
||||
) AS intent
|
||||
FROM outbox_event
|
||||
),
|
||||
origin AS (
|
||||
SELECT installation_origin
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration'
|
||||
)
|
||||
INSERT INTO outbox_publication_cutover_v2 (
|
||||
scope_id,
|
||||
active_epoch,
|
||||
previous_epoch,
|
||||
transition_kind,
|
||||
active_authority,
|
||||
legacy_row_count,
|
||||
legacy_pending_count,
|
||||
legacy_digest,
|
||||
schema_manifest_id,
|
||||
external_manifest_id,
|
||||
activated_at
|
||||
)
|
||||
SELECT
|
||||
'PRIMARY',
|
||||
1,
|
||||
0,
|
||||
CASE installation_origin
|
||||
WHEN 'FRESH' THEN 'GENESIS_FRESH'
|
||||
ELSE 'GENESIS_LEGACY'
|
||||
END,
|
||||
'LEGACY_POLLING',
|
||||
row_count,
|
||||
pending_count,
|
||||
md5(intent) || md5('outbox-v2:' || intent),
|
||||
'jpa-outbox-storage-v2-schema-revision-2',
|
||||
null,
|
||||
clock_timestamp()
|
||||
FROM legacy
|
||||
CROSS JOIN origin;
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
)
|
||||
SELECT
|
||||
'jpa-outbox-storage-v2',
|
||||
'db/migration/jpa/outbox-storage',
|
||||
installation_origin,
|
||||
1,
|
||||
2,
|
||||
'INSTALLED_INACTIVE'
|
||||
FROM capability_schema_registry
|
||||
WHERE capability_id = 'jpa-flyway-migration';
|
||||
+21
@@ -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);
|
||||
+29
@@ -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);
|
||||
+10
@@ -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)
|
||||
);
|
||||
+12
@@ -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;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- Bridge migration: preserve the immutable V1/V3/V4/V5 legacy history and record its
|
||||
-- installation origin before independent JPA capability streams are adopted.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.idempotency_record') IS NULL THEN
|
||||
RAISE EXCEPTION 'legacy adoption requires idempotency_record';
|
||||
END IF;
|
||||
IF to_regclass('public.outbox_event') IS NULL THEN
|
||||
RAISE EXCEPTION 'legacy adoption requires outbox_event';
|
||||
END IF;
|
||||
IF to_regclass('public.int_lock') IS NULL THEN
|
||||
RAISE EXCEPTION 'legacy adoption requires INT_LOCK';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE capability_schema_registry (
|
||||
capability_id varchar(128) NOT NULL,
|
||||
schema_stream varchar(32) NOT NULL,
|
||||
installation_origin varchar(32) NOT NULL,
|
||||
core_epoch integer NOT NULL,
|
||||
feature_revision integer NOT NULL,
|
||||
lifecycle_state varchar(32) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_capability_schema_registry PRIMARY KEY (capability_id),
|
||||
CONSTRAINT ck_capability_schema_registry_origin
|
||||
CHECK (installation_origin IN ('FRESH', 'LEGACY_ADOPTED')),
|
||||
CONSTRAINT ck_capability_schema_registry_epoch CHECK (core_epoch >= 0),
|
||||
CONSTRAINT ck_capability_schema_registry_revision CHECK (feature_revision >= 0)
|
||||
);
|
||||
|
||||
INSERT INTO capability_schema_registry (
|
||||
capability_id,
|
||||
schema_stream,
|
||||
installation_origin,
|
||||
core_epoch,
|
||||
feature_revision,
|
||||
lifecycle_state
|
||||
) VALUES
|
||||
('legacy-idempotency-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'),
|
||||
('legacy-outbox-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE'),
|
||||
('legacy-jdbc-coordination-v1', 'legacy', 'LEGACY_ADOPTED', 0, 1, 'INSTALLED_INACTIVE');
|
||||
+100
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PostgreSqlAggregateIntegrationTest {
|
||||
|
||||
private static PostgreSqlReadinessSupport postgres;
|
||||
|
||||
@BeforeAll
|
||||
static void startPostgreSql() throws Exception {
|
||||
PostgreSqlReadinessSupport.assertDockerAvailable();
|
||||
postgres = PostgreSqlReadinessSupport.start();
|
||||
postgres.execute(
|
||||
"create table readiness_aggregate("
|
||||
+ "id uuid primary key, title varchar(100) not null, "
|
||||
+ "occurred_at timestamptz not null, version bigint not null)");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (postgres != null) {
|
||||
postgres.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsUuidAndInstantAndDetectsExpectedVersionConflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Instant occurredAt = Instant.parse("2026-07-28T12:00:00.123456Z");
|
||||
try (Connection connection = postgres.connection();
|
||||
PreparedStatement insert =
|
||||
connection.prepareStatement(
|
||||
"insert into readiness_aggregate(id,title,occurred_at,version) "
|
||||
+ "values (?,?,?,0)")) {
|
||||
insert.setObject(1, id);
|
||||
insert.setString(2, "aggregate");
|
||||
insert.setObject(3, occurredAt.atOffset(ZoneOffset.UTC));
|
||||
assertThat(insert.executeUpdate()).isOne();
|
||||
}
|
||||
|
||||
try (Connection connection = postgres.connection();
|
||||
PreparedStatement query =
|
||||
connection.prepareStatement(
|
||||
"select id,title,occurred_at,version from readiness_aggregate where id=?")) {
|
||||
query.setObject(1, id);
|
||||
try (ResultSet row = query.executeQuery()) {
|
||||
assertThat(row.next()).isTrue();
|
||||
assertThat(row.getObject(1, UUID.class)).isEqualTo(id);
|
||||
assertThat(row.getString(2)).isEqualTo("aggregate");
|
||||
assertThat(row.getObject(3, java.time.OffsetDateTime.class).toInstant())
|
||||
.isEqualTo(occurredAt);
|
||||
assertThat(row.getLong(4)).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
try (Connection first = postgres.connection();
|
||||
Connection second = postgres.connection();
|
||||
PreparedStatement firstUpdate =
|
||||
first.prepareStatement(
|
||||
"update readiness_aggregate set title=?,version=version+1 "
|
||||
+ "where id=? and version=?");
|
||||
PreparedStatement secondUpdate =
|
||||
second.prepareStatement(
|
||||
"update readiness_aggregate set title=?,version=version+1 "
|
||||
+ "where id=? and version=?")) {
|
||||
first.setAutoCommit(false);
|
||||
second.setAutoCommit(false);
|
||||
bindUpdate(firstUpdate, "first", id, 0);
|
||||
bindUpdate(secondUpdate, "second", id, 0);
|
||||
assertThat(firstUpdate.executeUpdate()).isOne();
|
||||
first.commit();
|
||||
assertThat(secondUpdate.executeUpdate()).isZero();
|
||||
second.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindUpdate(
|
||||
PreparedStatement statement, String title, UUID id, long expectedVersion) throws Exception {
|
||||
statement.setString(1, title);
|
||||
statement.setObject(2, id);
|
||||
statement.setLong(3, expectedVersion);
|
||||
}
|
||||
}
|
||||
+470
@@ -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();
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.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();
|
||||
}
|
||||
}
|
||||
+494
@@ -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();
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore;
|
||||
import dev.caskeleton.application.idempotency.RequestFingerprint;
|
||||
import dev.caskeleton.application.idempotency.StoredResponse;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
|
||||
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
class PostgreSqlIdempotencyIntegrationTest {
|
||||
|
||||
private static final IdempotencyScopeDigest SCOPE =
|
||||
new IdempotencyScopeDigest("a".repeat(64), 1, "CREATE_WORK_LOG");
|
||||
private static final RequestFingerprint FINGERPRINT =
|
||||
RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
private static PostgreSqlReadinessSupport postgres;
|
||||
private static JdbcTemplate jdbc;
|
||||
private static TransactionTemplate transactions;
|
||||
private static PostgreSqlOwnerSafeIdempotencyStore store;
|
||||
|
||||
@BeforeAll
|
||||
static void startAndMigratePostgreSql() {
|
||||
PostgreSqlReadinessSupport.assertDockerAvailable();
|
||||
postgres = PostgreSqlReadinessSupport.start();
|
||||
migrate("classpath:db/migration/postgresql", "flyway_schema_history");
|
||||
migrateIndependent(
|
||||
"classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption");
|
||||
migrateIndependent(
|
||||
"classpath:db/migration/jpa/idempotency",
|
||||
"flyway_jpa_idempotency_history",
|
||||
"explicit-jpa-idempotency-adoption");
|
||||
|
||||
jdbc = new JdbcTemplate(postgres.dataSource());
|
||||
transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource()));
|
||||
store = new PostgreSqlOwnerSafeIdempotencyStore(jdbc);
|
||||
jdbc.update(
|
||||
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
|
||||
+ "where capability_id = 'jpa-idempotency-owner-safe-v2'");
|
||||
jdbc.execute(
|
||||
"create table idempotency_business_probe ("
|
||||
+ "scope_hash char(64) primary key, mutation_count integer not null)");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (postgres != null) {
|
||||
postgres.close();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clearRows() {
|
||||
jdbc.update("delete from idempotency_record where record_version = 2");
|
||||
jdbc.update("delete from idempotency_business_probe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameStoreTransactionCommitsBusinessMutationAndCompletionTogetherThenReplays() {
|
||||
IdempotencyClaimAttempt attempt = store.newClaimAttempt(new OperationId("claim-1"));
|
||||
IdempotencyClaimRequest request = request(attempt, Duration.ofSeconds(5));
|
||||
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
IdempotencyOwner owner =
|
||||
((IdempotencyClaimOutcome.Acquired) store.claim(request)).owner();
|
||||
owner =
|
||||
store.markExecutionStarted(owner, new OperationId("start-1")).owner().orElseThrow();
|
||||
jdbc.update(
|
||||
"insert into idempotency_business_probe(scope_hash, mutation_count) values (?, 1)",
|
||||
SCOPE.digest());
|
||||
|
||||
assertThat(
|
||||
store.complete(
|
||||
owner,
|
||||
new StoredResponse("{\"workLogId\":\"42\"}"),
|
||||
Duration.ofHours(24),
|
||||
new OperationId("complete-1")))
|
||||
.isEqualTo(IdempotencyCompleteOutcome.COMPLETED);
|
||||
});
|
||||
|
||||
IdempotencyClaimOutcome replay =
|
||||
transactions.execute(ignored -> store.claim(request(attempt, Duration.ofSeconds(5))));
|
||||
|
||||
assertThat(replay)
|
||||
.isInstanceOfSatisfying(
|
||||
IdempotencyClaimOutcome.CompletedReplay.class,
|
||||
completed ->
|
||||
assertThat(completed.response().payload()).isEqualTo("{\"workLogId\":\"42\"}"));
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select mutation_count from idempotency_business_probe where scope_hash = ?",
|
||||
Integer.class,
|
||||
SCOPE.digest()))
|
||||
.isEqualTo(1);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select idempotency_key from idempotency_record where scope_hash = ?",
|
||||
String.class,
|
||||
SCOPE.digest()))
|
||||
.isEqualTo(SCOPE.digest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredClaimCanBeTakenOverButTheStaleOwnerCannotStart() {
|
||||
IdempotencyOwner staleOwner =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
((IdempotencyClaimOutcome.Acquired)
|
||||
store.claim(
|
||||
request(
|
||||
store.newClaimAttempt(new OperationId("claim-old")),
|
||||
Duration.ofMillis(25))))
|
||||
.owner());
|
||||
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
|
||||
|
||||
IdempotencyClaimAttempt replacement =
|
||||
store.newClaimAttempt(new OperationId("claim-replacement"));
|
||||
IdempotencyClaimOutcome takeover =
|
||||
transactions.execute(ignored -> store.claim(request(replacement, Duration.ofSeconds(5))));
|
||||
|
||||
assertThat(takeover)
|
||||
.isInstanceOfSatisfying(
|
||||
IdempotencyClaimOutcome.TakenOverClaimed.class,
|
||||
result -> {
|
||||
assertThat(result.owner().attempt()).isEqualTo(2);
|
||||
assertThat(result.owner().ownerToken()).isEqualTo(replacement.ownerToken());
|
||||
});
|
||||
assertThat(
|
||||
transactions
|
||||
.execute(
|
||||
ignored ->
|
||||
store.markExecutionStarted(staleOwner, new OperationId("stale-start")))
|
||||
.outcome())
|
||||
.isEqualTo(IdempotencyStartOutcome.NOT_OWNER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredExecutingRecordRequiresReconciliationAndIsNeverBlindlyTakenOver() {
|
||||
IdempotencyClaimAttempt original = store.newClaimAttempt(new OperationId("claim-executing"));
|
||||
IdempotencyOwner executing =
|
||||
transactions.execute(
|
||||
ignored -> {
|
||||
IdempotencyOwner owner =
|
||||
((IdempotencyClaimOutcome.Acquired)
|
||||
store.claim(request(original, Duration.ofMillis(25))))
|
||||
.owner();
|
||||
return store
|
||||
.markExecutionStarted(owner, new OperationId("start-executing"))
|
||||
.owner()
|
||||
.orElseThrow();
|
||||
});
|
||||
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
|
||||
|
||||
IdempotencyClaimOutcome outcome =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
store.claim(
|
||||
request(
|
||||
store.newClaimAttempt(new OperationId("claim-after-unknown")),
|
||||
Duration.ofSeconds(5))));
|
||||
|
||||
assertThat(outcome)
|
||||
.isInstanceOfSatisfying(
|
||||
IdempotencyClaimOutcome.RecoveryRequired.class,
|
||||
recovery -> assertThat(recovery.currentAttempt()).isEqualTo(executing.attempt()));
|
||||
assertThat(
|
||||
store.inspect(new IdempotencyInspectionRequest(SCOPE, FINGERPRINT, original)).outcome())
|
||||
.isEqualTo(IdempotencyInspectionOutcome.ABANDONED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void competingTransactionCannotPassTheOwnerRowUntilTheFirstBusinessCommit() throws Exception {
|
||||
CountDownLatch firstHasCompletedInsideTransaction = new CountDownLatch(1);
|
||||
CountDownLatch allowFirstCommit = new CountDownLatch(1);
|
||||
IdempotencyClaimRequest firstRequest =
|
||||
request(store.newClaimAttempt(new OperationId("claim-first")), Duration.ofSeconds(5));
|
||||
IdempotencyClaimRequest secondRequest =
|
||||
request(store.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5));
|
||||
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
Future<Void> first =
|
||||
executor.submit(
|
||||
() -> {
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
IdempotencyOwner owner =
|
||||
((IdempotencyClaimOutcome.Acquired) store.claim(firstRequest)).owner();
|
||||
owner =
|
||||
store
|
||||
.markExecutionStarted(owner, new OperationId("start-first"))
|
||||
.owner()
|
||||
.orElseThrow();
|
||||
jdbc.update(
|
||||
"insert into idempotency_business_probe(scope_hash, mutation_count) "
|
||||
+ "values (?, 1)",
|
||||
SCOPE.digest());
|
||||
assertThat(
|
||||
store.complete(
|
||||
owner,
|
||||
new StoredResponse("done"),
|
||||
Duration.ofHours(1),
|
||||
new OperationId("complete-first")))
|
||||
.isEqualTo(IdempotencyCompleteOutcome.COMPLETED);
|
||||
firstHasCompletedInsideTransaction.countDown();
|
||||
await(allowFirstCommit);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
firstHasCompletedInsideTransaction.await();
|
||||
Future<IdempotencyClaimOutcome> second =
|
||||
executor.submit(() -> transactions.execute(ignored -> store.claim(secondRequest)));
|
||||
|
||||
assertThat(second.isDone()).isFalse();
|
||||
allowFirstCommit.countDown();
|
||||
|
||||
first.get();
|
||||
assertThat(second.get()).isInstanceOf(IdempotencyClaimOutcome.CompletedReplay.class);
|
||||
}
|
||||
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select mutation_count from idempotency_business_probe where scope_hash = ?",
|
||||
Integer.class,
|
||||
SCOPE.digest()))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
|
||||
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.idempotency());
|
||||
}
|
||||
|
||||
private static IdempotencyClaimRequest request(
|
||||
IdempotencyClaimAttempt attempt, Duration processingLease) {
|
||||
return new IdempotencyClaimRequest(
|
||||
SCOPE, FINGERPRINT, attempt, processingLease, Duration.ofHours(24), "json.v1", 2);
|
||||
}
|
||||
|
||||
private static void migrate(String location, String historyTable) {
|
||||
Flyway.configure()
|
||||
.dataSource(postgres.dataSource())
|
||||
.locations(location)
|
||||
.table(historyTable)
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
|
||||
private static void migrateIndependent(
|
||||
String location, String historyTable, String baselineDescription) {
|
||||
Flyway flyway =
|
||||
Flyway.configure()
|
||||
.dataSource(postgres.dataSource())
|
||||
.locations(location)
|
||||
.table(historyTable)
|
||||
.baselineVersion("0")
|
||||
.baselineDescription(baselineDescription)
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load();
|
||||
flyway.baseline();
|
||||
flyway.migrate();
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("test synchronization interrupted", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.readiness;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter;
|
||||
import dev.caskeleton.application.inbox.InboxClaimAttempt;
|
||||
import dev.caskeleton.application.inbox.InboxClaimOutcome;
|
||||
import dev.caskeleton.application.inbox.InboxClaimRequest;
|
||||
import dev.caskeleton.application.inbox.InboxOwner;
|
||||
import dev.caskeleton.application.inbox.InboxScopeDigest;
|
||||
import dev.caskeleton.application.inbox.InboxTransitionOutcome;
|
||||
import dev.caskeleton.application.transaction.OperationId;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
class PostgreSqlInboxIntegrationTest {
|
||||
|
||||
private static final InboxScopeDigest SCOPE = new InboxScopeDigest("a".repeat(64));
|
||||
private static final String INTENT = "b".repeat(64);
|
||||
|
||||
private static PostgreSqlReadinessSupport postgres;
|
||||
private static JdbcTemplate jdbc;
|
||||
private static TransactionTemplate transactions;
|
||||
private static PostgreSqlSameStoreInboxAdapter inbox;
|
||||
|
||||
@BeforeAll
|
||||
static void startAndMigratePostgreSql() {
|
||||
PostgreSqlReadinessSupport.assertDockerAvailable();
|
||||
postgres = PostgreSqlReadinessSupport.start();
|
||||
migrate("classpath:db/migration/postgresql", "flyway_schema_history");
|
||||
migrateIndependent(
|
||||
"classpath:db/migration/jpa/core", "flyway_jpa_core_history", "explicit-jpa-core-adoption");
|
||||
migrateIndependent(
|
||||
"classpath:db/migration/jpa/inbox",
|
||||
"flyway_jpa_inbox_history",
|
||||
"explicit-jpa-inbox-adoption");
|
||||
|
||||
jdbc = new JdbcTemplate(postgres.dataSource());
|
||||
transactions = new TransactionTemplate(new DataSourceTransactionManager(postgres.dataSource()));
|
||||
inbox = new PostgreSqlSameStoreInboxAdapter(postgres.dataSource());
|
||||
jdbc.update(
|
||||
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
|
||||
+ "where capability_id = 'jpa-inbox-same-store-v1'");
|
||||
jdbc.execute(
|
||||
"create table inbox_business_probe ("
|
||||
+ "scope_hash char(64) primary key, mutation_count integer not null)");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (postgres != null) {
|
||||
postgres.close();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clearRows() {
|
||||
jdbc.update("delete from inbox_record_v1");
|
||||
jdbc.update("delete from inbox_business_probe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimBusinessMutationAndCompletionCommitOrRollbackAsOneUnit() {
|
||||
InboxClaimAttempt rolledBackAttempt = inbox.newClaimAttempt(new OperationId("claim-rollback"));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
InboxOwner owner =
|
||||
((InboxClaimOutcome.Acquired)
|
||||
inbox.claim(request(rolledBackAttempt, Duration.ofSeconds(5))))
|
||||
.owner();
|
||||
owner =
|
||||
inbox
|
||||
.markProcessing(owner, new OperationId("start-rollback"))
|
||||
.owner()
|
||||
.orElseThrow();
|
||||
jdbc.update(
|
||||
"insert into inbox_business_probe(scope_hash, mutation_count) "
|
||||
+ "values (?, 1)",
|
||||
SCOPE.value());
|
||||
throw new IllegalStateException("rollback");
|
||||
}))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThat(jdbc.queryForObject("select count(*) from inbox_record_v1", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class))
|
||||
.isZero();
|
||||
|
||||
InboxClaimAttempt committedAttempt = inbox.newClaimAttempt(new OperationId("claim-commit"));
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
InboxOwner owner =
|
||||
((InboxClaimOutcome.Acquired)
|
||||
inbox.claim(request(committedAttempt, Duration.ofSeconds(5))))
|
||||
.owner();
|
||||
owner =
|
||||
inbox.markProcessing(owner, new OperationId("start-commit")).owner().orElseThrow();
|
||||
jdbc.update(
|
||||
"insert into inbox_business_probe(scope_hash, mutation_count) values (?, 1)",
|
||||
SCOPE.value());
|
||||
assertThat(inbox.complete(owner, new OperationId("complete-commit")))
|
||||
.isEqualTo(InboxTransitionOutcome.COMPLETED);
|
||||
});
|
||||
|
||||
InboxClaimOutcome redelivery =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
inbox.claim(
|
||||
request(
|
||||
inbox.newClaimAttempt(new OperationId("claim-redelivery")),
|
||||
Duration.ofSeconds(5))));
|
||||
assertThat(redelivery).isInstanceOf(InboxClaimOutcome.Completed.class);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select mutation_count from inbox_business_probe where scope_hash = ?",
|
||||
Integer.class,
|
||||
SCOPE.value()))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredReceivedCanBeTakenOverButStaleOwnerCannotStart() {
|
||||
InboxOwner stale =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
((InboxClaimOutcome.Acquired)
|
||||
inbox.claim(
|
||||
request(
|
||||
inbox.newClaimAttempt(new OperationId("claim-old")),
|
||||
Duration.ofMillis(25))))
|
||||
.owner());
|
||||
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
|
||||
|
||||
InboxClaimOutcome takeover =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
inbox.claim(
|
||||
request(
|
||||
inbox.newClaimAttempt(new OperationId("claim-new")),
|
||||
Duration.ofSeconds(5))));
|
||||
assertThat(takeover)
|
||||
.isInstanceOfSatisfying(
|
||||
InboxClaimOutcome.TakenOver.class,
|
||||
result -> assertThat(result.owner().attempt()).isEqualTo(2));
|
||||
assertThat(
|
||||
transactions
|
||||
.execute(ignored -> inbox.markProcessing(stale, new OperationId("stale-start")))
|
||||
.outcome())
|
||||
.isEqualTo(InboxTransitionOutcome.NOT_OWNER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredProcessingRequiresRecoveryInsteadOfBlindTakeover() {
|
||||
InboxClaimAttempt attempt = inbox.newClaimAttempt(new OperationId("claim-processing"));
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
InboxOwner owner =
|
||||
((InboxClaimOutcome.Acquired) inbox.claim(request(attempt, Duration.ofMillis(25))))
|
||||
.owner();
|
||||
inbox.markProcessing(owner, new OperationId("start-processing"));
|
||||
});
|
||||
jdbc.queryForObject("select pg_sleep(0.05)", Object.class);
|
||||
|
||||
InboxClaimOutcome outcome =
|
||||
transactions.execute(
|
||||
ignored ->
|
||||
inbox.claim(
|
||||
request(
|
||||
inbox.newClaimAttempt(new OperationId("claim-after-unknown")),
|
||||
Duration.ofSeconds(5))));
|
||||
assertThat(outcome).isInstanceOf(InboxClaimOutcome.RecoveryRequired.class);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"select state from inbox_record_v1 where scope_hash = ?",
|
||||
String.class,
|
||||
SCOPE.value()))
|
||||
.isEqualTo("DEAD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void takeoverCannotPassTheOwnerRowWhileBusinessTransactionIsOpen() throws Exception {
|
||||
CountDownLatch firstCompletedInsideTransaction = new CountDownLatch(1);
|
||||
CountDownLatch allowCommit = new CountDownLatch(1);
|
||||
InboxClaimRequest first =
|
||||
request(inbox.newClaimAttempt(new OperationId("claim-first")), Duration.ofMillis(25));
|
||||
InboxClaimRequest second =
|
||||
request(inbox.newClaimAttempt(new OperationId("claim-second")), Duration.ofSeconds(5));
|
||||
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
Future<Void> firstHandler =
|
||||
executor.submit(
|
||||
() -> {
|
||||
transactions.executeWithoutResult(
|
||||
ignored -> {
|
||||
InboxOwner owner = ((InboxClaimOutcome.Acquired) inbox.claim(first)).owner();
|
||||
owner =
|
||||
inbox
|
||||
.markProcessing(owner, new OperationId("start-first"))
|
||||
.owner()
|
||||
.orElseThrow();
|
||||
jdbc.update(
|
||||
"insert into inbox_business_probe(scope_hash, mutation_count) "
|
||||
+ "values (?, 1)",
|
||||
SCOPE.value());
|
||||
assertThat(inbox.complete(owner, new OperationId("complete-first")))
|
||||
.isEqualTo(InboxTransitionOutcome.COMPLETED);
|
||||
firstCompletedInsideTransaction.countDown();
|
||||
await(allowCommit);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
firstCompletedInsideTransaction.await();
|
||||
Future<InboxClaimOutcome> competing =
|
||||
executor.submit(() -> transactions.execute(ignored -> inbox.claim(second)));
|
||||
assertThat(competing.isDone()).isFalse();
|
||||
allowCommit.countDown();
|
||||
firstHandler.get();
|
||||
assertThat(competing.get()).isInstanceOf(InboxClaimOutcome.Completed.class);
|
||||
}
|
||||
|
||||
assertThat(jdbc.queryForObject("select count(*) from inbox_business_probe", Integer.class))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
|
||||
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.inbox());
|
||||
}
|
||||
|
||||
private static InboxClaimRequest request(InboxClaimAttempt attempt, Duration processingLease) {
|
||||
return new InboxClaimRequest(SCOPE, INTENT, attempt, processingLease, Duration.ofDays(7));
|
||||
}
|
||||
|
||||
private static void migrate(String location, String historyTable) {
|
||||
Flyway.configure()
|
||||
.dataSource(postgres.dataSource())
|
||||
.locations(location)
|
||||
.table(historyTable)
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
|
||||
private static void migrateIndependent(
|
||||
String location, String historyTable, String baselineDescription) {
|
||||
Flyway flyway =
|
||||
Flyway.configure()
|
||||
.dataSource(postgres.dataSource())
|
||||
.locations(location)
|
||||
.table(historyTable)
|
||||
.baselineVersion("0")
|
||||
.baselineDescription(baselineDescription)
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load();
|
||||
flyway.baseline();
|
||||
flyway.migrate();
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("test synchronization interrupted", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user