feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
+30 -1
View File
@@ -56,13 +56,37 @@ Package root: `dev.caskeleton.application`.
| `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 transactional boundaries. Implemented by `adapter-persistence`. |
| `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`. |
## 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.
@@ -103,10 +127,15 @@ application-core never self-registers with a DI framework.
| 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)
+44 -4
View File
@@ -14,6 +14,12 @@
`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
결과를 바꾸지 않는다.
---
## 유스케이스 계약 (usecase / command / query / capability)
@@ -71,13 +77,21 @@
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
- 가지 경계:
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)에서만 허용.
- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
- **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` 계층이
@@ -93,7 +107,33 @@
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
`@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출.
`@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
+26
View File
@@ -3,3 +3,29 @@
dependencies {
implementation project(':shared-contract')
}
sourceSets {
redisPolicyContractTest {
java.srcDir 'src/redisPolicyContractTest/java'
resources.srcDir 'src/redisPolicyContractTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisPolicyContractTestImplementation.extendsFrom testImplementation
redisPolicyContractTestCompileOnly.extendsFrom testCompileOnly
redisPolicyContractTestRuntimeOnly.extendsFrom testRuntimeOnly
}
tasks.register('redisPolicyContractTest', Test) {
group = 'redis verification'
description = 'Runs provider-neutral Redis policy contracts without a Redis/framework dependency.'
testClassesDirs = sourceSets.redisPolicyContractTest.output.classesDirs
classpath = sourceSets.redisPolicyContractTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
+32 -32
View File
@@ -1,40 +1,40 @@
# 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,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
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,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
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,testAnnotationProcessor
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,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
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,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
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,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
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=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=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
@@ -50,31 +50,31 @@ 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=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,testCompileClasspath
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,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
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.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
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,testAnnotationProcessor
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
@@ -0,0 +1,457 @@
package dev.caskeleton.application.cache;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.Objects;
/**
* Framework-free cache-aside orchestration with bounded local coalescing and source concurrency.
* Construct one instance per semantic cache region so its policy and protection bounds are shared.
*
* <p>When optional refresh coordination is enabled, the soft-lease owner refreshes synchronously.
* This executor does not schedule an asynchronous stale-while-revalidate task. A valid stale
* contender or a valid stale request facing coordination failure returns immediately instead.
*/
public final class CacheAsideExecutor<K, V> {
private final CacheAsidePolicy policy;
private final Clock clock;
private final CacheSingleFlight<K, SourceAttempt<V>> singleFlight;
private final CacheSourceBulkhead sourceBulkhead;
private final CacheRefreshCoordinationPort<K> refreshCoordinator;
private final CacheRefreshCoordinationPolicy refreshCoordinationPolicy;
public CacheAsideExecutor(CacheAsidePolicy policy, Clock clock) {
this(policy, clock, null, null);
}
public CacheAsideExecutor(
CacheAsidePolicy policy,
Clock clock,
CacheRefreshCoordinationPort<K> refreshCoordinator,
CacheRefreshCoordinationPolicy refreshCoordinationPolicy) {
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.refreshCoordinator = refreshCoordinator;
if (refreshCoordinator == null) {
if (refreshCoordinationPolicy != null) {
throw new IllegalArgumentException("refresh coordination policy requires a coordinator");
}
this.refreshCoordinationPolicy = null;
} else {
this.refreshCoordinationPolicy =
Objects.requireNonNull(
refreshCoordinationPolicy, "refreshCoordinationPolicy must be non-null")
.validateAgainst(policy);
}
singleFlight =
new CacheSingleFlight<>(policy.maximumInFlightSourceKeys(), policy.maximumWaitersPerKey());
sourceBulkhead = new CacheSourceBulkhead(policy.maximumConcurrentSourceLoads());
}
public CacheResult<V> getOrLoad(
K key, CacheRegionPort<K, V> region, CacheSourceLoader<K, V> sourceLoader) {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(region, "region must be non-null");
Objects.requireNonNull(sourceLoader, "sourceLoader must be non-null");
CacheLookup<V> lookup =
Objects.requireNonNull(region.lookup(key), "cache lookup must be non-null");
StaleCandidate<V> stale = null;
boolean hardMiss = false;
RefillCondition refillCondition = RefillCondition.absent(CacheWriteCondition.unavailable());
if (lookup instanceof CacheLookup.Hit<V> hit) {
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
return new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision());
}
if (!hit.observationToken().usable()) {
return new CacheResult.IncompatibleSchema<>(
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST);
}
stale =
new StaleCandidate<>(
hit.value(), hit.sourceRevision(), hit.hardExpiresAt(), hit.observationToken());
refillCondition = RefillCondition.observed(hit.observationToken(), hit.writeCondition());
} else if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
return new CacheResult.NegativeHit<>(negative.reason());
} else if (lookup instanceof CacheLookup.Miss<V> miss) {
refillCondition = RefillCondition.absent(miss.writeCondition());
hardMiss = true;
} else if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST) {
return new CacheResult.IncompatibleSchema<>(incompatible.category(), incompatible.policy());
}
if (!incompatible.observationToken().usable()) {
return new CacheResult.IncompatibleSchema<>(
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST);
}
refillCondition =
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition());
} else if (lookup instanceof CacheLookup.Unavailable<V> unavailable) {
refillCondition = RefillCondition.absent(unavailable.writeCondition());
}
RefillCondition selectedCondition = refillCondition;
StaleCandidate<V> selectedStale = stale;
boolean selectedHardMiss = hardMiss;
CacheSingleFlight.Outcome<SourceAttempt<V>> flight =
singleFlight.execute(
key,
policy.maximumWaitDuration(),
() ->
loadFromSource(
key, region, sourceLoader, selectedCondition, selectedStale, selectedHardMiss));
if (flight instanceof CacheSingleFlight.Rejected<SourceAttempt<V>> rejected) {
return new CacheResult.Rejected<>(
switch (rejected.reason()) {
case MAXIMUM_IN_FLIGHT_KEYS -> CacheResult.RejectionReason.MAXIMUM_IN_FLIGHT_KEYS;
case MAXIMUM_WAITERS -> CacheResult.RejectionReason.MAXIMUM_WAITERS;
case WAIT_TIMEOUT -> CacheResult.RejectionReason.WAIT_TIMEOUT;
});
}
if (flight instanceof CacheSingleFlight.Interrupted<SourceAttempt<V>>) {
return new CacheResult.Cancelled<>();
}
SourceAttempt<V> attempt = ((CacheSingleFlight.Completed<SourceAttempt<V>>) flight).value();
return toResult(attempt, stale);
}
int inFlightWaiterCount(K key) {
return singleFlight.waiterCount(key);
}
private SourceAttempt<V> loadFromSource(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition,
StaleCandidate<V> stale,
boolean hardMiss) {
CacheSourceBulkhead.Outcome<SourceAttempt<V>> admitted =
sourceBulkhead.execute(
policy.sourceAdmissionWait(),
() -> invokeSource(key, region, sourceLoader, refillCondition, stale, hardMiss));
if (admitted instanceof CacheSourceBulkhead.Rejected<SourceAttempt<V>>) {
return new SourceRejected<>();
}
if (admitted instanceof CacheSourceBulkhead.Interrupted<SourceAttempt<V>>) {
return new SourceInterrupted<>();
}
return ((CacheSourceBulkhead.Completed<SourceAttempt<V>>) admitted).value();
}
private SourceAttempt<V> invokeSource(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition,
StaleCandidate<V> stale,
boolean hardMiss) {
CacheRefreshClaimAttempt ownedAttempt = null;
RefillCondition selectedCondition = refillCondition;
try {
if (shouldCoordinate(stale, hardMiss)) {
CacheRefreshClaimAttempt attempt =
Objects.requireNonNull(
refreshCoordinator.newAttempt(), "cache refresh claim attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalStateException(
"enabled cache refresh coordinator returned an unusable attempt");
}
CacheRefreshClaimOutcome claim = claimWithOneUncertainRetry(key, attempt);
if (claim instanceof CacheRefreshClaimOutcome.Contended) {
SourceAttempt<V> deferred = staleDeferral(stale, claim);
if (deferred != null) {
return deferred;
}
if (hardMiss
&& refreshCoordinationPolicy.hardMissPolicy()
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD) {
if (!boundedWait(refreshCoordinationPolicy.hardMissWait())) {
return new SourceInterrupted<>();
}
Recheck<V> recheck = recheck(region, key);
if (recheck.immediateResult() != null) {
return new ImmediateResult<>(recheck.immediateResult());
}
selectedCondition = recheck.refillCondition();
}
} else if (claim instanceof CacheRefreshClaimOutcome.Unavailable
|| claim instanceof CacheRefreshClaimOutcome.Indeterminate) {
SourceAttempt<V> deferred = staleDeferral(stale, claim);
if (deferred != null) {
return deferred;
}
} else if (claim instanceof CacheRefreshClaimOutcome.Claimed
|| claim instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
ownedAttempt = attempt;
Recheck<V> recheck = recheck(region, key);
if (recheck.immediateResult() != null) {
return new ImmediateResult<>(recheck.immediateResult());
}
selectedCondition = recheck.refillCondition();
}
}
return invokeSourceDirect(key, region, sourceLoader, selectedCondition);
} finally {
if (ownedAttempt != null) {
Objects.requireNonNull(
refreshCoordinator.release(key, ownedAttempt),
"cache refresh release outcome must be non-null");
}
}
}
private SourceAttempt<V> invokeSourceDirect(
K key,
CacheRegionPort<K, V> region,
CacheSourceLoader<K, V> sourceLoader,
RefillCondition refillCondition) {
Instant deadline;
try {
deadline = clock.instant().plus(policy.sourceLoadDeadline());
} catch (DateTimeException exception) {
throw new IllegalStateException("cache source deadline cannot be represented", exception);
}
CacheCancellationToken cancellation = new CacheCancellationToken(deadline, clock);
if (cancellation.isInterrupted()) {
return new SourceInterrupted<>();
}
SourceLoadOutcome<V> outcome =
Objects.requireNonNull(
sourceLoader.load(key, cancellation), "source loader outcome must be non-null");
if (cancellation.isInterrupted() || outcome instanceof SourceLoadOutcome.Cancelled<V>) {
return new SourceInterrupted<>();
}
if (cancellation.isDeadlineExceeded()) {
return new SourceTimedOut<>();
}
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
CacheRecordOutcome recorded =
Objects.requireNonNull(
region.record(
key,
loaded.value(),
new CacheRecordMetadata(
loaded.sourceRevision(),
refillCondition.intent(),
refillCondition.observationToken(),
refillCondition.writeCondition())),
"cache record outcome must be non-null");
return new SourceResolved<>(outcome, recorded);
}
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
CacheRecordOutcome recorded =
Objects.requireNonNull(
region.recordAbsent(
key,
absent.reason(),
new CacheRecordMetadata(
absent.sourceRevision(),
refillCondition.intent(),
refillCondition.observationToken(),
refillCondition.writeCondition())),
"negative cache record outcome must be non-null");
return new SourceResolved<>(outcome, recorded);
}
return new SourceResolved<>(outcome, null);
}
private boolean shouldCoordinate(StaleCandidate<V> stale, boolean hardMiss) {
if (refreshCoordinator == null || !refreshCoordinator.enabled()) {
return false;
}
return stale != null
|| (hardMiss
&& refreshCoordinationPolicy.hardMissPolicy()
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD);
}
private CacheRefreshClaimOutcome claimWithOneUncertainRetry(
K key, CacheRefreshClaimAttempt attempt) {
CacheRefreshClaimOutcome first =
Objects.requireNonNull(
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
"cache refresh claim outcome must be non-null");
if (first instanceof CacheRefreshClaimOutcome.Indeterminate) {
return Objects.requireNonNull(
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
"cache refresh claim retry outcome must be non-null");
}
return first;
}
private SourceAttempt<V> staleDeferral(StaleCandidate<V> stale, CacheRefreshClaimOutcome claim) {
if (stale != null && clock.instant().isBefore(stale.hardExpiresAt())) {
CacheResult.RefreshDeferralReason reason =
claim instanceof CacheRefreshClaimOutcome.Contended
? CacheResult.RefreshDeferralReason.CONTENDED
: claim instanceof CacheRefreshClaimOutcome.Unavailable
? CacheResult.RefreshDeferralReason.COORDINATION_UNAVAILABLE
: CacheResult.RefreshDeferralReason.COORDINATION_INDETERMINATE;
return new ImmediateResult<>(
new CacheResult.StaleRefreshDeferred<>(stale.value(), stale.sourceRevision(), reason));
}
return null;
}
private boolean boundedWait(java.time.Duration duration) {
try {
long milliseconds = duration.toMillis();
int nanoseconds = (int) duration.minusMillis(milliseconds).toNanos();
Thread.sleep(milliseconds, nanoseconds);
return true;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return false;
}
}
private Recheck<V> recheck(CacheRegionPort<K, V> region, K key) {
CacheLookup<V> lookup =
Objects.requireNonNull(region.lookup(key), "cache recheck must be non-null");
if (lookup instanceof CacheLookup.Hit<V> hit) {
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
return Recheck.immediate(new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision()));
}
if (!hit.observationToken().usable()) {
return Recheck.immediate(
new CacheResult.IncompatibleSchema<>(
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST));
}
return Recheck.refill(RefillCondition.observed(hit.observationToken(), hit.writeCondition()));
}
if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
return Recheck.immediate(new CacheResult.NegativeHit<>(negative.reason()));
}
if (lookup instanceof CacheLookup.Miss<V> miss) {
return Recheck.refill(RefillCondition.absent(miss.writeCondition()));
}
if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST
|| !incompatible.observationToken().usable()) {
return Recheck.immediate(
new CacheResult.IncompatibleSchema<>(
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST));
}
return Recheck.refill(
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition()));
}
CacheLookup.Unavailable<V> unavailable = (CacheLookup.Unavailable<V>) lookup;
return Recheck.refill(RefillCondition.absent(unavailable.writeCondition()));
}
private CacheResult<V> toResult(SourceAttempt<V> attempt, StaleCandidate<V> stale) {
if (attempt instanceof ImmediateResult<V> immediate) {
return immediate.result();
}
if (attempt instanceof SourceRejected<V>) {
return new CacheResult.Rejected<>(CacheResult.RejectionReason.SOURCE_OVERLOADED);
}
if (attempt instanceof SourceTimedOut<V>) {
return new CacheResult.Rejected<>(CacheResult.RejectionReason.LOAD_TIMEOUT);
}
if (attempt instanceof SourceInterrupted<V>) {
return new CacheResult.Cancelled<>();
}
SourceResolved<V> resolved = (SourceResolved<V>) attempt;
SourceLoadOutcome<V> outcome = resolved.outcome();
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
return new CacheResult.LoadedFromSource<>(
loaded.value(), loaded.sourceRevision(), resolved.recordOutcome());
}
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
return new CacheResult.AuthoritativeAbsent<>(
absent.reason(), absent.sourceRevision(), resolved.recordOutcome());
}
if (outcome instanceof SourceLoadOutcome.TransientFailure<V> transientFailure) {
if (stale != null
&& policy.serveStaleOnTransientFailure()
&& clock.instant().isBefore(stale.hardExpiresAt())) {
return new CacheResult.StaleFallbackAfterTransientFailure<>(
stale.value(), stale.sourceRevision(), transientFailure.failure());
}
return new CacheResult.SourceFailed<>(
transientFailure.failure(), CacheResult.SourceFailureKind.TRANSIENT);
}
if (outcome instanceof SourceLoadOutcome.PermanentFailure<V> permanentFailure) {
return new CacheResult.SourceFailed<>(
permanentFailure.failure(), CacheResult.SourceFailureKind.PERMANENT);
}
return new CacheResult.Cancelled<>();
}
private sealed interface SourceAttempt<T>
permits SourceResolved, SourceRejected, SourceInterrupted, SourceTimedOut, ImmediateResult {}
private record SourceResolved<T>(SourceLoadOutcome<T> outcome, CacheRecordOutcome recordOutcome)
implements SourceAttempt<T> {
private SourceResolved {
Objects.requireNonNull(outcome, "outcome must be non-null");
}
}
private record SourceRejected<T>() implements SourceAttempt<T> {}
private record SourceInterrupted<T>() implements SourceAttempt<T> {}
private record SourceTimedOut<T>() implements SourceAttempt<T> {}
private record ImmediateResult<T>(CacheResult<T> result) implements SourceAttempt<T> {
private ImmediateResult {
Objects.requireNonNull(result, "result must be non-null");
}
}
private record StaleCandidate<T>(
T value,
String sourceRevision,
Instant hardExpiresAt,
CacheObservationToken observationToken) {
private StaleCandidate {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
Objects.requireNonNull(observationToken, "observationToken must be non-null");
}
}
private record RefillCondition(
CacheRecordIntent intent,
CacheObservationToken observationToken,
CacheWriteCondition writeCondition) {
private RefillCondition {
Objects.requireNonNull(intent, "intent must be non-null");
Objects.requireNonNull(observationToken, "observationToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
}
private static RefillCondition absent(CacheWriteCondition writeCondition) {
return new RefillCondition(
CacheRecordIntent.ONLY_IF_ABSENT, CacheObservationToken.unavailable(), writeCondition);
}
private static RefillCondition observed(
CacheObservationToken token, CacheWriteCondition writeCondition) {
return new RefillCondition(CacheRecordIntent.ONLY_IF_OBSERVED, token, writeCondition);
}
}
private record Recheck<T>(RefillCondition refillCondition, CacheResult<T> immediateResult) {
private static <T> Recheck<T> refill(RefillCondition refillCondition) {
return new Recheck<>(
Objects.requireNonNull(refillCondition, "refillCondition must be non-null"), null);
}
private static <T> Recheck<T> immediate(CacheResult<T> result) {
return new Recheck<>(null, Objects.requireNonNull(result, "result must be non-null"));
}
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/** Immutable per-region bounds for cache-aside fallback and local coalescing. */
public record CacheAsidePolicy(
int maximumInFlightSourceKeys,
int maximumWaitersPerKey,
int maximumConcurrentSourceLoads,
Duration sourceAdmissionWait,
Duration sourceLoadDeadline,
boolean serveStaleOnTransientFailure) {
private static final int MAXIMUM_COUNT_BOUND = 4096;
private static final Duration MAXIMUM_DURATION_BOUND = Duration.ofDays(30);
public CacheAsidePolicy {
requirePositiveBound(
maximumInFlightSourceKeys, "maximumInFlightSourceKeys", MAXIMUM_COUNT_BOUND);
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_COUNT_BOUND) {
throw new IllegalArgumentException(
"maximumWaitersPerKey must be in 0.." + MAXIMUM_COUNT_BOUND);
}
requirePositiveBound(
maximumConcurrentSourceLoads, "maximumConcurrentSourceLoads", MAXIMUM_COUNT_BOUND);
requireDuration(sourceAdmissionWait, "sourceAdmissionWait", true);
requireDuration(sourceLoadDeadline, "sourceLoadDeadline", false);
}
Duration maximumWaitDuration() {
return sourceAdmissionWait.plus(sourceLoadDeadline);
}
private static void requirePositiveBound(int value, String field, int maximum) {
if (value < 1 || value > maximum) {
throw new IllegalArgumentException(field + " must be in 1.." + maximum);
}
}
private static void requireDuration(Duration value, String field, boolean zeroAllowed) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isNegative()
|| (!zeroAllowed && value.isZero())
|| value.compareTo(MAXIMUM_DURATION_BOUND) > 0) {
throw new IllegalArgumentException(
field
+ " must be "
+ (zeroAllowed ? "non-negative" : "positive")
+ " and at most "
+ MAXIMUM_DURATION_BOUND);
}
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.cache;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
/**
* Cooperative source-load cancellation signal. It observes the executing thread's interrupt flag
* and an immutable deadline; it cannot forcibly stop arbitrary source code.
*/
public final class CacheCancellationToken {
private final Instant deadline;
private final Clock clock;
CacheCancellationToken(Instant deadline, Clock clock) {
this.deadline = Objects.requireNonNull(deadline, "deadline must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
public Instant deadline() {
return deadline;
}
public boolean isDeadlineExceeded() {
return !clock.instant().isBefore(deadline);
}
public boolean isInterrupted() {
return Thread.currentThread().isInterrupted();
}
public boolean isCancellationRequested() {
return isInterrupted() || isDeadlineExceeded();
}
}
@@ -1,5 +1,6 @@
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. */
@@ -10,7 +11,48 @@ public sealed interface CacheLookup<V>
CacheLookup.IncompatibleSchema,
CacheLookup.Unavailable {
record Hit<V>(V value, Freshness freshness, String sourceRevision) implements CacheLookup<V> {
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");
@@ -18,38 +60,74 @@ public sealed interface CacheLookup<V>
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) implements CacheLookup<V> {
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) implements CacheLookup<V> {
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)
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)
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");
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/**
* Framework-free, cache-only diagnostic events.
*
* <p>Events intentionally omit semantic keys, tenant/user identifiers and provider endpoints.
* {@code cacheName} is a bounded code-owned name suitable for a low-cardinality metric tag.
*/
public sealed interface CacheObservationEvent {
String cacheName();
/** One lookup at a concrete cache tier. */
record Lookup(String cacheName, Tier tier, LookupResult result, Duration entryAge)
implements CacheObservationEvent {
public Lookup {
cacheName = boundedCacheName(cacheName);
Objects.requireNonNull(tier, "tier must be non-null");
Objects.requireNonNull(result, "result must be non-null");
Objects.requireNonNull(entryAge, "entryAge must be non-null");
if (entryAge.isNegative() || entryAge.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("entryAge must be between zero and 30 days");
}
}
}
/** A bounded local-tier eviction, flush, subscriber or generation reconciliation action. */
record LocalMaintenance(
String cacheName,
MaintenanceAction action,
MaintenanceResult result,
MaintenanceCause cause,
int affectedEntries)
implements CacheObservationEvent {
public LocalMaintenance {
cacheName = boundedCacheName(cacheName);
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(result, "result must be non-null");
Objects.requireNonNull(cause, "cause must be non-null");
if (affectedEntries < 0 || affectedEntries > 1_000_000) {
throw new IllegalArgumentException("affectedEntries must be in 0..1000000");
}
}
}
enum Tier {
LOCAL_L1,
REDIS_L2
}
enum LookupResult {
HIT,
MISS,
ERROR,
BYPASS
}
enum MaintenanceAction {
EVICT,
FLUSH,
RECONCILE,
SUBSCRIBER_EVENT
}
enum MaintenanceResult {
SUCCESS,
FLUSHED,
DROPPED,
ERROR,
UNCHANGED
}
enum MaintenanceCause {
CARDINALITY,
WEIGHT,
TTL,
INVALIDATION,
GENERATION_CHANGED,
SUBSCRIBER_DISCONNECTED,
SUBSCRIBER_OVERFLOW,
MALFORMED_MESSAGE,
RECONCILIATION_FAILURE
}
private static String boundedCacheName(String value) {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"cacheName must be a code-owned lower-case slug with 1..63 characters");
}
return value;
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Framework-free output boundary for low-cardinality cache diagnostics. */
@FunctionalInterface
public interface CacheObservationPort {
void observe(CacheObservationEvent event);
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.cache;
/**
* Opaque provider observation used only for conditional cache replacement. Application code must
* not parse or manufacture it.
*/
public record CacheObservationToken(String value) {
private static final String UNAVAILABLE_VALUE = "observation-unavailable";
public CacheObservationToken {
if (value == null || !value.matches("[A-Za-z0-9_-]{16,128}")) {
throw new IllegalArgumentException(
"cache observation token must have a bounded opaque representation");
}
}
public static CacheObservationToken unavailable() {
return new CacheObservationToken(UNAVAILABLE_VALUE);
}
public boolean usable() {
return !UNAVAILABLE_VALUE.equals(value);
}
}
@@ -3,5 +3,7 @@ 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
}
@@ -2,13 +2,40 @@ package dev.caskeleton.application.cache;
import java.util.Objects;
/** Metadata derived from the authoritative source, never from a cache provider. */
public record CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) {
/** Source revision plus an optional opaque condition captured by the preceding cache lookup. */
public record CacheRecordMetadata(
String sourceRevision,
CacheRecordIntent intent,
CacheObservationToken observedToken,
CacheWriteCondition writeCondition) {
public CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) {
this(
sourceRevision,
intent,
CacheObservationToken.unavailable(),
CacheWriteCondition.unavailable());
}
public CacheRecordMetadata(
String sourceRevision, CacheRecordIntent intent, CacheObservationToken observedToken) {
this(sourceRevision, intent, observedToken, CacheWriteCondition.unavailable());
}
public CacheRecordMetadata {
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
}
Objects.requireNonNull(intent, "intent must be non-null");
Objects.requireNonNull(observedToken, "observedToken must be non-null");
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
if (intent == CacheRecordIntent.ONLY_IF_OBSERVED && !observedToken.usable()) {
throw new IllegalArgumentException(
"ONLY_IF_OBSERVED requires a usable cache observation token");
}
if (intent != CacheRecordIntent.ONLY_IF_OBSERVED && observedToken.usable()) {
throw new IllegalArgumentException(
"a cache observation token is valid only for ONLY_IF_OBSERVED");
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Owner plus operation identities that must be reused for an uncertain claim retry. */
public record CacheRefreshClaimAttempt(
CacheRefreshOwnerToken ownerToken, CacheRefreshOperationToken operationToken) {
private static final CacheRefreshClaimAttempt UNAVAILABLE =
new CacheRefreshClaimAttempt(
CacheRefreshOwnerToken.unavailable(), CacheRefreshOperationToken.unavailable());
public CacheRefreshClaimAttempt {
Objects.requireNonNull(ownerToken, "ownerToken must be non-null");
Objects.requireNonNull(operationToken, "operationToken must be non-null");
if (ownerToken.usable() != operationToken.usable()) {
throw new IllegalArgumentException("refresh claim attempt tokens must have equal usability");
}
}
public static CacheRefreshClaimAttempt unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return ownerToken.usable();
}
@Override
public String toString() {
return usable()
? "CacheRefreshClaimAttempt[redacted]"
: "CacheRefreshClaimAttempt[unavailable]";
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Provider-neutral result of attempting to own a cache-refresh soft lease. */
public sealed interface CacheRefreshClaimOutcome
permits CacheRefreshClaimOutcome.Claimed,
CacheRefreshClaimOutcome.AlreadyOwned,
CacheRefreshClaimOutcome.Contended,
CacheRefreshClaimOutcome.Disabled,
CacheRefreshClaimOutcome.Unavailable,
CacheRefreshClaimOutcome.Indeterminate {
record Claimed(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
public Claimed {
requireUsable(attempt);
}
}
record AlreadyOwned(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
public AlreadyOwned {
requireUsable(attempt);
}
}
record Contended() implements CacheRefreshClaimOutcome {}
record Disabled() implements CacheRefreshClaimOutcome {}
record Unavailable() implements CacheRefreshClaimOutcome {}
record Indeterminate() implements CacheRefreshClaimOutcome {}
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
Objects.requireNonNull(attempt, "attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalArgumentException("owned refresh claim requires a usable attempt");
}
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
/** Finite soft-lease and hard-miss behavior for optional distributed refresh coordination. */
public record CacheRefreshCoordinationPolicy(
Duration leaseTimeToLive, HardMissPolicy hardMissPolicy, Duration hardMissWait) {
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(5);
private static final Duration MAXIMUM_HARD_MISS_WAIT = Duration.ofSeconds(5);
public CacheRefreshCoordinationPolicy {
Objects.requireNonNull(leaseTimeToLive, "leaseTimeToLive must be non-null");
Objects.requireNonNull(hardMissPolicy, "hardMissPolicy must be non-null");
Objects.requireNonNull(hardMissWait, "hardMissWait must be non-null");
if (leaseTimeToLive.isZero()
|| leaseTimeToLive.isNegative()
|| leaseTimeToLive.compareTo(MAXIMUM_LEASE) > 0) {
throw new IllegalArgumentException(
"refresh lease TTL must be positive and at most 5 minutes");
}
if (hardMissWait.isNegative() || hardMissWait.compareTo(MAXIMUM_HARD_MISS_WAIT) > 0) {
throw new IllegalArgumentException("hard miss wait must be between zero and 5 seconds");
}
if (hardMissPolicy == HardMissPolicy.NORMAL_SOURCE_LOAD && !hardMissWait.isZero()) {
throw new IllegalArgumentException("normal hard miss source load requires zero wait");
}
if (hardMissPolicy == HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD && hardMissWait.isZero()) {
throw new IllegalArgumentException("bounded hard miss wait must be positive");
}
}
public CacheRefreshCoordinationPolicy validateAgainst(CacheAsidePolicy cacheAsidePolicy) {
Objects.requireNonNull(cacheAsidePolicy, "cacheAsidePolicy must be non-null");
if (leaseTimeToLive.compareTo(cacheAsidePolicy.sourceLoadDeadline()) <= 0) {
throw new IllegalArgumentException(
"refresh lease TTL must be longer than the source load deadline");
}
return this;
}
public enum HardMissPolicy {
NORMAL_SOURCE_LOAD,
BOUNDED_WAIT_THEN_SOURCE_LOAD
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
/**
* Optional soft-lease coordination for cache refresh admission.
*
* <p>This port reduces duplicate refresh work. It is not a correctness lock and must not protect
* domain invariants. The claim owner performs its source refresh synchronously; only a stale
* contender or a stale request facing coordination failure returns immediately with a deferral
* result.
*/
public interface CacheRefreshCoordinationPort<K> {
default boolean enabled() {
return true;
}
CacheRefreshClaimAttempt newAttempt();
CacheRefreshClaimOutcome claim(K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive);
CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt);
}
@@ -0,0 +1,62 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Opaque idempotency identity reused when a refresh-claim response is uncertain. */
public final class CacheRefreshOperationToken {
private static final CacheRefreshOperationToken UNAVAILABLE = new CacheRefreshOperationToken();
private final String value;
public CacheRefreshOperationToken(String value) {
this.value = validate(value);
}
private CacheRefreshOperationToken() {
value = null;
}
public static CacheRefreshOperationToken unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return value != null;
}
public String value() {
if (!usable()) {
throw new IllegalStateException("cache refresh operation token is unavailable");
}
return value;
}
@Override
public boolean equals(Object candidate) {
return candidate instanceof CacheRefreshOperationToken other
&& Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
@Override
public String toString() {
return usable()
? "CacheRefreshOperationToken[redacted]"
: "CacheRefreshOperationToken[unavailable]";
}
private static String validate(String value) {
if (value == null
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|| value.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"operation token must contain 16..128 URL-safe non-control characters");
}
return value;
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Opaque owner identity for one bounded cache-refresh soft lease. */
public final class CacheRefreshOwnerToken {
private static final CacheRefreshOwnerToken UNAVAILABLE = new CacheRefreshOwnerToken();
private final String value;
public CacheRefreshOwnerToken(String value) {
this.value = validate(value, "owner token");
}
private CacheRefreshOwnerToken() {
value = null;
}
public static CacheRefreshOwnerToken unavailable() {
return UNAVAILABLE;
}
public boolean usable() {
return value != null;
}
public String value() {
if (!usable()) {
throw new IllegalStateException("cache refresh owner token is unavailable");
}
return value;
}
@Override
public boolean equals(Object candidate) {
return candidate instanceof CacheRefreshOwnerToken other && Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
@Override
public String toString() {
return usable() ? "CacheRefreshOwnerToken[redacted]" : "CacheRefreshOwnerToken[unavailable]";
}
private static String validate(String value, String field) {
if (value == null
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|| value.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
field + " must contain 16..128 URL-safe non-control characters");
}
return value;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.cache;
/** Provider-neutral owner-safe release result for a cache-refresh soft lease. */
public sealed interface CacheRefreshReleaseOutcome
permits CacheRefreshReleaseOutcome.Released,
CacheRefreshReleaseOutcome.AlreadyReleased,
CacheRefreshReleaseOutcome.NotOwner,
CacheRefreshReleaseOutcome.Disabled,
CacheRefreshReleaseOutcome.Unavailable,
CacheRefreshReleaseOutcome.Indeterminate {
record Released() implements CacheRefreshReleaseOutcome {}
record AlreadyReleased() implements CacheRefreshReleaseOutcome {}
record NotOwner() implements CacheRefreshReleaseOutcome {}
record Disabled() implements CacheRefreshReleaseOutcome {}
record Unavailable() implements CacheRefreshReleaseOutcome {}
record Indeterminate() implements CacheRefreshReleaseOutcome {}
}
@@ -13,4 +13,11 @@ public interface CacheRegionPort<K, V> {
CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata);
CacheInvalidationOutcome invalidate(K key);
/**
* Makes every entry written under the previously captured region generation invisible.
*
* <p>This is a semantic mass invalidation, not a provider key scan or bulk delete.
*/
CacheInvalidationOutcome invalidateRegion();
}
@@ -0,0 +1,131 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** End-to-end cache-aside result without provider or transport types. */
public sealed interface CacheResult<V>
permits CacheResult.FreshHit,
CacheResult.NegativeHit,
CacheResult.LoadedFromSource,
CacheResult.AuthoritativeAbsent,
CacheResult.StaleFallbackAfterTransientFailure,
CacheResult.StaleRefreshDeferred,
CacheResult.SourceFailed,
CacheResult.Rejected,
CacheResult.Cancelled,
CacheResult.IncompatibleSchema {
record FreshHit<V>(V value, String sourceRevision) implements CacheResult<V> {
public FreshHit {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
}
}
record NegativeHit<V>(dev.caskeleton.application.cache.AuthoritativeAbsence reason)
implements CacheResult<V> {
public NegativeHit {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record LoadedFromSource<V>(V value, String sourceRevision, CacheRecordOutcome recordOutcome)
implements CacheResult<V> {
public LoadedFromSource {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
}
}
record AuthoritativeAbsent<V>(
dev.caskeleton.application.cache.AuthoritativeAbsence reason,
String sourceRevision,
CacheRecordOutcome recordOutcome)
implements CacheResult<V> {
public AuthoritativeAbsent {
Objects.requireNonNull(reason, "reason must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
}
}
record StaleFallbackAfterTransientFailure<V>(
V value, String sourceRevision, SourceFailure failure) implements CacheResult<V> {
public StaleFallbackAfterTransientFailure {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record StaleRefreshDeferred<V>(V value, String sourceRevision, RefreshDeferralReason reason)
implements CacheResult<V> {
public StaleRefreshDeferred {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record SourceFailed<V>(SourceFailure failure, SourceFailureKind kind) implements CacheResult<V> {
public SourceFailed {
Objects.requireNonNull(failure, "failure must be non-null");
Objects.requireNonNull(kind, "kind must be non-null");
}
}
record Rejected<V>(RejectionReason reason) implements CacheResult<V> {
public Rejected {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record Cancelled<V>() implements CacheResult<V> {}
record IncompatibleSchema<V>(CacheLookup.SchemaCategory category, CacheLookup.SchemaPolicy policy)
implements CacheResult<V> {
public IncompatibleSchema {
Objects.requireNonNull(category, "category must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
}
}
enum SourceFailureKind {
TRANSIENT,
PERMANENT
}
enum RejectionReason {
MAXIMUM_IN_FLIGHT_KEYS,
MAXIMUM_WAITERS,
SOURCE_OVERLOADED,
WAIT_TIMEOUT,
LOAD_TIMEOUT
}
enum RefreshDeferralReason {
CONTENDED,
COORDINATION_UNAVAILABLE,
COORDINATION_INDETERMINATE
}
private static void requireSourceRevision(String sourceRevision) {
if (sourceRevision == null
|| sourceRevision.isBlank()
|| sourceRevision.length() > 128
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 non-control characters");
}
}
}
@@ -0,0 +1,210 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** Bounded, synchronous local coalescing for one semantic cache region. */
public final class CacheSingleFlight<K, V> {
private static final int MAXIMUM_BOUND = 4096;
private final int maximumInFlightKeys;
private final int maximumWaitersPerKey;
private final LongSupplier monotonicTicker;
private final Map<K, Flight<V>> flights = new HashMap<>();
public CacheSingleFlight(int maximumInFlightKeys, int maximumWaitersPerKey) {
this(maximumInFlightKeys, maximumWaitersPerKey, System::nanoTime);
}
CacheSingleFlight(
int maximumInFlightKeys, int maximumWaitersPerKey, LongSupplier monotonicTicker) {
if (maximumInFlightKeys < 1 || maximumInFlightKeys > MAXIMUM_BOUND) {
throw new IllegalArgumentException("maximumInFlightKeys must be in 1.." + MAXIMUM_BOUND);
}
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_BOUND) {
throw new IllegalArgumentException("maximumWaitersPerKey must be in 0.." + MAXIMUM_BOUND);
}
this.maximumInFlightKeys = maximumInFlightKeys;
this.maximumWaitersPerKey = maximumWaitersPerKey;
this.monotonicTicker =
Objects.requireNonNull(monotonicTicker, "monotonicTicker must be non-null");
}
public Outcome<V> execute(K key, Duration waiterTimeout, Supplier<V> leaderAction) {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(waiterTimeout, "waiterTimeout must be non-null");
Objects.requireNonNull(leaderAction, "leaderAction must be non-null");
if (waiterTimeout.isNegative()) {
throw new IllegalArgumentException("waiterTimeout must be non-negative");
}
Flight<V> flight;
boolean leader;
synchronized (flights) {
long monotonicNow = monotonicTicker.getAsLong();
flight = flights.get(key);
if (flight != null && flight.isAbandonedAt(monotonicNow)) {
flights.remove(key, flight);
flight = null;
}
if (flight == null) {
if (flights.size() >= maximumInFlightKeys) {
flights.values().removeIf(candidate -> candidate.isAbandonedAt(monotonicNow));
if (flights.size() >= maximumInFlightKeys) {
return new Rejected<>(RejectionReason.MAXIMUM_IN_FLIGHT_KEYS);
}
}
flight = new Flight<>(deadlineFrom(monotonicNow, waiterTimeout));
flights.put(key, flight);
leader = true;
} else {
leader = false;
}
}
if (!leader) {
return await(flight, waiterTimeout);
}
try {
V value = Objects.requireNonNull(leaderAction.get(), "leader result must be non-null");
flight.result.complete(value);
return new Completed<>(value);
} catch (RuntimeException | Error failure) {
flight.result.completeExceptionally(failure);
throw failure;
} finally {
synchronized (flights) {
flights.remove(key, flight);
}
}
}
int inFlightCount() {
synchronized (flights) {
return flights.size();
}
}
int waiterCount(K key) {
synchronized (flights) {
Flight<V> flight = flights.get(key);
return flight == null ? 0 : flight.waiters.get();
}
}
private Outcome<V> await(Flight<V> flight, Duration waiterTimeout) {
if (Thread.currentThread().isInterrupted()) {
return new Interrupted<>();
}
if (!acquireWaiter(flight)) {
return new Rejected<>(RejectionReason.MAXIMUM_WAITERS);
}
try {
return new Completed<>(
flight.result.get(toNanosSaturated(waiterTimeout), TimeUnit.NANOSECONDS));
} catch (TimeoutException timeout) {
return new Rejected<>(RejectionReason.WAIT_TIMEOUT);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new Interrupted<>();
} catch (ExecutionException execution) {
return rethrow(execution.getCause());
} finally {
flight.waiters.decrementAndGet();
}
}
private boolean acquireWaiter(Flight<V> flight) {
while (true) {
int current = flight.waiters.get();
if (current >= maximumWaitersPerKey) {
return false;
}
if (flight.waiters.compareAndSet(current, current + 1)) {
return true;
}
}
}
private static long toNanosSaturated(Duration duration) {
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static long deadlineFrom(long monotonicNow, Duration timeout) {
long timeoutNanos = toNanosSaturated(timeout);
if (timeoutNanos == Long.MAX_VALUE) {
return Long.MAX_VALUE;
}
try {
return Math.addExact(monotonicNow, timeoutNanos);
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static <T> Outcome<T> rethrow(Throwable cause) {
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException(
"single-flight completed with an unexpected checked failure", cause);
}
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
public record Completed<T>(T value) implements Outcome<T> {
public Completed {
Objects.requireNonNull(value, "value must be non-null");
}
}
public record Rejected<T>(RejectionReason reason) implements Outcome<T> {
public Rejected {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
public record Interrupted<T>() implements Outcome<T> {}
public enum RejectionReason {
MAXIMUM_IN_FLIGHT_KEYS,
MAXIMUM_WAITERS,
WAIT_TIMEOUT
}
private static final class Flight<T> {
private final CompletableFuture<T> result = new CompletableFuture<>();
private final AtomicInteger waiters = new AtomicInteger();
private final long abandonAtNanos;
private Flight(long abandonAtNanos) {
this.abandonAtNanos = abandonAtNanos;
}
private boolean isAbandonedAt(long monotonicNow) {
return abandonAtNanos != Long.MAX_VALUE
&& monotonicNow - abandonAtNanos >= 0
&& !result.isDone();
}
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/** Bounded source concurrency admission shared by every key in one semantic cache region. */
public final class CacheSourceBulkhead {
private final Semaphore permits;
public CacheSourceBulkhead(int maximumConcurrentLoads) {
if (maximumConcurrentLoads < 1) {
throw new IllegalArgumentException("maximumConcurrentLoads must be positive");
}
permits = new Semaphore(maximumConcurrentLoads, true);
}
public <T> Outcome<T> execute(Duration admissionWait, Supplier<T> action) {
Objects.requireNonNull(admissionWait, "admissionWait must be non-null");
Objects.requireNonNull(action, "action must be non-null");
if (admissionWait.isNegative()) {
throw new IllegalArgumentException("admissionWait must be non-negative");
}
if (Thread.currentThread().isInterrupted()) {
return new Interrupted<>();
}
boolean acquired;
try {
acquired = permits.tryAcquire(toNanosSaturated(admissionWait), TimeUnit.NANOSECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new Interrupted<>();
}
if (!acquired) {
return new Rejected<>();
}
try {
return new Completed<>(
Objects.requireNonNull(action.get(), "source action result must be non-null"));
} finally {
permits.release();
}
}
private static long toNanosSaturated(Duration duration) {
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
public record Completed<T>(T value) implements Outcome<T> {
public Completed {
Objects.requireNonNull(value, "value must be non-null");
}
}
public record Rejected<T>() implements Outcome<T> {}
public record Interrupted<T>() implements Outcome<T> {}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Loads one semantic cache key from its authoritative source. */
@FunctionalInterface
public interface CacheSourceLoader<K, V> {
SourceLoadOutcome<V> load(K key, CacheCancellationToken cancellation);
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.cache;
import java.nio.charset.StandardCharsets;
/**
* Opaque provider snapshot captured by lookup and returned unchanged when recording a source load.
*
* <p>The application coordinates the token but never parses provider generation or revision
* details.
*/
public record CacheWriteCondition(String value) {
private static final String UNAVAILABLE_VALUE = "write-condition-unavailable";
private static final int MAXIMUM_BYTES = 512;
public CacheWriteCondition {
if (value == null
|| value.isBlank()
|| value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_BYTES) {
throw new IllegalArgumentException(
"cache write condition must contain a bounded opaque value of 1..512 UTF-8 bytes");
}
}
public static CacheWriteCondition unavailable() {
return new CacheWriteCondition(UNAVAILABLE_VALUE);
}
public boolean usable() {
return !UNAVAILABLE_VALUE.equals(value);
}
@Override
public String toString() {
return "CacheWriteCondition[REDACTED]";
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.cache;
/** Explicit no-op cache diagnostics adapter used when no metrics backend is installed. */
public final class DisabledCacheObservationPort implements CacheObservationPort {
private static final DisabledCacheObservationPort INSTANCE = new DisabledCacheObservationPort();
private DisabledCacheObservationPort() {}
public static DisabledCacheObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(CacheObservationEvent event) {
// Diagnostics must never change cache semantics.
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.cache;
import java.time.Duration;
/** Explicit no-op refresh coordinator for deployments that disable distributed soft leases. */
public final class DisabledCacheRefreshCoordinationPort<K>
implements CacheRefreshCoordinationPort<K> {
private static final DisabledCacheRefreshCoordinationPort<?> INSTANCE =
new DisabledCacheRefreshCoordinationPort<>();
private DisabledCacheRefreshCoordinationPort() {}
@SuppressWarnings("unchecked")
public static <K> DisabledCacheRefreshCoordinationPort<K> instance() {
return (DisabledCacheRefreshCoordinationPort<K>) INSTANCE;
}
@Override
public boolean enabled() {
return false;
}
@Override
public CacheRefreshClaimAttempt newAttempt() {
return CacheRefreshClaimAttempt.unavailable();
}
@Override
public CacheRefreshClaimOutcome claim(
K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
return new CacheRefreshClaimOutcome.Disabled();
}
@Override
public CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt) {
return new CacheRefreshReleaseOutcome.Disabled();
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/**
* Bounded source failure classification with its original cause preserved for the application
* boundary. The cause message must not be copied into cache state, metrics, or tags.
*/
public record SourceFailure(String code, Throwable cause) {
public SourceFailure {
if (code == null || !code.matches("[A-Z][A-Z0-9_]{0,63}")) {
throw new IllegalArgumentException("code must be a bounded uppercase failure code");
}
Objects.requireNonNull(cause, "cause must be non-null");
}
@Override
public String toString() {
return "SourceFailure[code=" + code + ']';
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Business-classified result of consulting a cache region's authoritative source. */
public sealed interface SourceLoadOutcome<V>
permits SourceLoadOutcome.Loaded,
SourceLoadOutcome.AuthoritativeAbsent,
SourceLoadOutcome.TransientFailure,
SourceLoadOutcome.PermanentFailure,
SourceLoadOutcome.Cancelled {
record Loaded<V>(V value, String sourceRevision) implements SourceLoadOutcome<V> {
public Loaded {
Objects.requireNonNull(value, "value must be non-null");
requireSourceRevision(sourceRevision);
}
}
record AuthoritativeAbsent<V>(
dev.caskeleton.application.cache.AuthoritativeAbsence reason, String sourceRevision)
implements SourceLoadOutcome<V> {
public AuthoritativeAbsent {
Objects.requireNonNull(reason, "reason must be non-null");
requireSourceRevision(sourceRevision);
}
}
record TransientFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
public TransientFailure {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record PermanentFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
public PermanentFailure {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record Cancelled<V>() implements SourceLoadOutcome<V> {}
private static void requireSourceRevision(String sourceRevision) {
if (sourceRevision == null
|| sourceRevision.isBlank()
|| sourceRevision.length() > 128
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 non-control characters");
}
}
}
@@ -47,6 +47,7 @@ public record FilePublishReceipt(
public enum DurabilityGuarantee {
PROCESS_LOCAL_SYNC,
FILE_AND_DIRECTORY_SYNC,
PROVIDER_ACK_ONLY
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.idempotency;
/** Caller-retained owner and operation tokens allocated before the first provider send. */
public record IdempotencyClaimAttempt(String ownerToken, String operationId) {
public IdempotencyClaimAttempt {
ownerToken = IdempotencyV2Validation.opaqueToken(ownerToken, "ownerToken");
operationId = IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "IdempotencyClaimAttempt[REDACTED]";
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/** Typed result of the atomic request-replay claim state machine. */
public sealed interface IdempotencyClaimOutcome {
record Acquired(IdempotencyOwner owner, Instant processingLeaseUntil)
implements IdempotencyClaimOutcome {
public Acquired {
Objects.requireNonNull(owner, "owner must be non-null");
IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil");
}
}
record ReplayedAcquire(IdempotencyOwner owner, Instant processingLeaseUntil)
implements IdempotencyClaimOutcome {
public ReplayedAcquire {
Objects.requireNonNull(owner, "owner must be non-null");
IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil");
}
}
record TakenOverClaimed(IdempotencyOwner owner, Instant processingLeaseUntil)
implements IdempotencyClaimOutcome {
public TakenOverClaimed {
Objects.requireNonNull(owner, "owner must be non-null");
IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil");
}
}
record CompletedReplay(StoredResponse response, Instant replayUntil)
implements IdempotencyClaimOutcome {
public CompletedReplay {
Objects.requireNonNull(response, "response must be non-null");
IdempotencyV2Validation.instant(replayUntil, "replayUntil");
}
@Override
public String toString() {
return "CompletedReplay[response=REDACTED, replayUntil=" + replayUntil + "]";
}
}
record InProgress(Duration retryAfter, long currentAttempt) implements IdempotencyClaimOutcome {
public InProgress {
retryAfter =
IdempotencyV2Validation.positiveBounded(
retryAfter, IdempotencyV2Validation.MAXIMUM_RETRY_AFTER, "retryAfter");
currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt");
}
}
record RecoveryRequired(long currentAttempt) implements IdempotencyClaimOutcome {
public RecoveryRequired {
currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt");
}
}
record FingerprintMismatch() implements IdempotencyClaimOutcome {}
record OwnerOperationConflict() implements IdempotencyClaimOutcome {}
record Indeterminate(String operationId) implements IdempotencyClaimOutcome {
public Indeterminate {
operationId = IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "Indeterminate[operationId=REDACTED]";
}
}
record Unavailable() implements IdempotencyClaimOutcome {}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
import java.util.Objects;
/**
* Atomic claim inputs with separate processing lease and durable recovery/replay retention.
*
* <p>{@code replayTtl} retains in-progress execution evidence as well as a completed response. It
* must outlive the processing lease so an expired {@code EXECUTING} record becomes recovery
* required instead of disappearing and being unsafely re-executed.
*/
public record IdempotencyClaimRequest(
IdempotencyScope scope,
RequestFingerprint fingerprint,
IdempotencyClaimAttempt claimAttempt,
Duration processingLeaseTtl,
Duration replayTtl,
String responseCodecId,
String policyRevision) {
public IdempotencyClaimRequest {
Objects.requireNonNull(scope, "scope must be non-null");
Objects.requireNonNull(fingerprint, "fingerprint must be non-null");
Objects.requireNonNull(claimAttempt, "claimAttempt must be non-null");
processingLeaseTtl =
IdempotencyV2Validation.positiveBounded(
processingLeaseTtl,
IdempotencyV2Validation.MAXIMUM_PROCESSING_LEASE,
"processingLeaseTtl");
replayTtl =
IdempotencyV2Validation.positiveBounded(
replayTtl, IdempotencyV2Validation.MAXIMUM_REPLAY_TTL, "replayTtl");
if (replayTtl.compareTo(processingLeaseTtl) <= 0) {
throw new IllegalArgumentException(
"replayTtl recovery retention must outlive processingLeaseTtl");
}
responseCodecId = IdempotencyV2Validation.boundedId(responseCodecId, "responseCodecId");
policyRevision = IdempotencyV2Validation.boundedId(policyRevision, "policyRevision");
}
/** Retention used for in-progress recovery evidence before the record becomes replayable. */
public Duration recoveryRetention() {
return replayTtl;
}
@Override
public String toString() {
return "IdempotencyClaimRequest[scope=REDACTED, fingerprint=REDACTED, "
+ "claimAttempt=REDACTED, processingLeaseTtl="
+ processingLeaseTtl
+ ", replayTtl="
+ replayTtl
+ ", responseCodecId="
+ responseCodecId
+ ", policyRevision="
+ policyRevision
+ "]";
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe response completion result with same-result replay and conflict separation. */
public record IdempotencyCompleteOutcome(Status status, String operationId) {
public IdempotencyCompleteOutcome {
Objects.requireNonNull(status, "status must be non-null");
operationId = validateOperation(status, operationId);
}
public static IdempotencyCompleteOutcome responseConflict() {
return new IdempotencyCompleteOutcome(Status.RESPONSE_CONFLICT, null);
}
public static IdempotencyCompleteOutcome operationConflict() {
return new IdempotencyCompleteOutcome(Status.OPERATION_CONFLICT, null);
}
public static IdempotencyCompleteOutcome indeterminate(String operationId) {
return new IdempotencyCompleteOutcome(Status.INDETERMINATE, operationId);
}
public static IdempotencyCompleteOutcome unavailable() {
return new IdempotencyCompleteOutcome(Status.UNAVAILABLE, null);
}
@Override
public String toString() {
return "IdempotencyCompleteOutcome[status=" + status + ", operationId=REDACTED]";
}
private static String validateOperation(Status status, String operationId) {
if (status == Status.INDETERMINATE) {
return IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
if (operationId != null) {
throw new IllegalArgumentException("operationId is valid only for INDETERMINATE");
}
return null;
}
public enum Status {
COMPLETED,
ALREADY_COMPLETED_SAME_RESULT,
RESPONSE_CONFLICT,
ABSENT,
NOT_OWNER,
NOT_IN_PROGRESS,
OPERATION_CONFLICT,
INDETERMINATE,
UNAVAILABLE
}
}
@@ -0,0 +1,230 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
import java.util.Objects;
/**
* Owner-safe request-replay lifecycle.
*
* <p>The action runs only after a confirmed {@code STARTED}. This orchestration preserves
* request-replay evidence but does not create a cross-store exactly-once boundary.
*/
public final class IdempotencyExecutorV2 {
private final IdempotencyStorePortV2 store;
private final Duration processingLeaseTtl;
private final Duration replayTtl;
private final Duration failureRetention;
private final String responseCodecId;
private final String policyRevision;
public IdempotencyExecutorV2(
IdempotencyStorePortV2 store,
Duration processingLeaseTtl,
Duration replayTtl,
Duration failureRetention,
String responseCodecId,
String policyRevision) {
this.store = Objects.requireNonNull(store, "store must be non-null");
this.processingLeaseTtl = Objects.requireNonNull(processingLeaseTtl);
this.replayTtl = Objects.requireNonNull(replayTtl);
this.failureRetention = Objects.requireNonNull(failureRetention);
this.responseCodecId = Objects.requireNonNull(responseCodecId);
this.policyRevision = Objects.requireNonNull(policyRevision);
new IdempotencyClaimRequest(
IdempotencyScope.of("validation", "validation", "validation"),
new RequestFingerprint("0".repeat(64)),
new IdempotencyClaimAttempt("validation_owner", "validation_operation"),
processingLeaseTtl,
replayTtl,
responseCodecId,
policyRevision);
IdempotencyV2Validation.positiveBounded(
failureRetention, IdempotencyV2Validation.MAXIMUM_REPLAY_TTL, "failureRetention");
}
public IdempotencyClaimAttempt newAttempt(String operationId) {
return store.newClaimAttempt(operationId);
}
public <R> R execute(
IdempotencyScope scope,
RequestFingerprint fingerprint,
IdempotencyClaimAttempt attempt,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
IdempotencyClaimRequest request =
new IdempotencyClaimRequest(
scope,
fingerprint,
attempt,
processingLeaseTtl,
replayTtl,
responseCodecId,
policyRevision);
IdempotencyClaimOutcome claim = store.claim(request);
if (claim instanceof IdempotencyClaimOutcome.CompletedReplay replay) {
return codec.deserialize(replay.response().payload());
}
if (claim instanceof IdempotencyClaimOutcome.FingerprintMismatch) {
throw new IdempotencyRequestMismatchException(scope);
}
if (claim instanceof IdempotencyClaimOutcome.InProgress) {
throw new IdempotencyInFlightException(scope);
}
if (claim instanceof IdempotencyClaimOutcome.RecoveryRequired
|| claim instanceof IdempotencyClaimOutcome.OwnerOperationConflict) {
throw recovery("claim requires reconciliation");
}
if (claim instanceof IdempotencyClaimOutcome.Unavailable) {
throw new IdempotencyUnavailableException();
}
if (claim instanceof IdempotencyClaimOutcome.Indeterminate) {
return reconcileClaim(request, action, codec);
}
IdempotencyOwner owner =
switch (claim) {
case IdempotencyClaimOutcome.Acquired acquired -> acquired.owner();
case IdempotencyClaimOutcome.ReplayedAcquire replayed -> replayed.owner();
case IdempotencyClaimOutcome.TakenOverClaimed takenOver -> takenOver.owner();
default -> throw recovery("unsupported claim outcome");
};
return startAndRun(request, owner, action, codec, false);
}
private <R> R reconcileClaim(
IdempotencyClaimRequest request,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
IdempotencyInspection inspection =
store.inspect(
new IdempotencyInspectionRequest(
request.scope(), request.fingerprint(), request.claimAttempt()));
return switch (inspection) {
case IdempotencyInspection.ClaimedSameOperation claimed ->
startAndRun(request, claimed.owner(), action, codec, false);
case IdempotencyInspection.ExecutingSameOperation executing ->
runStarted(request, executing.owner(), action, codec);
case IdempotencyInspection.CompletedReplay replay ->
codec.deserialize(replay.response().payload());
case IdempotencyInspection.FingerprintMismatch ignored ->
throw new IdempotencyRequestMismatchException(request.scope());
case IdempotencyInspection.Unavailable ignored -> throw new IdempotencyUnavailableException();
default -> throw recovery("indeterminate claim cannot be safely resumed");
};
}
private <R> R startAndRun(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec,
boolean retriedStart) {
String operationId = request.claimAttempt().operationId();
IdempotencyStartOutcome started = store.markExecutionStarted(owner, operationId);
if (started.status() == IdempotencyStartOutcome.Status.INDETERMINATE && !retriedStart) {
IdempotencyInspection inspection =
store.inspect(
new IdempotencyInspectionRequest(
request.scope(), request.fingerprint(), request.claimAttempt()));
if (inspection instanceof IdempotencyInspection.ClaimedSameOperation claimed) {
return startAndRun(request, claimed.owner(), action, codec, true);
}
if (inspection instanceof IdempotencyInspection.ExecutingSameOperation executing) {
return runStarted(request, executing.owner(), action, codec);
}
if (inspection instanceof IdempotencyInspection.CompletedReplay replay) {
return codec.deserialize(replay.response().payload());
}
throw recovery("execution start is indeterminate");
}
if (started.status() == IdempotencyStartOutcome.Status.UNAVAILABLE) {
throw new IdempotencyUnavailableException();
}
if (started.status() != IdempotencyStartOutcome.Status.STARTED
&& started.status() != IdempotencyStartOutcome.Status.ALREADY_STARTED_SAME_OPERATION) {
throw recovery("execution start was not confirmed for the exact operation");
}
return runStarted(request, owner, action, codec);
}
private <R> R runStarted(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
String operationId = request.claimAttempt().operationId();
IdempotentAction.Outcome<R> outcome;
try {
outcome = Objects.requireNonNull(action.run(), "action outcome must be non-null");
} catch (RuntimeException failure) {
preserveUnknown(owner, operationId);
throw failure;
}
return switch (outcome) {
case IdempotentAction.Outcome.Success<R> success ->
complete(request, owner, operationId, success.result(), codec, false);
case IdempotentAction.Outcome.RetryableNoEffect<R> retryable -> {
store.markFailed(
owner,
IdempotencyFailureDisposition.RETRYABLE_NO_EFFECT,
failureRetention,
operationId);
throw retryable.failure();
}
case IdempotentAction.Outcome.EffectUnknown<R> unknown -> {
preserveUnknown(owner, operationId);
throw unknown.failure();
}
};
}
private <R> R complete(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
String operationId,
R result,
IdempotentResponseCodec<R> codec,
boolean retried) {
StoredResponse response = new StoredResponse(codec.serialize(result));
IdempotencyCompleteOutcome completed = store.complete(owner, response, replayTtl, operationId);
if (completed.status() == IdempotencyCompleteOutcome.Status.COMPLETED
|| completed.status() == IdempotencyCompleteOutcome.Status.ALREADY_COMPLETED_SAME_RESULT) {
return result;
}
if (completed.status() == IdempotencyCompleteOutcome.Status.INDETERMINATE) {
IdempotencyInspection inspection =
store.inspect(
new IdempotencyInspectionRequest(
request.scope(), request.fingerprint(), request.claimAttempt()));
if (inspection instanceof IdempotencyInspection.CompletedReplay replay) {
if (response.equals(replay.response())) {
return result;
}
throw recovery("completion replay conflicts with the local response");
}
if (inspection instanceof IdempotencyInspection.ExecutingSameOperation && !retried) {
return complete(request, owner, operationId, result, codec, true);
}
throw recovery("completion response is indeterminate and could not be reconciled");
}
if (completed.status() == IdempotencyCompleteOutcome.Status.UNAVAILABLE) {
throw new IdempotencyUnavailableException();
}
throw recovery("completion was not confirmed");
}
private void preserveUnknown(IdempotencyOwner owner, String operationId) {
store.markFailed(
owner,
IdempotencyFailureDisposition.ABANDONED_EFFECT_UNKNOWN,
failureRetention,
operationId);
}
private static IdempotencyRecoveryRequiredException recovery(String message) {
return new IdempotencyRecoveryRequiredException(message);
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe failed/abandoned transition result. */
public record IdempotencyFailOutcome(Status status, String operationId) {
public IdempotencyFailOutcome {
Objects.requireNonNull(status, "status must be non-null");
operationId = validateOperation(status, operationId);
}
public static IdempotencyFailOutcome operationConflict() {
return new IdempotencyFailOutcome(Status.OPERATION_CONFLICT, null);
}
public static IdempotencyFailOutcome indeterminate(String operationId) {
return new IdempotencyFailOutcome(Status.INDETERMINATE, operationId);
}
public static IdempotencyFailOutcome unavailable() {
return new IdempotencyFailOutcome(Status.UNAVAILABLE, null);
}
@Override
public String toString() {
return "IdempotencyFailOutcome[status=" + status + ", operationId=REDACTED]";
}
private static String validateOperation(Status status, String operationId) {
if (status == Status.INDETERMINATE) {
return IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
if (operationId != null) {
throw new IllegalArgumentException("operationId is valid only for INDETERMINATE");
}
return null;
}
public enum Status {
MARKED_RETRYABLE,
MARKED_ABANDONED,
ALREADY_MARKED_SAME_OPERATION,
ABSENT,
NOT_OWNER,
NOT_IN_PROGRESS,
OPERATION_CONFLICT,
INDETERMINATE,
UNAVAILABLE
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.idempotency;
/** Application-confirmed effect disposition after execution started. */
public enum IdempotencyFailureDisposition {
RETRYABLE_NO_EFFECT,
ABANDONED_EFFECT_UNKNOWN
}
@@ -0,0 +1,69 @@
package dev.caskeleton.application.idempotency;
import java.time.Instant;
import java.util.Objects;
/** Read-only result used to reconcile claim/start responses without creating a new owner. */
public sealed interface IdempotencyInspection {
record Absent() implements IdempotencyInspection {}
record ClaimedSameOperation(IdempotencyOwner owner, Instant processingLeaseUntil)
implements IdempotencyInspection {
public ClaimedSameOperation {
Objects.requireNonNull(owner, "owner must be non-null");
IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil");
}
}
record ExecutingSameOperation(IdempotencyOwner owner, Instant processingLeaseUntil)
implements IdempotencyInspection {
public ExecutingSameOperation {
Objects.requireNonNull(owner, "owner must be non-null");
IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil");
}
}
record CompletedReplay(StoredResponse response, Instant replayUntil)
implements IdempotencyInspection {
public CompletedReplay {
Objects.requireNonNull(response, "response must be non-null");
IdempotencyV2Validation.instant(replayUntil, "replayUntil");
}
@Override
public String toString() {
return "CompletedReplay[response=REDACTED, replayUntil=" + replayUntil + "]";
}
}
record InProgressOther(long currentAttempt) implements IdempotencyInspection {
public InProgressOther {
currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt");
}
}
record FailedRetryable(long currentAttempt) implements IdempotencyInspection {
public FailedRetryable {
currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt");
}
}
record Abandoned(long currentAttempt) implements IdempotencyInspection {
public Abandoned {
currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt");
}
}
record FingerprintMismatch() implements IdempotencyInspection {}
record OperationConflict() implements IdempotencyInspection {}
record Unavailable() implements IdempotencyInspection {}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Read-only reconciliation input for a retained claim attempt after an uncertain response. */
public record IdempotencyInspectionRequest(
IdempotencyScope scope, RequestFingerprint fingerprint, IdempotencyClaimAttempt claimAttempt) {
public IdempotencyInspectionRequest {
Objects.requireNonNull(scope, "scope must be non-null");
Objects.requireNonNull(fingerprint, "fingerprint must be non-null");
Objects.requireNonNull(claimAttempt, "claimAttempt must be non-null");
}
@Override
public String toString() {
return "IdempotencyInspectionRequest[REDACTED]";
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe handle returned by a successful v2 claim. */
public record IdempotencyOwner(IdempotencyScope scope, String ownerToken, long attempt) {
public IdempotencyOwner {
Objects.requireNonNull(scope, "scope must be non-null");
ownerToken = IdempotencyV2Validation.opaqueToken(ownerToken, "ownerToken");
attempt = IdempotencyV2Validation.positiveAttempt(attempt, "attempt");
}
@Override
public String toString() {
return "IdempotencyOwner[attempt=" + attempt + ", scope=REDACTED, ownerToken=REDACTED]";
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.idempotency;
/** Raised when request replay cannot safely decide whether an external effect already occurred. */
public final class IdempotencyRecoveryRequiredException extends RuntimeException {
private static final long serialVersionUID = 1L;
public IdempotencyRecoveryRequiredException(String message) {
super(message);
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe release result valid only before execution starts. */
public record IdempotencyReleaseOutcome(Status status, String operationId) {
public IdempotencyReleaseOutcome {
Objects.requireNonNull(status, "status must be non-null");
operationId = validateOperation(status, operationId);
}
public static IdempotencyReleaseOutcome executionAlreadyStarted() {
return new IdempotencyReleaseOutcome(Status.EXECUTION_ALREADY_STARTED, null);
}
public static IdempotencyReleaseOutcome operationConflict() {
return new IdempotencyReleaseOutcome(Status.OPERATION_CONFLICT, null);
}
public static IdempotencyReleaseOutcome indeterminate(String operationId) {
return new IdempotencyReleaseOutcome(Status.INDETERMINATE, operationId);
}
public static IdempotencyReleaseOutcome unavailable() {
return new IdempotencyReleaseOutcome(Status.UNAVAILABLE, null);
}
@Override
public String toString() {
return "IdempotencyReleaseOutcome[status=" + status + ", operationId=REDACTED]";
}
private static String validateOperation(Status status, String operationId) {
if (status == Status.INDETERMINATE) {
return IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
if (operationId != null) {
throw new IllegalArgumentException("operationId is valid only for INDETERMINATE");
}
return null;
}
public enum Status {
RELEASED_BEFORE_EXECUTION,
ALREADY_RELEASED_SAME_OPERATION,
ABSENT,
NOT_OWNER,
EXECUTION_ALREADY_STARTED,
OPERATION_CONFLICT,
INDETERMINATE,
UNAVAILABLE
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe processing lease renewal result. */
public record IdempotencyRenewOutcome(Status status, String operationId) {
public IdempotencyRenewOutcome {
Objects.requireNonNull(status, "status must be non-null");
operationId = validateOperation(status, operationId);
}
public static IdempotencyRenewOutcome operationConflict() {
return new IdempotencyRenewOutcome(Status.OPERATION_CONFLICT, null);
}
public static IdempotencyRenewOutcome indeterminate(String operationId) {
return new IdempotencyRenewOutcome(Status.INDETERMINATE, operationId);
}
public static IdempotencyRenewOutcome unavailable() {
return new IdempotencyRenewOutcome(Status.UNAVAILABLE, null);
}
@Override
public String toString() {
return "IdempotencyRenewOutcome[status=" + status + ", operationId=REDACTED]";
}
private static String validateOperation(Status status, String operationId) {
if (status == Status.INDETERMINATE) {
return IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
if (operationId != null) {
throw new IllegalArgumentException("operationId is valid only for INDETERMINATE");
}
return null;
}
public enum Status {
RENEWED,
ALREADY_RENEWED_SAME_OPERATION,
ABSENT,
NOT_OWNER,
NOT_IN_PROGRESS,
OPERATION_CONFLICT,
INDETERMINATE,
UNAVAILABLE
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/** Owner-safe {@code CLAIMED -> EXECUTING} transition result. */
public record IdempotencyStartOutcome(Status status, String operationId) {
public IdempotencyStartOutcome {
Objects.requireNonNull(status, "status must be non-null");
operationId = validateOperation(status, operationId);
}
public static IdempotencyStartOutcome operationConflict() {
return new IdempotencyStartOutcome(Status.OPERATION_CONFLICT, null);
}
public static IdempotencyStartOutcome indeterminate(String operationId) {
return new IdempotencyStartOutcome(Status.INDETERMINATE, operationId);
}
public static IdempotencyStartOutcome unavailable() {
return new IdempotencyStartOutcome(Status.UNAVAILABLE, null);
}
@Override
public String toString() {
return "IdempotencyStartOutcome[status=" + status + ", operationId=REDACTED]";
}
private static String validateOperation(Status status, String operationId) {
if (status == Status.INDETERMINATE) {
return IdempotencyV2Validation.opaqueToken(operationId, "operationId");
}
if (operationId != null) {
throw new IllegalArgumentException("operationId is valid only for INDETERMINATE");
}
return null;
}
public enum Status {
STARTED,
ALREADY_STARTED_SAME_OPERATION,
ABSENT,
NOT_OWNER,
NOT_CLAIMED,
OPERATION_CONFLICT,
INDETERMINATE,
UNAVAILABLE
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
/**
* Owner-safe request-replay store contract.
*
* <p>This contract does not promise cross-store exactly-once. Every mutation compares the owner
* token and attempt, and every uncertain response remains inspectable with the caller-retained
* operation token.
*/
public interface IdempotencyStorePortV2 {
IdempotencyClaimAttempt newClaimAttempt(String operationId);
IdempotencyClaimOutcome claim(IdempotencyClaimRequest request);
IdempotencyStartOutcome markExecutionStarted(IdempotencyOwner owner, String operationId);
IdempotencyRenewOutcome renew(
IdempotencyOwner owner, Duration processingLeaseTtl, String operationId);
IdempotencyCompleteOutcome complete(
IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId);
IdempotencyFailOutcome markFailed(
IdempotencyOwner owner,
IdempotencyFailureDisposition disposition,
Duration retention,
String operationId);
IdempotencyReleaseOutcome releaseBeforeExecution(IdempotencyOwner owner, String operationId);
IdempotencyInspection inspect(IdempotencyInspectionRequest request);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.idempotency;
/** Fail-closed signal raised before an action when request-replay coordination is unavailable. */
public final class IdempotencyUnavailableException extends RuntimeException {
private static final long serialVersionUID = 1L;
public IdempotencyUnavailableException() {
super("idempotency coordination is unavailable");
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
final class IdempotencyV2Validation {
static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(24);
static final Duration MAXIMUM_REPLAY_TTL = Duration.ofDays(30);
static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private IdempotencyV2Validation() {}
static String opaqueToken(String value, String field) {
if (value == null
|| value.length() < 16
|| value.length() > 128
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(field + " must contain 16..128 Base64URL-safe characters");
}
return value;
}
static String boundedId(String value, String field) {
if (value == null || !value.matches("[a-z][a-z0-9._-]{0,62}")) {
throw new IllegalArgumentException(field + " must be a bounded identifier");
}
return value;
}
static Duration positiveBounded(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return value;
}
static Instant instant(Instant value, String field) {
return Objects.requireNonNull(value, field + " must be non-null");
}
static long positiveAttempt(long value, String field) {
if (value < 1 || value > 1_000_000_000L) {
throw new IllegalArgumentException(field + " must be in 1..1000000000");
}
return value;
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/**
* Explicitly classifies action effects after execution has started.
*
* <p>An ordinary thrown exception is intentionally not classified as no-effect; the v2 executor
* treats it as effect-unknown and preserves recovery evidence.
*/
@FunctionalInterface
public interface IdempotentAction<R> {
Outcome<R> run();
sealed interface Outcome<R> {
record Success<R>(R result) implements Outcome<R> {}
record RetryableNoEffect<R>(RuntimeException failure) implements Outcome<R> {
public RetryableNoEffect {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
record EffectUnknown<R>(RuntimeException failure) implements Outcome<R> {
public EffectUnknown {
Objects.requireNonNull(failure, "failure must be non-null");
}
}
}
}
@@ -18,9 +18,9 @@ public record RequestFingerprint(String hex) {
public RequestFingerprint {
Objects.requireNonNull(hex, "hex");
if (hex.length() != 64) {
if (!hex.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"SHA-256 fingerprint must be 64 hex chars, was " + hex.length());
"SHA-256 fingerprint must be 64 lowercase hexadecimal characters");
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.lease;
/**
* Provider-neutral v2 efficiency lease port.
*
* <p>Callers retain the same {@link LeaseAttempt} across acquire retries and inspection. This port
* does not provide fencing and must not be used as the sole authority for a domain invariant.
*/
public interface DistributedLeasePort {
LeaseAttempt newAttempt(String operationId);
LeaseAcquireOutcome tryAcquire(LeaseRequest request);
LeaseInspectionOutcome inspect(LeaseInspectionRequest request);
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.lease;
import java.time.Duration;
import java.util.Objects;
/** Typed acquire outcome retaining response-loss uncertainty. */
public sealed interface LeaseAcquireOutcome {
record Acquired(LeaseHandle handle) implements LeaseAcquireOutcome {
public Acquired {
Objects.requireNonNull(handle, "handle must be non-null");
}
}
record ReplayedSameOperation(LeaseHandle handle) implements LeaseAcquireOutcome {
public ReplayedSameOperation {
Objects.requireNonNull(handle, "handle must be non-null");
}
}
record Contended(Duration retryAfter) implements LeaseAcquireOutcome {
public Contended {
retryAfter =
LeaseValidation.positiveBounded(
retryAfter, LeaseValidation.MAXIMUM_RETRY_AFTER, "retryAfter");
}
}
record OwnerOperationConflict() implements LeaseAcquireOutcome {}
record Unavailable(LeaseUnavailableCategory category) implements LeaseAcquireOutcome {
public Unavailable {
Objects.requireNonNull(category, "category must be non-null");
}
}
record Overloaded() implements LeaseAcquireOutcome {}
record Indeterminate(String operationId) implements LeaseAcquireOutcome {
public Indeterminate {
operationId = LeaseValidation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "Indeterminate[operationId=REDACTED]";
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.lease;
/** Caller-retained owner and operation identity allocated before the first provider send. */
public record LeaseAttempt(String ownerToken, String operationId) {
public LeaseAttempt {
ownerToken = LeaseValidation.opaqueToken(ownerToken, "ownerToken");
operationId = LeaseValidation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "LeaseAttempt[ownerToken=REDACTED, operationId=REDACTED]";
}
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.lease;
/** The generic lease reduces duplicate work but cannot authorize correctness-sensitive writes. */
public enum LeaseGuarantee {
EFFICIENCY_ONLY
}
@@ -0,0 +1,58 @@
package dev.caskeleton.application.lease;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* Owner-safe efficiency lease handle.
*
* <p>The server expiry is diagnostic only. Implementations must calculate remaining validity from a
* local monotonic elapsed budget and move to {@link LeaseState#UNKNOWN} or {@link LeaseState#LOST}
* when renewal certainty is unavailable.
*/
public interface LeaseHandle extends AutoCloseable {
String ownerToken();
String operationId();
Instant acquiredAt();
Duration remainingValidity();
Instant observedServerExpiry();
LeaseState state();
LeaseRenewOutcome renew(Duration leaseTtl);
LeaseReleaseOutcome release();
/**
* Compatibility cleanup for try-with-resources.
*
* <p>Callers that need release certainty must invoke {@link #release()} and inspect its typed
* outcome before closing.
*/
@Override
default void close() {
release();
}
default LeaseGuarantee guarantee() {
return LeaseGuarantee.EFFICIENCY_ONLY;
}
default boolean isUsableFor(Duration workBudget) {
Objects.requireNonNull(workBudget, "workBudget must be non-null");
if (workBudget.isNegative()) {
throw new IllegalArgumentException("workBudget must not be negative");
}
Duration remaining =
Objects.requireNonNull(remainingValidity(), "remainingValidity must be non-null");
return state() == LeaseState.ACTIVE
&& !remaining.isNegative()
&& remaining.compareTo(workBudget) >= 0;
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.lease;
import java.util.Objects;
/** Typed reconciliation outcome for a retained acquire attempt. */
public sealed interface LeaseInspectionOutcome {
record Owned(LeaseHandle handle) implements LeaseInspectionOutcome {
public Owned {
Objects.requireNonNull(handle, "handle must be non-null");
}
}
record Absent() implements LeaseInspectionOutcome {}
record NotOwner() implements LeaseInspectionOutcome {}
record OwnerOperationConflict() implements LeaseInspectionOutcome {}
record Unavailable(LeaseUnavailableCategory category) implements LeaseInspectionOutcome {
public Unavailable {
Objects.requireNonNull(category, "category must be non-null");
}
}
record Indeterminate(String operationId) implements LeaseInspectionOutcome {
public Indeterminate {
operationId = LeaseValidation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "Indeterminate[operationId=REDACTED]";
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.lease;
import java.util.Objects;
/** Read-only reconciliation request using the exact caller-retained acquire attempt. */
public record LeaseInspectionRequest(String purpose, String resourceDigest, LeaseAttempt attempt) {
public LeaseInspectionRequest {
purpose = LeaseValidation.purpose(purpose);
resourceDigest = LeaseValidation.resourceDigest(resourceDigest);
Objects.requireNonNull(attempt, "attempt must be non-null");
}
@Override
public String toString() {
return "LeaseInspectionRequest[purpose="
+ purpose
+ ", resourceDigest=REDACTED, attempt=REDACTED]";
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.lease;
/** Owner-safe release outcome; blind deletion is not representable. */
public sealed interface LeaseReleaseOutcome {
record Released() implements LeaseReleaseOutcome {}
record AlreadyAbsent() implements LeaseReleaseOutcome {}
record NotOwner() implements LeaseReleaseOutcome {}
record Indeterminate(String operationId) implements LeaseReleaseOutcome {
public Indeterminate {
operationId = LeaseValidation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "Indeterminate[operationId=REDACTED]";
}
}
record Unavailable(LeaseUnavailableCategory category) implements LeaseReleaseOutcome {
public Unavailable {
if (category == null) {
throw new IllegalArgumentException("category must be non-null");
}
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.lease;
import java.time.Duration;
/** Owner-safe lease renewal outcome. */
public sealed interface LeaseRenewOutcome {
record Renewed(Duration remainingValidity) implements LeaseRenewOutcome {
public Renewed {
remainingValidity =
LeaseValidation.positiveBounded(
remainingValidity, LeaseValidation.MAXIMUM_LEASE, "remainingValidity");
}
}
record Absent() implements LeaseRenewOutcome {}
record NotOwner() implements LeaseRenewOutcome {}
record Indeterminate(String operationId) implements LeaseRenewOutcome {
public Indeterminate {
operationId = LeaseValidation.opaqueToken(operationId, "operationId");
}
@Override
public String toString() {
return "Indeterminate[operationId=REDACTED]";
}
}
record Unavailable(LeaseUnavailableCategory category) implements LeaseRenewOutcome {
public Unavailable {
if (category == null) {
throw new IllegalArgumentException("category must be non-null");
}
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.lease;
import java.time.Duration;
import java.util.Objects;
/** Bounded provider-neutral request for an efficiency lease. */
public record LeaseRequest(
String purpose,
String resourceDigest,
Duration waitTimeout,
Duration leaseTtl,
LeaseAttempt attempt) {
public LeaseRequest {
purpose = LeaseValidation.purpose(purpose);
resourceDigest = LeaseValidation.resourceDigest(resourceDigest);
waitTimeout =
LeaseValidation.nonNegativeBounded(
waitTimeout, LeaseValidation.MAXIMUM_WAIT, "waitTimeout");
leaseTtl = LeaseValidation.positiveBounded(leaseTtl, LeaseValidation.MAXIMUM_LEASE, "leaseTtl");
Objects.requireNonNull(attempt, "attempt must be non-null");
}
@Override
public String toString() {
return "LeaseRequest[purpose="
+ purpose
+ ", resourceDigest=REDACTED, waitTimeout="
+ waitTimeout
+ ", leaseTtl="
+ leaseTtl
+ ", attempt=REDACTED]";
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.lease;
/** Local handle state after command certainty and validity-budget evaluation. */
public enum LeaseState {
ACTIVE,
LOST,
RELEASED,
UNKNOWN
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.lease;
/** Provider-neutral acquisition or inspection failure category. */
public enum LeaseUnavailableCategory {
UNAVAILABLE_BEFORE_SEND,
ADMISSION_REJECTED,
DEADLINE_EXPIRED
}
@@ -0,0 +1,67 @@
package dev.caskeleton.application.lease;
import java.time.Duration;
import java.util.Objects;
final class LeaseValidation {
static final Duration MAXIMUM_WAIT = Duration.ofSeconds(30);
static final Duration MAXIMUM_LEASE = Duration.ofHours(24);
static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private LeaseValidation() {}
static String opaqueToken(String value, String field) {
if (value == null
|| value.length() < 16
|| value.length() > 128
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(field + " must contain 16..128 Base64URL-safe characters");
}
return value;
}
static String purpose(String value) {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException("purpose must be a bounded identifier");
}
return value;
}
static String resourceDigest(String value) {
if (value == null || !value.matches("hv[1-9][0-9]{0,3}:[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"resourceDigest must be a versioned lowercase SHA-256 digest");
}
return value;
}
static Duration nonNegativeBounded(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be non-negative and bounded");
}
return wholeMilliseconds(value, field);
}
static Duration positiveBounded(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return wholeMilliseconds(value, field);
}
private static Duration wholeMilliseconds(Duration value, String field) {
long milliseconds;
try {
milliseconds = value.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException(field + " exceeds supported milliseconds", exception);
}
if (!Duration.ofMillis(milliseconds).equals(value)) {
throw new IllegalArgumentException(field + " must use whole milliseconds");
}
return value;
}
}
@@ -0,0 +1,221 @@
package dev.caskeleton.application.lease;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
/**
* Bounded renewal scheduler for efficiency leases.
*
* <p>A watchdog reduces duplicate work only. It does not remove process pauses, Redis failover, or
* the requirement for a correctness authority at the protected resource.
*/
public final class LeaseWatchdog implements AutoCloseable {
private static final int MAXIMUM_WORKERS = 32;
private static final int MAXIMUM_REGISTRATIONS = 100_000;
private final ScheduledThreadPoolExecutor scheduler;
private final int maximumRegistrations;
private final Clock clock;
private final AtomicInteger registrations = new AtomicInteger();
private final AtomicBoolean closed = new AtomicBoolean();
private final Set<Registration> active = ConcurrentHashMap.newKeySet();
public LeaseWatchdog(int workerThreads, int maximumRegistrations, Clock clock) {
if (workerThreads < 1 || workerThreads > MAXIMUM_WORKERS) {
throw new IllegalArgumentException("workerThreads must be in 1..32");
}
if (maximumRegistrations < 1 || maximumRegistrations > MAXIMUM_REGISTRATIONS) {
throw new IllegalArgumentException("maximumRegistrations must be in 1..100000");
}
this.maximumRegistrations = maximumRegistrations;
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.scheduler =
new ScheduledThreadPoolExecutor(
workerThreads,
daemonThreadFactory(),
new java.util.concurrent.ThreadPoolExecutor.AbortPolicy());
this.scheduler.setRemoveOnCancelPolicy(true);
this.scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
this.scheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
}
public Registration watch(
LeaseHandle handle,
Duration leaseTtl,
Duration cadence,
Instant applicationDeadline,
Runnable cancelWork,
Consumer<LeaseState> lostListener) {
Objects.requireNonNull(handle, "handle must be non-null");
Duration boundedLeaseTtl =
LeaseValidation.positiveBounded(leaseTtl, LeaseValidation.MAXIMUM_LEASE, "leaseTtl");
Duration boundedCadence = LeaseValidation.positiveBounded(cadence, boundedLeaseTtl, "cadence");
if (boundedCadence.compareTo(boundedLeaseTtl.dividedBy(2)) > 0) {
throw new IllegalArgumentException("cadence must not exceed half of leaseTtl");
}
Objects.requireNonNull(applicationDeadline, "applicationDeadline must be non-null");
if (!applicationDeadline.isAfter(clock.instant())) {
throw new IllegalArgumentException("applicationDeadline must be in the future");
}
Objects.requireNonNull(cancelWork, "cancelWork must be non-null");
Objects.requireNonNull(lostListener, "lostListener must be non-null");
reserve();
Registration registration =
new Registration(handle, boundedLeaseTtl, applicationDeadline, cancelWork, lostListener);
active.add(registration);
try {
registration.future =
scheduler.scheduleWithFixedDelay(
registration::runOnce,
boundedCadence.toMillis(),
boundedCadence.toMillis(),
TimeUnit.MILLISECONDS);
return registration;
} catch (RuntimeException failure) {
registration.close();
throw failure;
}
}
private void reserve() {
while (true) {
if (closed.get()) {
throw new RejectedExecutionException("lease watchdog is closed");
}
int current = registrations.get();
if (current >= maximumRegistrations) {
throw new RejectedExecutionException("lease watchdog registration bound reached");
}
if (registrations.compareAndSet(current, current + 1)) {
if (closed.get()) {
registrations.decrementAndGet();
throw new RejectedExecutionException("lease watchdog is closed");
}
return;
}
}
}
int activeRegistrations() {
return registrations.get();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
for (Registration registration : active.toArray(Registration[]::new)) {
registration.close();
}
for (Runnable ignored : scheduler.shutdownNow()) {
// Iteration deliberately observes the returned cancelled tasks for Error Prone compliance.
}
}
}
public final class Registration implements AutoCloseable {
private final LeaseHandle handle;
private final Duration leaseTtl;
private final Instant applicationDeadline;
private final Runnable cancelWork;
private final Consumer<LeaseState> lostListener;
private final AtomicBoolean registrationClosed = new AtomicBoolean();
private final AtomicBoolean lossReported = new AtomicBoolean();
private volatile ScheduledFuture<?> future;
private Registration(
LeaseHandle handle,
Duration leaseTtl,
Instant applicationDeadline,
Runnable cancelWork,
Consumer<LeaseState> lostListener) {
this.handle = handle;
this.leaseTtl = leaseTtl;
this.applicationDeadline = applicationDeadline;
this.cancelWork = cancelWork;
this.lostListener = lostListener;
}
private void runOnce() {
if (registrationClosed.get()) {
return;
}
if (!applicationDeadline.isAfter(clock.instant())) {
terminateLost(LeaseState.LOST);
return;
}
LeaseState before = handle.state();
if (before != LeaseState.ACTIVE || handle.remainingValidity().isZero()) {
terminateLost(before == LeaseState.ACTIVE ? LeaseState.LOST : before);
return;
}
LeaseRenewOutcome outcome;
try {
outcome = Objects.requireNonNull(handle.renew(leaseTtl), "renew outcome must be non-null");
} catch (RuntimeException failure) {
terminateLost(LeaseState.UNKNOWN);
return;
}
if (!(outcome instanceof LeaseRenewOutcome.Renewed) || handle.state() != LeaseState.ACTIVE) {
LeaseState state = handle.state();
terminateLost(state == LeaseState.ACTIVE ? LeaseState.LOST : state);
}
}
void runOnceForTest() {
runOnce();
}
public boolean closed() {
return registrationClosed.get();
}
private void terminateLost(LeaseState state) {
if (lossReported.compareAndSet(false, true)) {
try {
cancelWork.run();
} finally {
try {
lostListener.accept(state);
} finally {
close();
}
}
}
}
@Override
public void close() {
if (registrationClosed.compareAndSet(false, true)) {
ScheduledFuture<?> scheduled = future;
if (scheduled != null) {
scheduled.cancel(false);
}
active.remove(this);
registrations.decrementAndGet();
}
}
}
private static ThreadFactory daemonThreadFactory() {
AtomicInteger sequence = new AtomicInteger();
return runnable -> {
Thread thread = new Thread(runnable, "ca-lease-watchdog-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Applies one already authenticated and normalized receipt. */
public record ApplyNotificationReceiptCommand(NormalizedNotificationReceiptCommand receipt)
implements Command {
public ApplyNotificationReceiptCommand {
Objects.requireNonNull(receipt, "normalized notification receipt must be non-null");
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Non-sensitive result of reducing one receipt event. */
public record ApplyNotificationReceiptResult(
Status status, NotificationReceiptProjection projection, boolean suppressionApplied) {
public ApplyNotificationReceiptResult {
Objects.requireNonNull(status, "notification receipt apply status must be non-null");
Objects.requireNonNull(projection, "notification receipt projection must be non-null");
if (status == Status.DUPLICATE && suppressionApplied) {
throw new IllegalArgumentException("duplicate receipt cannot repeat technical suppression");
}
}
public enum Status {
APPLIED,
DUPLICATE
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/**
* Appends one receipt fact and reduces its delivery projection in one physical root transaction.
*/
@RequiresPermission("notification:receipt")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public final class ApplyNotificationReceiptUseCase
implements CommandUseCase<ApplyNotificationReceiptCommand, ApplyNotificationReceiptResult> {
private final NotificationReceiptStorePort store;
private final NotificationTechnicalSuppressionPort suppression;
private final TransactionPort transactions;
private final Clock clock;
public ApplyNotificationReceiptUseCase(
NotificationReceiptStorePort store,
NotificationTechnicalSuppressionPort suppression,
TransactionPort transactions,
Clock clock) {
this.store = Objects.requireNonNull(store, "notification receipt store must be non-null");
this.suppression =
Objects.requireNonNull(suppression, "notification suppression port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public ApplyNotificationReceiptResult handle(ApplyNotificationReceiptCommand command) {
Objects.requireNonNull(command, "apply notification receipt command must be non-null");
return transactions.inRootWrite(() -> applyInsideRoot(command.receipt()));
}
private ApplyNotificationReceiptResult applyInsideRoot(
NormalizedNotificationReceiptCommand command) {
NotificationReceiptStorePort.AppendResult appendResult = store.appendIfAbsent(command);
if (appendResult instanceof NotificationReceiptStorePort.Duplicate duplicate) {
return new ApplyNotificationReceiptResult(
ApplyNotificationReceiptResult.Status.DUPLICATE, duplicate.projection(), false);
}
NotificationReceiptStorePort.ReceiptAggregate aggregate =
((NotificationReceiptStorePort.Appended) appendResult).aggregate();
if (!aggregate.deliveryId().equals(command.deliveryId())) {
throw new IllegalStateException(
"receipt aggregate delivery does not match normalized command");
}
NotificationReceiptProjection projection =
NotificationReceiptProjection.reduce(aggregate.facts());
store.saveProjection(aggregate.deliveryId(), projection);
boolean suppressionApplied = shouldSuppress(command.fact());
if (suppressionApplied) {
suppression.suppress(
new NotificationTechnicalSuppressionPort.SuppressionMutation(
aggregate.recipient(), command.fact().reasonCode(), clock.instant()));
}
return new ApplyNotificationReceiptResult(
ApplyNotificationReceiptResult.Status.APPLIED, projection, suppressionApplied);
}
private static boolean shouldSuppress(NotificationReceiptFact fact) {
return fact.type() == NotificationReceiptFact.Type.COMPLAINT
|| (fact.type() == NotificationReceiptFact.Type.BOUNCE
&& fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification;
/** Point at which recipient consent or preference must be established. */
public enum ConsentCheckMode {
SNAPSHOT_AT_APPEND,
RECHECK_BEFORE_EACH_DELIVERY
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.notification;
/** Opaque reference resolved to an email recipient only inside a qualified adapter. */
public record EmailRecipientReference(String reference) implements NotificationRecipientReference {
public EmailRecipientReference {
reference = NotificationIntentId.requireOpaque("email recipient reference", reference);
}
@Override
public NotificationChannel channel() {
return NotificationChannel.EMAIL;
}
@Override
public String toString() {
return "EmailRecipientReference[reference=<redacted>]";
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Reviewed all-route initialization request; partial route sets are rejected by the use case. */
public record InitializeNotificationWriterFencesCommand(
String operationToken,
NotificationCanonicalWriterRouteSet reviewedRoutes,
String reviewedRouteSetDigest,
String actorReference,
NotificationReasonCode reasonCode)
implements Command {
public InitializeNotificationWriterFencesCommand {
operationToken =
NotificationIntentId.requireOpaque("writer initialization operation token", operationToken);
Objects.requireNonNull(reviewedRoutes, "reviewed writer routes must be non-null");
reviewedRouteSetDigest = requireDigest(reviewedRouteSetDigest);
actorReference =
NotificationIntentId.requireOpaque("writer initialization actor", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
static String requireDigest(String digest) {
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"writer evidence digest must be 64 lowercase hex characters");
}
return digest;
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
/** Atomic persistence operation for absent-fence and empty-journal initialization. */
@FunctionalInterface
public interface InitializeNotificationWriterFencesOperation {
InitializeNotificationWriterFencesResult initialize(
InitializeNotificationWriterFencesCommand command,
NotificationWriterRouteSet trustedRoutes,
Instant requestedAt);
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Committed all-route fence initialization result. */
public record InitializeNotificationWriterFencesResult(
Status status, int initializedRouteCount, String routeSetDigest) {
public InitializeNotificationWriterFencesResult {
Objects.requireNonNull(status, "writer initialization status must be non-null");
if (initializedRouteCount < 1 || initializedRouteCount > 100) {
throw new IllegalArgumentException("initialized writer route count must be in 1..100");
}
routeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(routeSetDigest);
}
public enum Status {
INITIALIZED,
REPLAYED
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/** Root-commits the complete trusted writer fence and proof registry initialization batch. */
@RequiresPermission("notification:cutover")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
crossTenantAdmin = true)
public final class InitializeNotificationWriterFencesUseCase
implements CommandUseCase<
InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> {
private final NotificationWriterRouteSet routes;
private final InitializeNotificationWriterFencesOperation operation;
private final TransactionPort transactions;
private final Clock clock;
public InitializeNotificationWriterFencesUseCase(
NotificationWriterRouteSet routes,
InitializeNotificationWriterFencesOperation operation,
TransactionPort transactions,
Clock clock) {
this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null");
this.operation =
Objects.requireNonNull(operation, "writer initialization operation must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public InitializeNotificationWriterFencesResult handle(
InitializeNotificationWriterFencesCommand command) {
Objects.requireNonNull(command, "writer initialization command must be non-null");
if (!command.reviewedRoutes().equals(routes.canonicalRoutes())) {
throw new IllegalArgumentException(
"reviewed writer routes must exactly equal the trusted all-route set");
}
if (!command.reviewedRouteSetDigest().equals(routes.digest())) {
throw new IllegalArgumentException(
"reviewed writer route-set digest does not match trusted registry digest");
}
return transactions.inRootWrite(() -> operation.initialize(command, routes, clock.instant()));
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification;
/**
* Executes one bounded, non-durable inline attempt over a frozen application plan. The caller must
* establish the physical root-write sequencing contract before invoking this port.
*/
@FunctionalInterface
public interface InlineNotificationAttemptPort {
NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan);
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Framework-free receipt normalized by an authenticated inbound adapter. */
public record NormalizedNotificationReceiptCommand(
NotificationReceiptEventId receiptEventId,
NotificationDeliveryId deliveryId,
NotificationReceiptFact fact) {
public NormalizedNotificationReceiptCommand {
Objects.requireNonNull(receiptEventId, "notification receipt event ID must be non-null");
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(fact, "notification receipt fact must be non-null");
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.notification;
/** Code-owned dispatch admission and fairness class. */
public enum NotificationAdmissionClass {
SECURITY_CRITICAL,
TRANSACTIONAL,
BULK_LOW_VALUE
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Audited operator request to re-probe and resume one shared notification admission gate. */
public record NotificationAdmissionGateCommand(
String operationToken,
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
int maximumParkedLegs,
String actorReference,
NotificationReasonCode reasonCode)
implements Command {
public NotificationAdmissionGateCommand {
operationToken =
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("operator admission command cannot target DELIVERY scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
actorReference =
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
NotificationAdmissionReadinessPort.ResumeRequest toResumeRequest() {
return new NotificationAdmissionReadinessPort.ResumeRequest(
operationToken,
routeId,
policyRevision,
faultScope,
scopeReference,
expectedGeneration,
maximumParkedLegs,
actorReference,
reasonCode);
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/** Probes readiness outside a transaction and generation-CAS resumes inside one short write. */
@RequiresPermission("notification:operate")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true,
crossTenantAdmin = true)
public final class NotificationAdmissionGateUseCase
implements CommandUseCase<
NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> {
private final NotificationAdmissionReadinessPort admission;
private final TransactionPort transactions;
private final Clock clock;
public NotificationAdmissionGateUseCase(
NotificationAdmissionReadinessPort admission, TransactionPort transactions, Clock clock) {
this.admission =
Objects.requireNonNull(admission, "notification admission port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public Result handle(NotificationAdmissionGateCommand command) {
Objects.requireNonNull(command, "notification admission command must be non-null");
NotificationAdmissionReadinessPort.ResumeRequest request = command.toResumeRequest();
NotificationAdmissionReadinessPort.ReadinessProbe probe =
Objects.requireNonNull(admission.probe(request), "readiness probe must be non-null");
if (!probe.ready()) {
return new Result(Result.Status.NOT_READY, probe.reasonCode());
}
NotificationAdmissionReadinessPort.ResumeResult resumeResult =
Objects.requireNonNull(
transactions.inWrite(() -> admission.resume(request, probe, clock.instant())),
"notification admission resume result must be non-null");
validateResumeResult(request, resumeResult);
return switch (resumeResult.status()) {
case RESUMED -> new Result(Result.Status.RESUMED, probe.reasonCode());
case ALREADY_ACTIVE -> new Result(Result.Status.ALREADY_ACTIVE, probe.reasonCode());
case STALE_GENERATION ->
new Result(
Result.Status.STALE_GENERATION,
new NotificationReasonCode("STALE_ADMISSION_GENERATION"));
};
}
private static void validateResumeResult(
NotificationAdmissionReadinessPort.ResumeRequest request,
NotificationAdmissionReadinessPort.ResumeResult result) {
if (result.processedLegCount() > request.maximumParkedLegs()) {
throw new IllegalArgumentException(
"admission resume processed more parked legs than the requested bound");
}
if (result.status() == NotificationAdmissionReadinessPort.ResumeStatus.RESUMED
&& result.resultingGeneration() != request.expectedGeneration() + 1) {
throw new IllegalArgumentException(
"resumed admission gate must advance the exact expected generation");
}
}
public record Result(Status status, NotificationReasonCode reasonCode) {
public Result {
Objects.requireNonNull(status, "notification admission result status must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
public enum Status {
RESUMED,
ALREADY_ACTIVE,
NOT_READY,
STALE_GENERATION
}
}
}
@@ -0,0 +1,154 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
import java.util.Objects;
/** Persists shared route/provider/account admission readiness with generation-guarded CAS. */
@FunctionalInterface
public interface NotificationAdmissionReadinessPort {
ParkResult park(ParkRequest request);
default ReadinessProbe probe(ResumeRequest request) {
throw new UnsupportedOperationException("notification readiness probe is not implemented");
}
default ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
throw new UnsupportedOperationException("notification admission resume is not implemented");
}
record ParkRequest(
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
NotificationReasonCode reasonCode,
Instant parkedAt) {
public ParkRequest {
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
Objects.requireNonNull(parkedAt, "admission parked time must be non-null");
}
}
enum ParkResult {
NOT_REQUESTED,
PARKED,
ALREADY_PARKED,
STALE_GENERATION
}
record ResumeRequest(
String operationToken,
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
int maximumParkedLegs,
String actorReference,
NotificationReasonCode reasonCode) {
public ResumeRequest {
operationToken =
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
actorReference =
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record ReadinessProbe(boolean ready, NotificationReasonCode reasonCode) {
public ReadinessProbe {
Objects.requireNonNull(reasonCode, "notification readiness reason must be non-null");
}
}
enum ResumeStatus {
RESUMED,
ALREADY_ACTIVE,
STALE_GENERATION
}
/**
* Audited bounded result of rechecking every selected parked leg inside the gate-resume
* transaction. Initial R1 never activates fallback while resuming a binding park.
*/
record ResumeResult(
ResumeStatus status,
long resultingGeneration,
int queuedCount,
int expiredCount,
int cancelledCount,
int technicallySuppressedCount,
int policyRejectedCount,
int activatedFallbackCount) {
public ResumeResult {
Objects.requireNonNull(status, "notification admission resume status must be non-null");
if (resultingGeneration < 0
|| queuedCount < 0
|| expiredCount < 0
|| cancelledCount < 0
|| technicallySuppressedCount < 0
|| policyRejectedCount < 0
|| activatedFallbackCount < 0) {
throw new IllegalArgumentException(
"notification admission resume generation/counts must be non-negative");
}
int processedLegCount =
queuedCount
+ expiredCount
+ cancelledCount
+ technicallySuppressedCount
+ policyRejectedCount;
if (processedLegCount > 100) {
throw new IllegalArgumentException(
"notification admission resume leg count must be bounded by 100");
}
if (activatedFallbackCount != 0) {
throw new IllegalArgumentException(
"binding-park resume must not activate initial fallback legs");
}
if (status != ResumeStatus.RESUMED && processedLegCount != 0) {
throw new IllegalArgumentException(
"non-mutating admission resume status cannot report processed legs");
}
}
public int processedLegCount() {
return queuedCount
+ expiredCount
+ cancelledCount
+ technicallySuppressedCount
+ policyRejectedCount;
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Durable append result; neither appended nor duplicate means provider delivery succeeded. */
public sealed interface NotificationAppendResult
permits NotificationAppendResult.Appended,
NotificationAppendResult.DuplicateExisting,
NotificationAppendResult.Rejected {
record Appended(NotificationIntentId intentId) implements NotificationAppendResult {
public Appended {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
}
}
record DuplicateExisting(NotificationIntentId intentId) implements NotificationAppendResult {
public DuplicateExisting {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
}
}
record Rejected(NotificationReasonCode reasonCode) implements NotificationAppendResult {
public Rejected {
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Framework- and provider-neutral application failure carrying only a stable reason code. */
public final class NotificationApplicationException extends RuntimeException {
private final NotificationReasonCode reasonCode;
public NotificationApplicationException(NotificationReasonCode reasonCode, Throwable cause) {
super(
Objects.requireNonNull(reasonCode, "notification reason code must be non-null").value(),
cause);
this.reasonCode = reasonCode;
}
public NotificationReasonCode reasonCode() {
return reasonCode;
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Opaque identity of one authorized physical provider attempt. */
public record NotificationAttemptId(String value) {
public NotificationAttemptId {
value = NotificationIntentId.requireOpaque("attemptId", value);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/**
* Internal application collaborator asserting canonical ownership in an existing write boundary.
*/
public final class NotificationCanonicalWriterFenceGuard {
private final NotificationCanonicalWriterFencePort fence;
private final NotificationCanonicalWriterRouteSet routes;
public NotificationCanonicalWriterFenceGuard(
NotificationCanonicalWriterFencePort fence, NotificationCanonicalWriterRouteSet routes) {
this.fence = Objects.requireNonNull(fence, "canonical writer fence port must be non-null");
this.routes = Objects.requireNonNull(routes, "canonical writer route set must be non-null");
}
public void assertCanonical(
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
if (!routes.contains(route)) {
throw new IllegalArgumentException(
"route is outside canonical notification writer route set");
}
NotificationCanonicalWriterFencePort.FenceSnapshot snapshot =
Objects.requireNonNull(
fence.assertCanonicalInCallerTransaction(
new NotificationCanonicalWriterFencePort.FenceRequest(route, expectedGeneration)),
"canonical writer fence snapshot must be non-null");
if (!snapshot.route().equals(route)) {
throw failure("CANONICAL_WRITER_ROUTE_MISMATCH");
}
if (snapshot.owner() != NotificationWriterOwnership.CANONICAL) {
throw failure("CANONICAL_WRITER_NOT_OWNER");
}
if (snapshot.generation() != expectedGeneration) {
throw failure("STALE_CANONICAL_WRITER_GENERATION");
}
}
private static NotificationApplicationException failure(String reason) {
return new NotificationApplicationException(new NotificationReasonCode(reason), null);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/**
* Acquires a transaction-scoped shared fence assertion. The persistence implementation must hold
* the share lock until the caller's physical commit or rollback.
*/
@FunctionalInterface
public interface NotificationCanonicalWriterFencePort {
FenceSnapshot assertCanonicalInCallerTransaction(FenceRequest request);
record FenceRequest(
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
public FenceRequest {
Objects.requireNonNull(route, "notification writer route must be non-null");
if (expectedGeneration < 0) {
throw new IllegalArgumentException("expected writer generation must be non-negative");
}
}
}
record FenceSnapshot(
NotificationCanonicalWriterRouteSet.RouteRevision route,
NotificationWriterOwnership owner,
long generation) {
public FenceSnapshot {
Objects.requireNonNull(route, "notification writer route must be non-null");
Objects.requireNonNull(owner, "notification writer owner must be non-null");
if (generation < 0) {
throw new IllegalArgumentException("writer generation must be non-negative");
}
}
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.application.notification;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Bounded, ordered canonical route-revision set that production admission is allowed to use. */
public record NotificationCanonicalWriterRouteSet(List<RouteRevision> routes) {
public NotificationCanonicalWriterRouteSet {
Objects.requireNonNull(routes, "canonical notification writer routes must be non-null");
routes =
routes.stream()
.map(route -> Objects.requireNonNull(route, "canonical route must be non-null"))
.sorted(
Comparator.comparing((RouteRevision route) -> route.routeId().value())
.thenComparingInt(RouteRevision::routeRevision))
.toList();
if (routes.isEmpty() || routes.size() > 100) {
throw new IllegalArgumentException("canonical writer route set must contain 1..100 routes");
}
if (new HashSet<>(routes).size() != routes.size()) {
throw new IllegalArgumentException("canonical writer route set contains a duplicate route");
}
long distinctRouteKeys = routes.stream().map(RouteRevision::routeId).distinct().count();
if (distinctRouteKeys != routes.size()) {
throw new IllegalArgumentException(
"canonical writer route set contains multiple revisions for one route key");
}
}
public boolean contains(RouteRevision route) {
return routes.contains(route);
}
public String digest() {
MessageDigest digest = sha256();
routes.forEach(
route -> {
update(digest, route.routeId().value());
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(route.routeRevision()).array());
digest.update(
ByteBuffer.allocate(Long.BYTES).putLong(route.predecessorGeneration()).array());
});
return java.util.HexFormat.of().formatHex(digest.digest());
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException(
"SHA-256 must be available on every Java runtime", unavailable);
}
}
static void update(MessageDigest digest, String value) {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
digest.update(encoded);
}
public record RouteRevision(
NotificationRouteId routeId, int routeRevision, long predecessorGeneration) {
public RouteRevision {
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (routeRevision < 1 || routeRevision > 1_000_000 || predecessorGeneration < 0) {
throw new IllegalArgumentException(
"route revision must be in 1..1000000 and predecessor generation non-negative");
}
}
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.application.notification;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Pure validator over application-owned policy and provider/store/ingress capability facts. */
public final class NotificationCapabilityCompatibilityValidator {
public Compatibility validate(
NotificationKindPolicy policy,
NotificationProviderCapabilityDescriptor provider,
NotificationStoreCapabilityDescriptor store,
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
boolean receiptRequired) {
Objects.requireNonNull(policy, "notification kind policy must be non-null");
Objects.requireNonNull(provider, "notification provider descriptor must be non-null");
Objects.requireNonNull(store, "notification store descriptor must be non-null");
Objects.requireNonNull(receiptIngress, "receipt ingress container must be non-null");
List<NotificationReasonCode> reasons = new ArrayList<>();
addIf(reasons, provider.channel() != policy.channel(), "PROVIDER_CHANNEL_MISMATCH");
addIf(reasons, !provider.supportedModes().contains(policy.mode()), "PROVIDER_MODE_UNSUPPORTED");
addIf(reasons, !provider.hiddenRetriesControlled(), "PROVIDER_HIDDEN_RETRY_UNCONTROLLED");
addIf(
reasons,
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
addIf(
reasons,
!store.durableIntentStore() || !store.attemptJournal(),
"DURABLE_STORE_UNAVAILABLE");
}
addIf(
reasons,
!store.availablePolicyRevisions().contains(policy.policyRevision()),
"POLICY_REVISION_UNAVAILABLE");
addIf(
reasons,
!store.availableTemplateRevisions().contains(policy.templateRef()),
"TEMPLATE_REVISION_UNAVAILABLE");
if (receiptRequired) {
addIf(reasons, !provider.receiptSupported(), "PROVIDER_RECEIPT_UNSUPPORTED");
addIf(reasons, !store.receiptInbox(), "RECEIPT_STORE_UNAVAILABLE");
boolean ingressUnavailable =
receiptIngress.isEmpty()
|| !receiptIngress.orElseThrow().enabled()
|| !receiptIngress.orElseThrow().authenticated()
|| receiptIngress.orElseThrow().channel() != policy.channel()
|| receiptIngress.orElseThrow().supportedFactTypes().isEmpty();
addIf(reasons, ingressUnavailable, "RECEIPT_INGRESS_UNAVAILABLE");
}
return new Compatibility(reasons.isEmpty(), reasons);
}
public void requireCompatible(
NotificationKindPolicy policy,
NotificationProviderCapabilityDescriptor provider,
NotificationStoreCapabilityDescriptor store,
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
boolean receiptRequired) {
Compatibility compatibility =
validate(policy, provider, store, receiptIngress, receiptRequired);
if (!compatibility.compatible()) {
throw new NotificationApplicationException(
new NotificationReasonCode("NOTIFICATION_CAPABILITY_INCOMPATIBLE"), null);
}
}
private static void addIf(
List<NotificationReasonCode> reasons, boolean condition, String reasonCode) {
if (condition) {
reasons.add(new NotificationReasonCode(reasonCode));
}
}
public record Compatibility(boolean compatible, List<NotificationReasonCode> reasonCodes) {
public Compatibility {
reasonCodes =
List.copyOf(
Objects.requireNonNull(
reasonCodes, "notification compatibility reasons must be non-null"));
if (compatible != reasonCodes.isEmpty()) {
throw new IllegalArgumentException(
"compatible flag must equal an empty incompatibility reason set");
}
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification;
/** Provider-neutral delivery medium. */
public enum NotificationChannel {
EMAIL,
SLACK
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Opaque identity of one provider leg for a logical recipient. */
public record NotificationDeliveryId(String value) {
public NotificationDeliveryId {
value = NotificationIntentId.requireOpaque("deliveryId", value);
}
}
@@ -0,0 +1,204 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/** Durable delivery-leg state port; provider I/O is deliberately absent from this contract. */
public interface NotificationDeliveryStorePort {
List<ClaimedDelivery> claimEligible(int maximumClaims, Instant now);
AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now);
FinalizationResult finalizeAttempt(
AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now);
List<ReconciliationClaim> claimForReconciliation(int maximumClaims, Instant now);
ReconciliationFinalizationResult finalizeReconciliation(
ReconciliationClaim claim,
NotificationReconciliationPort.ReconciliationOutcome outcome,
Instant now);
int attachOrphanReceipts(int maximumAttachments, Instant now);
record ClaimedDelivery(
NotificationDeliveryId deliveryId,
NotificationFrozenPlan plan,
int targetOrdinal,
String claimToken,
long rowVersion,
long admissionGeneration) {
public ClaimedDelivery {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
}
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
if (rowVersion < 0 || admissionGeneration < 0) {
throw new IllegalArgumentException(
"row version and admission generation must be non-negative");
}
}
}
sealed interface AttemptAuthorization permits Authorized, StaleClaim, NotEligible {}
record Authorized(AuthorizedAttempt attempt) implements AttemptAuthorization {
public Authorized {
Objects.requireNonNull(attempt, "authorized notification attempt must be non-null");
}
}
record StaleClaim(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
implements AttemptAuthorization {
public StaleClaim {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record NotEligible(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
implements AttemptAuthorization {
public NotEligible {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record AuthorizedAttempt(
NotificationDeliveryId deliveryId,
NotificationAttemptId attemptId,
NotificationFrozenPlan plan,
int targetOrdinal,
String claimToken,
String executionToken,
long expectedRowVersion,
long admissionGeneration,
String admissionScopeReference,
Instant absoluteDeadline) {
public AuthorizedAttempt {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(attemptId, "notification attempt ID must be non-null");
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
}
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
executionToken =
NotificationIntentId.requireOpaque("attempt execution token", executionToken);
if (claimToken.equals(executionToken)) {
throw new IllegalArgumentException(
"attempt execution token must be distinct from the claim token");
}
if (expectedRowVersion < 0 || admissionGeneration < 0) {
throw new IllegalArgumentException(
"row version and admission generation must be non-negative");
}
admissionScopeReference =
NotificationIntentId.requireOpaque("admission scope reference", admissionScopeReference);
Objects.requireNonNull(absoluteDeadline, "absolute attempt deadline must be non-null");
}
@Override
public String toString() {
return "AuthorizedAttempt[deliveryId=<redacted>, attemptId="
+ attemptId
+ ", plan=<redacted>, targetOrdinal="
+ targetOrdinal
+ ", claimToken=<redacted>, executionToken=<redacted>, expectedRowVersion="
+ expectedRowVersion
+ ", admissionGeneration="
+ admissionGeneration
+ ", admissionScopeReference=<redacted>, absoluteDeadline="
+ absoluteDeadline
+ "]";
}
}
record AttemptFinalization(
ProviderAttemptOutcome providerOutcome,
TerminalState terminalState,
boolean fallbackEligible,
NotificationAdmissionReadinessPort.ParkResult parkResult) {
public AttemptFinalization {
Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null");
Objects.requireNonNull(terminalState, "notification terminal state must be non-null");
Objects.requireNonNull(parkResult, "admission park result must be non-null");
if (fallbackEligible
&& providerOutcome.submissionCertainty() != SubmissionCertainty.DEFINITELY_NOT_APPLIED) {
throw new IllegalArgumentException("fallback is eligible only for DEFINITELY_NOT_APPLIED");
}
if (terminalState == TerminalState.TERMINAL_INDETERMINATE
&& providerOutcome.submissionCertainty() != SubmissionCertainty.INDETERMINATE) {
throw new IllegalArgumentException(
"TERMINAL_INDETERMINATE requires an indeterminate provider outcome");
}
if (terminalState == TerminalState.PARKED_BINDING
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
}
}
}
enum TerminalState {
ACCEPTED,
RETRY_SCHEDULED,
PARKED_BINDING,
TERMINAL_FAILURE,
TERMINAL_INDETERMINATE
}
enum FinalizationResult {
APPLIED,
LATE_EXACT_APPLIED,
STALE_EXECUTION_TOKEN,
ALREADY_TERMINAL
}
record ReconciliationClaim(
NotificationDeliveryId deliveryId,
String executionToken,
long expectedRowVersion,
String providerMessageReference,
Instant absoluteDeadline) {
public ReconciliationClaim {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
executionToken =
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
if (expectedRowVersion < 0) {
throw new IllegalArgumentException("reconciliation row version must be non-negative");
}
providerMessageReference =
NotificationIntentId.requireOpaque(
"provider message reference", providerMessageReference);
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
}
@Override
public String toString() {
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
+ "expectedRowVersion="
+ expectedRowVersion
+ ", providerMessageReference=<redacted>, absoluteDeadline="
+ absoluteDeadline
+ "]";
}
}
enum ReconciliationFinalizationResult {
APPLIED,
LATE_EXACT_APPLIED,
STALE_EXECUTION_TOKEN,
ALREADY_TERMINAL
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
/** Requests one bounded cross-tenant dispatch cycle. */
public record NotificationDispatchCommand(int maximumClaims) implements Command {
public NotificationDispatchCommand {
if (maximumClaims < 1 || maximumClaims > 100) {
throw new IllegalArgumentException("maximum notification claims must be in 1..100");
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.notification;
/** Bounded non-sensitive aggregate outcome of one dispatch cycle. */
public record NotificationDispatchResult(
int claimedCount,
int authorizedCount,
int providerCallCount,
int finalizedCount,
int staleClaimCount,
int indeterminateCount,
int parkedCount) {
public NotificationDispatchResult {
int[] counts = {
claimedCount,
authorizedCount,
providerCallCount,
finalizedCount,
staleClaimCount,
indeterminateCount,
parkedCount
};
for (int count : counts) {
if (count < 0 || count > 100) {
throw new IllegalArgumentException("notification dispatch counts must be in 0..100");
}
}
if (authorizedCount > claimedCount
|| providerCallCount > authorizedCount
|| finalizedCount > providerCallCount
|| staleClaimCount > claimedCount
|| indeterminateCount > providerCallCount
|| parkedCount > providerCallCount) {
throw new IllegalArgumentException("notification dispatch counts are inconsistent");
}
}
}
@@ -0,0 +1,191 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.List;
import java.util.Objects;
/** Coordinates short store transactions around provider I/O for a bounded delivery batch. */
@RequiresPermission("notification:dispatch")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true,
sensitiveRead = true,
crossTenantAdmin = true)
public final class NotificationDispatchUseCase
implements CommandUseCase<NotificationDispatchCommand, NotificationDispatchResult> {
private final NotificationDeliveryStorePort store;
private final NotificationProviderAttemptPort provider;
private final NotificationAdmissionReadinessPort admission;
private final TransactionPort transactions;
private final Clock clock;
public NotificationDispatchUseCase(
NotificationDeliveryStorePort store,
NotificationProviderAttemptPort provider,
NotificationAdmissionReadinessPort admission,
TransactionPort transactions,
Clock clock) {
this.store = Objects.requireNonNull(store, "notification delivery store must be non-null");
this.provider = Objects.requireNonNull(provider, "notification provider port must be non-null");
this.admission =
Objects.requireNonNull(admission, "notification admission port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public NotificationDispatchResult handle(NotificationDispatchCommand command) {
Objects.requireNonNull(command, "notification dispatch command must be non-null");
List<NotificationDeliveryStorePort.ClaimedDelivery> claimed =
List.copyOf(
transactions.inWrite(
() -> store.claimEligible(command.maximumClaims(), clock.instant())));
if (claimed.size() > command.maximumClaims()) {
throw new IllegalStateException("notification store returned more claims than requested");
}
MutableCounts counts = new MutableCounts(claimed.size());
for (NotificationDeliveryStorePort.ClaimedDelivery delivery : claimed) {
dispatchOne(delivery, counts);
}
return counts.toResult();
}
private void dispatchOne(
NotificationDeliveryStorePort.ClaimedDelivery delivery, MutableCounts counts) {
NotificationDeliveryStorePort.AttemptAuthorization authorization =
transactions.inWrite(() -> store.reserveAndAuthorize(delivery, clock.instant()));
if (authorization instanceof NotificationDeliveryStorePort.StaleClaim) {
counts.staleClaims++;
return;
}
if (authorization instanceof NotificationDeliveryStorePort.NotEligible) {
return;
}
NotificationDeliveryStorePort.AuthorizedAttempt attempt =
((NotificationDeliveryStorePort.Authorized) authorization).attempt();
counts.authorized++;
ProviderAttemptOutcome outcome = invokeProvider(attempt);
counts.providerCalls++;
if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) {
counts.indeterminate++;
}
FinalizationExecution execution =
transactions.inWrite(() -> finalizeInsideTransaction(attempt, outcome));
boolean applied =
execution.result() == NotificationDeliveryStorePort.FinalizationResult.APPLIED
|| execution.result()
== NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED;
if (applied) {
counts.finalized++;
}
if (applied
&& execution.finalization().terminalState()
== NotificationDeliveryStorePort.TerminalState.PARKED_BINDING) {
counts.parked++;
}
}
private ProviderAttemptOutcome invokeProvider(
NotificationDeliveryStorePort.AuthorizedAttempt attempt) {
try {
return Objects.requireNonNull(
provider.attempt(attempt), "provider attempt outcome must be non-null");
} catch (RuntimeException providerFailure) {
return new ProviderAttemptOutcome(
SubmissionCertainty.INDETERMINATE,
RetryDisposition.NOT_APPLICABLE,
NotificationFaultScope.DELIVERY,
new NotificationReasonCode("UNCLASSIFIED_PROVIDER_FAILURE"),
java.util.Optional.empty(),
attempt.executionToken(),
java.util.Optional.empty());
}
}
private FinalizationExecution finalizeInsideTransaction(
NotificationDeliveryStorePort.AuthorizedAttempt attempt, ProviderAttemptOutcome outcome) {
NotificationAdmissionReadinessPort.ParkResult parkResult =
NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED;
if (outcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
parkResult =
admission.park(
new NotificationAdmissionReadinessPort.ParkRequest(
attempt.plan().routeId(),
attempt.plan().policy().policyRevision(),
outcome.faultScope(),
attempt.admissionScopeReference(),
attempt.admissionGeneration(),
outcome.reasonCode(),
clock.instant()));
}
NotificationDeliveryStorePort.AttemptFinalization finalization =
new NotificationDeliveryStorePort.AttemptFinalization(
outcome, terminalState(outcome), fallbackEligible(outcome), parkResult);
NotificationDeliveryStorePort.FinalizationResult result =
Objects.requireNonNull(
store.finalizeAttempt(attempt, finalization, clock.instant()),
"notification attempt finalization result must be non-null");
return new FinalizationExecution(finalization, result);
}
private static NotificationDeliveryStorePort.TerminalState terminalState(
ProviderAttemptOutcome outcome) {
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
}
if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) {
return NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE;
}
return switch (outcome.retryDisposition()) {
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
case NOT_APPLICABLE ->
throw new IllegalArgumentException(
"definitely-not-applied outcome requires an explicit disposition");
};
}
private static boolean fallbackEligible(ProviderAttemptOutcome outcome) {
return outcome.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED
&& outcome.retryDisposition() == RetryDisposition.TERMINAL;
}
private record FinalizationExecution(
NotificationDeliveryStorePort.AttemptFinalization finalization,
NotificationDeliveryStorePort.FinalizationResult result) {}
private static final class MutableCounts {
private final int claimed;
private int authorized;
private int providerCalls;
private int finalized;
private int staleClaims;
private int indeterminate;
private int parked;
private MutableCounts(int claimed) {
this.claimed = claimed;
}
private NotificationDispatchResult toResult() {
return new NotificationDispatchResult(
claimed, authorized, providerCalls, finalized, staleClaims, indeterminate, parked);
}
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Historical issuer-key decision retained with accepted signed evidence. */
public record NotificationEvidenceTrustSnapshot(
String catalogRevision, HistoricalKeyStatus historicalKeyStatus, String issuerKeyDigest) {
public NotificationEvidenceTrustSnapshot {
catalogRevision =
NotificationIntentId.requireSlug("evidence trust catalog revision", catalogRevision);
Objects.requireNonNull(historicalKeyStatus, "historical evidence key status must be non-null");
issuerKeyDigest = InitializeNotificationWriterFencesCommand.requireDigest(issuerKeyDigest);
if (historicalKeyStatus == HistoricalKeyStatus.REVOKED) {
throw new IllegalArgumentException("revoked evidence issuer key cannot be accepted");
}
}
public enum HistoricalKeyStatus {
ALLOWED,
RETIRING,
REVOKED
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Smallest durable scope affected by a classified attempt failure. */
public enum NotificationFaultScope {
DELIVERY,
ROUTE_REVISION,
PROVIDER_BINDING,
ACCOUNT
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/** Immutable provider-neutral plan snapshot returned by the application planning boundary. */
public record NotificationFrozenPlan(
NotificationIntentId intentId,
NotificationKindPolicy policy,
Locale selectedLocale,
NotificationRecipientReference recipient,
NotificationTemplateParameters parameters,
String idempotencyScope,
String sourceOperationId,
Optional<String> tenantReference,
String correlationReference,
Optional<String> causationReference,
Instant notBefore,
Instant expiresAt) {
public NotificationFrozenPlan {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
Objects.requireNonNull(policy, "notification kind policy must be non-null");
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
Objects.requireNonNull(recipient, "notification recipient must be non-null");
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
idempotencyScope =
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
sourceOperationId =
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
Objects.requireNonNull(tenantReference, "tenant reference container must be non-null");
correlationReference =
NotificationIntentId.requireOpaque(
"notification correlation reference", correlationReference);
Objects.requireNonNull(causationReference, "causation reference container must be non-null");
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
if (recipient.channel() != policy.channel()) {
throw new IllegalArgumentException("recipient channel must match notification kind channel");
}
if (!expiresAt.isAfter(notBefore)) {
throw new IllegalArgumentException("notification expiry must be after not-before");
}
}
public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) {
Objects.requireNonNull(draft, "notification intent draft must be non-null");
return new NotificationFrozenPlan(
draft.intentId(),
draft.policy(),
selectedLocale,
draft.recipient(),
draft.parameters(),
draft.idempotencyScope(),
draft.sourceOperationId(),
draft.tenantReference(),
draft.correlationReference(),
draft.causationReference(),
draft.notBefore(),
draft.expiresAt());
}
public NotificationMode mode() {
return policy.mode();
}
public NotificationRouteId routeId() {
return policy.routeId();
}
@Override
public String toString() {
return "NotificationFrozenPlan[intentId="
+ intentId
+ ", kindId="
+ policy.kindId()
+ ", policyRevision="
+ policy.policyRevision()
+ ", selectedLocale="
+ selectedLocale.toLanguageTag()
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
+ notBefore
+ ", expiresAt="
+ expiresAt
+ "]";
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification;
/**
* Appends a frozen intent to durable storage in the caller's current transaction. Implementations
* must not open an independent transaction.
*/
@FunctionalInterface
public interface NotificationIntentAppendPort {
NotificationAppendResult append(NotificationFrozenPlan plan);
}
@@ -0,0 +1,84 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/** Feature-policy output containing one logical recipient and no provider or transport types. */
public record NotificationIntentDraft(
NotificationIntentId intentId,
NotificationKindPolicy policy,
Locale requestedLocale,
NotificationRecipientReference recipient,
NotificationTemplateParameters parameters,
String idempotencyScope,
String sourceOperationId,
Optional<String> tenantReference,
String correlationReference,
Optional<String> causationReference,
Instant notBefore,
Instant expiresAt) {
public NotificationIntentDraft {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
Objects.requireNonNull(policy, "notification kind policy must be non-null");
requestedLocale = requireLocale("requested locale", requestedLocale);
Objects.requireNonNull(recipient, "notification recipient must be non-null");
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
if (recipient.channel() != policy.channel()) {
throw new IllegalArgumentException("recipient channel must match notification kind channel");
}
idempotencyScope =
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
sourceOperationId =
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
tenantReference = requireOptionalOpaque("tenant reference", tenantReference);
correlationReference =
NotificationIntentId.requireOpaque(
"notification correlation reference", correlationReference);
causationReference = requireOptionalOpaque("causation reference", causationReference);
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
Duration lifetime = Duration.between(notBefore, expiresAt);
if (lifetime.isZero()
|| lifetime.isNegative()
|| lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) {
throw new IllegalArgumentException(
"notification expiry must be after not-before and within the policy retry horizon");
}
}
static Locale requireLocale(String field, Locale locale) {
Objects.requireNonNull(locale, field + " must be non-null");
String languageTag = locale.toLanguageTag();
if (locale.equals(Locale.ROOT)
|| languageTag.equals("und")
|| languageTag.isBlank()
|| languageTag.length() > 35) {
throw new IllegalArgumentException(field + " must be an explicit bounded locale");
}
return Locale.forLanguageTag(languageTag);
}
private static Optional<String> requireOptionalOpaque(String field, Optional<String> reference) {
Objects.requireNonNull(reference, field + " container must be non-null");
return reference.map(value -> NotificationIntentId.requireOpaque(field, value));
}
@Override
public String toString() {
return "NotificationIntentDraft[intentId="
+ intentId
+ ", kindId="
+ policy.kindId()
+ ", requestedLocale="
+ requestedLocale.toLanguageTag()
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
+ notBefore
+ ", expiresAt="
+ expiresAt
+ "]";
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification;
/** Opaque identity of one logical business notification. */
public record NotificationIntentId(String value) {
public NotificationIntentId {
value = requireOpaque("intentId", value);
}
static String requireOpaque(String field, String value) {
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
field + " must contain 1..128 opaque identifier characters");
}
return value;
}
static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Closed-catalog notification business kind. */
public record NotificationKindId(String value) {
public NotificationKindId {
value = NotificationIntentId.requireSlug("kindId", value);
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.util.Objects;
/**
* Code-owned notification kind catalog row. Runtime configuration may assert these values and lower
* resource concurrency, but cannot replace semantic fields in this record.
*/
public record NotificationKindPolicy(
NotificationKindId kindId,
NotificationChannel channel,
NotificationRouteId routeId,
NotificationTemplateRef templateRef,
NotificationMode mode,
NotificationAdmissionClass admissionClass,
NotificationRouteStrategy routeStrategy,
ConsentCheckMode consentCheckMode,
int policyRevision,
int maxTargetsPerRecipient,
int maxPhysicalAttemptsPerDelivery,
int maxFallbackActivations,
int maxReconcileCalls,
int maxTotalProviderCallsPerIntent,
Duration maxElapsedRetryHorizon) {
private static final int MAXIMUM_TARGETS = 16;
private static final int MAXIMUM_ATTEMPTS_PER_DELIVERY = 10;
private static final int MAXIMUM_PROVIDER_CALLS = 64;
private static final Duration MAXIMUM_RETRY_HORIZON = Duration.ofDays(30);
public NotificationKindPolicy {
Objects.requireNonNull(kindId, "notification kind must be non-null");
Objects.requireNonNull(channel, "notification channel must be non-null");
Objects.requireNonNull(routeId, "notification route must be non-null");
Objects.requireNonNull(templateRef, "notification template must be non-null");
Objects.requireNonNull(mode, "notification mode must be non-null");
Objects.requireNonNull(admissionClass, "notification admission class must be non-null");
Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null");
Objects.requireNonNull(consentCheckMode, "consent check mode must be non-null");
Objects.requireNonNull(maxElapsedRetryHorizon, "maximum retry horizon must be non-null");
if (policyRevision < 1 || policyRevision > 1_000_000) {
throw new IllegalArgumentException("policy revision must be in 1..1000000");
}
if (maxTargetsPerRecipient < 1 || maxTargetsPerRecipient > MAXIMUM_TARGETS) {
throw new IllegalArgumentException("maximum targets per recipient must be in 1..16");
}
if (maxPhysicalAttemptsPerDelivery < 1
|| maxPhysicalAttemptsPerDelivery > MAXIMUM_ATTEMPTS_PER_DELIVERY) {
throw new IllegalArgumentException("maximum physical attempts per delivery must be in 1..10");
}
if (maxFallbackActivations < 0 || maxFallbackActivations >= MAXIMUM_TARGETS) {
throw new IllegalArgumentException("maximum fallback activations must be in 0..15");
}
if (maxReconcileCalls < 0 || maxReconcileCalls > 10) {
throw new IllegalArgumentException("maximum reconcile calls must be in 0..10");
}
if (maxTotalProviderCallsPerIntent < 1
|| maxTotalProviderCallsPerIntent > MAXIMUM_PROVIDER_CALLS) {
throw new IllegalArgumentException("maximum total provider calls must be in 1..64");
}
if (maxElapsedRetryHorizon.isZero()
|| maxElapsedRetryHorizon.isNegative()
|| maxElapsedRetryHorizon.compareTo(MAXIMUM_RETRY_HORIZON) > 0) {
throw new IllegalArgumentException(
"maximum retry horizon must be positive and at most 30 days");
}
if (admissionClass == NotificationAdmissionClass.SECURITY_CRITICAL
&& mode == NotificationMode.BEST_EFFORT_INLINE) {
throw new IllegalArgumentException(
"SECURITY_CRITICAL notification kind cannot use BEST_EFFORT_INLINE");
}
validateStrategy(routeStrategy, maxTargetsPerRecipient, maxFallbackActivations);
if (mode == NotificationMode.BEST_EFFORT_INLINE
&& (maxPhysicalAttemptsPerDelivery != 1 || maxReconcileCalls != 0)) {
throw new IllegalArgumentException(
"BEST_EFFORT_INLINE permits one attempt per target and no reconciliation");
}
long worstCaseProviderCalls =
Math.addExact(
Math.multiplyExact(
(long) maxTargetsPerRecipient, (long) maxPhysicalAttemptsPerDelivery),
maxReconcileCalls);
if (worstCaseProviderCalls > maxTotalProviderCallsPerIntent) {
throw new IllegalArgumentException(
"worst-case provider calls exceed maximum total provider calls per intent");
}
}
public NotificationKindPolicy assertRuntimeExpectation(
NotificationMode expectedMode, NotificationAdmissionClass expectedAdmissionClass) {
Objects.requireNonNull(expectedMode, "expected notification mode must be non-null");
Objects.requireNonNull(
expectedAdmissionClass, "expected notification admission class must be non-null");
if (mode != expectedMode) {
throw new IllegalStateException(
"runtime expected mode " + expectedMode + " does not match code-owned mode " + mode);
}
if (admissionClass != expectedAdmissionClass) {
throw new IllegalStateException(
"runtime expected admission "
+ expectedAdmissionClass
+ " does not match code-owned admission "
+ admissionClass);
}
return this;
}
private static void validateStrategy(
NotificationRouteStrategy strategy, int maximumTargets, int maximumFallbacks) {
if (strategy == NotificationRouteStrategy.SINGLE
&& (maximumTargets != 1 || maximumFallbacks != 0)) {
throw new IllegalArgumentException("SINGLE requires one target and zero fallbacks");
}
if (strategy == NotificationRouteStrategy.FAN_OUT_ALL && maximumFallbacks != 0) {
throw new IllegalArgumentException("FAN_OUT_ALL cannot activate fallback targets");
}
if (strategy == NotificationRouteStrategy.ORDERED_FALLBACK
&& (maximumTargets < 2 || maximumFallbacks < 1 || maximumFallbacks > maximumTargets - 1)) {
throw new IllegalArgumentException(
"ORDERED_FALLBACK requires 2..16 targets and 1..targetCount-1 fallbacks");
}
}
}

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