chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -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.
|
||||
@@ -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", ...)` 한다.
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
Vendored
+8
@@ -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
|
||||
}
|
||||
Vendored
+457
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Provider-neutral invalidation result. */
|
||||
public enum CacheInvalidationOutcome {
|
||||
INVALIDATED,
|
||||
ALREADY_ABSENT,
|
||||
DEGRADED_UNAVAILABLE,
|
||||
INDETERMINATE
|
||||
}
|
||||
+166
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -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);
|
||||
}
|
||||
Vendored
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
Vendored
+41
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -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
|
||||
}
|
||||
Vendored
+35
@@ -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]";
|
||||
}
|
||||
}
|
||||
Vendored
+42
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
Vendored
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+23
@@ -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 {}
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
+131
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+69
@@ -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> {}
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
Vendored
+37
@@ -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]";
|
||||
}
|
||||
}
|
||||
+18
@@ -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.
|
||||
}
|
||||
}
|
||||
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
+22
@@ -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 + ']';
|
||||
}
|
||||
}
|
||||
+56
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+35
@@ -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 {}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+65
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+45
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
+9
@@ -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();
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+270
@@ -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)));
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+32
@@ -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();
|
||||
}
|
||||
+47
@@ -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();
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+32
@@ -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));
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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 () -> {};
|
||||
}
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
+23
@@ -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));
|
||||
}
|
||||
}
|
||||
+22
@@ -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));
|
||||
}
|
||||
}
|
||||
+22
@@ -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
Reference in New Issue
Block a user