chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
# application-core — application use cases
## Registered identity
- Module ID: `application-core`
- Gradle path: `:application-core`
- Focused test (derived from Gradle path): `./gradlew :application-core: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.application`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
## Responsibility
- Use case inbound ports (`CommandUseCase`, `QueryUseCase`) and their command / query contracts.
- Outbound ports (`*Port` interfaces) the use cases depend on.
- Application exceptions and policy types.
- Coordinate domain models through ports.
- Own application transaction boundaries through the `TransactionPort` abstraction.
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
MDC or other concrete storage.
- Own the framework-free semantic integration-event draft, validated-event value contract and
exact typed payload contribution SPI. This is the messaging semantic contract R1 boundary only.
## Allowed
- `:domain-core`
- `:shared-contract`
- Java standard library types.
## Forbidden
- `adapter-*` implementation classes.
- `app-bootstrap`.
- Controller request/response DTOs.
- JPA entities and Spring Data repositories.
- HTTP status, transport types (`org.springframework.web..`).
- `org.springframework.transaction.annotation.Transactional` (use `TransactionPort` instead — D3).
- `org.springframework.context.ApplicationContext` — direct dependency forbidden
(`getBean(Class)` reflection-style bypass blocked by ArchUnit D11). String-key
bean lookup / `Class.forName(String)` / `BeanFactory#getBeansOfType` remain
ArchUnit's static-analysis blind spot per D12 — guard via code review checklist.
- Lombok (`lombok..`) — also forbidden in `domain-core`. Within `application-core`,
Lombok is currently not in scope for the contract; if you intend to use it,
weigh the bytecode opacity cost first.
- Persistence-layer transaction annotations of any kind inside this module.
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
diagnostic intent through a specific outbound `*Port`; adapters own rendering.
- Messaging provider/runtime types: physical topic, Kafka record or metadata, JSON tree/raw JSON
payload, serializer/schema-validator implementation, security topology and publication epoch.
## Contract types
| Type | Purpose |
|---|---|
| `usecase.UseCase<I, O>` | Base type for inbound ports. Concrete inbound ports MUST extend `CommandUseCase` or `QueryUseCase`. |
| `usecase.CommandUseCase<C extends Command, R>` | Inbound port for write use cases. Implementations MUST be annotated `@UseCaseCapability`. |
| `usecase.QueryUseCase<Q extends Query, R>` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. |
| `command.Command` | Marker for write intents. Plain immutable types built from domain values. |
| `query.Query` | Marker for read intents. Plain immutable types built from domain values. |
| `transaction.TransactionPort` | Outbound port for join-capable write/read, physical root-only write, and independent write boundaries. Implemented by `adapter-persistence`. |
| `transaction.NestedRootTransactionRejectedException` | Fail-fast signal raised before action/provider side effects when `inRootWrite` detects an actual ambient transaction. |
| `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. |
| `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. |
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. |
| `messaging.contract.IntegrationEventContractContribution<P>` | Closed exact-record payload type, canonical component order, local schema identity/hash and provider-neutral descriptor contribution. |
| `messaging.event.IntegrationEventDraft<P>` | Typed semantic event before local encoding; never JSON, Kafka or persistence state. |
| `messaging.event.ValidatedIntegrationEvent` | Stable semantic identities plus immutable exact encoded bytes and hashes, ready for a later durable append boundary. |
| `messaging.event.IntegrationEventEncoderPort` | Framework-free local draft-to-validated-event boundary implemented by an outbound adapter. |
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
`INDETERMINATE` submission is terminal and must not be blindly retried.
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
capability.
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
registries are application-owned exact inputs; signed inventory/quiescence verification is
delegated to narrow verifier ports and the persistence operation must enforce locked durable
state/journal invariants.
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
notification/persistence/bootstrap adapters.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
`INDETERMINATE` submission is terminal and must not be blindly retried.
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
capability.
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
registries are application-owned exact inputs; signed inventory/quiescence verification is
delegated to narrow verifier ports and the persistence operation must enforce locked durable
state/journal invariants.
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
notification/persistence/bootstrap adapters.
## Naming convention
- Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit.
- Outbound port interfaces end with `Port` (e.g. `NotificationPort`).
- Command records end with `Command`; query records end with `Query`.
## Canonical use case shape
```java
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public final class RegisterUserUseCase implements CommandUseCase<RegisterUserCommand, User> {
private final UserRepository users;
private final TransactionPort tx;
public RegisterUserUseCase(UserRepository users, TransactionPort tx) {
this.users = users;
this.tx = tx;
}
@Override
public User handle(RegisterUserCommand cmd) {
return tx.inWrite(() -> {
// ... domain coordination
});
}
}
```
The class is plain Java. A composition root constructs it with its ports and configuration values;
application-core never self-registers with a DI framework.
## Allowed transactional shapes
| Use case shape | `transactionMode` | TransactionPort call | When |
|---|---|---|---|
| Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. |
| Physical-root write command | `WRITE` | `tx.inRootWrite(...)` | Only when orchestration must prove there is no ambient transaction and expose a result after commit. |
| Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. |
| Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. |
`NESTED` and `NEVER` propagation are forbidden.
`inRootWrite` MUST reject an actual ambient transaction before invoking its action or
`PlatformTransactionManager`; it MUST NOT emulate root-only behavior with `REQUIRES_NEW`.
Both `inWrite` and `inRootWrite` satisfy the direct boundary fitness rule for a
`WRITE_REPOSITORY + WRITE` use case. READ and REQUIRES_NEW mappings remain exclusive.
### Callback signature contract (D11)
`TransactionPort` callbacks are `Supplier<T>` / `Runnable` and cannot throw checked
exceptions. This matches Spring's `TransactionCallback<T>` constraint. Wrap domain
checked exceptions into `RuntimeException` subclasses
(`DomainException extends RuntimeException`); `IOException``UncheckedIOException`;
`SQLException` is auto-translated by Spring's `DataAccessException` hierarchy.
### `inNew` pool-sizing constraint (D12)
`inNew` acquires a new physical JDBC connection. Pool size MUST satisfy:
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
**Forbidden**: calling `tx.inNew(...)` inside a loop over many records — pool
exhaustion + deadlock risk. Batch records inside ONE `inNew` call, or move the
loop outside the transaction boundary.
## Idempotency (KEYED) — feature-rate-limit-idempotency-contract
`@UseCaseCapability(idempotency = Idempotency.KEYED)` is now **supported** (the D14
freeze is lifted). A KEYED use case wraps its work with the `idempotency`-package
`IdempotencyExecutor`:
- **Key source**: the `Idempotency-Key` HTTP header, assembled by `adapter:inbound:web`'s
`IdempotencyKeySupport` into an `IdempotencyScope` of
`(authenticatedPrincipal, idempotencyKey, useCaseName)` (tenant 4-tuple when active).
- **Storage**: a DB table (`IdempotencyStore` port → `adapter-persistence`
`IdempotencyStoreAdapter` over `idempotency_record`); in-memory prod storage is forbidden.
- **TTL**: `APP_IDEMPOTENCY_TTL` (default 24h, ≤72h override).
- Concurrency (200ms in-flight wait → 409) and fingerprint mismatch (SHA-256 → 422)
are enforced by the executor; the codes live in `OperationalError`.
The former ArchUnit freeze rule `inbound_port_implementations_do_not_declare_keyed_idempotency`
and its fixture were removed when this branch merged.
## Read / query path (feature-application-query-bypass-contract)
The read side has two equally-valid shapes; pick per read, do not force one:
| Shape | Returns | When | How |
|---|---|---|---|
| **Through-aggregate** (default for simple reads) | domain aggregate via a `*Repository` port | read shape == write aggregate **and** the aggregate is the minimal invariant boundary (no lazy collections needed) | `QueryUseCase` → repository port → `WorkLog` |
| **Projection (CQRS-lite)** | application-layer projection DTO via a `*QueryPort` | read shape ≠ write, or to skip aggregate hydration / lazy-collection joins | `QueryUseCase``*QueryPort``WorkLogSummary` (record); query via JPQL `SELECT new` / JdbcTemplate |
- **D1 — purity guardrail (core, machine-enforced):** a read port whose simple name ends
with `QueryPort` MUST return application-layer projection DTOs only — never a domain
aggregate, JPA entity, or web type, **including through generic type arguments**
(`List<DomainType>`). Enforced by ArchUnit `query_ports_do_not_leak_domain_jpa_or_web_types`.
Projection usage itself is **opt-in**, not a forced default; the demo lives in
`sample-portfolio` (`WorkLogSummaryQueryPort` / `WorkLogSummary`).
- **D3 — Strict ceremony:** every read goes through a `QueryUseCase` bean. There is no thin
web→read-port path — that would bypass the mandatory `@UseCaseCapability` fitness function.
- **D4 — transaction:** reads default to `TransactionPort.inRead`. A no-tx (autocommit) read
is an opt-in only when `spring.jpa.open-in-view=false` is confirmed **and** the read is
projection-only (no lazy access) **and** a single statement; otherwise keep `inRead`.
- **D5 — capability:** a repository-backed projection read is still
`repositoryAccess = READ_REPOSITORY`. "Projection vs aggregate" is the return *shape* axis,
orthogonal to the repository-access *level* axis — no new enum. Outbound-HTTP reads (no
repository) stay `RepositoryAccess.NONE`.
- Full CQRS with a separate physical read store (**D2**) is out of scope — escalation only.
## ArchUnit guardrails (enforced)
- `application_does_not_depend_on_adapters_or_transport`
- `application_does_not_use_spring_transactional_annotation`
- `application_does_not_depend_on_application_context` (D11)
- `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`
- `inbound_port_implementations_end_with_use_case`
- `inbound_port_implementations_declare_capability`
- `query_ports_do_not_leak_domain_jpa_or_web_types` (query-bypass D1 — `*QueryPort` return purity)
## Test
```bash
cd src
./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
```
The messaging types above establish semantic contract R1 only. They do not claim JSON Schema
qualification, Kafka publication, durable outbox persistence or any messaging R2 capability.
+601
View File
@@ -0,0 +1,601 @@
# application-core — 설계 결정 참조
애플리케이션 유스케이스 계층. 패키지 루트: `dev.caskeleton.application`.
허용/금지 의존, 유스케이스 형태, 트랜잭션 모드, 명명 규칙 같은 **모듈 규칙**은
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도
추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.
이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP
타입뿐 아니라 SLF4J/JUL/Logback/Log4j/Micrometer도 직접 들이지 않고, 필요한 인프라 능력
(트랜잭션·락·인가·알림·운영 진단 등)은 전부 구체적인 목적의 `*Port` 인터페이스로 추상화한다.
구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. Gradle
`verifyApplicationCoreDependencyPurity`와 ArchUnit
`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`가 이 계약을 자동 검증한다.
Cache 진단도 같은 원칙을 따른다. `CacheObservationEvent`는 code-owned bounded cache name,
local/Redis tier, enum outcome과 finite duration/count만 표현하며 semantic key, user/tenant ID,
endpoint를 담지 않는다. `CacheObservationPort`는 이 event를 전달하는 framework-free 경계이고,
Micrometer meter/tag 렌더링은 Redis adapter가 소유한다. 관측 실패는 cache lookup/invalidation
결과를 바꾸지 않는다.
---
## 메시징 semantic contract R1
`messaging.contract``messaging.event`는 feature가 integration event를 동적 JSON이나 provider
타입으로 넘기지 않게 만드는 application 경계다.
- `IntegrationPayload` 구현은 feature가 소유한 불변 typed record다.
- `IntegrationEventContractContribution`은 contract ID와 payload version을 분리하고, exact final
record type token, 실제 record component 순서, repository-local schema resource/hash와
provider-neutral `ContractDescriptor`만 기여한다. assignable-type 탐색, `Class.forName`, Java
class-name routing, `Map`, raw JSON string/tree는 이 SPI에 들어오지 않는다.
- `IntegrationEventDraft`는 canonical event/aggregate/order/correlation identity와 typed payload를
보유한다. tenant가 없는 모드도 null 대신 canonical system tenant scope를
`AggregateIdentity`에 넣어 dedupe/order identity가 PostgreSQL nullable uniqueness에 기대지
않게 한다.
- `IntegrationEventEncoderPort` 뒤의 adapter가 deterministic encoding과 schema validation을
수행하고 `ValidatedIntegrationEvent`를 돌려준다. 결과는 logical destination, exact US-ASCII
partition key, exact encoded envelope bytes, schema/envelope hash와 catalog/binding revision을
defensive copy로 보존한다.
- `ContractDescriptor`는 owner module, logical destination, serializer ID, ordering requirement,
payload/envelope byte limit, sensitivity classification, same-event requeue horizon만 표현한다.
physical topic, Kafka cluster/security topology는 deployment binding의 책임이다.
이 단계의 완성 범위는 **framework-free semantic contract R1**이다. JSON Schema validator와
deterministic writer, Kafka ACK producer, PostgreSQL outbox append/relay는 후속 R2 작업이며 여기서
구현되었거나 검증됐다고 주장하지 않는다.
---
## 유스케이스 계약 (usecase / command / query / capability)
### UseCase / CommandUseCase / QueryUseCase
- inbound port(헥사고날의 primary port)다. 모든 구체 유스케이스는 쓰기면 `CommandUseCase`,
읽기면 `QueryUseCase` 를 extends 해서 **연산의 종류를 타입에 박아 둔다.** 이렇게 해야
capability·트랜잭션 모드 같은 계약을 컴파일/ArchUnit 단계에서 강제할 수 있다. 클래스 이름은
반드시 `UseCase` 로 끝난다(ArchUnit 강제).
- 제네릭 입출력 타입(`<I, O>`)에는 web 요청 DTO·JPA 엔티티·외부 클라이언트 응답 타입이 올 수
없다. 입력은 `Command`/`Query`, 출력은 도메인 객체·도메인 프로젝션·`Void` 만 허용. 이 자리에
transport 타입이 들어오면 application 계층 경계가 깨진다.
### Command / Query (마커)
- 둘 다 마커 인터페이스. `Command` = 쓰기 의도, `Query` = 읽기 의도.
- 반드시 불변 타입(가능하면 `record`)이고 도메인 타입·원시 값 객체로만 구성한다. web DTO,
JPA 엔티티, 외부 응답 타입은 필드에 넣을 수 없다 — 이게 들어오면 transport 관심사가
application 계층으로 새는 것.
### UseCaseCapability (모든 유스케이스 필수 애너테이션)
- 모든 구체 `*UseCase` 클래스에 필수. 유스케이스의 **트랜잭션 모양·멱등성·리포지토리 접근·외부
호출 여부를 본문을 읽지 않고도** 알 수 있게 만든다. ArchUnit 이 모든 구체 inbound port 에 이
애너테이션이 일관되게 붙어 있는지 검사한다.
- 일관성 규칙(ArchUnit 으로 강제):
- `QueryUseCase` 구현은 `transactionMode = READ_ONLY` + `repositoryAccess = READ_REPOSITORY` 여야 한다.
- `transactionMode = REQUIRES_NEW` 는 outbox / audit / compensation 흐름 전용이다.
- 외부 `*Port`(outbound adapter 에 바인딩된 포트)를 호출하려면 `externalOutboundAllowed = true`
가 필요하다. 없으면 outbound 어댑터 호출 금지.
- `repositoryAccess``WRITE_REPOSITORY` 가 아닌 유스케이스는 리포지토리 포트의 쓰기
메서드(save/delete/update/insert)를 호출할 수 없다 — `read_only_use_cases_do_not_call_repository_write_methods`
규칙이 막는다. 단 정적 분석은 **직접 호출만** 잡으므로, helper/mapper 를 거친 쓰기는 리뷰가 본다.
- `bulkWrite = true``WRITE_REPOSITORY` 를 함께 요구한다(대량 쓰기도 결국 쓰기).
`bulk_write_capability_requires_write_repository_access` 가 강제.
- 이 애너테이션은 `docs/registries/capabilities.yaml` 의 7개 capability 를 코드로 구현한 것이며,
**코드가 SSOT** 다. 추가 플래그의 의미:
- `sensitiveRead` — PII/자격증명/비밀을 읽는 유스케이스 선언. 필드 단위 마커(엔티티 FQN +
필드명 테이블)와 자동 강제는 이 영역의 책임이고 아직 미구현이라,
지금은 리뷰 기반의 선언적 계약이다. 이 마커를 **도메인/JPA 엔티티 애너테이션으로 표현하면 안
된다**(프레임워크 의존이 도메인에 새는 것을 막기 위함).
- `bulkWrite` — 단일 트랜잭션에서 100행/배치를 초과하는 쓰기(registry 임계치 = 100). 임계치
미만이면 평범한 `WRITE_REPOSITORY` 선언으로 충분하다.
- `crossTenantAdmin` — 테넌트 경계를 넘는 admin 연산. cross-tenant 접근 정책 자체는
이 영역의 책임(여기선 어휘만 제공). single-tenant 유스케이스에 붙이면
리뷰 reject.
---
## 트랜잭션 경계 (transaction)
### TransactionPort
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
- 네 가지 경계:
- `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본.
- `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient
transaction 이 하나라도 있으면 action 실행 전에
`NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만
호출자에게 반환되며, commit 실패는 그대로 전파된다.
- `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본.
- `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한
유스케이스(outbox/audit/compensation)에서만 허용.
- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다.
외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만
쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는
transaction 없는 진입점에서 이 경계를 시작해야 한다.
- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked
exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`).
`IOException``UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이
자동 변환한다. 콜백 안에서 던진 `RuntimeException` 은 롤백 + 호출자 전파.
- **`inNew` 풀 사이징 비용(D12)**: REQUIRES_NEW 는 바깥 트랜잭션의 커넥션을 잡아둔 채 **새 물리
JDBC 커넥션을 추가로** 잡는다. 즉 미완료 `inNew` 호출 하나당 풀에서 커넥션 하나를 더 쓴다.
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
여기서 `max_inNew_depth` 는 스레드당 미완료 `inNew` 중첩의 최대 깊이다.
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
`@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record
루프 호출.
---
## Notification R1 오케스트레이션 경계
`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수
애플리케이션 계약이다.
- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가
만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable
`NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다.
- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB
transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를
거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다.
- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만
technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다.
- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는
이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다.
- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit,
서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은
verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider
egress 차단은 후속 adapter 구현이 증명해야 한다.
현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider
protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다.
### TransactionMode
- `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. TransactionPort API 와 UseCaseCapability 양쪽에서 쓰여,
유스케이스의 트랜잭션 계약을 본문 없이 드러낸다.
- `READ_ONLY` 일 때 persistence adapter 는 `readOnly = true` 를 켜서 드라이버 읽기 최적화
(Hibernate flush-mode MANUAL 등)를 적용하는 것이 좋다.
### Isolation
- `READ_COMMITTED` 가 고정 기본값이고, **모든 트랜잭션 템플릿에 명시적으로** 설정한다. vendor
기본값에 위임하지 않는 이유: 엔진마다 기본 isolation 이 다르다(PostgreSQL = READ COMMITTED,
MySQL InnoDB = REPEATABLE READ). 위임하면 같은 코드가 DB 에 따라 다른 isolation 으로 도는
silent 위험이 생긴다.
- `REPEATABLE_READ` / `SERIALIZABLE` 은 쓰기 무거운/읽기 일관성 유스케이스를 위한 명시적
opt-in. `READ_UNCOMMITTED` 는 금지라 enum 에 아예 선언하지 않았다.
- 단, 더 엄격한 레벨을 TransactionPort 호출 경로로 라우팅하는 per-use-case 선택 메커니즘은
관련 추상화 설계와의 공동 변경이라 아직 `planned` 다.
그전까지 이 상수들은 **어휘만** 선언하고, 실제 호출 경로는 `READ_COMMITTED` 로 고정된다.
---
## 인가 (security)
인증("누구냐")은 web/security adapter 책임이고, 이 패키지는 인가("이걸 해도 되냐")만 소유한다.
### AuthorizationPort
- 제품 인가 enforcement point(PEP). 인증된 호출자가 권한이 필요한 연산을 수행해도 되는지
결정한다. 인증된 principal 의 raw role 을 입력으로 받는다(인증 자체는 베이스라인 소유).
- **왜 포트인가, `@PreAuthorize` 가 아니라**: Spring method-security 애너테이션은 빈을
`org.springframework.security` 타입에 묶는다. application/domain 은 프레임워크-free 여야 하므로
(TransactionPort 선례), enforcement *결정* 은 평범한 Java 포트로 표현하고 Spring 의존
enforcement *메커니즘*(커스텀 `AuthorizationManager`)은 web adapter 에 둔다.
- **fail-closed**: 호출자가 필요한 권한을 가진 경우에만 정상 반환한다. 그 외(미지 role, role 없음,
매핑 누락)는 전부 `AuthorizationDeniedException`.
### RequiresPermission (애너테이션)
- 인증된 호출자가 가드된 유스케이스를 호출하려면 가져야 하는 `Permission` 을 선언한다. 값은
`resource:action` 토큰(예: `"worklog:close"`). `@UseCaseCapability` 패턴을 그대로 따라, 유스케이스가
요구하는 권한을 본문 없이 보이게 한다.
- **순수 선언 — Spring-free**: Spring Security 타입을 일절 담지 않는다. 실제 enforcement 는 web
adapter 의 `RequiresPermissionAuthorizationManager` 가 RUNTIME retention 으로 이 애너테이션을
읽어 `AuthorizationPort` 에 위임한다. enforcement 가 Spring AOP 프록시를 거치므로
self-invocation 이나 비-Spring 빈 호출은 우회된다 — 모든 mutating 진입점을 가드하는 책임은
아키텍처 강제 규칙 몫이다.
- **적용 대상**: skeleton 기본은 mutating/민감 유스케이스(`repositoryAccess = WRITE_REPOSITORY`)에
필수, public read 는 항상 면제(D4). 타입/메서드 어디에도 붙일 수 있다.
### AuthorizationPrincipal
- `AuthorizationPort` 가 소비하는 프레임워크-free 호출자 뷰 — IdP subject + 호출자의 raw role
이름. web adapter 는 Spring 인지 principal(`AuthenticatedPrincipal`, Keycloak 스타일 raw role)을
노출하지만 application 계층은 그 타입을 import 할 수 없으므로, enforcement point 가 이 추상으로
내려 매핑한다.
- **role 은 raw, Spring authority 가 아니다**: role set 은 IdP 가 발급한 그대로의 문자열
(`"admin"`, `"user"`)이며 `ROLE_*` GrantedAuthority 형태가 아니다. role→permission 해석이 raw
이름을 키로 쓰므로(D3), 둘을 섞으면 권한 0개로 해석돼 fail-closed denial 이 된다.
### AuthorizationDeniedException
- 인증된 호출자가 필요한 `Permission` 을 못 가졌을 때 `AuthorizationPort` 가 던진다.
- **프레임워크-free, application 소유**: application 계층은 Spring Security 에 의존할 수 없어
Spring `AccessDeniedException` 을 던질 수 없다. web adapter 의 method-security enforcement
point 가 경계에서 이 예외를 잡아 Spring authorization 실패로 번역하고, error pipeline 이
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 매핑한다(코드 SSOT 는 베이스라인 소유).
- **fail-closed**: denial 은 필요한 권한과 호출자 subject 만 담고, 호출자의 *유효 권한 집합* 은
절대 담지 않는다(인가 표면을 클라이언트에 노출하지 않기 위함).
---
## 멱등성 (idempotency)
application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저장은
`IdempotencyStorePort` 로 위임한다.
### IdempotencyExecutor
- application 유스케이스 경계에서 멱등 실행을 조율한다. 하나의 `IdempotencyScope` 에 대해 순서대로:
1. **claim** — store 유니크 제약 위의 atomic insert-or-read(D7). 이긴 caller 가 action 을
정확히 한 번 실행한다.
2. **fingerprint mismatch** — 같은 key, 다른 body(다른 fingerprint)인 live record 는
`IdempotencyRequestMismatchException` → 422(D8).
3. **replay** — `COMPLETED` record 는 저장된 응답을 codec 으로 재생(§B).
4. **in-flight wait** — 동시 `IN_FLIGHT` record 는 최대 `IN_FLIGHT_WAIT`(200ms) 동안 폴링하고,
여전히 미해결이면 `IdempotencyInFlightException` → 409(D7).
- claim 한 action 이 예외를 던지면 in-flight record 를 `discard` 한다 — 그래야 재시도가 TTL
만료까지 409 에 갇히지 않는다.
- **200ms 대기 창의 출처(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: 이 200ms 대기 창은
인용 가능한 표준이 강제하는 값이 아니다(IETF/Toss 는 즉시 409 를 권장). 표준의 운영 친화적
변형이다 — "표준 기반 + 운영 변형" 으로 설명해야지 "표준을 따른다" 고 말하면 안 된다. 블로킹
폴링이 요청 스레드를 점유하므로, 중복 도착이 몰리면 스레드 점유 vs 클라이언트 재시도 친화성의
트레이드오프가 있다(임계치는 부하 테스트로 검증 필요).
- 상수: `IN_FLIGHT_WAIT = 200ms`(D7), `MAX_TTL = 72h`(D6 하드 캡), `POLL_INTERVAL = 20ms`.
- package-private 한 `Sleeper` 주입 생성자가 따로 있는 이유: 테스트 `Sleeper` 가 mutable clock 을
전진시켜 실제 시간 블로킹 없이 in-flight wait 를 결정적으로 검증하기 위함.
### IdempotencyStorePort
- 멱등 record 저장 outbound 포트(D3). `adapter-persistence` 가 유니크 제약
`(tenant, principal, idempotency_key, use_case_name)` 위에 구현한다. 포트는 저장 primitive
(tryBegin/find/complete/discard)만 노출하고, 정책은 executor 가 가진다.
- **in-memory 프로덕션 구현 금지**: 계약상 내구성 있고 유일성을 강제하는 백킹 스토어가 필요하다.
Redis 는 내구 스토어 앞단의 선택적 캐시로만 허용된다.
- **만료 record 는 reclaimable**: `expiresAt <= now` 인 record 는 없는 것처럼 취급해야 한다.
`tryBegin` 은 만료된 죽은 행을 purge/replace 해서 새 claim 이 이기게 하고, `find` 는 만료된
record 를 absent 로 보아 만료 replay 를 거부한다(§E / TTL 경계).
### IdempotencyScope
- 멱등 요청의 정체성이자 계약의 SSOT scope 모양(D2). 기본은 triple
`(authenticatedPrincipal, idempotencyKey, useCaseName)`. 테넌트 격리가 활성이면 `tenant` 차원을
앞에 붙인 4-tuple 이 되고, single-tenant 모드에선 `tenant = null`.
- **여기서 막는 실패 모드**: `principal`/`useCaseName` 이 빠진 key 는 전역적으로 충돌해 다른
caller 의 응답을 재생할 수 있다. `of` 팩토리가 blank 필수 차원을 `IdempotencyScopeMissingException`
으로 거부해, scope 없는 key 가 store 에 닿지 못하게 막는다. 막으려는 실패 모드는 "scope 누락으로
인한 silent 전역 충돌"이다.
- `storageKey()` 는 진단/단일 컬럼 조회용으로 사람이 읽는 join 문자열일 뿐이다. 유일성은 차원
컬럼에 강제되지 이 문자열에 강제되지 않는다.
### RequestFingerprint
- 요청 body 의 SHA-256 지문(D8). 같은 scope 인데 body 가 다르면(지문이 다르면) 클라이언트
버그로 보고 executor 가 422 로 거부한다. null/빈 body 는 zero-length payload 지문으로 취급해
빈 replay 의 일관성을 지킨다.
- **SHA-256 선택(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: MD5/SHA-1 대비 충돌 저항성 때문에
SHA-256 을 골랐다 — 인용 가능한 표준이 강제한 것도, 측정된 성능 근거가 있는 것도 아니다. body
**정규화**(JSON 키 순서·공백·인코딩)는 의도적으로 적용하지 않는다 — 전송된 raw 바이트를 그대로
해시한다. 정규 동등성이 필요한 caller 는 `ofSha256` 호출 전에 직접 정규화해야 한다. 안 하면 키
순서만 바뀐 의미상 동일 replay 가 false mismatch 가 된다(부하와 실제 요청 형태로 검증 필요).
- `NoSuchAlgorithmException` catch 는 도달 불가다(SHA-256 은 모든 JDK 에 필수). 그래서
`IllegalStateException` 으로 감싼다.
### 멱등성 값/계약 타입 (Context · Record · StoredResponse · Codec · Status · Sleeper)
- `IdempotencyContext` — executor 입력. scope + fingerprint + 선택적 per-use-case TTL
override(D6). 결제/송금 같은 장기 유스케이스가 72h 캡까지 override 가능하고, 캡 초과는 executor 가
거부한다.
- `IdempotencyRecord` — application 이 보는 영속 record. `COMPLETED` 면 반드시 response 를
동반해야 한다(생성자가 강제). adapter 가 테이블 행과 매핑한다.
- `StoredResponse` — 완료된 응답의 불투명 직렬화 표현으로, 중복 caller 에게 그대로 재생된다(§B).
**어디에** 물리 저장되는지(작으면 행 인라인 ≤8KB, 크면 object store + 행엔 ref 만 — §F/D9)는
전적으로 persistence adapter 관심사라 여기선 보이지 않는다 → application 계층을 transport·저장
중립으로 유지.
- `IdempotentResponseCodec` — 유스케이스 결과를 `StoredResponse` payload 로 직렬화/역직렬화.
application 은 wire-format 중립이다 — 구체 JSON 인코딩은 `R` 의 응답 모양을 아는 `adapter-web`
caller 가 소유하고, executor 는 그 문자열을 store 로 왕복시키기만 한다.
- `IdempotencyStatus` — `IN_FLIGHT` / `COMPLETED` 두 값.
- `Sleeper` — `Thread.sleep` 우회 인터페이스. executor 의 in-flight 폴링 대기를 결정적으로
테스트하기 위함(테스트 Sleeper 가 실제 시간 블로킹 대신 mutable clock 전진).
### 멱등성 예외 3종 (공통 패턴)
- `IdempotencyRequestMismatchException`(422), `IdempotencyInFlightException`(409),
`IdempotencyScopeMissingException`(400, `VALIDATION_FAILED`).
- 셋 다 **프레임워크-free, application 소유**(`AuthorizationDeniedException` 선례). web adapter 가
경계에서 각각의 HTTP 코드로 매핑한다. 진단 메시지는 scope storage key / 누락 차원을 **로그용으로만**
담고, 클라이언트에는 핸들러가 고정 client-safe 메시지로 대체한다(fingerprint 자체는 노출 안 함).
- retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지
단순 재시도를 하면 안 된다.
### Owner-safe idempotency V2
`idempotency.v2`는 V1의 scope-only `tryBegin/complete/discard`를 대체하는 additive contract다.
provider가 JPA인지 Redis인지와 무관하게 claim에는 secure owner token과 stable operation ID가
필요하고, 모든 mutation은 owner/attempt/claim-operation/state-revision을 검증한다.
- processing lease와 completed replay TTL을 분리한다.
- expired `CLAIMED`만 takeover하고 expired `EXECUTING`은 `RECOVERY_REQUIRED`로 닫는다.
- complete/fail/release는 operation ID와 result digest가 같은 재호출만 prior result로 replay한다.
- `SAME_STORE_TRANSACTIONAL` JPA profile의 response는 8 KiB 이하 inline 값만 지원한다.
- raw principal/client key는 versioned HMAC scope digest로 바꾼 뒤 adapter에 전달한다.
V1은 rolling migration compatibility를 위해 유지된다. 새 reliability profile이 V2 claim과 V1
scope-only mutation을 섞는 것은 금지한다.
---
## 트랜잭셔널 아웃박스 릴레이 (outbox)
핵심 패턴: 비즈니스 쓰기와 같은 트랜잭션에 이벤트를 append → 별도 relay 가 짧은 트랜잭션으로
claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
### OutboxAppendPort
- 트랜잭셔널 아웃박스에 새 이벤트를 append 하는 outbound 포트.
- **dual-write 금지(D2)**: 반드시 이벤트를 만든 비즈니스 연산과 **같은 DB 트랜잭션 안에서**
호출해야 한다(그 트랜잭션은 caller 가 `TransactionPort.inWrite` 로 연다). 트랜잭션 밖에서, 또는
비즈니스 연산과 다른 트랜잭션에서 호출하면 dual-write 금지 위반이다 — 비즈니스는 커밋됐는데
outbox append 가 안 되거나(또는 그 반대) 이벤트가 silent 손실/허위 방출된다. 따라서 구현은
내부에서 새 트랜잭션을 열면 안 되고, 바깥 트랜잭션에 참여해야 한다.
### OutboxStorePort
- relay 측에서 outbox store 에 접근하는 포트. 모든 mutating 연산
(`claimBatch`/`markPublished`/`markFailed`/`markDead`)은 relay 유스케이스가 소유한
`TransactionPort.inWrite` 경계 안에서 호출하며, 구현은 자체 트랜잭션을 열지 않는다.
- **claim 의미(I4 / I6)** — `claimBatch` 가 적격 행을 atomic 하게 `IN_FLIGHT` 로 전이시킨다:
- 적격 행: `PENDING`, `next_attempt_at <= now` 인 `FAILED`, `next_attempt_at <= now` 인
`IN_FLIGHT`(고아 in-flight — I6 timeout 재사용).
- **FIFO 게이트(I4)**: 같은 `aggregate_id` 의 더 이른 `occurred_at` 행이 아직 `PUBLISHED` 가
아니면 그 aggregate 의 뒤 행들은 skip 한다. `DEAD` 행도 그 aggregate 큐를 막는다(strict
FIFO — 해제하려면 runbook 개입 필요).
- claim 후: `status = IN_FLIGHT`, `attempt_count += 1`,
`next_attempt_at = now + inFlightTimeout`.
- 순서: adapter 는 claim 쿼리에 `ORDER BY occurred_at ASC` 를 쓰는 게 좋고, relay 도 받은
배치를 방어적으로 재정렬한다.
- **in-flight timeout 재사용(I6)**: `next_attempt_at` 컬럼을 in-flight 고아 timeout 으로
재사용한다. relay 인스턴스가 죽어 `IN_FLIGHT` 로 남은 행이 `next_attempt_at` 을 지나면 다음
폴링에서 다시 claim 가능해진다. 별도 `claimed_at` 컬럼 없이 at-least-once 전달을 보장하는 트릭.
- `countByStatus` / `oldestUnpublishedAgeSecondsByEventType` 는 메트릭 게이지
(`outbox.pending.size`, `outbox.publisher.lag`)의 데이터 소스다. 트랜잭션 밖 read-only
(metric-scrape 경로)로 호출된다.
### OutboxMessagePublishPort
- claim 된 outbox 이벤트를 메시지 브로커에 발행하는 outbound 포트.
- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은
예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고
로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를
예외로 관측해야 FAILED/DEAD 전이 + typed failure report를 구동할 수 있기 때문. 삼킨 실패
(예외 없음·전이 없음·report 없음)가
금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔
이상이 안 보인다.
- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim →
트랜잭션 해제 → 발행 → 결과별로 다시 짧은 `inWrite` 로 상태 갱신. 브로커 호출이 진행되는 동안
트랜잭션 보유 시간을 최소화한다.
### PublishPendingOutboxEventsUseCase (릴레이 본체)
- pending outbox 이벤트를 claim 해 브로커에 발행하는 relay 유스케이스.
- **알고리즘(claim short, publish outside tx)**:
1. 짧은 write 트랜잭션 안에서 배치를 claim 한다.
2. `occurred_at` 오름차순으로 방어적 정렬(claim 쿼리도 정렬하지만, adapter 가 안 해도 relay 가
FIFO 를 강제한다).
3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다:
- 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`.
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead`, 아니면
`markFailed(nextAttemptAt)`를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다.
- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을
구동하고, 성공한 전이만 `OutboxRelayFailureReportPort`로 보고한 뒤 rethrow 하지 않는다
(스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는
없다. reporter가 `RuntimeException`을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지
못한다.
- **안전한 allowlist report**: `OutboxRelayFailureReport`는
`code/eventId/eventType/aggregateId/correlationId/attemptCount/nextAttemptAt/cause`만 가진다.
payload, idempotency key, whole `OutboxEvent`, severity/template, arbitrary map은 타입 수준에서
전달할 수 없다. retry factory는 `OUTBOX_PUBLISH_FAILED`와 필수 `nextAttemptAt`, dead factory는
`OUTBOX_DEAD_LETTER`와 null retry time을 고정한다.
- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라
에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를
dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아
visibility-timeout 재claim 경로로 복구된다 → 재발행 → consumer dedupe 가 중복을
흡수(at-least-once). 배치 중간의 상태 갱신 실패가 그 tick 의 남은 배치를 중단시키는 것은
허용된다 — DB 가 실패 중이면 뒤따르는 갱신도 실패할 테고, 다음 tick 이 모든 `IN_FLIGHT` 고아를
재시도하기 때문.
- **수동 와이어링(@Service 아님, context 빈 아님)**: `app-bootstrap` 이 수동으로 생성한다
(`batchSize`·`inFlightTimeout` 같은 설정값이 필요해서 자동 등록이 안 된다). `UseCaseCapability`
는 빈 등록 방식과 무관하게 필수다(ArchUnit 강제). 그리고 **Spring 빈으로 등록하면 안 된다**:
클래스 레벨 `@RequiresPermission` pointcut(web `MethodSecurityConfig`)이 이 타입 빈을 CGLIB
프록시하려 드는데, `final` 클래스라 불가능하고, 스케줄러 스레드엔 `Authentication` 이 없어 매
relay tick 이 fail-closed 거부될 것이다.
- **권한**: `"outbox:relay"` 는 시스템 내부 권한이다. relay 는 엔드유저가 아니라 스케줄러 빈이
호출한다. `@RequiresPermission` 선언은 ArchUnit `mutating_use_cases_declare_required_permission`
(D4) 을 충족시키기 위함이고, 스케줄러 컨텍스트에서의 실제 강제는 관례에 맡긴다(스케줄러는
app-bootstrap 내부).
### OutboxBackoffPolicy
- relay 재시도 스케줄링용 exponential-backoff-with-full-jitter 정책(I10).
- 공식: `base = 30s`, `maxAttempts = 3`, `delay = base × 2^(attemptCount-1) + jitter(0..base)`,
`nextAttemptAt = now + delay`. 여기서 `attemptCount` 는 방금 실패한 시도의 1-based 번호다.
- full jitter 는 주입된 `RandomGenerator` 로 `[0, base]` 균등 분포에서 뽑는다. 고정 시드/0 을
반환하는 generator 를 주면 테스트가 결정적이 된다(I10).
- **상수는 고정이다(I10)**: `BASE_DELAY`·`MAX_ATTEMPTS`·jitter 범위는 설정 프로퍼티가 아니다.
외부화하면 registry alert 임계치(`error-codes.yaml` 의 `retry_after_seconds=30`)와
`outbox-publish-failed` runbook 이 실제 런타임 값과 어긋난다. 이 상수는 registry·runbook
업데이트와 **함께만** 바꾼다.
- 지수 계산은 overflow 방지를 위해 `2^30` 에서 cap 한다(`2^30 × 30s > 30년` 이라 안전).
### outbox 값/상태 타입 (Status · Event · NewOutboxEvent · RelayResult · Command)
- `OutboxEventStatus` — 행 상태 머신(D5). `PENDING → IN_FLIGHT → {PUBLISHED | FAILED | DEAD}`,
`FAILED → IN_FLIGHT`(재claim), `IN_FLIGHT 고아 → IN_FLIGHT`(I6). `DEAD` 는 FIFO 형제를 막는다 —
같은 aggregate 의 뒤 행 claim 을 차단하므로 runbook 개입이 필요하다.
- `OutboxEvent` — `claimBatch` 가 반환하는 claim-result 읽기 모델. `NewOutboxEvent` 필드 + 현재
`status` + `attemptCount`. relay 가 `attemptCount` 로 다음 실패 시 dead-letter 여부를 결정한다.
- `NewOutboxEvent` — caller 의 write 트랜잭션 안에서 append 할 새 이벤트 값 객체. 모든 필드가
필수이고 null/blank 면 생성 시점에 거부한다(D12). caller 가 `eventId`·`idempotencyKey` 를
공급한다(I12 — outbox core 는 ID 생성에 비의존). 권장 기본값은 `idempotencyKey = eventId`
(per-event dedup, CloudEvents-C2).
- `OutboxRelayResult` — relay 한 사이클의 결과. `claimedCount` + per-event outcome 목록. 내부
`Outcome` enum 이 `OutboxEventStatus` 의 5값을 재사용하지 않고 3값(PUBLISHED/FAILED/DEAD)을
따로 두는 이유: relay 관점은 "이번 실행에서 무슨 일이 있었나" 이지 "행의 현재 영속 상태" 가
아니다(후자는 store 가 5값으로 추적). 별도 enum 이 메트릭·스케줄러 로깅 consumer 에게 relay API
를 깔끔하게 유지해 준다.
- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가
없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를
만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다.
- `OutboxRelayFailureReportPort` / `OutboxRelayFailureReport` — confirmed FAILED/DEAD 상태를
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
messaging adapter가 소유한다.
### Immutable outbox/polling delivery V2
`outbox.v2`는 domain event intent와 delivery state를 분리한다.
- `NewOutboxEventV2`의 aggregate version과 deterministic ordinal이 ordering authority다.
- `OutboxAppendPortV2`는 caller의 primary write transaction에 참여하고 publication epoch와
authority를 DB control row에서 얻는다.
- immutable event ID 충돌과 aggregate ordering tuple 충돌은 서로 다른 outcome이다.
- `OutboxPollingDeliveryPortV2`는 publish 밖의 짧은 claim/completion transaction만 소유하고
owner/token/attempt/version/epoch CAS로 stale relay를 거절한다.
- broker publish와 DB completion 사이 ACK 유실은 stable event ID의 duplicate publish를 만들 수
있으므로 exactly-once delivery로 표현하지 않는다.
### Same-store inbox
`inbox.InboxStorePort`는 broker redelivery를 DB business mutation과 같은 transaction에서
deduplicate한다. `RECEIVED -> PROCESSING -> COMPLETED`가 기본이며 expired `PROCESSING`은 blind
takeover하지 않는다. broker ACK는 transaction commit 이후 adapter 바깥에서만 수행하고 remote
side effect는 outbox/workflow로 옮긴다.
---
## 분산 락 (lock)
application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapter 가 소유한다.
### DistributedLockPort
- 분산 락 획득 outbound 포트.
- **왜 이 포트가 있나(D2)**: 유스케이스는 락 클라이언트·Spring `LockRegistry`·advisory-lock SQL
을 직접 import 하면 안 된다. 이 포트가 application-core 안에서 협조적 상호배제를 위해 허용된
유일한 의존이다. adapter 구현(`JdbcLockRegistry` 멀티 인스턴스, `DefaultLockRegistry` 단일
인스턴스)은 `adapter-persistence` 에 있고 컴파일 타임엔 application 계층에 안 보인다.
- **try-lock + 유한 waitTime + 필수 leaseTtl(D5)**: `tryAcquire` 는 try-lock 이다 — 최대
`waitTime` 만 블로킹하고 held 핸들을 반환하거나 `LockAcquisitionTimeoutException` 을 던진다.
무한 블로킹은 금지(항상 유한 `waitTime` 공급). `leaseTtl` 은 JVM 이 `close()` 전에 죽어도
adapter 가 락을 유지하는 최대 시간으로, 죽은 보유자가 시스템을 무한 데드락 시키는 것을
막는다. `JdbcLockRegistry` 는 registry 기본 TTL 과 호출별 lock TTL 을 강제하고, in-process
`DefaultLockRegistry` 는 advisory(강제 없음)다.
- **트랜잭션 커밋 순서 불변식(D4)**: 반환된 핸들은 보호 작업의 DB 트랜잭션이 **커밋된 후에만**
해제해야 한다. 트랜잭션 안(커밋 전)에서 해제하면 lost-update 경합이 생긴다 — 두 번째 스레드가
락을 얻어, 첫 트랜잭션의 쓰기가 DB 에 보이기 전에 자신의 read-modify-write 를 시작한다.
- 올바른 패턴: `tryAcquire` → `try { txPort.inWrite(...) } finally { lock.close() }`(커밋 후 해제).
- 금지된 역순: `inWrite` 콜백 안에서 `lock.close()`(커밋 전 해제 → 다른 스레드가 stale 상태를 본다).
- **효율 락이지 정합성 락이 아니다(D6)**: 이 락은 경합·불필요한 재시도를 줄이는 *효율* 장치다.
정합성(중복·충돌 쓰기 방지)은 여전히 DB 제약(유니크 인덱스·낙관적 동시성)이 강제한다. 이 락
하나만 정합성 가드로 의존하면 안 된다.
- **lease 만료 시 해제(SI-LOCK-C5)**: TTL 기반 provider(`JdbcLockRegistry`)에서 보유자가
`close()` 하기 전에 lease 가 만료되면, 락 행은 이미 다른 인스턴스가 회수했을 수 있다. metered
`distributedLockProvider` 가 이를 로그 + 카운트 이벤트(`lock.lease.expired`)로 표면화하고
`close()` 는 정상 반환한다 — 만료가 caller 의 `finally` 를 터뜨리거나 보호 작업 자신의 예외를
가리지 않게 하기 위함.
- `leaseTtl` 이 provider 설정 TTL 을 초과하면 `IllegalArgumentException`. shipped 와이어링은 provider
설정과 같은 `LockSettings.leaseTtl()` 로 바인딩하므로 런타임엔 안 터지고, mis-wired caller/test 를
잡는 가드다.
### DistributedLock (핸들)
- 획득한 분산 락 핸들(`AutoCloseable`). `close()` 가 해제이고 `finally` 에서 한 번 호출하기에
안전하며, 구현은 idempotent 해야 한다(여러 번 close 해도 throw 금지).
- D4 커밋 순서 불변식은 `DistributedLockPort` 와 동일하다 — 커밋 후에만 `close`.
- `close()` 가 `AutoCloseable` 의 `throws Exception` 을 제거하도록 override 한 이유: 구현이 해제
시 checked exception 을 던지지 못하게 해서, caller 가 checked-exception 의식 없이 `finally` 에
둘 수 있게 하기 위함.
### LockAcquisitionTimeoutException
- `waitTime` 안에 락을 얻지 못하면 `tryAcquire` 가 던진다(D5).
- `OperationalError.LOCK_ACQUISITION_TIMEOUT`(CONFLICT, 409, retryable=true)을 운반한다. 락
경합은 일시적이다 — 현재 보유자가 임계 구역을 떠나거나 lease TTL 이 만료되면 재시도가
획득한다(D6 효율 락).
- web adapter 가 `errorCode()` + 고정 client-safe 메시지로 409 를 만든다. 진단용 `getMessage()`
(key·waitTime 포함)는 서버 로그 전용이고 API 클라이언트에 전달하면 안 된다.
---
## 알림 포트 (notification)
### NotificationPort
- named channel + 논리 route 로 알림을 전달하는 outbound 포트.
- 2-인자 overload 는 관례적 `"default"` route 를 쓰는 편의 메서드다. adapter 가
`app.notification.routes.<channel>.<route>` 에서 구체 provider 목록을 해석하므로, application
계층은 provider 선택과 분리된다(호출부에 provider id 가 없다).
- fan-out(route 당 여러 provider)은 adapter 관심사다. 어떤 provider 가 실패하면 adapter 가
fail-open 정책(관측만, 전파 안 함)을 적용하고, route 자체가 unbound 면
`AdapterDisabledException("notification", ...)` 을 던진다.
- outbound 포트의 `*Port` 접미사 명명 규칙을 따른다(CLAUDE.md).
### Notification (값 객체)
- transport-중립 알림 값으로, routing target 과 내용만 운반한다.
- **절대 logger(또는 dependency logger)에 넘기면 안 된다** — recipient 주소·body 가 로그 라인에
닿을 수 없게 하기 위함(PII 계약).
- `adapter-outbound` 에서 `application-core` 로 옮긴 이유: 유스케이스가 adapter 타입을 import 하지
않고도 알림을 만들어 `NotificationPort` 로 전달할 수 있게 하기 위함(clean-architecture
HARD-STOP #3).
### Channel
- 알림 채널 판별자(`EMAIL`/`SLACK`). provider 와 독립적으로 전달 매체를 식별한다.
- caller 가 `Channel` 값(compile-safe) + 논리 route 이름을 `NotificationPort` 에 넘기면 adapter 가
`app.notification.routes.<channel>.<route>` 로 provider 목록을 해석한다 — 도메인은 provider
세부를 들여다보지 않는다(HARD-STOP #4).
---
## 로그 가명화 포트 (observability)
### CorrelationIdPort
- 현재 application invocation의 correlation id를 `Optional<String>`으로 읽는 framework-free
경계다. application/sample use case는 MDC나 SLF4J를 직접 알지 않는다.
- inbound web adapter가 sanitized `correlation_id` MDC 슬롯을 구현 세부로 읽는다.
- 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존
self-correlation 동작을 유지한다.
### UserPrincipalPseudonymizerPort
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound
포트.
- **null/blank 의미**: `rawPrincipal` 이 null/blank 면 null 을 반환한다. 가명화할 게 없으니, 이
경우 caller 는 `user_principal` MDC 키에 아무 값도 넣으면 안 된다.
- **안정 토큰 계약**: non-blank 입력에 대해 (1) 같은 입력 + 같은 salt 는 같은 salt epoch 안에서
항상 같은 출력을 낸다(안정), (2) raw principal 의 trivial 복원이 불가능하다(일방향 derivation
이지 인코딩·가역 변환이 아니다), (3) `user_principal` MDC 와 로그 라인에 안전하다.
- **알고리즘 SSOT**: 구체 알고리즘(90일 회전 salt 로 keyed 한 HMAC-SHA-256)은
이 영역의 책임다. 이 포트는
"보안 principal 을 가명 형태로 기록한다" 는 계약만 소유한다. 구현은 `adapter-identifier` 에
있고 `app-bootstrap` 이 와이어링한다.
- **계층**: 순수 Java 인터페이스다. 구현은 application-core/domain-core 에서 참조하면 안 된다.
consumer(`adapter-web` 의 `RequestLoggingFilter`)가 이 포트를 주입받아
`AuthenticatedPrincipal.idpUserId()` 를 가명화한 뒤 `MDC.put("user_principal", ...)` 한다.
+27
View File
@@ -0,0 +1,27 @@
// Framework-free application use-case contract. Runtime dependencies are project-only;
// composition and diagnostic rendering belong to adapters/bootstrap.
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencies {
implementation project(':shared-contract')
// Property-based verification for bounded object-storage identity and value contracts.
testImplementation 'net.jqwik:jqwik:1.9.1'
}
def messagingApplicationContractQualification = registerStrictQualificationTest(
name: 'messagingApplicationContractQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: [
'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest',
'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest',
'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest'
],
junitXmlOutput: rootProject.layout.buildDirectory.dir(
'test-results/messaging-evidence/application'),
binaryResultsOutput: rootProject.layout.buildDirectory.dir(
'test-results/messaging-evidence-binary/application'),
description: 'Runs exact Messaging application contract qualification tests.')
messagingApplicationContractQualification.configure {
dependsOn ':prepareMessagingContractEvidence'
}
+88
View File
@@ -0,0 +1,88 @@
# 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.
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
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,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
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,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy:1.17.8=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-api:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-engine:1.9.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
net.jqwik:jqwik-time:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik-web:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.jqwik:jqwik:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,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=spotbugs
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
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.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,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.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,redisPolicyContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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.pcollections:pcollections:4.0.1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Business-classified source absence that is safe to negative-cache. */
public enum AuthoritativeAbsence {
NOT_FOUND,
DELETED,
NOT_APPLICABLE
}
@@ -0,0 +1,457 @@
package dev.caskeleton.application.cache;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.Objects;
/**
* Framework-free cache-aside orchestration with bounded local coalescing and source concurrency.
* Construct one instance per semantic cache region so its policy and protection bounds are shared.
*
* <p>When optional refresh coordination is enabled, the soft-lease owner refreshes synchronously.
* This executor does not schedule an asynchronous stale-while-revalidate task. A valid stale
* contender or a valid stale request facing coordination failure returns immediately instead.
*/
public final class CacheAsideExecutor<K, V> {
private final CacheAsidePolicy policy;
private final Clock clock;
private final CacheSingleFlight<K, SourceAttempt<V>> singleFlight;
private final CacheSourceBulkhead sourceBulkhead;
private final CacheRefreshCoordinationPort<K> refreshCoordinator;
private final CacheRefreshCoordinationPolicy refreshCoordinationPolicy;
public CacheAsideExecutor(CacheAsidePolicy policy, Clock clock) {
this(policy, clock, null, null);
}
public CacheAsideExecutor(
CacheAsidePolicy policy,
Clock clock,
CacheRefreshCoordinationPort<K> refreshCoordinator,
CacheRefreshCoordinationPolicy refreshCoordinationPolicy) {
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.refreshCoordinator = refreshCoordinator;
if (refreshCoordinator == null) {
if (refreshCoordinationPolicy != null) {
throw new IllegalArgumentException("refresh coordination policy requires a coordinator");
}
this.refreshCoordinationPolicy = null;
} else {
this.refreshCoordinationPolicy =
Objects.requireNonNull(
refreshCoordinationPolicy, "refreshCoordinationPolicy must be non-null")
.validateAgainst(policy);
}
singleFlight =
new CacheSingleFlight<>(policy.maximumInFlightSourceKeys(), policy.maximumWaitersPerKey());
sourceBulkhead = new CacheSourceBulkhead(policy.maximumConcurrentSourceLoads());
}
public CacheResult<V> getOrLoad(
K key, CacheRegionPort<K, V> region, CacheSourceLoader<K, V> sourceLoader) {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(region, "region must be non-null");
Objects.requireNonNull(sourceLoader, "sourceLoader must be non-null");
CacheLookup<V> lookup =
Objects.requireNonNull(region.lookup(key), "cache lookup must be non-null");
StaleCandidate<V> stale = null;
boolean hardMiss = false;
RefillCondition refillCondition = RefillCondition.absent(CacheWriteCondition.unavailable());
if (lookup instanceof CacheLookup.Hit<V> hit) {
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
return new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision());
}
if (!hit.observationToken().usable()) {
return new CacheResult.IncompatibleSchema<>(
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST);
}
stale =
new StaleCandidate<>(
hit.value(), hit.sourceRevision(), hit.hardExpiresAt(), hit.observationToken());
refillCondition = RefillCondition.observed(hit.observationToken(), hit.writeCondition());
} else if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
return new CacheResult.NegativeHit<>(negative.reason());
} else if (lookup instanceof CacheLookup.Miss<V> miss) {
refillCondition = RefillCondition.absent(miss.writeCondition());
hardMiss = true;
} else if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST) {
return new CacheResult.IncompatibleSchema<>(incompatible.category(), incompatible.policy());
}
if (!incompatible.observationToken().usable()) {
return new CacheResult.IncompatibleSchema<>(
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST);
}
refillCondition =
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition());
} else if (lookup instanceof CacheLookup.Unavailable<V> unavailable) {
refillCondition = RefillCondition.absent(unavailable.writeCondition());
}
RefillCondition selectedCondition = refillCondition;
StaleCandidate<V> selectedStale = stale;
boolean selectedHardMiss = hardMiss;
CacheSingleFlight.Outcome<SourceAttempt<V>> flight =
singleFlight.execute(
key,
policy.maximumWaitDuration(),
() ->
loadFromSource(
key, region, sourceLoader, selectedCondition, selectedStale, selectedHardMiss));
if (flight instanceof CacheSingleFlight.Rejected<SourceAttempt<V>> rejected) {
return new CacheResult.Rejected<>(
switch (rejected.reason()) {
case MAXIMUM_IN_FLIGHT_KEYS -> CacheResult.RejectionReason.MAXIMUM_IN_FLIGHT_KEYS;
case MAXIMUM_WAITERS -> CacheResult.RejectionReason.MAXIMUM_WAITERS;
case WAIT_TIMEOUT -> CacheResult.RejectionReason.WAIT_TIMEOUT;
});
}
if (flight instanceof CacheSingleFlight.Interrupted<SourceAttempt<V>>) {
return new CacheResult.Cancelled<>();
}
SourceAttempt<V> attempt = ((CacheSingleFlight.Completed<SourceAttempt<V>>) flight).value();
return toResult(attempt, stale);
}
int inFlightWaiterCount(K key) {
return singleFlight.waiterCount(key);
}
private SourceAttempt<V> loadFromSource(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition,
StaleCandidate<V> stale,
boolean hardMiss) {
CacheSourceBulkhead.Outcome<SourceAttempt<V>> admitted =
sourceBulkhead.execute(
policy.sourceAdmissionWait(),
() -> invokeSource(key, region, sourceLoader, refillCondition, stale, hardMiss));
if (admitted instanceof CacheSourceBulkhead.Rejected<SourceAttempt<V>>) {
return new SourceRejected<>();
}
if (admitted instanceof CacheSourceBulkhead.Interrupted<SourceAttempt<V>>) {
return new SourceInterrupted<>();
}
return ((CacheSourceBulkhead.Completed<SourceAttempt<V>>) admitted).value();
}
private SourceAttempt<V> invokeSource(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition,
StaleCandidate<V> stale,
boolean hardMiss) {
CacheRefreshClaimAttempt ownedAttempt = null;
RefillCondition selectedCondition = refillCondition;
try {
if (shouldCoordinate(stale, hardMiss)) {
CacheRefreshClaimAttempt attempt =
Objects.requireNonNull(
refreshCoordinator.newAttempt(), "cache refresh claim attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalStateException(
"enabled cache refresh coordinator returned an unusable attempt");
}
CacheRefreshClaimOutcome claim = claimWithOneUncertainRetry(key, attempt);
if (claim instanceof CacheRefreshClaimOutcome.Contended) {
SourceAttempt<V> deferred = staleDeferral(stale, claim);
if (deferred != null) {
return deferred;
}
if (hardMiss
&& refreshCoordinationPolicy.hardMissPolicy()
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD) {
if (!boundedWait(refreshCoordinationPolicy.hardMissWait())) {
return new SourceInterrupted<>();
}
Recheck<V> recheck = recheck(region, key);
if (recheck.immediateResult() != null) {
return new ImmediateResult<>(recheck.immediateResult());
}
selectedCondition = recheck.refillCondition();
}
} else if (claim instanceof CacheRefreshClaimOutcome.Unavailable
|| claim instanceof CacheRefreshClaimOutcome.Indeterminate) {
SourceAttempt<V> deferred = staleDeferral(stale, claim);
if (deferred != null) {
return deferred;
}
} else if (claim instanceof CacheRefreshClaimOutcome.Claimed
|| claim instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
ownedAttempt = attempt;
Recheck<V> recheck = recheck(region, key);
if (recheck.immediateResult() != null) {
return new ImmediateResult<>(recheck.immediateResult());
}
selectedCondition = recheck.refillCondition();
}
}
return invokeSourceDirect(key, region, sourceLoader, selectedCondition);
} finally {
if (ownedAttempt != null) {
Objects.requireNonNull(
refreshCoordinator.release(key, ownedAttempt),
"cache refresh release outcome must be non-null");
}
}
}
private SourceAttempt<V> invokeSourceDirect(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition) {
Instant deadline;
try {
deadline = clock.instant().plus(policy.sourceLoadDeadline());
} catch (DateTimeException exception) {
throw new IllegalStateException("cache source deadline cannot be represented", exception);
}
CacheCancellationToken cancellation = new CacheCancellationToken(deadline, clock);
if (cancellation.isInterrupted()) {
return new SourceInterrupted<>();
}
SourceLoadOutcome<V> outcome =
Objects.requireNonNull(
sourceLoader.load(key, cancellation), "source loader outcome must be non-null");
if (cancellation.isInterrupted() || outcome instanceof SourceLoadOutcome.Cancelled<V>) {
return new SourceInterrupted<>();
}
if (cancellation.isDeadlineExceeded()) {
return new SourceTimedOut<>();
}
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
CacheRecordOutcome recorded =
Objects.requireNonNull(
region.record(
key,
loaded.value(),
new CacheRecordMetadata(
loaded.sourceRevision(),
refillCondition.intent(),
refillCondition.observationToken(),
refillCondition.writeCondition())),
"cache record outcome must be non-null");
return new SourceResolved<>(outcome, recorded);
}
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
CacheRecordOutcome recorded =
Objects.requireNonNull(
region.recordAbsent(
key,
absent.reason(),
new CacheRecordMetadata(
absent.sourceRevision(),
refillCondition.intent(),
refillCondition.observationToken(),
refillCondition.writeCondition())),
"negative cache record outcome must be non-null");
return new SourceResolved<>(outcome, recorded);
}
return new SourceResolved<>(outcome, null);
}
private boolean shouldCoordinate(StaleCandidate<V> stale, boolean hardMiss) {
if (refreshCoordinator == null || !refreshCoordinator.enabled()) {
return false;
}
return stale != null
|| (hardMiss
&& refreshCoordinationPolicy.hardMissPolicy()
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD);
}
private CacheRefreshClaimOutcome claimWithOneUncertainRetry(
K key, CacheRefreshClaimAttempt attempt) {
CacheRefreshClaimOutcome first =
Objects.requireNonNull(
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
"cache refresh claim outcome must be non-null");
if (first instanceof CacheRefreshClaimOutcome.Indeterminate) {
return Objects.requireNonNull(
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
"cache refresh claim retry outcome must be non-null");
}
return first;
}
private SourceAttempt<V> staleDeferral(StaleCandidate<V> stale, CacheRefreshClaimOutcome claim) {
if (stale != null && clock.instant().isBefore(stale.hardExpiresAt())) {
CacheResult.RefreshDeferralReason reason =
claim instanceof CacheRefreshClaimOutcome.Contended
? CacheResult.RefreshDeferralReason.CONTENDED
: claim instanceof CacheRefreshClaimOutcome.Unavailable
? CacheResult.RefreshDeferralReason.COORDINATION_UNAVAILABLE
: CacheResult.RefreshDeferralReason.COORDINATION_INDETERMINATE;
return new ImmediateResult<>(
new CacheResult.StaleRefreshDeferred<>(stale.value(), stale.sourceRevision(), reason));
}
return null;
}
private boolean boundedWait(java.time.Duration duration) {
try {
long milliseconds = duration.toMillis();
int nanoseconds = (int) duration.minusMillis(milliseconds).toNanos();
Thread.sleep(milliseconds, nanoseconds);
return true;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return false;
}
}
private Recheck<V> recheck(CacheRegionPort<K, V> region, K key) {
CacheLookup<V> lookup =
Objects.requireNonNull(region.lookup(key), "cache recheck must be non-null");
if (lookup instanceof CacheLookup.Hit<V> hit) {
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
return Recheck.immediate(new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision()));
}
if (!hit.observationToken().usable()) {
return Recheck.immediate(
new CacheResult.IncompatibleSchema<>(
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST));
}
return Recheck.refill(RefillCondition.observed(hit.observationToken(), hit.writeCondition()));
}
if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
return Recheck.immediate(new CacheResult.NegativeHit<>(negative.reason()));
}
if (lookup instanceof CacheLookup.Miss<V> miss) {
return Recheck.refill(RefillCondition.absent(miss.writeCondition()));
}
if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST
|| !incompatible.observationToken().usable()) {
return Recheck.immediate(
new CacheResult.IncompatibleSchema<>(
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST));
}
return Recheck.refill(
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition()));
}
CacheLookup.Unavailable<V> unavailable = (CacheLookup.Unavailable<V>) lookup;
return Recheck.refill(RefillCondition.absent(unavailable.writeCondition()));
}
private CacheResult<V> toResult(SourceAttempt<V> attempt, StaleCandidate<V> stale) {
if (attempt instanceof ImmediateResult<V> immediate) {
return immediate.result();
}
if (attempt instanceof SourceRejected<V>) {
return new CacheResult.Rejected<>(CacheResult.RejectionReason.SOURCE_OVERLOADED);
}
if (attempt instanceof SourceTimedOut<V>) {
return new CacheResult.Rejected<>(CacheResult.RejectionReason.LOAD_TIMEOUT);
}
if (attempt instanceof SourceInterrupted<V>) {
return new CacheResult.Cancelled<>();
}
SourceResolved<V> resolved = (SourceResolved<V>) attempt;
SourceLoadOutcome<V> outcome = resolved.outcome();
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
return new CacheResult.LoadedFromSource<>(
loaded.value(), loaded.sourceRevision(), resolved.recordOutcome());
}
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
return new CacheResult.AuthoritativeAbsent<>(
absent.reason(), absent.sourceRevision(), resolved.recordOutcome());
}
if (outcome instanceof SourceLoadOutcome.TransientFailure<V> transientFailure) {
if (stale != null
&& policy.serveStaleOnTransientFailure()
&& clock.instant().isBefore(stale.hardExpiresAt())) {
return new CacheResult.StaleFallbackAfterTransientFailure<>(
stale.value(), stale.sourceRevision(), transientFailure.failure());
}
return new CacheResult.SourceFailed<>(
transientFailure.failure(), CacheResult.SourceFailureKind.TRANSIENT);
}
if (outcome instanceof SourceLoadOutcome.PermanentFailure<V> permanentFailure) {
return new CacheResult.SourceFailed<>(
permanentFailure.failure(), CacheResult.SourceFailureKind.PERMANENT);
}
return new CacheResult.Cancelled<>();
}
private sealed interface SourceAttempt<T>
permits SourceResolved, SourceRejected, SourceInterrupted, SourceTimedOut, ImmediateResult {}
private record SourceResolved<T>(SourceLoadOutcome<T> outcome, CacheRecordOutcome recordOutcome)
implements SourceAttempt<T> {
private SourceResolved {
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
private record SourceRejected<T>() implements SourceAttempt<T> {}
private record SourceInterrupted<T>() implements SourceAttempt<T> {}
private record SourceTimedOut<T>() implements SourceAttempt<T> {}
private record ImmediateResult<T>(CacheResult<T> result) implements SourceAttempt<T> {
private ImmediateResult {
Objects.requireNonNull(result, "result must be non-null");
}
}
private record StaleCandidate<T>(
T value,
String sourceRevision,
Instant hardExpiresAt,
CacheObservationToken observationToken) {
private StaleCandidate {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
Objects.requireNonNull(observationToken, "observationToken must be non-null");
}
}
private record RefillCondition(
CacheRecordIntent intent,
CacheObservationToken observationToken,
CacheWriteCondition writeCondition) {
private RefillCondition {
Objects.requireNonNull(intent, "intent must be non-null");
Objects.requireNonNull(observationToken, "observationToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
private static RefillCondition absent(CacheWriteCondition writeCondition) {
return new RefillCondition(
CacheRecordIntent.ONLY_IF_ABSENT, CacheObservationToken.unavailable(), writeCondition);
}
private static RefillCondition observed(
CacheObservationToken token, CacheWriteCondition writeCondition) {
return new RefillCondition(CacheRecordIntent.ONLY_IF_OBSERVED, token, writeCondition);
}
}
private record Recheck<T>(RefillCondition refillCondition, CacheResult<T> immediateResult) {
private static <T> Recheck<T> refill(RefillCondition refillCondition) {
return new Recheck<>(
Objects.requireNonNull(refillCondition, "refillCondition must be non-null"), null);
}
private static <T> Recheck<T> immediate(CacheResult<T> result) {
return new Recheck<>(null, Objects.requireNonNull(result, "result must be non-null"));
}
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/** Immutable per-region bounds for cache-aside fallback and local coalescing. */
public record CacheAsidePolicy(
int maximumInFlightSourceKeys,
int maximumWaitersPerKey,
int maximumConcurrentSourceLoads,
Duration sourceAdmissionWait,
Duration sourceLoadDeadline,
boolean serveStaleOnTransientFailure) {
private static final int MAXIMUM_COUNT_BOUND = 4096;
private static final Duration MAXIMUM_DURATION_BOUND = Duration.ofDays(30);
public CacheAsidePolicy {
requirePositiveBound(
maximumInFlightSourceKeys, "maximumInFlightSourceKeys", MAXIMUM_COUNT_BOUND);
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_COUNT_BOUND) {
throw new IllegalArgumentException(
"maximumWaitersPerKey must be in 0.." + MAXIMUM_COUNT_BOUND);
}
requirePositiveBound(
maximumConcurrentSourceLoads, "maximumConcurrentSourceLoads", MAXIMUM_COUNT_BOUND);
requireDuration(sourceAdmissionWait, "sourceAdmissionWait", true);
requireDuration(sourceLoadDeadline, "sourceLoadDeadline", false);
}
Duration maximumWaitDuration() {
return sourceAdmissionWait.plus(sourceLoadDeadline);
}
private static void requirePositiveBound(int value, String field, int maximum) {
if (value < 1 || value > maximum) {
throw new IllegalArgumentException(field + " must be in 1.." + maximum);
}
}
private static void requireDuration(Duration value, String field, boolean zeroAllowed) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isNegative()
|| (!zeroAllowed && value.isZero())
|| value.compareTo(MAXIMUM_DURATION_BOUND) > 0) {
throw new IllegalArgumentException(
field
+ " must be "
+ (zeroAllowed ? "non-negative" : "positive")
+ " and at most "
+ MAXIMUM_DURATION_BOUND);
}
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.cache;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
/**
* Cooperative source-load cancellation signal. It observes the executing thread's interrupt flag
* and an immutable deadline; it cannot forcibly stop arbitrary source code.
*/
public final class CacheCancellationToken {
private final Instant deadline;
private final Clock clock;
CacheCancellationToken(Instant deadline, Clock clock) {
this.deadline = Objects.requireNonNull(deadline, "deadline must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
public Instant deadline() {
return deadline;
}
public boolean isDeadlineExceeded() {
return !clock.instant().isBefore(deadline);
}
public boolean isInterrupted() {
return Thread.currentThread().isInterrupted();
}
public boolean isCancellationRequested() {
return isInterrupted() || isDeadlineExceeded();
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.cache;
/** Provider-neutral invalidation result. */
public enum CacheInvalidationOutcome {
INVALIDATED,
ALREADY_ABSENT,
DEGRADED_UNAVAILABLE,
INDETERMINATE
}
@@ -0,0 +1,166 @@
package dev.caskeleton.application.cache;
import java.time.Instant;
import java.util.Objects;
/** Lookup result that never collapses provider failure, negative entries, and normal misses. */
public sealed interface CacheLookup<V>
permits CacheLookup.Hit,
CacheLookup.NegativeHit,
CacheLookup.Miss,
CacheLookup.IncompatibleSchema,
CacheLookup.Unavailable {
record Hit<V>(
V value,
Freshness freshness,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
CacheObservationToken observationToken,
CacheWriteCondition writeCondition)
implements CacheLookup<V> {
public Hit(
V value,
Freshness freshness,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt) {
this(
value,
freshness,
sourceRevision,
softExpiresAt,
hardExpiresAt,
CacheObservationToken.unavailable(),
CacheWriteCondition.unavailable());
}
public Hit(
V value,
Freshness freshness,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
CacheObservationToken observationToken) {
this(
value,
freshness,
sourceRevision,
softExpiresAt,
hardExpiresAt,
observationToken,
CacheWriteCondition.unavailable());
}
public Hit {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(freshness, "freshness must be non-null");
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
}
Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
if (softExpiresAt.isAfter(hardExpiresAt)) {
throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt");
}
Objects.requireNonNull(observationToken, "observationToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
}
record NegativeHit<V>(AuthoritativeAbsence reason, Instant hardExpiresAt)
implements CacheLookup<V> {
public NegativeHit {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
}
}
record Miss<V>(MissReason reason, CacheWriteCondition writeCondition) implements CacheLookup<V> {
public Miss(MissReason reason) {
this(reason, CacheWriteCondition.unavailable());
}
public Miss {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
}
record IncompatibleSchema<V>(
SchemaCategory category,
SchemaPolicy policy,
CacheObservationToken observationToken,
CacheWriteCondition writeCondition)
implements CacheLookup<V> {
public IncompatibleSchema(SchemaCategory category, SchemaPolicy policy) {
this(
category, policy, CacheObservationToken.unavailable(), CacheWriteCondition.unavailable());
}
public IncompatibleSchema(
SchemaCategory category, SchemaPolicy policy, CacheObservationToken observationToken) {
this(category, policy, observationToken, CacheWriteCondition.unavailable());
}
public IncompatibleSchema {
Objects.requireNonNull(category, "category must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
Objects.requireNonNull(observationToken, "observationToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
}
record Unavailable<V>(
UnavailabilityReason reason, OperationCertainty certainty, CacheWriteCondition writeCondition)
implements CacheLookup<V> {
public Unavailable(UnavailabilityReason reason, OperationCertainty certainty) {
this(reason, certainty, CacheWriteCondition.unavailable());
}
public Unavailable {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
}
enum Freshness {
FRESH,
STALE
}
enum MissReason {
ABSENT,
EXPIRED,
INVALIDATED
}
enum SchemaCategory {
FUTURE_VERSION,
RETIRED_VERSION,
UNKNOWN_ENVELOPE,
CORRUPT_ENVELOPE
}
enum SchemaPolicy {
FAIL_FAST,
QUARANTINE_AND_RELOAD
}
enum UnavailabilityReason {
UNAVAILABLE,
OVERLOADED
}
enum OperationCertainty {
NOT_APPLIED,
INDETERMINATE
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/**
* Framework-free, cache-only diagnostic events.
*
* <p>Events intentionally omit semantic keys, tenant/user identifiers and provider endpoints.
* {@code cacheName} is a bounded code-owned name suitable for a low-cardinality metric tag.
*/
public sealed interface CacheObservationEvent {
String cacheName();
/** One lookup at a concrete cache tier. */
record Lookup(String cacheName, Tier tier, LookupResult result, Duration entryAge)
implements CacheObservationEvent {
public Lookup {
cacheName = boundedCacheName(cacheName);
Objects.requireNonNull(tier, "tier must be non-null");
Objects.requireNonNull(result, "result must be non-null");
Objects.requireNonNull(entryAge, "entryAge must be non-null");
if (entryAge.isNegative() || entryAge.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("entryAge must be between zero and 30 days");
}
}
}
/** A bounded local-tier eviction, flush, subscriber or generation reconciliation action. */
record LocalMaintenance(
String cacheName,
MaintenanceAction action,
MaintenanceResult result,
MaintenanceCause cause,
int affectedEntries)
implements CacheObservationEvent {
public LocalMaintenance {
cacheName = boundedCacheName(cacheName);
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(result, "result must be non-null");
Objects.requireNonNull(cause, "cause must be non-null");
if (affectedEntries < 0 || affectedEntries > 1_000_000) {
throw new IllegalArgumentException("affectedEntries must be in 0..1000000");
}
}
}
enum Tier {
LOCAL_L1,
REDIS_L2
}
enum LookupResult {
HIT,
MISS,
ERROR,
BYPASS
}
enum MaintenanceAction {
EVICT,
FLUSH,
RECONCILE,
SUBSCRIBER_EVENT
}
enum MaintenanceResult {
SUCCESS,
FLUSHED,
DROPPED,
ERROR,
UNCHANGED
}
enum MaintenanceCause {
CARDINALITY,
WEIGHT,
TTL,
INVALIDATION,
GENERATION_CHANGED,
SUBSCRIBER_DISCONNECTED,
SUBSCRIBER_OVERFLOW,
MALFORMED_MESSAGE,
RECONCILIATION_FAILURE
}
private static String boundedCacheName(String value) {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"cacheName must be a code-owned lower-case slug with 1..63 characters");
}
return value;
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Framework-free output boundary for low-cardinality cache diagnostics. */
@FunctionalInterface
public interface CacheObservationPort {
void observe(CacheObservationEvent event);
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.cache;
/**
* Opaque provider observation used only for conditional cache replacement. Application code must
* not parse or manufacture it.
*/
public record CacheObservationToken(String value) {
private static final String UNAVAILABLE_VALUE = "observation-unavailable";
public CacheObservationToken {
if (value == null || !value.matches("[A-Za-z0-9_-]{16,128}")) {
throw new IllegalArgumentException(
"cache observation token must have a bounded opaque representation");
}
}
public static CacheObservationToken unavailable() {
return new CacheObservationToken(UNAVAILABLE_VALUE);
}
public boolean usable() {
return !UNAVAILABLE_VALUE.equals(value);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.cache;
/** Application-visible consistency intent; technical TTL and codec remain provider policy. */
public enum CacheRecordIntent {
UPSERT,
ONLY_IF_ABSENT,
ONLY_IF_OBSERVED,
ONLY_IF_SOURCE_REVISION_NEWER
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Source revision plus an optional opaque condition captured by the preceding cache lookup. */
public record CacheRecordMetadata(
String sourceRevision,
CacheRecordIntent intent,
CacheObservationToken observedToken,
CacheWriteCondition writeCondition) {
public CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) {
this(
sourceRevision,
intent,
CacheObservationToken.unavailable(),
CacheWriteCondition.unavailable());
}
public CacheRecordMetadata(
String sourceRevision, CacheRecordIntent intent, CacheObservationToken observedToken) {
this(sourceRevision, intent, observedToken, CacheWriteCondition.unavailable());
}
public CacheRecordMetadata {
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
}
Objects.requireNonNull(intent, "intent must be non-null");
Objects.requireNonNull(observedToken, "observedToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
if (intent == CacheRecordIntent.ONLY_IF_OBSERVED && !observedToken.usable()) {
throw new IllegalArgumentException(
"ONLY_IF_OBSERVED requires a usable cache observation token");
}
if (intent != CacheRecordIntent.ONLY_IF_OBSERVED && observedToken.usable()) {
throw new IllegalArgumentException(
"a cache observation token is valid only for ONLY_IF_OBSERVED");
}
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.cache;
/** Provider-neutral result of recording a positive or authoritative-negative entry. */
public enum CacheRecordOutcome {
RECORDED,
NOT_RECORDED_CONDITION,
NOT_RECORDED_PROVIDER_POLICY,
DEGRADED_UNAVAILABLE,
INDETERMINATE
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Owner plus operation identities that must be reused for an uncertain claim retry. */
public record CacheRefreshClaimAttempt(
CacheRefreshOwnerToken ownerToken, CacheRefreshOperationToken operationToken) {
private static final CacheRefreshClaimAttempt UNAVAILABLE =
new CacheRefreshClaimAttempt(
CacheRefreshOwnerToken.unavailable(), CacheRefreshOperationToken.unavailable());
public CacheRefreshClaimAttempt {
Objects.requireNonNull(ownerToken, "ownerToken must be non-null");
Objects.requireNonNull(operationToken, "operationToken must be non-null");
if (ownerToken.usable() != operationToken.usable()) {
throw new IllegalArgumentException("refresh claim attempt tokens must have equal usability");
}
}
public static CacheRefreshClaimAttempt unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return ownerToken.usable();
}
@Override
public String toString() {
return usable()
? "CacheRefreshClaimAttempt[redacted]"
: "CacheRefreshClaimAttempt[unavailable]";
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Provider-neutral result of attempting to own a cache-refresh soft lease. */
public sealed interface CacheRefreshClaimOutcome
permits CacheRefreshClaimOutcome.Claimed,
CacheRefreshClaimOutcome.AlreadyOwned,
CacheRefreshClaimOutcome.Contended,
CacheRefreshClaimOutcome.Disabled,
CacheRefreshClaimOutcome.Unavailable,
CacheRefreshClaimOutcome.Indeterminate {
record Claimed(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
public Claimed {
requireUsable(attempt);
}
}
record AlreadyOwned(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
public AlreadyOwned {
requireUsable(attempt);
}
}
record Contended() implements CacheRefreshClaimOutcome {}
record Disabled() implements CacheRefreshClaimOutcome {}
record Unavailable() implements CacheRefreshClaimOutcome {}
record Indeterminate() implements CacheRefreshClaimOutcome {}
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
Objects.requireNonNull(attempt, "attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalArgumentException("owned refresh claim requires a usable attempt");
}
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/** Finite soft-lease and hard-miss behavior for optional distributed refresh coordination. */
public record CacheRefreshCoordinationPolicy(
Duration leaseTimeToLive, HardMissPolicy hardMissPolicy, Duration hardMissWait) {
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(5);
private static final Duration MAXIMUM_HARD_MISS_WAIT = Duration.ofSeconds(5);
public CacheRefreshCoordinationPolicy {
Objects.requireNonNull(leaseTimeToLive, "leaseTimeToLive must be non-null");
Objects.requireNonNull(hardMissPolicy, "hardMissPolicy must be non-null");
Objects.requireNonNull(hardMissWait, "hardMissWait must be non-null");
if (leaseTimeToLive.isZero()
|| leaseTimeToLive.isNegative()
|| leaseTimeToLive.compareTo(MAXIMUM_LEASE) > 0) {
throw new IllegalArgumentException(
"refresh lease TTL must be positive and at most 5 minutes");
}
if (hardMissWait.isNegative() || hardMissWait.compareTo(MAXIMUM_HARD_MISS_WAIT) > 0) {
throw new IllegalArgumentException("hard miss wait must be between zero and 5 seconds");
}
if (hardMissPolicy == HardMissPolicy.NORMAL_SOURCE_LOAD && !hardMissWait.isZero()) {
throw new IllegalArgumentException("normal hard miss source load requires zero wait");
}
if (hardMissPolicy == HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD && hardMissWait.isZero()) {
throw new IllegalArgumentException("bounded hard miss wait must be positive");
}
}
public CacheRefreshCoordinationPolicy validateAgainst(CacheAsidePolicy cacheAsidePolicy) {
Objects.requireNonNull(cacheAsidePolicy, "cacheAsidePolicy must be non-null");
if (leaseTimeToLive.compareTo(cacheAsidePolicy.sourceLoadDeadline()) <= 0) {
throw new IllegalArgumentException(
"refresh lease TTL must be longer than the source load deadline");
}
return this;
}
public enum HardMissPolicy {
NORMAL_SOURCE_LOAD,
BOUNDED_WAIT_THEN_SOURCE_LOAD
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
/**
* Optional soft-lease coordination for cache refresh admission.
*
* <p>This port reduces duplicate refresh work. It is not a correctness lock and must not protect
* domain invariants. The claim owner performs its source refresh synchronously; only a stale
* contender or a stale request facing coordination failure returns immediately with a deferral
* result.
*/
public interface CacheRefreshCoordinationPort<K> {
default boolean enabled() {
return true;
}
CacheRefreshClaimAttempt newAttempt();
CacheRefreshClaimOutcome claim(K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive);
CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt);
}
@@ -0,0 +1,62 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Opaque idempotency identity reused when a refresh-claim response is uncertain. */
public final class CacheRefreshOperationToken {
private static final CacheRefreshOperationToken UNAVAILABLE = new CacheRefreshOperationToken();
private final String value;
public CacheRefreshOperationToken(String value) {
this.value = validate(value);
}
private CacheRefreshOperationToken() {
value = null;
}
public static CacheRefreshOperationToken unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return value != null;
}
public String value() {
if (!usable()) {
throw new IllegalStateException("cache refresh operation token is unavailable");
}
return value;
}
@Override
public boolean equals(Object candidate) {
return candidate instanceof CacheRefreshOperationToken other
&& Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
@Override
public String toString() {
return usable()
? "CacheRefreshOperationToken[redacted]"
: "CacheRefreshOperationToken[unavailable]";
}
private static String validate(String value) {
if (value == null
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|| value.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"operation token must contain 16..128 URL-safe non-control characters");
}
return value;
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Opaque owner identity for one bounded cache-refresh soft lease. */
public final class CacheRefreshOwnerToken {
private static final CacheRefreshOwnerToken UNAVAILABLE = new CacheRefreshOwnerToken();
private final String value;
public CacheRefreshOwnerToken(String value) {
this.value = validate(value, "owner token");
}
private CacheRefreshOwnerToken() {
value = null;
}
public static CacheRefreshOwnerToken unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return value != null;
}
public String value() {
if (!usable()) {
throw new IllegalStateException("cache refresh owner token is unavailable");
}
return value;
}
@Override
public boolean equals(Object candidate) {
return candidate instanceof CacheRefreshOwnerToken other && Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
@Override
public String toString() {
return usable() ? "CacheRefreshOwnerToken[redacted]" : "CacheRefreshOwnerToken[unavailable]";
}
private static String validate(String value, String field) {
if (value == null
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|| value.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
field + " must contain 16..128 URL-safe non-control characters");
}
return value;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.cache;
/** Provider-neutral owner-safe release result for a cache-refresh soft lease. */
public sealed interface CacheRefreshReleaseOutcome
permits CacheRefreshReleaseOutcome.Released,
CacheRefreshReleaseOutcome.AlreadyReleased,
CacheRefreshReleaseOutcome.NotOwner,
CacheRefreshReleaseOutcome.Disabled,
CacheRefreshReleaseOutcome.Unavailable,
CacheRefreshReleaseOutcome.Indeterminate {
record Released() implements CacheRefreshReleaseOutcome {}
record AlreadyReleased() implements CacheRefreshReleaseOutcome {}
record NotOwner() implements CacheRefreshReleaseOutcome {}
record Disabled() implements CacheRefreshReleaseOutcome {}
record Unavailable() implements CacheRefreshReleaseOutcome {}
record Indeterminate() implements CacheRefreshReleaseOutcome {}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.cache;
/**
* Provider-neutral cache-region contract. Concrete use cases should extend this interface with a
* semantic port name and domain-specific key/value types.
*/
public interface CacheRegionPort<K, V> {
CacheLookup<V> lookup(K key);
CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata);
CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata);
CacheInvalidationOutcome invalidate(K key);
/**
* Makes every entry written under the previously captured region generation invisible.
*
* <p>This is a semantic mass invalidation, not a provider key scan or bulk delete.
*/
CacheInvalidationOutcome invalidateRegion();
}
@@ -0,0 +1,131 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** End-to-end cache-aside result without provider or transport types. */
public sealed interface CacheResult<V>
permits CacheResult.FreshHit,
CacheResult.NegativeHit,
CacheResult.LoadedFromSource,
CacheResult.AuthoritativeAbsent,
CacheResult.StaleFallbackAfterTransientFailure,
CacheResult.StaleRefreshDeferred,
CacheResult.SourceFailed,
CacheResult.Rejected,
CacheResult.Cancelled,
CacheResult.IncompatibleSchema {
record FreshHit<V>(V value, String sourceRevision) implements CacheResult<V> {
public FreshHit {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
}
}
record NegativeHit<V>(dev.caskeleton.application.cache.AuthoritativeAbsence reason)
implements CacheResult<V> {
public NegativeHit {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record LoadedFromSource<V>(V value, String sourceRevision, CacheRecordOutcome recordOutcome)
implements CacheResult<V> {
public LoadedFromSource {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
}
}
record AuthoritativeAbsent<V>(
dev.caskeleton.application.cache.AuthoritativeAbsence reason,
String sourceRevision,
CacheRecordOutcome recordOutcome)
implements CacheResult<V> {
public AuthoritativeAbsent {
Objects.requireNonNull(reason, "reason must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
}
}
record StaleFallbackAfterTransientFailure<V>(
V value, String sourceRevision, SourceFailure failure) implements CacheResult<V> {
public StaleFallbackAfterTransientFailure {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record StaleRefreshDeferred<V>(V value, String sourceRevision, RefreshDeferralReason reason)
implements CacheResult<V> {
public StaleRefreshDeferred {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record SourceFailed<V>(SourceFailure failure, SourceFailureKind kind) implements CacheResult<V> {
public SourceFailed {
Objects.requireNonNull(failure, "failure must be non-null");
Objects.requireNonNull(kind, "kind must be non-null");
}
}
record Rejected<V>(RejectionReason reason) implements CacheResult<V> {
public Rejected {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record Cancelled<V>() implements CacheResult<V> {}
record IncompatibleSchema<V>(CacheLookup.SchemaCategory category, CacheLookup.SchemaPolicy policy)
implements CacheResult<V> {
public IncompatibleSchema {
Objects.requireNonNull(category, "category must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
}
}
enum SourceFailureKind {
TRANSIENT,
PERMANENT
}
enum RejectionReason {
MAXIMUM_IN_FLIGHT_KEYS,
MAXIMUM_WAITERS,
SOURCE_OVERLOADED,
WAIT_TIMEOUT,
LOAD_TIMEOUT
}
enum RefreshDeferralReason {
CONTENDED,
COORDINATION_UNAVAILABLE,
COORDINATION_INDETERMINATE
}
private static void requireSourceRevision(String sourceRevision) {
if (sourceRevision == null
|| sourceRevision.isBlank()
|| sourceRevision.length() > 128
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 non-control characters");
}
}
}
@@ -0,0 +1,210 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** Bounded, synchronous local coalescing for one semantic cache region. */
public final class CacheSingleFlight<K, V> {
private static final int MAXIMUM_BOUND = 4096;
private final int maximumInFlightKeys;
private final int maximumWaitersPerKey;
private final LongSupplier monotonicTicker;
private final Map<K, Flight<V>> flights = new HashMap<>();
public CacheSingleFlight(int maximumInFlightKeys, int maximumWaitersPerKey) {
this(maximumInFlightKeys, maximumWaitersPerKey, System::nanoTime);
}
CacheSingleFlight(
int maximumInFlightKeys, int maximumWaitersPerKey, LongSupplier monotonicTicker) {
if (maximumInFlightKeys < 1 || maximumInFlightKeys > MAXIMUM_BOUND) {
throw new IllegalArgumentException("maximumInFlightKeys must be in 1.." + MAXIMUM_BOUND);
}
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_BOUND) {
throw new IllegalArgumentException("maximumWaitersPerKey must be in 0.." + MAXIMUM_BOUND);
}
this.maximumInFlightKeys = maximumInFlightKeys;
this.maximumWaitersPerKey = maximumWaitersPerKey;
this.monotonicTicker =
Objects.requireNonNull(monotonicTicker, "monotonicTicker must be non-null");
}
public Outcome<V> execute(K key, Duration waiterTimeout, Supplier<V> leaderAction) {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(waiterTimeout, "waiterTimeout must be non-null");
Objects.requireNonNull(leaderAction, "leaderAction must be non-null");
if (waiterTimeout.isNegative()) {
throw new IllegalArgumentException("waiterTimeout must be non-negative");
}
Flight<V> flight;
boolean leader;
synchronized (flights) {
long monotonicNow = monotonicTicker.getAsLong();
flight = flights.get(key);
if (flight != null && flight.isAbandonedAt(monotonicNow)) {
flights.remove(key, flight);
flight = null;
}
if (flight == null) {
if (flights.size() >= maximumInFlightKeys) {
flights.values().removeIf(candidate -> candidate.isAbandonedAt(monotonicNow));
if (flights.size() >= maximumInFlightKeys) {
return new Rejected<>(RejectionReason.MAXIMUM_IN_FLIGHT_KEYS);
}
}
flight = new Flight<>(deadlineFrom(monotonicNow, waiterTimeout));
flights.put(key, flight);
leader = true;
} else {
leader = false;
}
}
if (!leader) {
return await(flight, waiterTimeout);
}
try {
V value = Objects.requireNonNull(leaderAction.get(), "leader result must be non-null");
flight.result.complete(value);
return new Completed<>(value);
} catch (RuntimeException | Error failure) {
flight.result.completeExceptionally(failure);
throw failure;
} finally {
synchronized (flights) {
flights.remove(key, flight);
}
}
}
int inFlightCount() {
synchronized (flights) {
return flights.size();
}
}
int waiterCount(K key) {
synchronized (flights) {
Flight<V> flight = flights.get(key);
return flight == null ? 0 : flight.waiters.get();
}
}
private Outcome<V> await(Flight<V> flight, Duration waiterTimeout) {
if (Thread.currentThread().isInterrupted()) {
return new Interrupted<>();
}
if (!acquireWaiter(flight)) {
return new Rejected<>(RejectionReason.MAXIMUM_WAITERS);
}
try {
return new Completed<>(
flight.result.get(toNanosSaturated(waiterTimeout), TimeUnit.NANOSECONDS));
} catch (TimeoutException timeout) {
return new Rejected<>(RejectionReason.WAIT_TIMEOUT);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new Interrupted<>();
} catch (ExecutionException execution) {
return rethrow(execution.getCause());
} finally {
flight.waiters.decrementAndGet();
}
}
private boolean acquireWaiter(Flight<V> flight) {
while (true) {
int current = flight.waiters.get();
if (current >= maximumWaitersPerKey) {
return false;
}
if (flight.waiters.compareAndSet(current, current + 1)) {
return true;
}
}
}
private static long toNanosSaturated(Duration duration) {
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static long deadlineFrom(long monotonicNow, Duration timeout) {
long timeoutNanos = toNanosSaturated(timeout);
if (timeoutNanos == Long.MAX_VALUE) {
return Long.MAX_VALUE;
}
try {
return Math.addExact(monotonicNow, timeoutNanos);
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static <T> Outcome<T> rethrow(Throwable cause) {
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException(
"single-flight completed with an unexpected checked failure", cause);
}
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
public record Completed<T>(T value) implements Outcome<T> {
public Completed {
Objects.requireNonNull(value, "value must be non-null");
}
}
public record Rejected<T>(RejectionReason reason) implements Outcome<T> {
public Rejected {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
public record Interrupted<T>() implements Outcome<T> {}
public enum RejectionReason {
MAXIMUM_IN_FLIGHT_KEYS,
MAXIMUM_WAITERS,
WAIT_TIMEOUT
}
private static final class Flight<T> {
private final CompletableFuture<T> result = new CompletableFuture<>();
private final AtomicInteger waiters = new AtomicInteger();
private final long abandonAtNanos;
private Flight(long abandonAtNanos) {
this.abandonAtNanos = abandonAtNanos;
}
private boolean isAbandonedAt(long monotonicNow) {
return abandonAtNanos != Long.MAX_VALUE
&& monotonicNow - abandonAtNanos >= 0
&& !result.isDone();
}
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/** Bounded source concurrency admission shared by every key in one semantic cache region. */
public final class CacheSourceBulkhead {
private final Semaphore permits;
public CacheSourceBulkhead(int maximumConcurrentLoads) {
if (maximumConcurrentLoads < 1) {
throw new IllegalArgumentException("maximumConcurrentLoads must be positive");
}
permits = new Semaphore(maximumConcurrentLoads, true);
}
public <T> Outcome<T> execute(Duration admissionWait, Supplier<T> action) {
Objects.requireNonNull(admissionWait, "admissionWait must be non-null");
Objects.requireNonNull(action, "action must be non-null");
if (admissionWait.isNegative()) {
throw new IllegalArgumentException("admissionWait must be non-negative");
}
if (Thread.currentThread().isInterrupted()) {
return new Interrupted<>();
}
boolean acquired;
try {
acquired = permits.tryAcquire(toNanosSaturated(admissionWait), TimeUnit.NANOSECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new Interrupted<>();
}
if (!acquired) {
return new Rejected<>();
}
try {
return new Completed<>(
Objects.requireNonNull(action.get(), "source action result must be non-null"));
} finally {
permits.release();
}
}
private static long toNanosSaturated(Duration duration) {
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
public record Completed<T>(T value) implements Outcome<T> {
public Completed {
Objects.requireNonNull(value, "value must be non-null");
}
}
public record Rejected<T>() implements Outcome<T> {}
public record Interrupted<T>() implements Outcome<T> {}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Loads one semantic cache key from its authoritative source. */
@FunctionalInterface
public interface CacheSourceLoader<K, V> {
SourceLoadOutcome<V> load(K key, CacheCancellationToken cancellation);
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.cache;
import java.nio.charset.StandardCharsets;
/**
* Opaque provider snapshot captured by lookup and returned unchanged when recording a source load.
*
* <p>The application coordinates the token but never parses provider generation or revision
* details.
*/
public record CacheWriteCondition(String value) {
private static final String UNAVAILABLE_VALUE = "write-condition-unavailable";
private static final int MAXIMUM_BYTES = 512;
public CacheWriteCondition {
if (value == null
|| value.isBlank()
|| value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_BYTES) {
throw new IllegalArgumentException(
"cache write condition must contain a bounded opaque value of 1..512 UTF-8 bytes");
}
}
public static CacheWriteCondition unavailable() {
return new CacheWriteCondition(UNAVAILABLE_VALUE);
}
public boolean usable() {
return !UNAVAILABLE_VALUE.equals(value);
}
@Override
public String toString() {
return "CacheWriteCondition[REDACTED]";
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.cache;
/** Explicit no-op cache diagnostics adapter used when no metrics backend is installed. */
public final class DisabledCacheObservationPort implements CacheObservationPort {
private static final DisabledCacheObservationPort INSTANCE = new DisabledCacheObservationPort();
private DisabledCacheObservationPort() {}
public static DisabledCacheObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(CacheObservationEvent event) {
// Diagnostics must never change cache semantics.
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
/** Explicit no-op refresh coordinator for deployments that disable distributed soft leases. */
public final class DisabledCacheRefreshCoordinationPort<K>
implements CacheRefreshCoordinationPort<K> {
private static final DisabledCacheRefreshCoordinationPort<?> INSTANCE =
new DisabledCacheRefreshCoordinationPort<>();
private DisabledCacheRefreshCoordinationPort() {}
@SuppressWarnings("unchecked")
public static <K> DisabledCacheRefreshCoordinationPort<K> instance() {
return (DisabledCacheRefreshCoordinationPort<K>) INSTANCE;
}
@Override
public boolean enabled() {
return false;
}
@Override
public CacheRefreshClaimAttempt newAttempt() {
return CacheRefreshClaimAttempt.unavailable();
}
@Override
public CacheRefreshClaimOutcome claim(
K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
return new CacheRefreshClaimOutcome.Disabled();
}
@Override
public CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt) {
return new CacheRefreshReleaseOutcome.Disabled();
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/**
* Bounded source failure classification with its original cause preserved for the application
* boundary. The cause message must not be copied into cache state, metrics, or tags.
*/
public record SourceFailure(String code, Throwable cause) {
public SourceFailure {
if (code == null || !code.matches("[A-Z][A-Z0-9_]{0,63}")) {
throw new IllegalArgumentException("code must be a bounded uppercase failure code");
}
Objects.requireNonNull(cause, "cause must be non-null");
}
@Override
public String toString() {
return "SourceFailure[code=" + code + ']';
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Business-classified result of consulting a cache region's authoritative source. */
public sealed interface SourceLoadOutcome<V>
permits SourceLoadOutcome.Loaded,
SourceLoadOutcome.AuthoritativeAbsent,
SourceLoadOutcome.TransientFailure,
SourceLoadOutcome.PermanentFailure,
SourceLoadOutcome.Cancelled {
record Loaded<V>(V value, String sourceRevision) implements SourceLoadOutcome<V> {
public Loaded {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
}
}
record AuthoritativeAbsent<V>(
dev.caskeleton.application.cache.AuthoritativeAbsence reason, String sourceRevision)
implements SourceLoadOutcome<V> {
public AuthoritativeAbsent {
Objects.requireNonNull(reason, "reason must be non-null");
requireSourceRevision(sourceRevision);
}
}
record TransientFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
public TransientFailure {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record PermanentFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
public PermanentFailure {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record Cancelled<V>() implements SourceLoadOutcome<V> {}
private static void requireSourceRevision(String sourceRevision) {
if (sourceRevision == null
|| sourceRevision.isBlank()
|| sourceRevision.length() > 128
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 non-control characters");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.capability;
/**
* Idempotency contract a write use case declares — the basis downstream contracts (rate-limit,
* idempotency-key, retry) build on. See README.
*/
public enum Idempotency {
/** Safe to retry with the same input — same effect, same result. */
IDEMPOTENT,
/** Safe to retry only after applying an idempotency key (e.g. de-dup table). */
KEYED,
/** Repeating the operation produces a new effect (create / append / charge). */
NOT_IDEMPOTENT
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.capability;
/**
* Declares the repository access a use case is allowed to perform. Paired with {@link
* dev.caskeleton.application.transaction.TransactionMode} so an undeclared read→write upgrade is
* caught by review and fitness functions.
*/
public enum RepositoryAccess {
/** Use case does not touch any repository port. */
NONE,
/** Use case may only call read methods of repository ports. */
READ_REPOSITORY,
/** Use case may call read and write methods of repository ports. */
WRITE_REPOSITORY
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.capability;
import dev.caskeleton.application.transaction.TransactionMode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Mandatory capability contract attached to every concrete {@code *UseCase} class: it makes the
* transactional shape, idempotency, repository access, and outbound-side-effect surface readable
* without inspecting the body. ArchUnit enforces its presence and the coherence rules. See README
* for the coherence rules and the {@code capabilities.yaml} registry mapping.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UseCaseCapability {
TransactionMode transactionMode();
Idempotency idempotency();
RepositoryAccess repositoryAccess();
boolean externalOutboundAllowed() default false;
/** Declares the use case reads sensitive fields (PII / credentials / secrets). See README. */
boolean sensitiveRead() default false;
/** Declares a bulk write (>100 rows/batch); requires {@code WRITE_REPOSITORY}. See README. */
boolean bulkWrite() default false;
/** Declares the use case crosses tenant boundaries (an admin operation). See README. */
boolean crossTenantAdmin() default false;
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.command;
/**
* Marker for application command contracts (write intents). A {@code Command} is a plain immutable
* type (preferably a {@code record}) of domain/primitive values. See README for the forbidden field
* types.
*/
public interface Command {}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.fileexport;
/**
* Immutable receipt for a file written through {@link FileExportPort}. Framework-neutral value
* object (no Spring / IO types) so the application layer stays decoupled from the export backend.
*
* @param fileName the name the file was written under (never null/blank)
* @param path the absolute path the file was written to (never null/blank)
* @param byteSize the written file size in bytes (never negative)
* @param rowCount the number of data rows written, excluding the header (never negative)
*/
public record ExportedFile(String fileName, String path, long byteSize, long rowCount) {
public ExportedFile {
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("ExportedFile.fileName must be non-null and non-blank");
}
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("ExportedFile.path must be non-null and non-blank");
}
if (byteSize < 0) {
throw new IllegalArgumentException(
"ExportedFile.byteSize must be non-negative, was " + byteSize);
}
if (rowCount < 0) {
throw new IllegalArgumentException(
"ExportedFile.rowCount must be non-negative, was " + rowCount);
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.fileexport;
import java.util.List;
/**
* Outbound port for exporting tabular data as a delimited file — the "file server" boundary (a
* stand-in for an NFS mount, shared file server, or SFTP drop). The application layer hands over
* plain strings, so use cases stay decoupled from the export format and the destination filesystem.
* The adapter is selected by configuration ({@code app.file-export}); see the {@code
* adapter:outbound:fileserver} README for the on-disk layout and the CSV-escaping contract.
*
* <p>The contract is deliberately domain-neutral: no framework or domain type crosses it. A caller
* supplies a bare {@code fileName}, an optional {@code header} row, and the data {@code rows} as
* lists of already-stringified field values; the adapter owns file placement, RFC-4180 escaping,
* and byte encoding, and returns an {@link ExportedFile} receipt.
*/
public interface FileExportPort {
/**
* Writes {@code header} + {@code rows} as a CSV file named {@code fileName} under the adapter's
* configured base directory, overwriting any existing file at that name.
*
* <p>Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage
* return, or line feed is wrapped in double-quotes with embedded quotes doubled. A {@code null}
* field is written as an empty field. The file is UTF-8 encoded.
*
* @param fileName the target file name (not a path); must be non-null and non-blank and must not
* escape the base directory (no path separators that resolve outside it)
* @param header the column names written as the first line; must be non-null (may be empty, in
* which case no header line is written)
* @param rows the data rows, each a list of field values in column order; must be non-null (may
* be empty); individual field values may be {@code null}
* @return an {@link ExportedFile} receipt (file name, absolute path, byte size, row count)
* @throws IllegalArgumentException if {@code fileName} is blank or escapes the base directory
*/
ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows);
}
@@ -0,0 +1,65 @@
package dev.caskeleton.application.filepublication;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Ordered, versioned schema for tabular publication. */
public record ExportSchema(String schemaId, int version, List<Column> columns) {
public ExportSchema {
schemaId = FilePublicationValues.requireOpaque("schemaId", schemaId, 128);
if (version < 1) {
throw new IllegalArgumentException("schema version must be >= 1");
}
if (columns == null || columns.isEmpty()) {
throw new IllegalArgumentException("schema columns must be non-empty");
}
columns = List.copyOf(columns);
Set<String> names = new HashSet<>();
for (Column column : columns) {
Objects.requireNonNull(column, "schema column must be non-null");
if (!names.add(column.name())) {
throw new IllegalArgumentException("duplicate schema column: " + column.name());
}
}
}
public record Column(
String name,
CellType cellType,
boolean nullable,
FormulaPolicy formulaPolicy,
int maximumUtf8Bytes) {
public Column {
name = FilePublicationValues.requireOpaque("column name", name, 128);
// The formula policy governs cell values; the header row had no policy at all, so a column
// named "=cmd|'/c calc'!A1" was written verbatim and executed by the spreadsheet that opened
// the export. Rejecting rather than mitigating is deliberate: prefixing a header with a quote
// would silently rename the column and break whatever parses it downstream.
FilePublicationValues.requireNotFormulaShaped("column name", name);
Objects.requireNonNull(cellType, "cellType must be non-null");
Objects.requireNonNull(formulaPolicy, "formulaPolicy must be non-null");
if (maximumUtf8Bytes < 1) {
throw new IllegalArgumentException("maximumUtf8Bytes must be >= 1");
}
}
}
public enum CellType {
TEXT,
INTEGER,
DECIMAL,
BOOLEAN,
DATE,
INSTANT
}
public enum FormulaPolicy {
ALLOW,
MITIGATE,
REJECT
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.filepublication;
/** Registered logical file destination; never a path, URI, host, or provider identifier. */
public record FileDestinationId(String value) {
public FileDestinationId {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException("destinationId must match [a-z][a-z0-9-]{0,62}");
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.filepublication;
/**
* Provider-neutral publication failure. The reason is stable application-facing vocabulary; paths,
* credentials, and provider exception messages must not be embedded in it.
*/
public final class FilePublicationException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final Reason reason;
public FilePublicationException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public FilePublicationException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
public enum Reason {
INVALID_REQUEST,
CONFLICT,
CAPACITY_EXCEEDED,
UNAVAILABLE,
CANCELLED,
PUBLISH_INDETERMINATE
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.filepublication;
/**
* Outbound application port for publishing a bounded tabular artifact to a registered logical
* destination.
*/
public interface FilePublicationPort {
FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
}
@@ -0,0 +1,45 @@
package dev.caskeleton.application.filepublication;
final class FilePublicationValues {
private FilePublicationValues() {}
static String requireOpaque(String field, String value, int maximumLength) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must be non-null and non-blank");
}
String normalized = value.trim();
if (normalized.length() > maximumLength) {
throw new IllegalArgumentException(field + " exceeds " + maximumLength + " characters");
}
if (normalized.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must not contain control characters");
}
return normalized;
}
/**
* Refuses a value a spreadsheet would evaluate as a formula.
*
* <p>A CSV is data to the producer and a program to Excel, LibreOffice and Sheets: a field
* starting with {@code =}, {@code +}, {@code -}, {@code @}, a tab or a carriage return is
* evaluated on open, and {@code =cmd|'/c calc'!A1} is a remote-code-execution vector against
* whoever opens the report. Escaping belongs to cell values, where a leading quote is invisible;
* for identifiers such as a column name there is nothing to escape into, so it is refused.
*/
static void requireNotFormulaShaped(String field, String value) {
if (value.isEmpty()) {
return;
}
char first = value.charAt(0);
if (first == '='
|| first == '+'
|| first == '-'
|| first == '@'
|| first == '\t'
|| first == '\r') {
throw new IllegalArgumentException(
field + " must not start with a spreadsheet formula character (= + - @ tab CR)");
}
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Stable opaque operation identity retained across resolution and retry. */
public record FilePublishOperationId(String value) {
public FilePublishOperationId {
value = FilePublicationValues.requireOpaque("operationId", value, 128);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.filepublication;
import java.time.Instant;
import java.util.Objects;
/** Sanitized publication receipt. It intentionally contains no local/remote path or credential. */
public record FilePublishReceipt(
FilePublishOperationId operationId,
PublishedFileReference reference,
FileDestinationId destinationId,
String publishedFileName,
FileVersion version,
String formatProfileId,
String mediaType,
String charset,
long byteSize,
long dataRowCount,
int columnCount,
String sha256,
Instant publishedAt,
PublicationGuarantee publicationGuarantee,
DurabilityGuarantee durabilityGuarantee,
long formulaMitigatedCount) {
public FilePublishReceipt {
Objects.requireNonNull(operationId, "operationId must be non-null");
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(destinationId, "destinationId must be non-null");
publishedFileName =
FilePublicationValues.requireOpaque("publishedFileName", publishedFileName, 256);
Objects.requireNonNull(version, "version must be non-null");
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
mediaType = FilePublicationValues.requireOpaque("mediaType", mediaType, 128);
charset = FilePublicationValues.requireOpaque("charset", charset, 64);
sha256 = FilePublicationValues.requireOpaque("sha256", sha256, 64);
Objects.requireNonNull(publishedAt, "publishedAt must be non-null");
Objects.requireNonNull(publicationGuarantee, "publicationGuarantee must be non-null");
Objects.requireNonNull(durabilityGuarantee, "durabilityGuarantee must be non-null");
if (byteSize < 0 || dataRowCount < 0 || columnCount < 1 || formulaMitigatedCount < 0) {
throw new IllegalArgumentException("receipt counts and sizes are out of range");
}
}
public enum PublicationGuarantee {
UNIQUE_ATOMIC_CREATE
}
public enum DurabilityGuarantee {
PROCESS_LOCAL_SYNC,
FILE_AND_DIRECTORY_SYNC,
PROVIDER_ACK_ONLY
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.filepublication;
import java.util.Objects;
/** Provider-neutral publication intent. */
public record FilePublishRequest(
FilePublishOperationId operationId,
FileDestinationId destinationId,
LogicalFileName logicalFileName,
SourceRevision sourceRevision,
ExportSchema schema,
String formatProfileId) {
public FilePublishRequest {
Objects.requireNonNull(operationId, "operationId must be non-null");
Objects.requireNonNull(destinationId, "destinationId must be non-null");
Objects.requireNonNull(logicalFileName, "logicalFileName must be non-null");
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
Objects.requireNonNull(schema, "schema must be non-null");
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Opaque immutable version of a published artifact. */
public record FileVersion(String value) {
public FileVersion {
value = FilePublicationValues.requireOpaque("fileVersion", value, 128);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.filepublication;
/** Display/naming input that cannot carry filesystem path syntax. */
public record LogicalFileName(String value) {
public LogicalFileName {
value = FilePublicationValues.requireOpaque("logicalFileName", value, 128);
if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) {
throw new IllegalArgumentException("logicalFileName must not contain path syntax");
}
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Opaque reference that never exposes a filesystem path, host, or provider location. */
public record PublishedFileReference(String value) {
public PublishedFileReference {
value = FilePublicationValues.requireOpaque("publishedFileReference", value, 256);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Stable source snapshot or source fingerprint selected by the application. */
public record SourceRevision(String value) {
public SourceRevision {
value = FilePublicationValues.requireOpaque("sourceRevision", value, 256);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.application.filepublication;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
/** Closed framework-free set of cells supported by the baseline tabular publisher. */
public sealed interface TabularCell {
ExportSchema.CellType cellType();
record TextCell(String value) implements TabularCell {
public TextCell {
Objects.requireNonNull(value, "text value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.TEXT;
}
}
record IntegerCell(long value) implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.INTEGER;
}
}
record DecimalCell(BigDecimal value) implements TabularCell {
public DecimalCell {
Objects.requireNonNull(value, "decimal value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.DECIMAL;
}
}
record BooleanCell(boolean value) implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.BOOLEAN;
}
}
record DateCell(LocalDate value) implements TabularCell {
public DateCell {
Objects.requireNonNull(value, "date value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.DATE;
}
}
record InstantCell(Instant value) implements TabularCell {
public InstantCell {
Objects.requireNonNull(value, "instant value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.INSTANT;
}
}
record NullCell() implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
throw new IllegalStateException("null cells do not have a concrete cell type");
}
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.filepublication;
import java.util.List;
import java.util.Objects;
/** Immutable ordered row. */
public record TabularRow(List<TabularCell> cells) {
public TabularRow {
Objects.requireNonNull(cells, "cells must be non-null");
cells = List.copyOf(cells);
if (cells.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("cells must not contain null; use NullCell");
}
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.filepublication;
/** Synchronous single-attempt producer for bounded row-by-row publication. */
@FunctionalInterface
public interface TabularRowProducer {
void produce(TabularRowSink sink);
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Attempt-scoped non-thread-safe sink owned by the file publication adapter. */
public interface TabularRowSink {
void write(TabularRow row);
void checkpoint();
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.fileserver.admin;
/**
* Durable sink for administrative actions.
*
* <p>Every mutating admin operation writes here, including the ones that failed: a rejected
* force-delete attempt is exactly the event a reviewer most wants to see.
*/
public interface AdminAuditPort {
void record(AdminAuditRecord record);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.fileserver.admin;
import java.time.Instant;
import java.util.Objects;
/**
* One administrative action, recorded for review.
*
* <p>{@code actorFingerprint} is a stable pseudonym rather than a principal id, and no path, mount,
* filename, or raw scanner response ever appears. An audit trail that leaked those would become a
* second copy of exactly the data the rest of the design refuses to disclose.
*/
public record AdminAuditRecord(
String operation,
String reasonCode,
String actorFingerprint,
String traceId,
boolean succeeded,
String subjectId,
Instant occurredAt) {
public AdminAuditRecord {
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(reasonCode, "reasonCode");
Objects.requireNonNull(actorFingerprint, "actorFingerprint");
Objects.requireNonNull(traceId, "traceId");
Objects.requireNonNull(subjectId, "subjectId");
Objects.requireNonNull(occurredAt, "occurredAt");
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.ContentKey;
/**
* Answers whether any record still claims a physical object.
*
* <p>An orphan scan walks storage and must decide, per object, whether deleting it would destroy
* live data. That decision belongs to the metadata side, and it is deliberately the only question
* the scan is allowed to ask: a scan that could read records would be tempted to reconstruct them.
*/
@FunctionalInterface
public interface ContentReferenceLedger {
/** True when a file record names {@code key}, in any state. */
boolean isReferenced(ContentKey key);
}
@@ -0,0 +1,270 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.error.FileNotFoundException;
import dev.caskeleton.application.fileserver.api.error.FileNotReadyException;
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.FileRecordMutation;
import dev.caskeleton.application.fileserver.api.metadata.UploadSession;
import dev.caskeleton.application.fileserver.api.metadata.UploadSessionStore;
import dev.caskeleton.application.fileserver.api.security.FileAccessPolicy;
import dev.caskeleton.application.fileserver.api.security.FileOperation;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
import dev.caskeleton.application.fileserver.cleanup.CleanupQueue;
import dev.caskeleton.application.fileserver.cleanup.CleanupRequest;
import dev.caskeleton.application.fileserver.cleanup.CleanupService;
import dev.caskeleton.application.fileserver.cleanup.CleanupType;
import dev.caskeleton.application.fileserver.observability.SafeFileFingerprint;
import dev.caskeleton.application.fileserver.upload.FileView;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* The management plane, with its own authority and its own audit trail.
*
* <p>Two rules shape everything here. A reconcile is a dry run unless the caller explicitly opted
* out <em>and</em> echoed the fingerprints it was shown, so a stale scan can never turn into a mass
* delete. And every mutating action is audited whether it succeeded or not, because a refused
* force-delete is exactly the event a reviewer needs to see.
*/
public final class DefaultFileserverAdminService implements FileserverAdminService {
private static final int MAXIMUM_ADMIN_PAGE = 1000;
private final StorageHealthPort healthPort;
private final OrphanScanPort orphanScanPort;
private final FileMetadataStore metadataStore;
private final UploadSessionStore sessionStore;
private final CleanupQueue cleanupQueue;
private final CleanupService cleanupService;
private final FileAccessPolicy accessPolicy;
private final AdminAuditPort auditPort;
private final SafeFileFingerprint fingerprint;
private final TransactionPort transactions;
private final Clock clock;
public DefaultFileserverAdminService(
StorageHealthPort healthPort,
OrphanScanPort orphanScanPort,
FileMetadataStore metadataStore,
UploadSessionStore sessionStore,
CleanupQueue cleanupQueue,
CleanupService cleanupService,
FileAccessPolicy accessPolicy,
AdminAuditPort auditPort,
SafeFileFingerprint fingerprint,
TransactionPort transactions,
Clock clock) {
this.healthPort = healthPort;
this.orphanScanPort = orphanScanPort;
this.metadataStore = metadataStore;
this.sessionStore = sessionStore;
this.cleanupQueue = cleanupQueue;
this.cleanupService = cleanupService;
this.accessPolicy = accessPolicy;
this.auditPort = auditPort;
this.fingerprint = fingerprint;
this.transactions = transactions;
this.clock = clock;
}
@Override
public StorageHealthReport storageHealth(RequestContext context) {
authorizeRead(context);
return healthPort.health();
}
@Override
public RuntimeCapabilityReport capabilities(RequestContext context) {
authorizeRead(context);
return healthPort.capabilities();
}
@Override
public List<OrphanObject> orphans(int limit, RequestContext context) {
authorizeRead(context);
return orphanScanPort.scan(boundedLimit(limit));
}
@Override
public OrphanReconcileReport reconcileOrphans(
OrphanReconcileCommand command, RequestContext context) {
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
List<OrphanObject> candidates = orphanScanPort.scan(boundedLimit(command.limit()));
if (command.dryRun()) {
audit("orphans:reconcile", command.reasonCode(), context, true, "dry-run");
return new OrphanReconcileReport(true, candidates, 0, 0, 0);
}
int deleted = 0;
int mismatched = 0;
long reclaimed = 0;
for (OrphanObject candidate : candidates) {
if (!command.expectedFingerprints().contains(candidate.fingerprint())) {
mismatched++;
continue;
}
if (reclaimed + candidate.sizeBytes() > command.maxBytes()) {
break;
}
if (orphanScanPort.deleteIfFingerprintMatches(
candidate.contentKey(), candidate.fingerprint())) {
// Retirement moved the object to quarantine; the durable record of that intent is queued
// immediately after. Without it a node that dies here leaves an object nobody is looking
// for, in an area nothing scans.
transactions.inWrite(
() -> cleanupQueue.enqueue(CleanupRequest.forOrphan(candidate.contentKey())));
deleted++;
reclaimed += candidate.sizeBytes();
} else {
mismatched++;
}
}
audit("orphans:reconcile", command.reasonCode(), context, true, "apply");
return new OrphanReconcileReport(false, candidates, deleted, mismatched, reclaimed);
}
@Override
public FileView reverify(FileId fileId, RequestContext context) {
accessPolicy.authorize(FileOperation.ADMIN_REVERIFY, context.subject(), Optional.empty());
FileRecord record = requireRecord(fileId);
if (record.state() != FileState.QUARANTINED) {
audit("files:reverify", "REVERIFY_REJECTED", context, false, fileId.canonicalText());
throw new FileNotReadyException(
"only a quarantined file can be re-verified",
FileserverFailureContext.forFileState(
FileserverErrorCode.FILE_NOT_READY, fileId, record.state(), false));
}
FileRecord verifying =
transactions.inWrite(
() ->
metadataStore.transition(
record.fileId(),
record.version(),
FileState.QUARANTINED,
FileState.VERIFYING,
FileRecordMutation.none()));
audit("files:reverify", "OPERATOR_REVERIFY", context, true, fileId.canonicalText());
return FileView.of(verifying.toDescriptor());
}
@Override
public void forceDelete(ForceDeleteCommand command, RequestContext context) {
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
FileRecord record = requireRecord(command.fileId());
// An operator force-delete must not be able to leave a file unreachable with nothing queued to
// reclaim its bytes, so retiring the record and queueing the content commit together.
transactions.inWrite(
() -> {
FileRecord deleting = metadataStore.markDeleting(record.fileId(), record.version());
deleting
.contentKey()
.ifPresent(
key ->
cleanupQueue.enqueue(
CleanupRequest.forContent(
CleanupType.DELETED_READY_CONTENT, deleting.fileId(), key)));
});
audit(
"files:force-delete",
command.reasonCode(),
context,
true,
command.fileId().canonicalText());
}
@Override
public List<IncompleteUploadView> incompleteUploads(int limit, RequestContext context) {
authorizeRead(context);
List<IncompleteUploadView> views = new ArrayList<>();
for (UploadSession session : sessionStore.findExpired(clock.instant(), boundedLimit(limit))) {
views.add(
new IncompleteUploadView(
session.uploadId(),
session.fileId(),
session.committedOffset(),
session.expiresAt(),
session.leaseOwner(),
session.leaseUntil()));
}
return List.copyOf(views);
}
@Override
public CleanupBatchResult cleanupUploads(int maxItems, long maxBytes, RequestContext context) {
accessPolicy.authorize(FileOperation.ADMIN_FORCE_DELETE, context.subject(), Optional.empty());
CleanupBatchResult result = cleanupService.runBatch(boundedLimit(maxItems), maxBytes);
audit("uploads:cleanup", "OPERATOR_CLEANUP", context, true, "batch");
return result;
}
private void authorizeRead(RequestContext context) {
accessPolicy.authorize(FileOperation.READ_METADATA, context.subject(), Optional.empty());
}
/**
* Caps every admin query.
*
* <p>An unbounded admin listing is a self-inflicted outage: it walks production storage or the
* whole metadata table on an operator's keystroke.
*/
private static int boundedLimit(int requested) {
return Math.max(1, Math.min(requested, MAXIMUM_ADMIN_PAGE));
}
private void audit(
String operation,
String reasonCode,
RequestContext context,
boolean succeeded,
String subjectId) {
auditPort.record(
new AdminAuditRecord(
operation,
reasonCode,
actorFingerprint(context),
context.traceId(),
succeeded,
subjectFingerprint(subjectId),
clock.instant()));
}
/**
* Stable pseudonym for the acting operator.
*
* <p>An audit trail needs to correlate actions by the same actor without becoming a second
* directory of who works here, so the principal is reduced to a fingerprint.
*
* <p>A keyed HMAC, not {@code hashCode()}. A 32-bit unkeyed hash over an enumerable identifier
* space is reversible with a laptop and collides often enough that two operators can share a
* pseudonym — which is worse than no pseudonym, because the trail then reads as if one person did
* both things.
*/
private String actorFingerprint(RequestContext context) {
return fingerprint.of(context.subject().principalId());
}
/** The same reduction for the object of the action; a raw file id is a disclosure too. */
private String subjectFingerprint(String subjectId) {
return subjectId.isBlank() ? subjectId : fingerprint.of(subjectId);
}
private FileRecord requireRecord(FileId fileId) {
return metadataStore
.find(fileId)
.orElseThrow(
() ->
new FileNotFoundException(
"file record does not exist",
FileserverFailureContext.forFile(
FileserverErrorCode.FILE_NOT_FOUND, fileId, false)));
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
import dev.caskeleton.application.fileserver.upload.FileView;
import java.util.List;
/**
* The management-plane surface.
*
* <p>Every method here is deliberately separate from the public application services: an operator
* action is authorized against a different authority, is always audited, and may see counters a
* tenant never should. Mixing them into the public services is how an admin capability ends up one
* missing check away from being publicly reachable.
*/
public interface FileserverAdminService {
StorageHealthReport storageHealth(RequestContext context);
RuntimeCapabilityReport capabilities(RequestContext context);
List<OrphanObject> orphans(int limit, RequestContext context);
OrphanReconcileReport reconcileOrphans(OrphanReconcileCommand command, RequestContext context);
FileView reverify(FileId fileId, RequestContext context);
void forceDelete(ForceDeleteCommand command, RequestContext context);
List<IncompleteUploadView> incompleteUploads(int limit, RequestContext context);
CleanupBatchResult cleanupUploads(int maxItems, long maxBytes, RequestContext context);
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.FileId;
import java.util.Objects;
/**
* Operator-forced removal of a file, bypassing the normal precondition.
*
* <p>The reason is mandatory and the caller must hold the second, force-delete-specific authority.
* A force delete that needed only the ordinary delete permission would make every operator able to
* destroy content the lifecycle rules exist to protect.
*/
public record ForceDeleteCommand(FileId fileId, String reasonCode) {
private static final int MINIMUM_REASON_LENGTH = 8;
public ForceDeleteCommand {
Objects.requireNonNull(fileId, "fileId");
Objects.requireNonNull(reasonCode, "reasonCode");
if (reasonCode.strip().length() < MINIMUM_REASON_LENGTH) {
throw new IllegalArgumentException("force delete requires an explicit reason");
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.UploadId;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Operator view of an upload that never completed.
*
* <p>The original filename is deliberately absent: an operator triaging stuck uploads needs the
* identity, the offset, and the lease, and a filename is user-supplied content that would put
* arbitrary text into an admin console.
*/
public record IncompleteUploadView(
UploadId uploadId,
FileId fileId,
long committedOffset,
Instant expiresAt,
Optional<String> leaseOwner,
Optional<Instant> leaseUntil) {
public IncompleteUploadView {
Objects.requireNonNull(uploadId, "uploadId");
Objects.requireNonNull(fileId, "fileId");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(leaseOwner, "leaseOwner");
Objects.requireNonNull(leaseUntil, "leaseUntil");
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.time.Instant;
import java.util.Objects;
/**
* A physical object with no metadata record pointing at it.
*
* <p>{@code fingerprint} is what makes an apply safe: the caller must echo the exact fingerprint it
* was shown, so an object that changed between the scan and the apply is never deleted.
*/
public record OrphanObject(
ContentKey contentKey, long sizeBytes, Instant observedAt, String fingerprint) {
public OrphanObject {
Objects.requireNonNull(contentKey, "contentKey");
Objects.requireNonNull(observedAt, "observedAt");
Objects.requireNonNull(fingerprint, "fingerprint");
if (sizeBytes < 0) {
throw new IllegalArgumentException("sizeBytes must not be negative");
}
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.fileserver.admin;
import java.util.List;
import java.util.Objects;
/**
* Request to reconcile orphaned physical objects.
*
* <p>{@code dryRun} defaults to true at every layer above this record. An apply additionally has to
* name the exact fingerprints it intends to remove and a byte budget, so a reconcile can never turn
* into an unbounded mass delete driven by a stale scan.
*/
public record OrphanReconcileCommand(
boolean dryRun,
int limit,
long maxBytes,
List<String> expectedFingerprints,
String reasonCode) {
public OrphanReconcileCommand {
Objects.requireNonNull(expectedFingerprints, "expectedFingerprints");
Objects.requireNonNull(reasonCode, "reasonCode");
if (limit < 1 || maxBytes < 1) {
throw new IllegalArgumentException("limit and maxBytes must be positive");
}
if (!dryRun && expectedFingerprints.isEmpty()) {
throw new IllegalArgumentException(
"an apply must name the fingerprints it intends to remove");
}
if (reasonCode.isBlank()) {
throw new IllegalArgumentException("reasonCode must be non-blank");
}
expectedFingerprints = List.copyOf(expectedFingerprints);
}
/** Bounded dry run, the default and the only shape a caller can reach without opting in. */
public static OrphanReconcileCommand dryRun(int limit) {
return new OrphanReconcileCommand(true, limit, Long.MAX_VALUE, List.of(), "ORPHAN_SCAN");
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.fileserver.admin;
import java.util.List;
import java.util.Objects;
/**
* Outcome of one reconcile.
*
* <p>{@code dryRun} is echoed back deliberately: an operator reading a report must never have to
* infer whether it described a plan or an action already taken.
*/
public record OrphanReconcileReport(
boolean dryRun,
List<OrphanObject> candidates,
int deleted,
int skippedFingerprintMismatch,
long reclaimedBytes) {
public OrphanReconcileReport {
Objects.requireNonNull(candidates, "candidates");
if (deleted < 0 || skippedFingerprintMismatch < 0 || reclaimedBytes < 0) {
throw new IllegalArgumentException("counters must not be negative");
}
candidates = List.copyOf(candidates);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.util.List;
/**
* Bounded scan for physical objects with no metadata record.
*
* <p>The scan is always bounded; an unbounded walk of a production content root is itself an
* availability incident.
*/
public interface OrphanScanPort {
List<OrphanObject> scan(int limit);
/**
* Retires one orphan, guarded by the fingerprint the caller was shown.
*
* <p>"Retires" rather than "deletes" on purpose. Checking that nothing references an object and
* then unlinking it is not atomic against a record committed in between, and that ordering has no
* safe variant: whichever step runs first, a live object can be destroyed with nothing left to
* restore. The implementation therefore moves the object aside reversibly and re-checks, so a
* record that appeared during the move puts the object straight back.
*
* @return true when the object was retired, false when it was left in place
*/
boolean deleteIfFingerprintMatches(ContentKey key, String expectedFingerprint);
/**
* Reclaims a retired object for good, once nothing references it.
*
* <p>Separated from retirement so the destructive step is a second, later decision rather than
* part of the same racing sequence.
*/
boolean purgeQuarantined(ContentKey key);
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.fileserver.admin;
import dev.caskeleton.application.fileserver.api.content.ContentStoreCapabilities;
import dev.caskeleton.application.fileserver.api.content.PublishMode;
import java.util.Objects;
/**
* What this deployment can actually do, as proven by the startup probe.
*
* <p>Every flag here came from a real filesystem probe rather than configuration, which is what
* makes the endpoint useful for diagnosing a misconfigured mount. No physical path appears.
*/
public record RuntimeCapabilityReport(
String storageType,
PublishMode publishMode,
ContentStoreCapabilities capabilities,
String filesystemProfile) {
public RuntimeCapabilityReport {
Objects.requireNonNull(storageType, "storageType");
Objects.requireNonNull(publishMode, "publishMode");
Objects.requireNonNull(capabilities, "capabilities");
Objects.requireNonNull(filesystemProfile, "filesystemProfile");
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.fileserver.admin;
/**
* Live capacity and probe observation.
*
* <p>Behind this port sits the same startup probe that gated the application's boot, so the admin
* answer and the startup decision can never disagree.
*/
public interface StorageHealthPort {
StorageHealthReport health();
RuntimeCapabilityReport capabilities();
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.fileserver.admin;
import java.util.List;
import java.util.Objects;
/**
* Operator view of storage capacity and probe results.
*
* <p>It reports proportions and boolean probe outcomes, never the physical root, the mount, or the
* device. An operator needs to know whether storage is healthy; disclosing where it lives only adds
* a target.
*/
public record StorageHealthReport(
long totalBytes,
long usableBytes,
double usedFraction,
boolean writable,
boolean atomicPublishProven,
String filesystemProfile,
List<String> probeWarnings) {
public StorageHealthReport {
Objects.requireNonNull(filesystemProfile, "filesystemProfile");
Objects.requireNonNull(probeWarnings, "probeWarnings");
if (totalBytes < 0 || usableBytes < 0) {
throw new IllegalArgumentException("byte counters must not be negative");
}
probeWarnings = List.copyOf(probeWarnings);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.application.fileserver.api;
/**
* Inclusive byte range over a concrete representation.
*
* <p>HTTP suffix and open-ended ranges are normalized into this value object by the transport range
* resolver, using the current representation length. The core never sees an unresolved range.
*/
public record ByteRange(long startInclusive, long endInclusive) {
public ByteRange {
if (startInclusive < 0 || endInclusive < startInclusive) {
throw new IllegalArgumentException("invalid byte range");
}
}
public static ByteRange of(long startInclusive, long endInclusive) {
return new ByteRange(startInclusive, endInclusive);
}
/** Full-representation range for a non-empty representation. */
public static ByteRange entire(long representationLength) {
if (representationLength <= 0) {
throw new IllegalArgumentException("representation length must be positive");
}
return new ByteRange(0, representationLength - 1);
}
public long length() {
return Math.addExact(Math.subtractExact(endInclusive, startInclusive), 1);
}
public boolean overlaps(ByteRange other) {
return startInclusive <= other.endInclusive && other.startInclusive <= endInclusive;
}
/** True when this range and {@code other} touch or overlap and can be merged into one range. */
public boolean isAdjacentOrOverlapping(ByteRange other) {
return overlaps(other)
|| endInclusive + 1 == other.startInclusive
|| other.endInclusive + 1 == startInclusive;
}
public ByteRange merge(ByteRange other) {
return new ByteRange(
Math.min(startInclusive, other.startInclusive), Math.max(endInclusive, other.endInclusive));
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.fileserver.api;
import java.util.regex.Pattern;
/**
* Server-generated physical content key.
*
* <p>The key is never part of the public HTTP contract and never derives from a client filename.
* The character class deliberately excludes {@code .}, so no traversal or extension-shaped segment
* can survive validation.
*/
public record ContentKey(String value) {
private static final Pattern CANONICAL = Pattern.compile("[a-z0-9/_-]{16,200}");
public ContentKey {
if (value == null || !CANONICAL.matcher(value).matches()) {
throw new IllegalArgumentException("invalid content key");
}
}
public static ContentKey of(String value) {
return new ContentKey(value);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.fileserver.api;
import java.util.Map;
import java.util.Set;
/**
* Exact transition table from the Fileserver platform design.
*
* <p>Recovery transitions out of {@link FileState#FAILED} are structurally allowed here; the
* recovery policy separately decides whether the stored {@code lastErrorCode} permits them.
*/
public final class DefaultFileStateMachine implements FileStateMachine {
private static final Map<FileState, Set<FileState>> ALLOWED =
Map.ofEntries(
Map.entry(FileState.CREATED, Set.of(FileState.UPLOADING)),
Map.entry(
FileState.UPLOADING,
Set.of(FileState.UPLOADED, FileState.FAILED, FileState.EXPIRED, FileState.DELETING)),
Map.entry(
FileState.UPLOADED,
Set.of(FileState.VERIFYING, FileState.FAILED, FileState.DELETING)),
Map.entry(
FileState.VERIFYING,
Set.of(FileState.READY, FileState.QUARANTINED, FileState.REJECTED, FileState.FAILED)),
Map.entry(
FileState.QUARANTINED,
Set.of(FileState.VERIFYING, FileState.READY, FileState.REJECTED, FileState.DELETING)),
Map.entry(FileState.READY, Set.of(FileState.DELETING)),
Map.entry(FileState.REJECTED, Set.of(FileState.DELETING)),
Map.entry(
FileState.FAILED,
Set.of(
FileState.UPLOADING, FileState.VERIFYING, FileState.DELETING, FileState.EXPIRED)),
Map.entry(FileState.DELETING, Set.of(FileState.DELETED, FileState.FAILED)),
Map.entry(FileState.EXPIRED, Set.of(FileState.DELETING)),
Map.entry(FileState.DELETED, Set.of()));
@Override
public boolean canTransition(FileState current, FileState target) {
if (current == null || target == null) {
return false;
}
return ALLOWED.getOrDefault(current, Set.of()).contains(target);
}
@Override
public void requireTransition(FileState current, FileState target) {
if (!canTransition(current, target)) {
throw new IllegalStateException("illegal file transition: " + current + " -> " + target);
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.fileserver.api;
import java.util.Objects;
import java.util.UUID;
/**
* Opaque public identity of a stored file.
*
* <p>The value is hard to guess but is never treated as a bearer secret: every public operation
* still runs the authorization hook. It never encodes a path, a physical key, or an original
* filename.
*/
public record FileId(UUID value) {
public FileId {
Objects.requireNonNull(value, "value");
}
public static FileId of(UUID value) {
return new FileId(value);
}
public static FileId parse(String canonicalText) {
Objects.requireNonNull(canonicalText, "canonicalText");
return new FileId(UUID.fromString(canonicalText));
}
public String canonicalText() {
return value.toString();
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.fileserver.api;
/**
* Authoritative lifecycle state of a file record.
*
* <p>Only {@link #READY} exposes readable immutable content. Every other state is excluded from
* direct download and from delegated (Nginx) transfer.
*/
public enum FileState {
CREATED,
UPLOADING,
UPLOADED,
VERIFYING,
QUARANTINED,
READY,
REJECTED,
FAILED,
DELETING,
DELETED,
EXPIRED;
/** True when the state permits public download authorization. */
public boolean isPubliclyReadable() {
return this == READY;
}
/** True when no further lifecycle progress is possible through the public API. */
public boolean isTerminal() {
return this == DELETED;
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.fileserver.api;
/**
* Single authority for allowed file lifecycle transitions.
*
* <p>Persistence adapters and transport adapters never assign {@link FileState} directly; they ask
* this contract first so an illegal transition cannot enter the metadata store.
*/
public interface FileStateMachine {
void requireTransition(FileState current, FileState target);
boolean canTransition(FileState current, FileState target);
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.fileserver.api;
import java.util.regex.Pattern;
/**
* Logical storage and ownership namespace.
*
* <p>A namespace is a metadata grouping only. It is never a directory, a mount, or a bucket name.
*/
public record StorageNamespace(String value) {
private static final Pattern CANONICAL = Pattern.compile("[a-z][a-z0-9-]{1,62}");
public StorageNamespace {
if (value == null || !CANONICAL.matcher(value).matches()) {
throw new IllegalArgumentException("invalid storage namespace");
}
}
public static StorageNamespace of(String value) {
return new StorageNamespace(value);
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.fileserver.api;
import java.util.Objects;
import java.util.UUID;
/**
* Opaque public identity of a resumable upload resource.
*
* <p>An upload resource has a lifecycle independent of the READY file it eventually produces, so
* the two identities are never interchangeable.
*/
public record UploadId(UUID value) {
public UploadId {
Objects.requireNonNull(value, "value");
}
public static UploadId of(UUID value) {
return new UploadId(value);
}
public static UploadId parse(String canonicalText) {
Objects.requireNonNull(canonicalText, "canonicalText");
return new UploadId(UUID.fromString(canonicalText));
}
public String canonicalText() {
return value.toString();
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.fileserver.api.content;
import java.util.Objects;
/**
* Outcome of one durable append.
*
* <p>{@code committedOffset} only advances by bytes the store observed reaching the channel, so a
* partial write can never inflate the resumable offset.
*/
public record AppendResult(long committedOffset, long appendedBytes, String sha256) {
public AppendResult {
if (committedOffset < 0 || appendedBytes < 0) {
throw new IllegalArgumentException("append offsets must not be negative");
}
Objects.requireNonNull(sha256, "sha256");
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
/**
* Non-blocking storage SPI with the same semantics as {@link BlockingContentStore}.
*
* <p>Streaming uses {@link Flow.Publisher} of {@link ByteBuffer} so the core stays free of Reactor
* and Spring buffer types; the WebFlux adapter owns the conversion and the pooled-buffer lifecycle.
*/
public interface AsyncContentStore {
CompletionStage<UploadHandle> createUpload(CreateContentCommand command);
CompletionStage<AppendResult> append(
UploadHandle handle, long expectedOffset, Flow.Publisher<ByteBuffer> content);
CompletionStage<StoredContent> finalizeUpload(
UploadHandle handle, FinalizeContentCommand command);
CompletionStage<ContentMetadata> stat(ContentKey key);
Flow.Publisher<ByteBuffer> openRead(ContentKey key, ByteRange range);
CompletionStage<DeleteResult> delete(ContentKey key, DeletePrecondition precondition);
ContentStoreCapabilities capabilities();
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.nio.channels.ReadableByteChannel;
/**
* Blocking storage SPI shared by every content store implementation.
*
* <p>The contract is deliberately expressed as create / append / finalize / stat / openRead /
* delete semantics rather than as a mirror of filesystem commands. No signature may name a {@code
* Path}, a Spring {@code Resource}, a {@code DataBuffer}, a Reactor type, or a provider SDK type.
*/
public interface BlockingContentStore {
UploadHandle createUpload(CreateContentCommand command);
/**
* Appends under a fence that is re-checked as the transfer proceeds.
*
* <p>The fence is a parameter rather than store state because ownership belongs to the caller's
* lease, not to the object: the store knows how to stop writing, but only the caller knows when
* it has lost the right to.
*/
AppendResult append(
UploadHandle handle,
long expectedOffset,
ReadableByteChannel source,
long contentLength,
WriteFence fence);
/** Appends with no ownership to lose; see {@link WriteFence#unfenced()}. */
default AppendResult append(
UploadHandle handle, long expectedOffset, ReadableByteChannel source, long contentLength) {
return append(handle, expectedOffset, source, contentLength, WriteFence.unfenced());
}
StoredContent finalizeUpload(UploadHandle handle, FinalizeContentCommand command);
ContentMetadata stat(ContentKey key);
ReadableByteChannel openRead(ContentKey key, ByteRange range);
DeleteResult delete(ContentKey key, DeletePrecondition precondition);
ContentStoreCapabilities capabilities();
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Optional capacity reporting used by admission control and the admin plane.
*
* <p>Stores that cannot answer capacity simply do not implement this interface; the high-water
* guards then degrade to reservation-only accounting.
*/
public interface CapacityAwareContentStore {
StorageCapacity capacity();
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.time.Instant;
import java.util.Objects;
/**
* Physical observation of a stored object.
*
* <p>This is used for publish verification and reconciliation only. It is never the source of
* public metadata, and its timestamp is never served as {@code Last-Modified}.
*/
public record ContentMetadata(ContentKey contentKey, long size, Instant lastModified) {
public ContentMetadata {
Objects.requireNonNull(contentKey, "contentKey");
Objects.requireNonNull(lastModified, "lastModified");
if (size < 0) {
throw new IllegalArgumentException("size must not be negative");
}
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Runtime capabilities of a content store, produced by a real startup probe.
*
* <p>These flags are never read from configuration alone: the local adapter proves each one against
* the configured storage root before the application accepts traffic.
*/
public record ContentStoreCapabilities(
boolean rangedRead,
boolean atomicCreate,
boolean atomicPublish,
boolean conditionalWrite,
boolean serverSideCopy,
boolean delegatedDownload,
boolean resumableAppend) {
/** Capability set with every optional feature disabled. */
public static ContentStoreCapabilities none() {
return new ContentStoreCapabilities(false, false, false, false, false, false, false);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.util.concurrent.CompletionStage;
/**
* Optional server-side copy capability.
*
* <p>Stores without it fall back to an application-level stream copy. Automatic rollback of a
* failed copy is never promised; an incomplete target goes to the cleanup queue.
*/
public interface CopyCapableContentStore {
CompletionStage<StoredContent> copy(
ContentKey source, ContentKey target, CopyPrecondition precondition);
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Conditions a server-side copy must satisfy.
*
* <p>The default is create-only: an existing target is a failure, never a silent overwrite.
*/
public record CopyPrecondition(boolean createOnly) {
/** Create-only copy, the Fileserver default. */
public static CopyPrecondition requireCreateOnly() {
return new CopyPrecondition(true);
}
/** Conditional replace, reachable only from a path that already validated a precondition. */
public static CopyPrecondition allowConditionalReplace() {
return new CopyPrecondition(false);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import java.util.Objects;
import java.util.OptionalLong;
/**
* Request to create a new staging object.
*
* <p>The command carries no client filename: the physical object is named from server-generated
* identity only. {@code expectedLength} is advisory and is re-verified against the bytes that are
* actually written.
*/
public record CreateContentCommand(
UploadId uploadId,
StorageNamespace namespace,
OptionalLong expectedLength,
long maximumLength) {
public CreateContentCommand {
Objects.requireNonNull(uploadId, "uploadId");
Objects.requireNonNull(namespace, "namespace");
Objects.requireNonNull(expectedLength, "expectedLength");
if (maximumLength <= 0) {
throw new IllegalArgumentException("maximumLength must be positive");
}
if (expectedLength.isPresent() && expectedLength.getAsLong() < 0) {
throw new IllegalArgumentException("expectedLength must not be negative");
}
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.fileserver.api.content;
import java.time.Duration;
import java.util.Objects;
/**
* Internal descriptor a front proxy consumes to perform the actual transfer.
*
* <p>{@code internalUri} is always relative and always below the configured internal prefix.
*/
public record DelegatedDownloadDescriptor(String internalUri, Duration ttl) {
public DelegatedDownloadDescriptor {
Objects.requireNonNull(internalUri, "internalUri");
Objects.requireNonNull(ttl, "ttl");
if (!internalUri.startsWith("/") || internalUri.contains("..")) {
throw new IllegalArgumentException("internal uri must be a relative-rooted safe path");
}
if (ttl.isNegative() || ttl.isZero()) {
throw new IllegalArgumentException("ttl must be positive");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.time.Duration;
import java.util.Optional;
/**
* Optional capability to hand a transfer to a front proxy instead of streaming it in-process.
*
* <p>The descriptor is a validated relative internal URI. It never contains an absolute physical
* path, and issuing a publicly signed URL is out of scope for this store family.
*/
public interface DelegatedDownloadStore {
DelegatedDownloadDescriptor createDelegation(
ContentKey key, Optional<ByteRange> range, Duration ttl);
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.fileserver.api.content;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Conditions a physical delete must satisfy before it runs.
*
* <p>Cleanup never deletes an object whose observed size or digest disagrees with the record that
* scheduled the deletion.
*/
public record DeletePrecondition(OptionalLong expectedSize, Optional<String> expectedSha256) {
public DeletePrecondition {
Objects.requireNonNull(expectedSize, "expectedSize");
Objects.requireNonNull(expectedSha256, "expectedSha256");
}
/** Unconditional delete, used only where the caller already proved ownership. */
public static DeletePrecondition none() {
return new DeletePrecondition(OptionalLong.empty(), Optional.empty());
}
public static DeletePrecondition ofSize(long expectedSize) {
return new DeletePrecondition(OptionalLong.of(expectedSize), Optional.empty());
}
public static DeletePrecondition ofSizeAndDigest(long expectedSize, String expectedSha256) {
return new DeletePrecondition(OptionalLong.of(expectedSize), Optional.of(expectedSha256));
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Outcome of a physical delete.
*
* <p>A delete of an object that is already gone is an idempotent success, but it is reported with
* {@code alreadyAbsent} so reconciliation can record the divergence.
*/
public record DeleteResult(boolean deleted, boolean alreadyAbsent, long reclaimedBytes) {
public DeleteResult {
if (reclaimedBytes < 0) {
throw new IllegalArgumentException("reclaimedBytes must not be negative");
}
}
public static DeleteResult removed(long reclaimedBytes) {
return new DeleteResult(true, false, reclaimedBytes);
}
public static DeleteResult alreadyGone() {
return new DeleteResult(true, true, 0);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.fileserver.api.content;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Request to turn a completed staging object into immutable published content.
*
* <p>{@code expectedSha256} is the digest the server computed while streaming, not a client
* assertion. The store re-verifies length and digest before it exposes anything.
*/
public record FinalizeContentCommand(
OptionalLong expectedLength,
Optional<String> expectedSha256,
PublishMode publishMode,
boolean forceDurable) {
public FinalizeContentCommand {
Objects.requireNonNull(expectedLength, "expectedLength");
Objects.requireNonNull(expectedSha256, "expectedSha256");
Objects.requireNonNull(publishMode, "publishMode");
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* How a completed upload becomes publicly visible.
*
* <p>{@link #ATOMIC_MOVE_PREFERRED} is the default: use a same-FileStore atomic move when the probe
* proves it works, otherwise fall back to publishing a metadata pointer to an already-complete
* immutable object.
*/
public enum PublishMode {
ATOMIC_MOVE_REQUIRED,
ATOMIC_MOVE_PREFERRED,
METADATA_POINTER
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Usable and total bytes of a storage pool.
*
* <p>Neither value identifies a mount point or a physical root.
*/
public record StorageCapacity(long usableBytes, long totalBytes) {
public StorageCapacity {
if (usableBytes < 0 || totalBytes < 0 || usableBytes > totalBytes) {
throw new IllegalArgumentException("invalid storage capacity");
}
}
/** Fraction of the pool already consumed, in the closed interval zero to one. */
public double usedFraction() {
if (totalBytes == 0) {
return 0;
}
return (double) (totalBytes - usableBytes) / (double) totalBytes;
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.ContentKey;
import java.util.Objects;
/**
* Immutable content that a store has finished publishing.
*
* <p>Publication here means the physical object is complete and verified; the file becomes publicly
* readable only once the metadata store commits the READY transition.
*/
public record StoredContent(
ContentKey contentKey, long size, String sha256, boolean atomicMoveUsed) {
public StoredContent {
Objects.requireNonNull(contentKey, "contentKey");
Objects.requireNonNull(sha256, "sha256");
if (size < 0) {
throw new IllegalArgumentException("size must not be negative");
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.fileserver.api.content;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.api.UploadId;
import java.util.Objects;
/**
* Opaque handle to an in-progress staging object held by a content store.
*
* <p>The handle never exposes a path. Adapters that need physical detail keep it in their own
* package-private subtype and downcast internally.
*/
public interface UploadHandle {
UploadId uploadId();
StorageNamespace namespace();
/**
* Store-specific opaque token that lets the same store re-attach to the staging object after a
* restart. It is never returned to a client.
*/
String stagingToken();
/** Throws when {@code handle} was produced by a different content store implementation. */
static <T extends UploadHandle> T requireOwn(UploadHandle handle, Class<T> ownType) {
Objects.requireNonNull(handle, "handle");
if (!ownType.isInstance(handle)) {
throw new IllegalArgumentException(
"upload handle was not produced by " + ownType.getSimpleName());
}
return ownType.cast(handle);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.fileserver.api.content;
/**
* Permission to keep writing, re-checked while a transfer is still in flight.
*
* <p>An upload holds a writer lease that is granted once and expires on a timer, but the transfer
* it authorizes can run for minutes. Checking ownership only at the start leaves the interval where
* a slow writer's lease lapses, another node takes it over, and both are appending to the same
* staging object — with the loser's bytes landing at offsets the winner never accounted for.
*
* <p>The store therefore re-asks between buffers rather than trusting the initial grant. A refusal
* aborts the transfer before the next write, and the store's own rollback returns the object to the
* offset the append started from, so a fenced-out writer leaves no trace on the volume.
*/
@FunctionalInterface
public interface WriteFence {
/**
* Confirms this writer may still mutate the physical object.
*
* @throws dev.caskeleton.application.fileserver.api.error.FileserverException when ownership was
* lost; the caller must not write again
*/
void requireStillOwned();
/**
* A fence that never refuses.
*
* <p>For call sites with no ownership to lose — a contract test driving the store directly, or a
* copy between two objects only this thread can reach.
*/
static WriteFence unfenced() {
return () -> {};
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.fileserver.api.error;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.UploadId;
/**
* The operation may or may not have taken effect and the server cannot decide which.
*
* <p>Typical causes are a lost rename response on a network filesystem, a vanished mount after a
* successful force, and a missing database commit acknowledgement. This failure is never downgraded
* to a retryable error and never blind-retried: it always carries {@code reconciliationRequired}.
*/
public final class AmbiguousCompletionException extends FileserverException {
private static final long serialVersionUID = 1L;
public AmbiguousCompletionException(String message, FileserverFailureContext context) {
super(message, context);
}
public AmbiguousCompletionException(
String message, Throwable cause, FileserverFailureContext context) {
super(message, cause, context);
}
/** Ambiguous outcome for an upload resource. */
public static AmbiguousCompletionException forUpload(String message, UploadId uploadId) {
return new AmbiguousCompletionException(
message,
FileserverFailureContext.forUpload(
FileserverErrorCode.AMBIGUOUS_COMPLETION, uploadId, false, true, true));
}
/** Ambiguous outcome for a file record, typically a publish or metadata commit. */
public static AmbiguousCompletionException forFile(String message, FileId fileId) {
return new AmbiguousCompletionException(
message,
FileserverFailureContext.forFile(FileserverErrorCode.AMBIGUOUS_COMPLETION, fileId, false)
.ambiguousRequiringReconciliation());
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.fileserver.api.error;
/** Atomic publish was required by configuration but the storage probe proved it unavailable. */
public final class AtomicPublishUnsupportedException extends FileserverException {
private static final long serialVersionUID = 1L;
public AtomicPublishUnsupportedException(String message, FileserverFailureContext context) {
super(message, context);
}
public AtomicPublishUnsupportedException(
String message, Throwable cause, FileserverFailureContext context) {
super(message, cause, context);
}
/** Failure with no file or upload correlation. */
public static AtomicPublishUnsupportedException of(String message) {
return new AtomicPublishUnsupportedException(
message,
FileserverFailureContext.of(FileserverErrorCode.ATOMIC_PUBLISH_UNSUPPORTED, false));
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.fileserver.api.error;
/** An optimistic version or writer-lease precondition lost to a concurrent writer. */
public final class ConcurrentFileModificationException extends FileserverException {
private static final long serialVersionUID = 1L;
public ConcurrentFileModificationException(String message, FileserverFailureContext context) {
super(message, context);
}
public ConcurrentFileModificationException(
String message, Throwable cause, FileserverFailureContext context) {
super(message, cause, context);
}
/** Failure with no file or upload correlation. */
public static ConcurrentFileModificationException of(String message) {
return new ConcurrentFileModificationException(
message, FileserverFailureContext.of(FileserverErrorCode.CONCURRENT_MODIFICATION, true));
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.fileserver.api.error;
/** The injected access policy denied the operation before any quota or storage mutation. */
public final class FileAccessDeniedException extends FileserverException {
private static final long serialVersionUID = 1L;
public FileAccessDeniedException(String message, FileserverFailureContext context) {
super(message, context);
}
public FileAccessDeniedException(
String message, Throwable cause, FileserverFailureContext context) {
super(message, cause, context);
}
/** Failure with no file or upload correlation. */
public static FileAccessDeniedException of(String message) {
return new FileAccessDeniedException(
message, FileserverFailureContext.of(FileserverErrorCode.ACCESS_DENIED, false));
}
}

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