feat: add production capability foundations
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+457
@@ -0,0 +1,457 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Framework-free cache-aside orchestration with bounded local coalescing and source concurrency.
|
||||
* Construct one instance per semantic cache region so its policy and protection bounds are shared.
|
||||
*
|
||||
* <p>When optional refresh coordination is enabled, the soft-lease owner refreshes synchronously.
|
||||
* This executor does not schedule an asynchronous stale-while-revalidate task. A valid stale
|
||||
* contender or a valid stale request facing coordination failure returns immediately instead.
|
||||
*/
|
||||
public final class CacheAsideExecutor<K, V> {
|
||||
|
||||
private final CacheAsidePolicy policy;
|
||||
private final Clock clock;
|
||||
private final CacheSingleFlight<K, SourceAttempt<V>> singleFlight;
|
||||
private final CacheSourceBulkhead sourceBulkhead;
|
||||
private final CacheRefreshCoordinationPort<K> refreshCoordinator;
|
||||
private final CacheRefreshCoordinationPolicy refreshCoordinationPolicy;
|
||||
|
||||
public CacheAsideExecutor(CacheAsidePolicy policy, Clock clock) {
|
||||
this(policy, clock, null, null);
|
||||
}
|
||||
|
||||
public CacheAsideExecutor(
|
||||
CacheAsidePolicy policy,
|
||||
Clock clock,
|
||||
CacheRefreshCoordinationPort<K> refreshCoordinator,
|
||||
CacheRefreshCoordinationPolicy refreshCoordinationPolicy) {
|
||||
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
this.refreshCoordinator = refreshCoordinator;
|
||||
if (refreshCoordinator == null) {
|
||||
if (refreshCoordinationPolicy != null) {
|
||||
throw new IllegalArgumentException("refresh coordination policy requires a coordinator");
|
||||
}
|
||||
this.refreshCoordinationPolicy = null;
|
||||
} else {
|
||||
this.refreshCoordinationPolicy =
|
||||
Objects.requireNonNull(
|
||||
refreshCoordinationPolicy, "refreshCoordinationPolicy must be non-null")
|
||||
.validateAgainst(policy);
|
||||
}
|
||||
singleFlight =
|
||||
new CacheSingleFlight<>(policy.maximumInFlightSourceKeys(), policy.maximumWaitersPerKey());
|
||||
sourceBulkhead = new CacheSourceBulkhead(policy.maximumConcurrentSourceLoads());
|
||||
}
|
||||
|
||||
public CacheResult<V> getOrLoad(
|
||||
K key, CacheRegionPort<K, V> region, CacheSourceLoader<K, V> sourceLoader) {
|
||||
Objects.requireNonNull(key, "key must be non-null");
|
||||
Objects.requireNonNull(region, "region must be non-null");
|
||||
Objects.requireNonNull(sourceLoader, "sourceLoader must be non-null");
|
||||
|
||||
CacheLookup<V> lookup =
|
||||
Objects.requireNonNull(region.lookup(key), "cache lookup must be non-null");
|
||||
StaleCandidate<V> stale = null;
|
||||
boolean hardMiss = false;
|
||||
RefillCondition refillCondition = RefillCondition.absent(CacheWriteCondition.unavailable());
|
||||
if (lookup instanceof CacheLookup.Hit<V> hit) {
|
||||
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
|
||||
return new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision());
|
||||
}
|
||||
if (!hit.observationToken().usable()) {
|
||||
return new CacheResult.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST);
|
||||
}
|
||||
stale =
|
||||
new StaleCandidate<>(
|
||||
hit.value(), hit.sourceRevision(), hit.hardExpiresAt(), hit.observationToken());
|
||||
refillCondition = RefillCondition.observed(hit.observationToken(), hit.writeCondition());
|
||||
} else if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
|
||||
return new CacheResult.NegativeHit<>(negative.reason());
|
||||
} else if (lookup instanceof CacheLookup.Miss<V> miss) {
|
||||
refillCondition = RefillCondition.absent(miss.writeCondition());
|
||||
hardMiss = true;
|
||||
} else if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
|
||||
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST) {
|
||||
return new CacheResult.IncompatibleSchema<>(incompatible.category(), incompatible.policy());
|
||||
}
|
||||
if (!incompatible.observationToken().usable()) {
|
||||
return new CacheResult.IncompatibleSchema<>(
|
||||
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST);
|
||||
}
|
||||
refillCondition =
|
||||
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition());
|
||||
} else if (lookup instanceof CacheLookup.Unavailable<V> unavailable) {
|
||||
refillCondition = RefillCondition.absent(unavailable.writeCondition());
|
||||
}
|
||||
|
||||
RefillCondition selectedCondition = refillCondition;
|
||||
StaleCandidate<V> selectedStale = stale;
|
||||
boolean selectedHardMiss = hardMiss;
|
||||
CacheSingleFlight.Outcome<SourceAttempt<V>> flight =
|
||||
singleFlight.execute(
|
||||
key,
|
||||
policy.maximumWaitDuration(),
|
||||
() ->
|
||||
loadFromSource(
|
||||
key, region, sourceLoader, selectedCondition, selectedStale, selectedHardMiss));
|
||||
if (flight instanceof CacheSingleFlight.Rejected<SourceAttempt<V>> rejected) {
|
||||
return new CacheResult.Rejected<>(
|
||||
switch (rejected.reason()) {
|
||||
case MAXIMUM_IN_FLIGHT_KEYS -> CacheResult.RejectionReason.MAXIMUM_IN_FLIGHT_KEYS;
|
||||
case MAXIMUM_WAITERS -> CacheResult.RejectionReason.MAXIMUM_WAITERS;
|
||||
case WAIT_TIMEOUT -> CacheResult.RejectionReason.WAIT_TIMEOUT;
|
||||
});
|
||||
}
|
||||
if (flight instanceof CacheSingleFlight.Interrupted<SourceAttempt<V>>) {
|
||||
return new CacheResult.Cancelled<>();
|
||||
}
|
||||
SourceAttempt<V> attempt = ((CacheSingleFlight.Completed<SourceAttempt<V>>) flight).value();
|
||||
return toResult(attempt, stale);
|
||||
}
|
||||
|
||||
int inFlightWaiterCount(K key) {
|
||||
return singleFlight.waiterCount(key);
|
||||
}
|
||||
|
||||
private SourceAttempt<V> loadFromSource(
|
||||
K key,
|
||||
CacheRegionPort<K, V> region,
|
||||
CacheSourceLoader<K, V> sourceLoader,
|
||||
RefillCondition refillCondition,
|
||||
StaleCandidate<V> stale,
|
||||
boolean hardMiss) {
|
||||
CacheSourceBulkhead.Outcome<SourceAttempt<V>> admitted =
|
||||
sourceBulkhead.execute(
|
||||
policy.sourceAdmissionWait(),
|
||||
() -> invokeSource(key, region, sourceLoader, refillCondition, stale, hardMiss));
|
||||
if (admitted instanceof CacheSourceBulkhead.Rejected<SourceAttempt<V>>) {
|
||||
return new SourceRejected<>();
|
||||
}
|
||||
if (admitted instanceof CacheSourceBulkhead.Interrupted<SourceAttempt<V>>) {
|
||||
return new SourceInterrupted<>();
|
||||
}
|
||||
return ((CacheSourceBulkhead.Completed<SourceAttempt<V>>) admitted).value();
|
||||
}
|
||||
|
||||
private SourceAttempt<V> invokeSource(
|
||||
K key,
|
||||
CacheRegionPort<K, V> region,
|
||||
CacheSourceLoader<K, V> sourceLoader,
|
||||
RefillCondition refillCondition,
|
||||
StaleCandidate<V> stale,
|
||||
boolean hardMiss) {
|
||||
CacheRefreshClaimAttempt ownedAttempt = null;
|
||||
RefillCondition selectedCondition = refillCondition;
|
||||
try {
|
||||
if (shouldCoordinate(stale, hardMiss)) {
|
||||
CacheRefreshClaimAttempt attempt =
|
||||
Objects.requireNonNull(
|
||||
refreshCoordinator.newAttempt(), "cache refresh claim attempt must be non-null");
|
||||
if (!attempt.usable()) {
|
||||
throw new IllegalStateException(
|
||||
"enabled cache refresh coordinator returned an unusable attempt");
|
||||
}
|
||||
CacheRefreshClaimOutcome claim = claimWithOneUncertainRetry(key, attempt);
|
||||
if (claim instanceof CacheRefreshClaimOutcome.Contended) {
|
||||
SourceAttempt<V> deferred = staleDeferral(stale, claim);
|
||||
if (deferred != null) {
|
||||
return deferred;
|
||||
}
|
||||
if (hardMiss
|
||||
&& refreshCoordinationPolicy.hardMissPolicy()
|
||||
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD) {
|
||||
if (!boundedWait(refreshCoordinationPolicy.hardMissWait())) {
|
||||
return new SourceInterrupted<>();
|
||||
}
|
||||
Recheck<V> recheck = recheck(region, key);
|
||||
if (recheck.immediateResult() != null) {
|
||||
return new ImmediateResult<>(recheck.immediateResult());
|
||||
}
|
||||
selectedCondition = recheck.refillCondition();
|
||||
}
|
||||
} else if (claim instanceof CacheRefreshClaimOutcome.Unavailable
|
||||
|| claim instanceof CacheRefreshClaimOutcome.Indeterminate) {
|
||||
SourceAttempt<V> deferred = staleDeferral(stale, claim);
|
||||
if (deferred != null) {
|
||||
return deferred;
|
||||
}
|
||||
} else if (claim instanceof CacheRefreshClaimOutcome.Claimed
|
||||
|| claim instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
|
||||
ownedAttempt = attempt;
|
||||
Recheck<V> recheck = recheck(region, key);
|
||||
if (recheck.immediateResult() != null) {
|
||||
return new ImmediateResult<>(recheck.immediateResult());
|
||||
}
|
||||
selectedCondition = recheck.refillCondition();
|
||||
}
|
||||
}
|
||||
return invokeSourceDirect(key, region, sourceLoader, selectedCondition);
|
||||
} finally {
|
||||
if (ownedAttempt != null) {
|
||||
Objects.requireNonNull(
|
||||
refreshCoordinator.release(key, ownedAttempt),
|
||||
"cache refresh release outcome must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SourceAttempt<V> invokeSourceDirect(
|
||||
K key,
|
||||
CacheRegionPort<K, V> region,
|
||||
CacheSourceLoader<K, V> sourceLoader,
|
||||
RefillCondition refillCondition) {
|
||||
Instant deadline;
|
||||
try {
|
||||
deadline = clock.instant().plus(policy.sourceLoadDeadline());
|
||||
} catch (DateTimeException exception) {
|
||||
throw new IllegalStateException("cache source deadline cannot be represented", exception);
|
||||
}
|
||||
CacheCancellationToken cancellation = new CacheCancellationToken(deadline, clock);
|
||||
if (cancellation.isInterrupted()) {
|
||||
return new SourceInterrupted<>();
|
||||
}
|
||||
|
||||
SourceLoadOutcome<V> outcome =
|
||||
Objects.requireNonNull(
|
||||
sourceLoader.load(key, cancellation), "source loader outcome must be non-null");
|
||||
if (cancellation.isInterrupted() || outcome instanceof SourceLoadOutcome.Cancelled<V>) {
|
||||
return new SourceInterrupted<>();
|
||||
}
|
||||
if (cancellation.isDeadlineExceeded()) {
|
||||
return new SourceTimedOut<>();
|
||||
}
|
||||
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
|
||||
CacheRecordOutcome recorded =
|
||||
Objects.requireNonNull(
|
||||
region.record(
|
||||
key,
|
||||
loaded.value(),
|
||||
new CacheRecordMetadata(
|
||||
loaded.sourceRevision(),
|
||||
refillCondition.intent(),
|
||||
refillCondition.observationToken(),
|
||||
refillCondition.writeCondition())),
|
||||
"cache record outcome must be non-null");
|
||||
return new SourceResolved<>(outcome, recorded);
|
||||
}
|
||||
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
|
||||
CacheRecordOutcome recorded =
|
||||
Objects.requireNonNull(
|
||||
region.recordAbsent(
|
||||
key,
|
||||
absent.reason(),
|
||||
new CacheRecordMetadata(
|
||||
absent.sourceRevision(),
|
||||
refillCondition.intent(),
|
||||
refillCondition.observationToken(),
|
||||
refillCondition.writeCondition())),
|
||||
"negative cache record outcome must be non-null");
|
||||
return new SourceResolved<>(outcome, recorded);
|
||||
}
|
||||
return new SourceResolved<>(outcome, null);
|
||||
}
|
||||
|
||||
private boolean shouldCoordinate(StaleCandidate<V> stale, boolean hardMiss) {
|
||||
if (refreshCoordinator == null || !refreshCoordinator.enabled()) {
|
||||
return false;
|
||||
}
|
||||
return stale != null
|
||||
|| (hardMiss
|
||||
&& refreshCoordinationPolicy.hardMissPolicy()
|
||||
== CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD);
|
||||
}
|
||||
|
||||
private CacheRefreshClaimOutcome claimWithOneUncertainRetry(
|
||||
K key, CacheRefreshClaimAttempt attempt) {
|
||||
CacheRefreshClaimOutcome first =
|
||||
Objects.requireNonNull(
|
||||
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
|
||||
"cache refresh claim outcome must be non-null");
|
||||
if (first instanceof CacheRefreshClaimOutcome.Indeterminate) {
|
||||
return Objects.requireNonNull(
|
||||
refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()),
|
||||
"cache refresh claim retry outcome must be non-null");
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
private SourceAttempt<V> staleDeferral(StaleCandidate<V> stale, CacheRefreshClaimOutcome claim) {
|
||||
if (stale != null && clock.instant().isBefore(stale.hardExpiresAt())) {
|
||||
CacheResult.RefreshDeferralReason reason =
|
||||
claim instanceof CacheRefreshClaimOutcome.Contended
|
||||
? CacheResult.RefreshDeferralReason.CONTENDED
|
||||
: claim instanceof CacheRefreshClaimOutcome.Unavailable
|
||||
? CacheResult.RefreshDeferralReason.COORDINATION_UNAVAILABLE
|
||||
: CacheResult.RefreshDeferralReason.COORDINATION_INDETERMINATE;
|
||||
return new ImmediateResult<>(
|
||||
new CacheResult.StaleRefreshDeferred<>(stale.value(), stale.sourceRevision(), reason));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean boundedWait(java.time.Duration duration) {
|
||||
try {
|
||||
long milliseconds = duration.toMillis();
|
||||
int nanoseconds = (int) duration.minusMillis(milliseconds).toNanos();
|
||||
Thread.sleep(milliseconds, nanoseconds);
|
||||
return true;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Recheck<V> recheck(CacheRegionPort<K, V> region, K key) {
|
||||
CacheLookup<V> lookup =
|
||||
Objects.requireNonNull(region.lookup(key), "cache recheck must be non-null");
|
||||
if (lookup instanceof CacheLookup.Hit<V> hit) {
|
||||
if (hit.freshness() == CacheLookup.Freshness.FRESH) {
|
||||
return Recheck.immediate(new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision()));
|
||||
}
|
||||
if (!hit.observationToken().usable()) {
|
||||
return Recheck.immediate(
|
||||
new CacheResult.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST));
|
||||
}
|
||||
return Recheck.refill(RefillCondition.observed(hit.observationToken(), hit.writeCondition()));
|
||||
}
|
||||
if (lookup instanceof CacheLookup.NegativeHit<V> negative) {
|
||||
return Recheck.immediate(new CacheResult.NegativeHit<>(negative.reason()));
|
||||
}
|
||||
if (lookup instanceof CacheLookup.Miss<V> miss) {
|
||||
return Recheck.refill(RefillCondition.absent(miss.writeCondition()));
|
||||
}
|
||||
if (lookup instanceof CacheLookup.IncompatibleSchema<V> incompatible) {
|
||||
if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST
|
||||
|| !incompatible.observationToken().usable()) {
|
||||
return Recheck.immediate(
|
||||
new CacheResult.IncompatibleSchema<>(
|
||||
incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST));
|
||||
}
|
||||
return Recheck.refill(
|
||||
RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition()));
|
||||
}
|
||||
CacheLookup.Unavailable<V> unavailable = (CacheLookup.Unavailable<V>) lookup;
|
||||
return Recheck.refill(RefillCondition.absent(unavailable.writeCondition()));
|
||||
}
|
||||
|
||||
private CacheResult<V> toResult(SourceAttempt<V> attempt, StaleCandidate<V> stale) {
|
||||
if (attempt instanceof ImmediateResult<V> immediate) {
|
||||
return immediate.result();
|
||||
}
|
||||
if (attempt instanceof SourceRejected<V>) {
|
||||
return new CacheResult.Rejected<>(CacheResult.RejectionReason.SOURCE_OVERLOADED);
|
||||
}
|
||||
if (attempt instanceof SourceTimedOut<V>) {
|
||||
return new CacheResult.Rejected<>(CacheResult.RejectionReason.LOAD_TIMEOUT);
|
||||
}
|
||||
if (attempt instanceof SourceInterrupted<V>) {
|
||||
return new CacheResult.Cancelled<>();
|
||||
}
|
||||
SourceResolved<V> resolved = (SourceResolved<V>) attempt;
|
||||
SourceLoadOutcome<V> outcome = resolved.outcome();
|
||||
if (outcome instanceof SourceLoadOutcome.Loaded<V> loaded) {
|
||||
return new CacheResult.LoadedFromSource<>(
|
||||
loaded.value(), loaded.sourceRevision(), resolved.recordOutcome());
|
||||
}
|
||||
if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent<V> absent) {
|
||||
return new CacheResult.AuthoritativeAbsent<>(
|
||||
absent.reason(), absent.sourceRevision(), resolved.recordOutcome());
|
||||
}
|
||||
if (outcome instanceof SourceLoadOutcome.TransientFailure<V> transientFailure) {
|
||||
if (stale != null
|
||||
&& policy.serveStaleOnTransientFailure()
|
||||
&& clock.instant().isBefore(stale.hardExpiresAt())) {
|
||||
return new CacheResult.StaleFallbackAfterTransientFailure<>(
|
||||
stale.value(), stale.sourceRevision(), transientFailure.failure());
|
||||
}
|
||||
return new CacheResult.SourceFailed<>(
|
||||
transientFailure.failure(), CacheResult.SourceFailureKind.TRANSIENT);
|
||||
}
|
||||
if (outcome instanceof SourceLoadOutcome.PermanentFailure<V> permanentFailure) {
|
||||
return new CacheResult.SourceFailed<>(
|
||||
permanentFailure.failure(), CacheResult.SourceFailureKind.PERMANENT);
|
||||
}
|
||||
return new CacheResult.Cancelled<>();
|
||||
}
|
||||
|
||||
private sealed interface SourceAttempt<T>
|
||||
permits SourceResolved, SourceRejected, SourceInterrupted, SourceTimedOut, ImmediateResult {}
|
||||
|
||||
private record SourceResolved<T>(SourceLoadOutcome<T> outcome, CacheRecordOutcome recordOutcome)
|
||||
implements SourceAttempt<T> {
|
||||
|
||||
private SourceResolved {
|
||||
Objects.requireNonNull(outcome, "outcome must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
private record SourceRejected<T>() implements SourceAttempt<T> {}
|
||||
|
||||
private record SourceInterrupted<T>() implements SourceAttempt<T> {}
|
||||
|
||||
private record SourceTimedOut<T>() implements SourceAttempt<T> {}
|
||||
|
||||
private record ImmediateResult<T>(CacheResult<T> result) implements SourceAttempt<T> {
|
||||
|
||||
private ImmediateResult {
|
||||
Objects.requireNonNull(result, "result must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
private record StaleCandidate<T>(
|
||||
T value,
|
||||
String sourceRevision,
|
||||
Instant hardExpiresAt,
|
||||
CacheObservationToken observationToken) {
|
||||
|
||||
private StaleCandidate {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
|
||||
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
|
||||
Objects.requireNonNull(observationToken, "observationToken must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
private record RefillCondition(
|
||||
CacheRecordIntent intent,
|
||||
CacheObservationToken observationToken,
|
||||
CacheWriteCondition writeCondition) {
|
||||
|
||||
private RefillCondition {
|
||||
Objects.requireNonNull(intent, "intent must be non-null");
|
||||
Objects.requireNonNull(observationToken, "observationToken must be non-null");
|
||||
Objects.requireNonNull(writeCondition, "writeCondition must be non-null");
|
||||
}
|
||||
|
||||
private static RefillCondition absent(CacheWriteCondition writeCondition) {
|
||||
return new RefillCondition(
|
||||
CacheRecordIntent.ONLY_IF_ABSENT, CacheObservationToken.unavailable(), writeCondition);
|
||||
}
|
||||
|
||||
private static RefillCondition observed(
|
||||
CacheObservationToken token, CacheWriteCondition writeCondition) {
|
||||
return new RefillCondition(CacheRecordIntent.ONLY_IF_OBSERVED, token, writeCondition);
|
||||
}
|
||||
}
|
||||
|
||||
private record Recheck<T>(RefillCondition refillCondition, CacheResult<T> immediateResult) {
|
||||
|
||||
private static <T> Recheck<T> refill(RefillCondition refillCondition) {
|
||||
return new Recheck<>(
|
||||
Objects.requireNonNull(refillCondition, "refillCondition must be non-null"), null);
|
||||
}
|
||||
|
||||
private static <T> Recheck<T> immediate(CacheResult<T> result) {
|
||||
return new Recheck<>(null, Objects.requireNonNull(result, "result must be non-null"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable per-region bounds for cache-aside fallback and local coalescing. */
|
||||
public record CacheAsidePolicy(
|
||||
int maximumInFlightSourceKeys,
|
||||
int maximumWaitersPerKey,
|
||||
int maximumConcurrentSourceLoads,
|
||||
Duration sourceAdmissionWait,
|
||||
Duration sourceLoadDeadline,
|
||||
boolean serveStaleOnTransientFailure) {
|
||||
|
||||
private static final int MAXIMUM_COUNT_BOUND = 4096;
|
||||
private static final Duration MAXIMUM_DURATION_BOUND = Duration.ofDays(30);
|
||||
|
||||
public CacheAsidePolicy {
|
||||
requirePositiveBound(
|
||||
maximumInFlightSourceKeys, "maximumInFlightSourceKeys", MAXIMUM_COUNT_BOUND);
|
||||
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_COUNT_BOUND) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumWaitersPerKey must be in 0.." + MAXIMUM_COUNT_BOUND);
|
||||
}
|
||||
requirePositiveBound(
|
||||
maximumConcurrentSourceLoads, "maximumConcurrentSourceLoads", MAXIMUM_COUNT_BOUND);
|
||||
requireDuration(sourceAdmissionWait, "sourceAdmissionWait", true);
|
||||
requireDuration(sourceLoadDeadline, "sourceLoadDeadline", false);
|
||||
}
|
||||
|
||||
Duration maximumWaitDuration() {
|
||||
return sourceAdmissionWait.plus(sourceLoadDeadline);
|
||||
}
|
||||
|
||||
private static void requirePositiveBound(int value, String field, int maximum) {
|
||||
if (value < 1 || value > maximum) {
|
||||
throw new IllegalArgumentException(field + " must be in 1.." + maximum);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDuration(Duration value, String field, boolean zeroAllowed) {
|
||||
Objects.requireNonNull(value, field + " must be non-null");
|
||||
if (value.isNegative()
|
||||
|| (!zeroAllowed && value.isZero())
|
||||
|| value.compareTo(MAXIMUM_DURATION_BOUND) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
field
|
||||
+ " must be "
|
||||
+ (zeroAllowed ? "non-negative" : "positive")
|
||||
+ " and at most "
|
||||
+ MAXIMUM_DURATION_BOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Cooperative source-load cancellation signal. It observes the executing thread's interrupt flag
|
||||
* and an immutable deadline; it cannot forcibly stop arbitrary source code.
|
||||
*/
|
||||
public final class CacheCancellationToken {
|
||||
|
||||
private final Instant deadline;
|
||||
private final Clock clock;
|
||||
|
||||
CacheCancellationToken(Instant deadline, Clock clock) {
|
||||
this.deadline = Objects.requireNonNull(deadline, "deadline must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
}
|
||||
|
||||
public Instant deadline() {
|
||||
return deadline;
|
||||
}
|
||||
|
||||
public boolean isDeadlineExceeded() {
|
||||
return !clock.instant().isBefore(deadline);
|
||||
}
|
||||
|
||||
public boolean isInterrupted() {
|
||||
return Thread.currentThread().isInterrupted();
|
||||
}
|
||||
|
||||
public boolean isCancellationRequested() {
|
||||
return isInterrupted() || isDeadlineExceeded();
|
||||
}
|
||||
}
|
||||
+83
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Framework-free, cache-only diagnostic events.
|
||||
*
|
||||
* <p>Events intentionally omit semantic keys, tenant/user identifiers and provider endpoints.
|
||||
* {@code cacheName} is a bounded code-owned name suitable for a low-cardinality metric tag.
|
||||
*/
|
||||
public sealed interface CacheObservationEvent {
|
||||
|
||||
String cacheName();
|
||||
|
||||
/** One lookup at a concrete cache tier. */
|
||||
record Lookup(String cacheName, Tier tier, LookupResult result, Duration entryAge)
|
||||
implements CacheObservationEvent {
|
||||
|
||||
public Lookup {
|
||||
cacheName = boundedCacheName(cacheName);
|
||||
Objects.requireNonNull(tier, "tier must be non-null");
|
||||
Objects.requireNonNull(result, "result must be non-null");
|
||||
Objects.requireNonNull(entryAge, "entryAge must be non-null");
|
||||
if (entryAge.isNegative() || entryAge.compareTo(Duration.ofDays(30)) > 0) {
|
||||
throw new IllegalArgumentException("entryAge must be between zero and 30 days");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded local-tier eviction, flush, subscriber or generation reconciliation action. */
|
||||
record LocalMaintenance(
|
||||
String cacheName,
|
||||
MaintenanceAction action,
|
||||
MaintenanceResult result,
|
||||
MaintenanceCause cause,
|
||||
int affectedEntries)
|
||||
implements CacheObservationEvent {
|
||||
|
||||
public LocalMaintenance {
|
||||
cacheName = boundedCacheName(cacheName);
|
||||
Objects.requireNonNull(action, "action must be non-null");
|
||||
Objects.requireNonNull(result, "result must be non-null");
|
||||
Objects.requireNonNull(cause, "cause must be non-null");
|
||||
if (affectedEntries < 0 || affectedEntries > 1_000_000) {
|
||||
throw new IllegalArgumentException("affectedEntries must be in 0..1000000");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Tier {
|
||||
LOCAL_L1,
|
||||
REDIS_L2
|
||||
}
|
||||
|
||||
enum LookupResult {
|
||||
HIT,
|
||||
MISS,
|
||||
ERROR,
|
||||
BYPASS
|
||||
}
|
||||
|
||||
enum MaintenanceAction {
|
||||
EVICT,
|
||||
FLUSH,
|
||||
RECONCILE,
|
||||
SUBSCRIBER_EVENT
|
||||
}
|
||||
|
||||
enum MaintenanceResult {
|
||||
SUCCESS,
|
||||
FLUSHED,
|
||||
DROPPED,
|
||||
ERROR,
|
||||
UNCHANGED
|
||||
}
|
||||
|
||||
enum MaintenanceCause {
|
||||
CARDINALITY,
|
||||
WEIGHT,
|
||||
TTL,
|
||||
INVALIDATION,
|
||||
GENERATION_CHANGED,
|
||||
SUBSCRIBER_DISCONNECTED,
|
||||
SUBSCRIBER_OVERFLOW,
|
||||
MALFORMED_MESSAGE,
|
||||
RECONCILIATION_FAILURE
|
||||
}
|
||||
|
||||
private static String boundedCacheName(String value) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"cacheName must be a code-owned lower-case slug with 1..63 characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Framework-free output boundary for low-cardinality cache diagnostics. */
|
||||
@FunctionalInterface
|
||||
public interface CacheObservationPort {
|
||||
|
||||
void observe(CacheObservationEvent event);
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/**
|
||||
* Opaque provider observation used only for conditional cache replacement. Application code must
|
||||
* not parse or manufacture it.
|
||||
*/
|
||||
public record CacheObservationToken(String value) {
|
||||
|
||||
private static final String UNAVAILABLE_VALUE = "observation-unavailable";
|
||||
|
||||
public CacheObservationToken {
|
||||
if (value == null || !value.matches("[A-Za-z0-9_-]{16,128}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"cache observation token must have a bounded opaque representation");
|
||||
}
|
||||
}
|
||||
|
||||
public static CacheObservationToken unavailable() {
|
||||
return new CacheObservationToken(UNAVAILABLE_VALUE);
|
||||
}
|
||||
|
||||
public boolean usable() {
|
||||
return !UNAVAILABLE_VALUE.equals(value);
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+29
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Owner plus operation identities that must be reused for an uncertain claim retry. */
|
||||
public record CacheRefreshClaimAttempt(
|
||||
CacheRefreshOwnerToken ownerToken, CacheRefreshOperationToken operationToken) {
|
||||
|
||||
private static final CacheRefreshClaimAttempt UNAVAILABLE =
|
||||
new CacheRefreshClaimAttempt(
|
||||
CacheRefreshOwnerToken.unavailable(), CacheRefreshOperationToken.unavailable());
|
||||
|
||||
public CacheRefreshClaimAttempt {
|
||||
Objects.requireNonNull(ownerToken, "ownerToken must be non-null");
|
||||
Objects.requireNonNull(operationToken, "operationToken must be non-null");
|
||||
if (ownerToken.usable() != operationToken.usable()) {
|
||||
throw new IllegalArgumentException("refresh claim attempt tokens must have equal usability");
|
||||
}
|
||||
}
|
||||
|
||||
public static CacheRefreshClaimAttempt unavailable() {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
public boolean usable() {
|
||||
return ownerToken.usable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return usable()
|
||||
? "CacheRefreshClaimAttempt[redacted]"
|
||||
: "CacheRefreshClaimAttempt[unavailable]";
|
||||
}
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Provider-neutral result of attempting to own a cache-refresh soft lease. */
|
||||
public sealed interface CacheRefreshClaimOutcome
|
||||
permits CacheRefreshClaimOutcome.Claimed,
|
||||
CacheRefreshClaimOutcome.AlreadyOwned,
|
||||
CacheRefreshClaimOutcome.Contended,
|
||||
CacheRefreshClaimOutcome.Disabled,
|
||||
CacheRefreshClaimOutcome.Unavailable,
|
||||
CacheRefreshClaimOutcome.Indeterminate {
|
||||
|
||||
record Claimed(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
|
||||
|
||||
public Claimed {
|
||||
requireUsable(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
record AlreadyOwned(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome {
|
||||
|
||||
public AlreadyOwned {
|
||||
requireUsable(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
record Contended() implements CacheRefreshClaimOutcome {}
|
||||
|
||||
record Disabled() implements CacheRefreshClaimOutcome {}
|
||||
|
||||
record Unavailable() implements CacheRefreshClaimOutcome {}
|
||||
|
||||
record Indeterminate() implements CacheRefreshClaimOutcome {}
|
||||
|
||||
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
|
||||
Objects.requireNonNull(attempt, "attempt must be non-null");
|
||||
if (!attempt.usable()) {
|
||||
throw new IllegalArgumentException("owned refresh claim requires a usable attempt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Finite soft-lease and hard-miss behavior for optional distributed refresh coordination. */
|
||||
public record CacheRefreshCoordinationPolicy(
|
||||
Duration leaseTimeToLive, HardMissPolicy hardMissPolicy, Duration hardMissWait) {
|
||||
|
||||
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(5);
|
||||
private static final Duration MAXIMUM_HARD_MISS_WAIT = Duration.ofSeconds(5);
|
||||
|
||||
public CacheRefreshCoordinationPolicy {
|
||||
Objects.requireNonNull(leaseTimeToLive, "leaseTimeToLive must be non-null");
|
||||
Objects.requireNonNull(hardMissPolicy, "hardMissPolicy must be non-null");
|
||||
Objects.requireNonNull(hardMissWait, "hardMissWait must be non-null");
|
||||
if (leaseTimeToLive.isZero()
|
||||
|| leaseTimeToLive.isNegative()
|
||||
|| leaseTimeToLive.compareTo(MAXIMUM_LEASE) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"refresh lease TTL must be positive and at most 5 minutes");
|
||||
}
|
||||
if (hardMissWait.isNegative() || hardMissWait.compareTo(MAXIMUM_HARD_MISS_WAIT) > 0) {
|
||||
throw new IllegalArgumentException("hard miss wait must be between zero and 5 seconds");
|
||||
}
|
||||
if (hardMissPolicy == HardMissPolicy.NORMAL_SOURCE_LOAD && !hardMissWait.isZero()) {
|
||||
throw new IllegalArgumentException("normal hard miss source load requires zero wait");
|
||||
}
|
||||
if (hardMissPolicy == HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD && hardMissWait.isZero()) {
|
||||
throw new IllegalArgumentException("bounded hard miss wait must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public CacheRefreshCoordinationPolicy validateAgainst(CacheAsidePolicy cacheAsidePolicy) {
|
||||
Objects.requireNonNull(cacheAsidePolicy, "cacheAsidePolicy must be non-null");
|
||||
if (leaseTimeToLive.compareTo(cacheAsidePolicy.sourceLoadDeadline()) <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"refresh lease TTL must be longer than the source load deadline");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public enum HardMissPolicy {
|
||||
NORMAL_SOURCE_LOAD,
|
||||
BOUNDED_WAIT_THEN_SOURCE_LOAD
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Optional soft-lease coordination for cache refresh admission.
|
||||
*
|
||||
* <p>This port reduces duplicate refresh work. It is not a correctness lock and must not protect
|
||||
* domain invariants. The claim owner performs its source refresh synchronously; only a stale
|
||||
* contender or a stale request facing coordination failure returns immediately with a deferral
|
||||
* result.
|
||||
*/
|
||||
public interface CacheRefreshCoordinationPort<K> {
|
||||
|
||||
default boolean enabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
CacheRefreshClaimAttempt newAttempt();
|
||||
|
||||
CacheRefreshClaimOutcome claim(K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive);
|
||||
|
||||
CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt);
|
||||
}
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Opaque idempotency identity reused when a refresh-claim response is uncertain. */
|
||||
public final class CacheRefreshOperationToken {
|
||||
|
||||
private static final CacheRefreshOperationToken UNAVAILABLE = new CacheRefreshOperationToken();
|
||||
|
||||
private final String value;
|
||||
|
||||
public CacheRefreshOperationToken(String value) {
|
||||
this.value = validate(value);
|
||||
}
|
||||
|
||||
private CacheRefreshOperationToken() {
|
||||
value = null;
|
||||
}
|
||||
|
||||
public static CacheRefreshOperationToken unavailable() {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
public boolean usable() {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
public String value() {
|
||||
if (!usable()) {
|
||||
throw new IllegalStateException("cache refresh operation token is unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object candidate) {
|
||||
return candidate instanceof CacheRefreshOperationToken other
|
||||
&& Objects.equals(value, other.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return usable()
|
||||
? "CacheRefreshOperationToken[redacted]"
|
||||
: "CacheRefreshOperationToken[unavailable]";
|
||||
}
|
||||
|
||||
private static String validate(String value) {
|
||||
if (value == null
|
||||
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|
||||
|| value.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(
|
||||
"operation token must contain 16..128 URL-safe non-control characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Opaque owner identity for one bounded cache-refresh soft lease. */
|
||||
public final class CacheRefreshOwnerToken {
|
||||
|
||||
private static final CacheRefreshOwnerToken UNAVAILABLE = new CacheRefreshOwnerToken();
|
||||
|
||||
private final String value;
|
||||
|
||||
public CacheRefreshOwnerToken(String value) {
|
||||
this.value = validate(value, "owner token");
|
||||
}
|
||||
|
||||
private CacheRefreshOwnerToken() {
|
||||
value = null;
|
||||
}
|
||||
|
||||
public static CacheRefreshOwnerToken unavailable() {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
public boolean usable() {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
public String value() {
|
||||
if (!usable()) {
|
||||
throw new IllegalStateException("cache refresh owner token is unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object candidate) {
|
||||
return candidate instanceof CacheRefreshOwnerToken other && Objects.equals(value, other.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return usable() ? "CacheRefreshOwnerToken[redacted]" : "CacheRefreshOwnerToken[unavailable]";
|
||||
}
|
||||
|
||||
private static String validate(String value, String field) {
|
||||
if (value == null
|
||||
|| !value.matches("[A-Za-z0-9_-]{16,128}")
|
||||
|| value.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must contain 16..128 URL-safe non-control characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Provider-neutral owner-safe release result for a cache-refresh soft lease. */
|
||||
public sealed interface CacheRefreshReleaseOutcome
|
||||
permits CacheRefreshReleaseOutcome.Released,
|
||||
CacheRefreshReleaseOutcome.AlreadyReleased,
|
||||
CacheRefreshReleaseOutcome.NotOwner,
|
||||
CacheRefreshReleaseOutcome.Disabled,
|
||||
CacheRefreshReleaseOutcome.Unavailable,
|
||||
CacheRefreshReleaseOutcome.Indeterminate {
|
||||
|
||||
record Released() implements CacheRefreshReleaseOutcome {}
|
||||
|
||||
record AlreadyReleased() implements CacheRefreshReleaseOutcome {}
|
||||
|
||||
record NotOwner() implements CacheRefreshReleaseOutcome {}
|
||||
|
||||
record Disabled() implements CacheRefreshReleaseOutcome {}
|
||||
|
||||
record Unavailable() implements CacheRefreshReleaseOutcome {}
|
||||
|
||||
record Indeterminate() implements CacheRefreshReleaseOutcome {}
|
||||
}
|
||||
+7
@@ -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();
|
||||
}
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** End-to-end cache-aside result without provider or transport types. */
|
||||
public sealed interface CacheResult<V>
|
||||
permits CacheResult.FreshHit,
|
||||
CacheResult.NegativeHit,
|
||||
CacheResult.LoadedFromSource,
|
||||
CacheResult.AuthoritativeAbsent,
|
||||
CacheResult.StaleFallbackAfterTransientFailure,
|
||||
CacheResult.StaleRefreshDeferred,
|
||||
CacheResult.SourceFailed,
|
||||
CacheResult.Rejected,
|
||||
CacheResult.Cancelled,
|
||||
CacheResult.IncompatibleSchema {
|
||||
|
||||
record FreshHit<V>(V value, String sourceRevision) implements CacheResult<V> {
|
||||
|
||||
public FreshHit {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
}
|
||||
}
|
||||
|
||||
record NegativeHit<V>(dev.caskeleton.application.cache.AuthoritativeAbsence reason)
|
||||
implements CacheResult<V> {
|
||||
|
||||
public NegativeHit {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record LoadedFromSource<V>(V value, String sourceRevision, CacheRecordOutcome recordOutcome)
|
||||
implements CacheResult<V> {
|
||||
|
||||
public LoadedFromSource {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record AuthoritativeAbsent<V>(
|
||||
dev.caskeleton.application.cache.AuthoritativeAbsence reason,
|
||||
String sourceRevision,
|
||||
CacheRecordOutcome recordOutcome)
|
||||
implements CacheResult<V> {
|
||||
|
||||
public AuthoritativeAbsent {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record StaleFallbackAfterTransientFailure<V>(
|
||||
V value, String sourceRevision, SourceFailure failure) implements CacheResult<V> {
|
||||
|
||||
public StaleFallbackAfterTransientFailure {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
Objects.requireNonNull(failure, "failure must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record StaleRefreshDeferred<V>(V value, String sourceRevision, RefreshDeferralReason reason)
|
||||
implements CacheResult<V> {
|
||||
|
||||
public StaleRefreshDeferred {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record SourceFailed<V>(SourceFailure failure, SourceFailureKind kind) implements CacheResult<V> {
|
||||
|
||||
public SourceFailed {
|
||||
Objects.requireNonNull(failure, "failure must be non-null");
|
||||
Objects.requireNonNull(kind, "kind must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Rejected<V>(RejectionReason reason) implements CacheResult<V> {
|
||||
|
||||
public Rejected {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Cancelled<V>() implements CacheResult<V> {}
|
||||
|
||||
record IncompatibleSchema<V>(CacheLookup.SchemaCategory category, CacheLookup.SchemaPolicy policy)
|
||||
implements CacheResult<V> {
|
||||
|
||||
public IncompatibleSchema {
|
||||
Objects.requireNonNull(category, "category must be non-null");
|
||||
Objects.requireNonNull(policy, "policy must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
enum SourceFailureKind {
|
||||
TRANSIENT,
|
||||
PERMANENT
|
||||
}
|
||||
|
||||
enum RejectionReason {
|
||||
MAXIMUM_IN_FLIGHT_KEYS,
|
||||
MAXIMUM_WAITERS,
|
||||
SOURCE_OVERLOADED,
|
||||
WAIT_TIMEOUT,
|
||||
LOAD_TIMEOUT
|
||||
}
|
||||
|
||||
enum RefreshDeferralReason {
|
||||
CONTENDED,
|
||||
COORDINATION_UNAVAILABLE,
|
||||
COORDINATION_INDETERMINATE
|
||||
}
|
||||
|
||||
private static void requireSourceRevision(String sourceRevision) {
|
||||
if (sourceRevision == null
|
||||
|| sourceRevision.isBlank()
|
||||
|| sourceRevision.length() > 128
|
||||
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(
|
||||
"sourceRevision must contain 1..128 non-control characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Bounded, synchronous local coalescing for one semantic cache region. */
|
||||
public final class CacheSingleFlight<K, V> {
|
||||
|
||||
private static final int MAXIMUM_BOUND = 4096;
|
||||
|
||||
private final int maximumInFlightKeys;
|
||||
private final int maximumWaitersPerKey;
|
||||
private final LongSupplier monotonicTicker;
|
||||
private final Map<K, Flight<V>> flights = new HashMap<>();
|
||||
|
||||
public CacheSingleFlight(int maximumInFlightKeys, int maximumWaitersPerKey) {
|
||||
this(maximumInFlightKeys, maximumWaitersPerKey, System::nanoTime);
|
||||
}
|
||||
|
||||
CacheSingleFlight(
|
||||
int maximumInFlightKeys, int maximumWaitersPerKey, LongSupplier monotonicTicker) {
|
||||
if (maximumInFlightKeys < 1 || maximumInFlightKeys > MAXIMUM_BOUND) {
|
||||
throw new IllegalArgumentException("maximumInFlightKeys must be in 1.." + MAXIMUM_BOUND);
|
||||
}
|
||||
if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_BOUND) {
|
||||
throw new IllegalArgumentException("maximumWaitersPerKey must be in 0.." + MAXIMUM_BOUND);
|
||||
}
|
||||
this.maximumInFlightKeys = maximumInFlightKeys;
|
||||
this.maximumWaitersPerKey = maximumWaitersPerKey;
|
||||
this.monotonicTicker =
|
||||
Objects.requireNonNull(monotonicTicker, "monotonicTicker must be non-null");
|
||||
}
|
||||
|
||||
public Outcome<V> execute(K key, Duration waiterTimeout, Supplier<V> leaderAction) {
|
||||
Objects.requireNonNull(key, "key must be non-null");
|
||||
Objects.requireNonNull(waiterTimeout, "waiterTimeout must be non-null");
|
||||
Objects.requireNonNull(leaderAction, "leaderAction must be non-null");
|
||||
if (waiterTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("waiterTimeout must be non-negative");
|
||||
}
|
||||
|
||||
Flight<V> flight;
|
||||
boolean leader;
|
||||
synchronized (flights) {
|
||||
long monotonicNow = monotonicTicker.getAsLong();
|
||||
flight = flights.get(key);
|
||||
if (flight != null && flight.isAbandonedAt(monotonicNow)) {
|
||||
flights.remove(key, flight);
|
||||
flight = null;
|
||||
}
|
||||
if (flight == null) {
|
||||
if (flights.size() >= maximumInFlightKeys) {
|
||||
flights.values().removeIf(candidate -> candidate.isAbandonedAt(monotonicNow));
|
||||
if (flights.size() >= maximumInFlightKeys) {
|
||||
return new Rejected<>(RejectionReason.MAXIMUM_IN_FLIGHT_KEYS);
|
||||
}
|
||||
}
|
||||
flight = new Flight<>(deadlineFrom(monotonicNow, waiterTimeout));
|
||||
flights.put(key, flight);
|
||||
leader = true;
|
||||
} else {
|
||||
leader = false;
|
||||
}
|
||||
}
|
||||
if (!leader) {
|
||||
return await(flight, waiterTimeout);
|
||||
}
|
||||
|
||||
try {
|
||||
V value = Objects.requireNonNull(leaderAction.get(), "leader result must be non-null");
|
||||
flight.result.complete(value);
|
||||
return new Completed<>(value);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
flight.result.completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
synchronized (flights) {
|
||||
flights.remove(key, flight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int inFlightCount() {
|
||||
synchronized (flights) {
|
||||
return flights.size();
|
||||
}
|
||||
}
|
||||
|
||||
int waiterCount(K key) {
|
||||
synchronized (flights) {
|
||||
Flight<V> flight = flights.get(key);
|
||||
return flight == null ? 0 : flight.waiters.get();
|
||||
}
|
||||
}
|
||||
|
||||
private Outcome<V> await(Flight<V> flight, Duration waiterTimeout) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return new Interrupted<>();
|
||||
}
|
||||
if (!acquireWaiter(flight)) {
|
||||
return new Rejected<>(RejectionReason.MAXIMUM_WAITERS);
|
||||
}
|
||||
try {
|
||||
return new Completed<>(
|
||||
flight.result.get(toNanosSaturated(waiterTimeout), TimeUnit.NANOSECONDS));
|
||||
} catch (TimeoutException timeout) {
|
||||
return new Rejected<>(RejectionReason.WAIT_TIMEOUT);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new Interrupted<>();
|
||||
} catch (ExecutionException execution) {
|
||||
return rethrow(execution.getCause());
|
||||
} finally {
|
||||
flight.waiters.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean acquireWaiter(Flight<V> flight) {
|
||||
while (true) {
|
||||
int current = flight.waiters.get();
|
||||
if (current >= maximumWaitersPerKey) {
|
||||
return false;
|
||||
}
|
||||
if (flight.waiters.compareAndSet(current, current + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long toNanosSaturated(Duration duration) {
|
||||
try {
|
||||
return duration.toNanos();
|
||||
} catch (ArithmeticException overflow) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static long deadlineFrom(long monotonicNow, Duration timeout) {
|
||||
long timeoutNanos = toNanosSaturated(timeout);
|
||||
if (timeoutNanos == Long.MAX_VALUE) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
try {
|
||||
return Math.addExact(monotonicNow, timeoutNanos);
|
||||
} catch (ArithmeticException overflow) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> Outcome<T> rethrow(Throwable cause) {
|
||||
if (cause instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
if (cause instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"single-flight completed with an unexpected checked failure", cause);
|
||||
}
|
||||
|
||||
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
|
||||
|
||||
public record Completed<T>(T value) implements Outcome<T> {
|
||||
|
||||
public Completed {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
public record Rejected<T>(RejectionReason reason) implements Outcome<T> {
|
||||
|
||||
public Rejected {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
public record Interrupted<T>() implements Outcome<T> {}
|
||||
|
||||
public enum RejectionReason {
|
||||
MAXIMUM_IN_FLIGHT_KEYS,
|
||||
MAXIMUM_WAITERS,
|
||||
WAIT_TIMEOUT
|
||||
}
|
||||
|
||||
private static final class Flight<T> {
|
||||
|
||||
private final CompletableFuture<T> result = new CompletableFuture<>();
|
||||
private final AtomicInteger waiters = new AtomicInteger();
|
||||
private final long abandonAtNanos;
|
||||
|
||||
private Flight(long abandonAtNanos) {
|
||||
this.abandonAtNanos = abandonAtNanos;
|
||||
}
|
||||
|
||||
private boolean isAbandonedAt(long monotonicNow) {
|
||||
return abandonAtNanos != Long.MAX_VALUE
|
||||
&& monotonicNow - abandonAtNanos >= 0
|
||||
&& !result.isDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Bounded source concurrency admission shared by every key in one semantic cache region. */
|
||||
public final class CacheSourceBulkhead {
|
||||
|
||||
private final Semaphore permits;
|
||||
|
||||
public CacheSourceBulkhead(int maximumConcurrentLoads) {
|
||||
if (maximumConcurrentLoads < 1) {
|
||||
throw new IllegalArgumentException("maximumConcurrentLoads must be positive");
|
||||
}
|
||||
permits = new Semaphore(maximumConcurrentLoads, true);
|
||||
}
|
||||
|
||||
public <T> Outcome<T> execute(Duration admissionWait, Supplier<T> action) {
|
||||
Objects.requireNonNull(admissionWait, "admissionWait must be non-null");
|
||||
Objects.requireNonNull(action, "action must be non-null");
|
||||
if (admissionWait.isNegative()) {
|
||||
throw new IllegalArgumentException("admissionWait must be non-negative");
|
||||
}
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return new Interrupted<>();
|
||||
}
|
||||
|
||||
boolean acquired;
|
||||
try {
|
||||
acquired = permits.tryAcquire(toNanosSaturated(admissionWait), TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new Interrupted<>();
|
||||
}
|
||||
if (!acquired) {
|
||||
return new Rejected<>();
|
||||
}
|
||||
try {
|
||||
return new Completed<>(
|
||||
Objects.requireNonNull(action.get(), "source action result must be non-null"));
|
||||
} finally {
|
||||
permits.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static long toNanosSaturated(Duration duration) {
|
||||
try {
|
||||
return duration.toNanos();
|
||||
} catch (ArithmeticException overflow) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed interface Outcome<T> permits Completed, Rejected, Interrupted {}
|
||||
|
||||
public record Completed<T>(T value) implements Outcome<T> {
|
||||
|
||||
public Completed {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
public record Rejected<T>() implements Outcome<T> {}
|
||||
|
||||
public record Interrupted<T>() implements Outcome<T> {}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Loads one semantic cache key from its authoritative source. */
|
||||
@FunctionalInterface
|
||||
public interface CacheSourceLoader<K, V> {
|
||||
|
||||
SourceLoadOutcome<V> load(K key, CacheCancellationToken cancellation);
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Opaque provider snapshot captured by lookup and returned unchanged when recording a source load.
|
||||
*
|
||||
* <p>The application coordinates the token but never parses provider generation or revision
|
||||
* details.
|
||||
*/
|
||||
public record CacheWriteCondition(String value) {
|
||||
|
||||
private static final String UNAVAILABLE_VALUE = "write-condition-unavailable";
|
||||
private static final int MAXIMUM_BYTES = 512;
|
||||
|
||||
public CacheWriteCondition {
|
||||
if (value == null
|
||||
|| value.isBlank()
|
||||
|| value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"cache write condition must contain a bounded opaque value of 1..512 UTF-8 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
public static CacheWriteCondition unavailable() {
|
||||
return new CacheWriteCondition(UNAVAILABLE_VALUE);
|
||||
}
|
||||
|
||||
public boolean usable() {
|
||||
return !UNAVAILABLE_VALUE.equals(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CacheWriteCondition[REDACTED]";
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Explicit no-op cache diagnostics adapter used when no metrics backend is installed. */
|
||||
public final class DisabledCacheObservationPort implements CacheObservationPort {
|
||||
|
||||
private static final DisabledCacheObservationPort INSTANCE = new DisabledCacheObservationPort();
|
||||
|
||||
private DisabledCacheObservationPort() {}
|
||||
|
||||
public static DisabledCacheObservationPort instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(CacheObservationEvent event) {
|
||||
// Diagnostics must never change cache semantics.
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Explicit no-op refresh coordinator for deployments that disable distributed soft leases. */
|
||||
public final class DisabledCacheRefreshCoordinationPort<K>
|
||||
implements CacheRefreshCoordinationPort<K> {
|
||||
|
||||
private static final DisabledCacheRefreshCoordinationPort<?> INSTANCE =
|
||||
new DisabledCacheRefreshCoordinationPort<>();
|
||||
|
||||
private DisabledCacheRefreshCoordinationPort() {}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <K> DisabledCacheRefreshCoordinationPort<K> instance() {
|
||||
return (DisabledCacheRefreshCoordinationPort<K>) INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshClaimAttempt newAttempt() {
|
||||
return CacheRefreshClaimAttempt.unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshClaimOutcome claim(
|
||||
K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
|
||||
return new CacheRefreshClaimOutcome.Disabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt) {
|
||||
return new CacheRefreshReleaseOutcome.Disabled();
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Bounded source failure classification with its original cause preserved for the application
|
||||
* boundary. The cause message must not be copied into cache state, metrics, or tags.
|
||||
*/
|
||||
public record SourceFailure(String code, Throwable cause) {
|
||||
|
||||
public SourceFailure {
|
||||
if (code == null || !code.matches("[A-Z][A-Z0-9_]{0,63}")) {
|
||||
throw new IllegalArgumentException("code must be a bounded uppercase failure code");
|
||||
}
|
||||
Objects.requireNonNull(cause, "cause must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SourceFailure[code=" + code + ']';
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Business-classified result of consulting a cache region's authoritative source. */
|
||||
public sealed interface SourceLoadOutcome<V>
|
||||
permits SourceLoadOutcome.Loaded,
|
||||
SourceLoadOutcome.AuthoritativeAbsent,
|
||||
SourceLoadOutcome.TransientFailure,
|
||||
SourceLoadOutcome.PermanentFailure,
|
||||
SourceLoadOutcome.Cancelled {
|
||||
|
||||
record Loaded<V>(V value, String sourceRevision) implements SourceLoadOutcome<V> {
|
||||
|
||||
public Loaded {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
}
|
||||
}
|
||||
|
||||
record AuthoritativeAbsent<V>(
|
||||
dev.caskeleton.application.cache.AuthoritativeAbsence reason, String sourceRevision)
|
||||
implements SourceLoadOutcome<V> {
|
||||
|
||||
public AuthoritativeAbsent {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
requireSourceRevision(sourceRevision);
|
||||
}
|
||||
}
|
||||
|
||||
record TransientFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
|
||||
|
||||
public TransientFailure {
|
||||
Objects.requireNonNull(failure, "failure must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record PermanentFailure<V>(SourceFailure failure) implements SourceLoadOutcome<V> {
|
||||
|
||||
public PermanentFailure {
|
||||
Objects.requireNonNull(failure, "failure must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Cancelled<V>() implements SourceLoadOutcome<V> {}
|
||||
|
||||
private static void requireSourceRevision(String sourceRevision) {
|
||||
if (sourceRevision == null
|
||||
|| sourceRevision.isBlank()
|
||||
|| sourceRevision.length() > 128
|
||||
|| sourceRevision.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(
|
||||
"sourceRevision must contain 1..128 non-control characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -47,6 +47,7 @@ public record FilePublishReceipt(
|
||||
|
||||
public enum DurabilityGuarantee {
|
||||
PROCESS_LOCAL_SYNC,
|
||||
FILE_AND_DIRECTORY_SYNC,
|
||||
PROVIDER_ACK_ONLY
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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]";
|
||||
}
|
||||
}
|
||||
+85
@@ -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 {}
|
||||
}
|
||||
+60
@@ -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
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+230
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+69
@@ -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 {}
|
||||
}
|
||||
+19
@@ -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]";
|
||||
}
|
||||
}
|
||||
+18
@@ -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]";
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
+54
@@ -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]";
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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]";
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+221
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
@@ -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");
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+19
@@ -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>]";
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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()));
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+16
@@ -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");
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.notification;
|
||||
|
||||
/** Provider-neutral delivery medium. */
|
||||
public enum NotificationChannel {
|
||||
EMAIL,
|
||||
SLACK
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+204
@@ -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
|
||||
}
|
||||
}
|
||||
+13
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+89
@@ -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
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+84
@@ -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
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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
Reference in New Issue
Block a user