feat: redis, fileserver, httpclient 런타임 시점 구현 추가
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `application-core`
|
||||
- Gradle path: `:application-core`
|
||||
- Focused test: `./gradlew :application-core:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :application-core:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.application`.
|
||||
|
||||
@@ -19,15 +19,14 @@ Package root: `dev.caskeleton.application`.
|
||||
- Application exceptions and policy types.
|
||||
- Coordinate domain models through ports.
|
||||
- Own application transaction boundaries through the `TransactionPort` abstraction.
|
||||
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
|
||||
MDC or other concrete storage.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:domain-core`
|
||||
- `:shared-contract`
|
||||
- `org.springframework.boot:spring-boot-starter` — so use cases may opt into
|
||||
`@Service` / `@Component` DI registration (D13). Spring core (`spring-context` /
|
||||
`spring-beans`) is intentionally kept on the compile classpath because the
|
||||
alternative — manual `@Configuration` per use case — explodes boilerplate.
|
||||
- Java standard library types.
|
||||
|
||||
## Forbidden
|
||||
|
||||
@@ -45,6 +44,8 @@ Package root: `dev.caskeleton.application`.
|
||||
Lombok is currently not in scope for the contract; if you intend to use it,
|
||||
weigh the bytecode opacity cost first.
|
||||
- Persistence-layer transaction annotations of any kind inside this module.
|
||||
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
|
||||
diagnostic intent through a specific outbound `*Port`; adapters own rendering.
|
||||
|
||||
## Contract types
|
||||
|
||||
@@ -71,7 +72,6 @@ Package root: `dev.caskeleton.application`.
|
||||
## Canonical use case shape
|
||||
|
||||
```java
|
||||
@Service
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.KEYED,
|
||||
@@ -95,6 +95,9 @@ public final class RegisterUserUseCase implements CommandUseCase<RegisterUserCom
|
||||
}
|
||||
```
|
||||
|
||||
The class is plain Java. A composition root constructs it with its ports and configuration values;
|
||||
application-core never self-registers with a DI framework.
|
||||
|
||||
## Allowed transactional shapes
|
||||
|
||||
| Use case shape | `transactionMode` | TransactionPort call | When |
|
||||
@@ -174,6 +177,7 @@ The read side has two equally-valid shapes; pick per read, do not force one:
|
||||
- `application_does_not_depend_on_adapters_or_transport`
|
||||
- `application_does_not_use_spring_transactional_annotation`
|
||||
- `application_does_not_depend_on_application_context` (D11)
|
||||
- `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`
|
||||
- `inbound_port_implementations_end_with_use_case`
|
||||
- `inbound_port_implementations_declare_capability`
|
||||
- `query_ports_do_not_leak_domain_jpa_or_web_types` (query-bypass D1 — `*QueryPort` return purity)
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.
|
||||
|
||||
이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP
|
||||
타입을 직접 들이지 않고, 필요한 인프라 능력(트랜잭션·락·인가·알림 등)은 전부 `*Port`
|
||||
인터페이스로 추상화한다. 구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. 아래 결정
|
||||
대부분이 이 원칙에서 파생된다.
|
||||
타입뿐 아니라 SLF4J/JUL/Logback/Log4j/Micrometer도 직접 들이지 않고, 필요한 인프라 능력
|
||||
(트랜잭션·락·인가·알림·운영 진단 등)은 전부 구체적인 목적의 `*Port` 인터페이스로 추상화한다.
|
||||
구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. Gradle
|
||||
`verifyApplicationCoreDependencyPurity`와 ArchUnit
|
||||
`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`가 이 계약을 자동 검증한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -298,8 +300,8 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
|
||||
- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은
|
||||
예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고
|
||||
로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를
|
||||
예외로 관측해야 FAILED/DEAD 전이 + 에러 코드 로그(`OUTBOX_PUBLISH_FAILED` /
|
||||
`OUTBOX_DEAD_LETTER`)를 구동할 수 있기 때문. 삼킨 실패(예외 없음·전이 없음·ERROR 로그 없음)가
|
||||
예외로 관측해야 FAILED/DEAD 전이 + typed failure report를 구동할 수 있기 때문. 삼킨 실패
|
||||
(예외 없음·전이 없음·report 없음)가
|
||||
금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔
|
||||
이상이 안 보인다.
|
||||
- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim →
|
||||
@@ -315,13 +317,18 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
|
||||
FIFO 를 강제한다).
|
||||
3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다:
|
||||
- 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`.
|
||||
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead` +
|
||||
`OUTBOX_DEAD_LETTER` ERROR 로그; 아니면 `markFailed(nextAttemptAt)` +
|
||||
`OUTBOX_PUBLISH_FAILED` ERROR 로그.
|
||||
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead`, 아니면
|
||||
`markFailed(nextAttemptAt)`를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다.
|
||||
- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을
|
||||
구동하고 ERROR 로그를 낸 뒤 rethrow 하지 않는다(스케줄러 루프가 다음 이벤트로 계속 가야
|
||||
하므로). 모든 발행 실패는 반드시 (a) 상태 전이와 (b) error code·correlationId·eventId·eventType·
|
||||
attemptCount 를 담은 ERROR 로그를 **둘 다** 남긴다. 둘 중 하나라도 빠지면 금지된 silent-swallow.
|
||||
구동하고, 성공한 전이만 `OutboxRelayFailureReportPort`로 보고한 뒤 rethrow 하지 않는다
|
||||
(스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는
|
||||
없다. reporter가 `RuntimeException`을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지
|
||||
못한다.
|
||||
- **안전한 allowlist report**: `OutboxRelayFailureReport`는
|
||||
`code/eventId/eventType/aggregateId/correlationId/attemptCount/nextAttemptAt/cause`만 가진다.
|
||||
payload, idempotency key, whole `OutboxEvent`, severity/template, arbitrary map은 타입 수준에서
|
||||
전달할 수 없다. retry factory는 `OUTBOX_PUBLISH_FAILED`와 필수 `nextAttemptAt`, dead factory는
|
||||
`OUTBOX_DEAD_LETTER`와 null retry time을 고정한다.
|
||||
- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라
|
||||
에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를
|
||||
dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아
|
||||
@@ -372,6 +379,9 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
|
||||
- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가
|
||||
없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를
|
||||
만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다.
|
||||
- `OutboxRelayFailureReportPort` / `OutboxRelayFailureReport` — confirmed FAILED/DEAD 상태를
|
||||
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
|
||||
messaging adapter가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -462,6 +472,14 @@ application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapt
|
||||
|
||||
## 로그 가명화 포트 (observability)
|
||||
|
||||
### CorrelationIdPort
|
||||
|
||||
- 현재 application invocation의 correlation id를 `Optional<String>`으로 읽는 framework-free
|
||||
경계다. application/sample use case는 MDC나 SLF4J를 직접 알지 않는다.
|
||||
- inbound web adapter가 sanitized `correlation_id` MDC 슬롯을 구현 세부로 읽는다.
|
||||
- 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존
|
||||
self-correlation 동작을 유지한다.
|
||||
|
||||
### UserPrincipalPseudonymizerPort
|
||||
|
||||
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
// Application use case contract.
|
||||
//
|
||||
// Depends only on the domain and operational contracts. spring-boot-starter is kept on
|
||||
// the compile classpath so application use cases can opt into @Service registration
|
||||
// without depending on transport / persistence frameworks.
|
||||
//
|
||||
// spring-tx is intentionally NOT declared: application code MUST NOT import
|
||||
// `org.springframework.transaction.annotation.Transactional`. Use the
|
||||
// `TransactionPort` abstraction. The CleanArchitectureTest ArchUnit suite enforces
|
||||
// this for any module that resides under `..application..`.
|
||||
// Framework-free application use-case contract. Runtime dependencies are project-only;
|
||||
// composition and diagnostic rendering belong to adapters/bootstrap.
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
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_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -31,27 +25,16 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=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
|
||||
@@ -60,28 +43,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
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.awaitility:awaitility:4.3.0=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.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
@@ -91,61 +68,15 @@ 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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Business-classified source absence that is safe to negative-cache. */
|
||||
public enum AuthoritativeAbsence {
|
||||
NOT_FOUND,
|
||||
DELETED,
|
||||
NOT_APPLICABLE
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Provider-neutral invalidation result. */
|
||||
public enum CacheInvalidationOutcome {
|
||||
INVALIDATED,
|
||||
ALREADY_ABSENT,
|
||||
DEGRADED_UNAVAILABLE,
|
||||
INDETERMINATE
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Lookup result that never collapses provider failure, negative entries, and normal misses. */
|
||||
public sealed interface CacheLookup<V>
|
||||
permits CacheLookup.Hit,
|
||||
CacheLookup.NegativeHit,
|
||||
CacheLookup.Miss,
|
||||
CacheLookup.IncompatibleSchema,
|
||||
CacheLookup.Unavailable {
|
||||
|
||||
record Hit<V>(V value, Freshness freshness, String sourceRevision) implements CacheLookup<V> {
|
||||
|
||||
public Hit {
|
||||
Objects.requireNonNull(value, "value must be non-null");
|
||||
Objects.requireNonNull(freshness, "freshness must be non-null");
|
||||
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
|
||||
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record NegativeHit<V>(AuthoritativeAbsence reason) implements CacheLookup<V> {
|
||||
|
||||
public NegativeHit {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Miss<V>(MissReason reason) implements CacheLookup<V> {
|
||||
|
||||
public Miss {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record IncompatibleSchema<V>(SchemaCategory category, SchemaPolicy policy)
|
||||
implements CacheLookup<V> {
|
||||
|
||||
public IncompatibleSchema {
|
||||
Objects.requireNonNull(category, "category must be non-null");
|
||||
Objects.requireNonNull(policy, "policy must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record Unavailable<V>(UnavailabilityReason reason, OperationCertainty certainty)
|
||||
implements CacheLookup<V> {
|
||||
|
||||
public Unavailable {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
Objects.requireNonNull(certainty, "certainty must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
enum Freshness {
|
||||
FRESH,
|
||||
STALE
|
||||
}
|
||||
|
||||
enum MissReason {
|
||||
ABSENT,
|
||||
EXPIRED,
|
||||
INVALIDATED
|
||||
}
|
||||
|
||||
enum SchemaCategory {
|
||||
FUTURE_VERSION,
|
||||
RETIRED_VERSION,
|
||||
UNKNOWN_ENVELOPE,
|
||||
CORRUPT_ENVELOPE
|
||||
}
|
||||
|
||||
enum SchemaPolicy {
|
||||
FAIL_FAST,
|
||||
QUARANTINE_AND_RELOAD
|
||||
}
|
||||
|
||||
enum UnavailabilityReason {
|
||||
UNAVAILABLE,
|
||||
OVERLOADED
|
||||
}
|
||||
|
||||
enum OperationCertainty {
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Application-visible consistency intent; technical TTL and codec remain provider policy. */
|
||||
public enum CacheRecordIntent {
|
||||
UPSERT,
|
||||
ONLY_IF_SOURCE_REVISION_NEWER
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
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) {
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/** Provider-neutral result of recording a positive or authoritative-negative entry. */
|
||||
public enum CacheRecordOutcome {
|
||||
RECORDED,
|
||||
NOT_RECORDED_CONDITION,
|
||||
NOT_RECORDED_PROVIDER_POLICY,
|
||||
DEGRADED_UNAVAILABLE,
|
||||
INDETERMINATE
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
/**
|
||||
* Provider-neutral cache-region contract. Concrete use cases should extend this interface with a
|
||||
* semantic port name and domain-specific key/value types.
|
||||
*/
|
||||
public interface CacheRegionPort<K, V> {
|
||||
|
||||
CacheLookup<V> lookup(K key);
|
||||
|
||||
CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata);
|
||||
|
||||
CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata);
|
||||
|
||||
CacheInvalidationOutcome invalidate(K key);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Ordered, versioned schema for tabular publication. */
|
||||
public record ExportSchema(String schemaId, int version, List<Column> columns) {
|
||||
|
||||
public ExportSchema {
|
||||
schemaId = FilePublicationValues.requireOpaque("schemaId", schemaId, 128);
|
||||
if (version < 1) {
|
||||
throw new IllegalArgumentException("schema version must be >= 1");
|
||||
}
|
||||
if (columns == null || columns.isEmpty()) {
|
||||
throw new IllegalArgumentException("schema columns must be non-empty");
|
||||
}
|
||||
columns = List.copyOf(columns);
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Column column : columns) {
|
||||
Objects.requireNonNull(column, "schema column must be non-null");
|
||||
if (!names.add(column.name())) {
|
||||
throw new IllegalArgumentException("duplicate schema column: " + column.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Column(
|
||||
String name,
|
||||
CellType cellType,
|
||||
boolean nullable,
|
||||
FormulaPolicy formulaPolicy,
|
||||
int maximumUtf8Bytes) {
|
||||
|
||||
public Column {
|
||||
name = FilePublicationValues.requireOpaque("column name", name, 128);
|
||||
Objects.requireNonNull(cellType, "cellType must be non-null");
|
||||
Objects.requireNonNull(formulaPolicy, "formulaPolicy must be non-null");
|
||||
if (maximumUtf8Bytes < 1) {
|
||||
throw new IllegalArgumentException("maximumUtf8Bytes must be >= 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CellType {
|
||||
TEXT,
|
||||
INTEGER,
|
||||
DECIMAL,
|
||||
BOOLEAN,
|
||||
DATE,
|
||||
INSTANT
|
||||
}
|
||||
|
||||
public enum FormulaPolicy {
|
||||
ALLOW,
|
||||
MITIGATE,
|
||||
REJECT
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Registered logical file destination; never a path, URI, host, or provider identifier. */
|
||||
public record FileDestinationId(String value) {
|
||||
|
||||
public FileDestinationId {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||
throw new IllegalArgumentException("destinationId must match [a-z][a-z0-9-]{0,62}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/**
|
||||
* Provider-neutral publication failure. The reason is stable application-facing vocabulary; paths,
|
||||
* credentials, and provider exception messages must not be embedded in it.
|
||||
*/
|
||||
public final class FilePublicationException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public FilePublicationException(Reason reason, String message) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public FilePublicationException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Reason reason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public enum Reason {
|
||||
INVALID_REQUEST,
|
||||
CONFLICT,
|
||||
CAPACITY_EXCEEDED,
|
||||
UNAVAILABLE,
|
||||
CANCELLED,
|
||||
PUBLISH_INDETERMINATE
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/**
|
||||
* Outbound application port for publishing a bounded tabular artifact to a registered logical
|
||||
* destination.
|
||||
*/
|
||||
public interface FilePublicationPort {
|
||||
|
||||
FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
final class FilePublicationValues {
|
||||
|
||||
private FilePublicationValues() {}
|
||||
|
||||
static String requireOpaque(String field, String value, int maximumLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must be non-null and non-blank");
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (normalized.length() > maximumLength) {
|
||||
throw new IllegalArgumentException(field + " exceeds " + maximumLength + " characters");
|
||||
}
|
||||
if (normalized.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(field + " must not contain control characters");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Stable opaque operation identity retained across resolution and retry. */
|
||||
public record FilePublishOperationId(String value) {
|
||||
|
||||
public FilePublishOperationId {
|
||||
value = FilePublicationValues.requireOpaque("operationId", value, 128);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Sanitized publication receipt. It intentionally contains no local/remote path or credential. */
|
||||
public record FilePublishReceipt(
|
||||
FilePublishOperationId operationId,
|
||||
PublishedFileReference reference,
|
||||
FileDestinationId destinationId,
|
||||
String publishedFileName,
|
||||
FileVersion version,
|
||||
String formatProfileId,
|
||||
String mediaType,
|
||||
String charset,
|
||||
long byteSize,
|
||||
long dataRowCount,
|
||||
int columnCount,
|
||||
String sha256,
|
||||
Instant publishedAt,
|
||||
PublicationGuarantee publicationGuarantee,
|
||||
DurabilityGuarantee durabilityGuarantee,
|
||||
long formulaMitigatedCount) {
|
||||
|
||||
public FilePublishReceipt {
|
||||
Objects.requireNonNull(operationId, "operationId must be non-null");
|
||||
Objects.requireNonNull(reference, "reference must be non-null");
|
||||
Objects.requireNonNull(destinationId, "destinationId must be non-null");
|
||||
publishedFileName =
|
||||
FilePublicationValues.requireOpaque("publishedFileName", publishedFileName, 256);
|
||||
Objects.requireNonNull(version, "version must be non-null");
|
||||
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
|
||||
mediaType = FilePublicationValues.requireOpaque("mediaType", mediaType, 128);
|
||||
charset = FilePublicationValues.requireOpaque("charset", charset, 64);
|
||||
sha256 = FilePublicationValues.requireOpaque("sha256", sha256, 64);
|
||||
Objects.requireNonNull(publishedAt, "publishedAt must be non-null");
|
||||
Objects.requireNonNull(publicationGuarantee, "publicationGuarantee must be non-null");
|
||||
Objects.requireNonNull(durabilityGuarantee, "durabilityGuarantee must be non-null");
|
||||
if (byteSize < 0 || dataRowCount < 0 || columnCount < 1 || formulaMitigatedCount < 0) {
|
||||
throw new IllegalArgumentException("receipt counts and sizes are out of range");
|
||||
}
|
||||
}
|
||||
|
||||
public enum PublicationGuarantee {
|
||||
UNIQUE_ATOMIC_CREATE
|
||||
}
|
||||
|
||||
public enum DurabilityGuarantee {
|
||||
PROCESS_LOCAL_SYNC,
|
||||
PROVIDER_ACK_ONLY
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Provider-neutral publication intent. */
|
||||
public record FilePublishRequest(
|
||||
FilePublishOperationId operationId,
|
||||
FileDestinationId destinationId,
|
||||
LogicalFileName logicalFileName,
|
||||
SourceRevision sourceRevision,
|
||||
ExportSchema schema,
|
||||
String formatProfileId) {
|
||||
|
||||
public FilePublishRequest {
|
||||
Objects.requireNonNull(operationId, "operationId must be non-null");
|
||||
Objects.requireNonNull(destinationId, "destinationId must be non-null");
|
||||
Objects.requireNonNull(logicalFileName, "logicalFileName must be non-null");
|
||||
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
|
||||
Objects.requireNonNull(schema, "schema must be non-null");
|
||||
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Opaque immutable version of a published artifact. */
|
||||
public record FileVersion(String value) {
|
||||
|
||||
public FileVersion {
|
||||
value = FilePublicationValues.requireOpaque("fileVersion", value, 128);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Display/naming input that cannot carry filesystem path syntax. */
|
||||
public record LogicalFileName(String value) {
|
||||
|
||||
public LogicalFileName {
|
||||
value = FilePublicationValues.requireOpaque("logicalFileName", value, 128);
|
||||
if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) {
|
||||
throw new IllegalArgumentException("logicalFileName must not contain path syntax");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Opaque reference that never exposes a filesystem path, host, or provider location. */
|
||||
public record PublishedFileReference(String value) {
|
||||
|
||||
public PublishedFileReference {
|
||||
value = FilePublicationValues.requireOpaque("publishedFileReference", value, 256);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Stable source snapshot or source fingerprint selected by the application. */
|
||||
public record SourceRevision(String value) {
|
||||
|
||||
public SourceRevision {
|
||||
value = FilePublicationValues.requireOpaque("sourceRevision", value, 256);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed framework-free set of cells supported by the baseline tabular publisher. */
|
||||
public sealed interface TabularCell {
|
||||
|
||||
ExportSchema.CellType cellType();
|
||||
|
||||
record TextCell(String value) implements TabularCell {
|
||||
public TextCell {
|
||||
Objects.requireNonNull(value, "text value must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
record IntegerCell(long value) implements TabularCell {
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.INTEGER;
|
||||
}
|
||||
}
|
||||
|
||||
record DecimalCell(BigDecimal value) implements TabularCell {
|
||||
public DecimalCell {
|
||||
Objects.requireNonNull(value, "decimal value must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.DECIMAL;
|
||||
}
|
||||
}
|
||||
|
||||
record BooleanCell(boolean value) implements TabularCell {
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.BOOLEAN;
|
||||
}
|
||||
}
|
||||
|
||||
record DateCell(LocalDate value) implements TabularCell {
|
||||
public DateCell {
|
||||
Objects.requireNonNull(value, "date value must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.DATE;
|
||||
}
|
||||
}
|
||||
|
||||
record InstantCell(Instant value) implements TabularCell {
|
||||
public InstantCell {
|
||||
Objects.requireNonNull(value, "instant value must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
return ExportSchema.CellType.INSTANT;
|
||||
}
|
||||
}
|
||||
|
||||
record NullCell() implements TabularCell {
|
||||
@Override
|
||||
public ExportSchema.CellType cellType() {
|
||||
throw new IllegalStateException("null cells do not have a concrete cell type");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable ordered row. */
|
||||
public record TabularRow(List<TabularCell> cells) {
|
||||
|
||||
public TabularRow {
|
||||
Objects.requireNonNull(cells, "cells must be non-null");
|
||||
cells = List.copyOf(cells);
|
||||
if (cells.stream().anyMatch(Objects::isNull)) {
|
||||
throw new IllegalArgumentException("cells must not contain null; use NullCell");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Synchronous single-attempt producer for bounded row-by-row publication. */
|
||||
@FunctionalInterface
|
||||
public interface TabularRowProducer {
|
||||
|
||||
void produce(TabularRowSink sink);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
/** Attempt-scoped non-thread-safe sink owned by the file publication adapter. */
|
||||
public interface TabularRowSink {
|
||||
|
||||
void write(TabularRow row);
|
||||
|
||||
void checkpoint();
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.application.observability;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads the correlation identifier associated with the current application invocation.
|
||||
*
|
||||
* <p>Implementations own transport or diagnostic storage. They return {@link Optional#empty()} when
|
||||
* no non-blank correlation identifier is available.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface CorrelationIdPort {
|
||||
|
||||
Optional<String> currentCorrelationId();
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.application.outbound;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Absolute monotonic deadline carrier for nested application calls. It is process-local and must
|
||||
* never be serialized as a wall-clock timestamp.
|
||||
*/
|
||||
public record CallBudget(long monotonicDeadlineNanos) {
|
||||
|
||||
private static final Duration MAXIMUM_BUDGET = Duration.ofDays(365);
|
||||
|
||||
public static CallBudget fromNow(Duration duration) {
|
||||
return after(System.nanoTime(), duration);
|
||||
}
|
||||
|
||||
public static CallBudget after(long monotonicNowNanos, Duration duration) {
|
||||
Objects.requireNonNull(duration, "duration must be non-null");
|
||||
if (duration.isZero() || duration.isNegative() || duration.compareTo(MAXIMUM_BUDGET) > 0) {
|
||||
throw new IllegalArgumentException("call budget duration must be in (0, 365 days]");
|
||||
}
|
||||
long durationNanos;
|
||||
try {
|
||||
durationNanos = duration.toNanos();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"call budget duration exceeds the supported range", exception);
|
||||
}
|
||||
return new CallBudget(monotonicNowNanos + durationNanos);
|
||||
}
|
||||
|
||||
public long remainingNanosAt(long monotonicNowNanos) {
|
||||
long remaining = monotonicDeadlineNanos - monotonicNowNanos;
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
public boolean isExpiredAt(long monotonicNowNanos) {
|
||||
return monotonicDeadlineNanos - monotonicNowNanos <= 0;
|
||||
}
|
||||
|
||||
public CallBudget intersect(CallBudget other) {
|
||||
Objects.requireNonNull(other, "other must be non-null");
|
||||
return monotonicDeadlineNanos - other.monotonicDeadlineNanos <= 0 ? this : other;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Safe immutable allowlist for reporting a confirmed FAILED or DEAD outbox relay transition.
|
||||
*
|
||||
* <p>The report deliberately cannot carry an event payload, idempotency key, rendered message,
|
||||
* severity, arbitrary fields, or the whole {@link OutboxEvent}.
|
||||
*/
|
||||
public record OutboxRelayFailureReport(
|
||||
OperationalError code,
|
||||
String eventId,
|
||||
String eventType,
|
||||
String aggregateId,
|
||||
String correlationId,
|
||||
int attemptCount,
|
||||
Instant nextAttemptAt,
|
||||
RuntimeException cause) {
|
||||
|
||||
public OutboxRelayFailureReport {
|
||||
Objects.requireNonNull(code, "code must not be null");
|
||||
requireNonBlank(eventId, "eventId");
|
||||
requireNonBlank(eventType, "eventType");
|
||||
requireNonBlank(aggregateId, "aggregateId");
|
||||
requireNonBlank(correlationId, "correlationId");
|
||||
Objects.requireNonNull(cause, "cause must not be null");
|
||||
if (attemptCount < 1) {
|
||||
throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
|
||||
}
|
||||
if (code == OperationalError.OUTBOX_PUBLISH_FAILED) {
|
||||
if (nextAttemptAt == null) {
|
||||
throw new IllegalArgumentException("nextAttemptAt is required for OUTBOX_PUBLISH_FAILED");
|
||||
}
|
||||
} else if (code == OperationalError.OUTBOX_DEAD_LETTER) {
|
||||
if (nextAttemptAt != null) {
|
||||
throw new IllegalArgumentException("nextAttemptAt is forbidden for OUTBOX_DEAD_LETTER");
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("unsupported outbox relay failure code: " + code);
|
||||
}
|
||||
}
|
||||
|
||||
public static OutboxRelayFailureReport retryableFailure(
|
||||
String eventId,
|
||||
String eventType,
|
||||
String aggregateId,
|
||||
String correlationId,
|
||||
int attemptCount,
|
||||
Instant nextAttemptAt,
|
||||
RuntimeException cause) {
|
||||
return new OutboxRelayFailureReport(
|
||||
OperationalError.OUTBOX_PUBLISH_FAILED,
|
||||
eventId,
|
||||
eventType,
|
||||
aggregateId,
|
||||
correlationId,
|
||||
attemptCount,
|
||||
nextAttemptAt,
|
||||
cause);
|
||||
}
|
||||
|
||||
public static OutboxRelayFailureReport deadLetter(
|
||||
String eventId,
|
||||
String eventType,
|
||||
String aggregateId,
|
||||
String correlationId,
|
||||
int attemptCount,
|
||||
RuntimeException cause) {
|
||||
return new OutboxRelayFailureReport(
|
||||
OperationalError.OUTBOX_DEAD_LETTER,
|
||||
eventId,
|
||||
eventType,
|
||||
aggregateId,
|
||||
correlationId,
|
||||
attemptCount,
|
||||
null,
|
||||
cause);
|
||||
}
|
||||
|
||||
private static void requireNonBlank(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
/**
|
||||
* Reports a confirmed outbox relay failure transition to an operational diagnostics adapter.
|
||||
*
|
||||
* <p>Implementations must not throw. Callers still defend against {@link RuntimeException} so a
|
||||
* diagnostic failure can never change the authoritative persisted relay outcome.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface OutboxRelayFailureReportPort {
|
||||
|
||||
void report(OutboxRelayFailureReport report);
|
||||
}
|
||||
+42
-35
@@ -14,17 +14,17 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Relay use case that claims pending outbox events and publishes them to the broker: claim a batch
|
||||
* in a short write transaction, sort by {@code occurredAt}, then publish each event
|
||||
* <em>outside</em> any transaction and drive the PUBLISHED / FAILED / DEAD state machine per
|
||||
* result. Publish failures are logged and never rethrown; a status-update failure after a
|
||||
* successful publish propagates and the row is recovered via the in-flight timeout. Wired manually
|
||||
* by {@code app-bootstrap} (not a Spring bean). See README for the full algorithm, failure
|
||||
* semantics, manual-wiring rationale, and the {@code "outbox:relay"} permission.
|
||||
* result. Confirmed failure transitions are reported through a typed outbound port and publish
|
||||
* failures are never rethrown; a status-update failure after a successful publish propagates and
|
||||
* the row is recovered via the in-flight timeout. Wired manually by {@code app-bootstrap} (not a
|
||||
* Spring bean). See README for the full algorithm, failure semantics, manual-wiring rationale, and
|
||||
* the {@code "outbox:relay"} permission.
|
||||
*/
|
||||
@RequiresPermission("outbox:relay")
|
||||
@UseCaseCapability(
|
||||
@@ -35,11 +35,9 @@ import org.slf4j.LoggerFactory;
|
||||
public final class PublishPendingOutboxEventsUseCase
|
||||
implements CommandUseCase<PublishPendingOutboxEventsCommand, OutboxRelayResult> {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(PublishPendingOutboxEventsUseCase.class);
|
||||
|
||||
private final OutboxStorePort store;
|
||||
private final OutboxMessagePublishPort publishPort;
|
||||
private final OutboxRelayFailureReportPort failureReporter;
|
||||
private final TransactionPort tx;
|
||||
private final OutboxBackoffPolicy backoffPolicy;
|
||||
private final Clock clock;
|
||||
@@ -52,6 +50,7 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
*
|
||||
* @param store outbox store port (claim + status update)
|
||||
* @param publishPort fail-closed broker publish port
|
||||
* @param failureReporter diagnostics port for confirmed FAILED/DEAD transitions
|
||||
* @param tx transaction port for short write boundaries
|
||||
* @param backoffPolicy retry backoff policy
|
||||
* @param clock wall-clock source (injected for testability)
|
||||
@@ -61,6 +60,7 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
public PublishPendingOutboxEventsUseCase(
|
||||
OutboxStorePort store,
|
||||
OutboxMessagePublishPort publishPort,
|
||||
OutboxRelayFailureReportPort failureReporter,
|
||||
TransactionPort tx,
|
||||
OutboxBackoffPolicy backoffPolicy,
|
||||
Clock clock,
|
||||
@@ -68,6 +68,8 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
Duration inFlightTimeout) {
|
||||
this.store = Objects.requireNonNull(store, "store must not be null");
|
||||
this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null");
|
||||
this.failureReporter =
|
||||
Objects.requireNonNull(failureReporter, "failureReporter must not be null");
|
||||
this.tx = Objects.requireNonNull(tx, "tx must not be null");
|
||||
this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
@@ -116,7 +118,7 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
try {
|
||||
publishPort.publish(event);
|
||||
} catch (RuntimeException publishEx) {
|
||||
// Publish failure: drive FAILED/DEAD state machine + ERROR log; do NOT rethrow.
|
||||
// Publish failure: drive FAILED/DEAD state machine + typed report; do NOT rethrow.
|
||||
return handlePublishFailure(event, now, publishEx);
|
||||
}
|
||||
// markPublished failure (if any) propagates: the row stays IN_FLIGHT and is
|
||||
@@ -126,9 +128,9 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the FAILED/DEAD state transition and produces a mandatory ERROR log — always both a
|
||||
* status transition and an ERROR log (omitting either is the forbidden silent-swallow). See
|
||||
* README.
|
||||
* Drives the FAILED/DEAD state transition and reports it only after persistence succeeds. A
|
||||
* transition failure remains authoritative and propagates without a report. A diagnostic adapter
|
||||
* failure is contained and cannot change the persisted outcome. See README.
|
||||
*/
|
||||
private OutboxRelayResult.Outcome handlePublishFailure(
|
||||
OutboxEvent event, Instant now, RuntimeException cause) {
|
||||
@@ -136,34 +138,39 @@ public final class PublishPendingOutboxEventsUseCase
|
||||
if (event.attemptCount() >= backoffPolicy.maxAttempts()) {
|
||||
// All attempts exhausted — DEAD-letter the event.
|
||||
tx.inWrite(() -> store.markDead(event.eventId()));
|
||||
log.error(
|
||||
"error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} "
|
||||
+ "— outbox event dead-lettered after {} attempts; manual intervention required",
|
||||
"OUTBOX_DEAD_LETTER",
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.aggregateId(),
|
||||
event.correlationId(),
|
||||
event.attemptCount(),
|
||||
backoffPolicy.maxAttempts(),
|
||||
cause);
|
||||
reportFailure(
|
||||
() ->
|
||||
OutboxRelayFailureReport.deadLetter(
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.aggregateId(),
|
||||
event.correlationId(),
|
||||
event.attemptCount(),
|
||||
cause));
|
||||
return OutboxRelayResult.Outcome.DEAD;
|
||||
} else {
|
||||
// Transient failure — schedule retry with exponential backoff.
|
||||
Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now);
|
||||
tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt));
|
||||
log.error(
|
||||
"error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} "
|
||||
+ "nextAttemptAt={} — outbox publish failed transiently; will retry",
|
||||
"OUTBOX_PUBLISH_FAILED",
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.aggregateId(),
|
||||
event.correlationId(),
|
||||
event.attemptCount(),
|
||||
nextAttemptAt,
|
||||
cause);
|
||||
reportFailure(
|
||||
() ->
|
||||
OutboxRelayFailureReport.retryableFailure(
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.aggregateId(),
|
||||
event.correlationId(),
|
||||
event.attemptCount(),
|
||||
nextAttemptAt,
|
||||
cause));
|
||||
return OutboxRelayResult.Outcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private void reportFailure(Supplier<OutboxRelayFailureReport> reportFactory) {
|
||||
try {
|
||||
failureReporter.report(reportFactory.get());
|
||||
} catch (RuntimeException ignored) {
|
||||
// Report construction and delivery are non-authoritative. The persisted transition remains.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.application.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CacheRegionContractTest {
|
||||
|
||||
@Test
|
||||
void keepsMissNegativeHitAndUnavailableDistinct() {
|
||||
CacheLookup<String> miss = new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT);
|
||||
CacheLookup<String> negative = new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND);
|
||||
CacheLookup<String> unavailable =
|
||||
new CacheLookup.Unavailable<>(
|
||||
CacheLookup.UnavailabilityReason.OVERLOADED,
|
||||
CacheLookup.OperationCertainty.NOT_APPLIED);
|
||||
|
||||
assertThat(miss).isInstanceOf(CacheLookup.Miss.class);
|
||||
assertThat(negative).isInstanceOf(CacheLookup.NegativeHit.class);
|
||||
assertThat(unavailable).isInstanceOf(CacheLookup.Unavailable.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hitCarriesFreshnessAndSourceRevisionWithoutProviderTypes() {
|
||||
CacheLookup.Hit<String> hit =
|
||||
new CacheLookup.Hit<>("snapshot", CacheLookup.Freshness.STALE, "source-42");
|
||||
|
||||
assertThat(hit.value()).isEqualTo("snapshot");
|
||||
assertThat(hit.freshness()).isEqualTo(CacheLookup.Freshness.STALE);
|
||||
assertThat(hit.sourceRevision()).isEqualTo("source-42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataRejectsBlankRevision() {
|
||||
assertThatThrownBy(
|
||||
() -> new CacheRecordMetadata(" ", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.application.filepublication;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FilePublicationContractTest {
|
||||
|
||||
@Test
|
||||
void logicalFileNameRejectsPathSyntax() {
|
||||
assertThatThrownBy(() -> new LogicalFileName("../report.csv"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new LogicalFileName("nested/report.csv"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new LogicalFileName("nested\\report.csv"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaRejectsDuplicateColumnNames() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ExportSchema(
|
||||
"worklog-v1",
|
||||
1,
|
||||
List.of(
|
||||
new ExportSchema.Column(
|
||||
"id",
|
||||
ExportSchema.CellType.INTEGER,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.REJECT,
|
||||
64),
|
||||
new ExportSchema.Column(
|
||||
"id",
|
||||
ExportSchema.CellType.TEXT,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.MITIGATE,
|
||||
128))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDefensivelyCopiesColumns() {
|
||||
ExportSchema schema =
|
||||
new ExportSchema(
|
||||
"worklog-v1",
|
||||
1,
|
||||
List.of(
|
||||
new ExportSchema.Column(
|
||||
"id",
|
||||
ExportSchema.CellType.INTEGER,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.REJECT,
|
||||
64)));
|
||||
|
||||
assertThat(schema.columns()).hasSize(1);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
schema
|
||||
.columns()
|
||||
.add(
|
||||
new ExportSchema.Column(
|
||||
"other",
|
||||
ExportSchema.CellType.TEXT,
|
||||
true,
|
||||
ExportSchema.FormulaPolicy.MITIGATE,
|
||||
128)))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestRequiresRegisteredIdentifiersAndFormatProfile() {
|
||||
ExportSchema schema =
|
||||
new ExportSchema(
|
||||
"worklog-v1",
|
||||
1,
|
||||
List.of(
|
||||
new ExportSchema.Column(
|
||||
"id",
|
||||
ExportSchema.CellType.INTEGER,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.REJECT,
|
||||
64)));
|
||||
|
||||
FilePublishRequest request =
|
||||
new FilePublishRequest(
|
||||
new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"),
|
||||
new FileDestinationId("local-export"),
|
||||
new LogicalFileName("worklogs"),
|
||||
new SourceRevision("snapshot-42"),
|
||||
schema,
|
||||
"csv-rfc4180-v1");
|
||||
|
||||
assertThat(request.formatProfileId()).isEqualTo("csv-rfc4180-v1");
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.application.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CorrelationIdPortTest {
|
||||
|
||||
@Test
|
||||
void exposesPresentCorrelationIdWithoutAFrameworkType() {
|
||||
CorrelationIdPort port = () -> Optional.of("corr-123");
|
||||
|
||||
assertThat(port.currentCorrelationId()).contains("corr-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesAbsenceExplicitly() {
|
||||
CorrelationIdPort port = Optional::empty;
|
||||
|
||||
assertThat(port.currentCorrelationId()).isEmpty();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.application.outbound;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CallBudgetTest {
|
||||
|
||||
@Test
|
||||
void measuresRemainingTimeInTheMonotonicDomain() {
|
||||
CallBudget budget = CallBudget.after(1_000, Duration.ofNanos(250));
|
||||
|
||||
assertThat(budget.remainingNanosAt(1_100)).isEqualTo(150);
|
||||
assertThat(budget.isExpiredAt(1_249)).isFalse();
|
||||
assertThat(budget.isExpiredAt(1_250)).isTrue();
|
||||
assertThat(budget.remainingNanosAt(1_300)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void childBudgetCannotOutliveItsParent() {
|
||||
CallBudget parent = CallBudget.after(1_000, Duration.ofNanos(200));
|
||||
CallBudget longerChild = CallBudget.after(1_050, Duration.ofNanos(500));
|
||||
|
||||
assertThat(parent.intersect(longerChild)).isEqualTo(parent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPositiveAndUnreasonablyLargeDurations() {
|
||||
assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ofDays(366)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OutboxRelayFailureReportTest {
|
||||
|
||||
private static final Instant NEXT_ATTEMPT_AT = Instant.parse("2026-07-25T01:02:03Z");
|
||||
private static final RuntimeException CAUSE = new RuntimeException("broker unavailable");
|
||||
|
||||
@Test
|
||||
void recordComponentsAreAnExactSafeAllowlist() {
|
||||
assertThat(
|
||||
Arrays.stream(OutboxRelayFailureReport.class.getRecordComponents())
|
||||
.map(RecordComponent::getName))
|
||||
.containsExactly(
|
||||
"code",
|
||||
"eventId",
|
||||
"eventType",
|
||||
"aggregateId",
|
||||
"correlationId",
|
||||
"attemptCount",
|
||||
"nextAttemptAt",
|
||||
"cause");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryableFailureFactoryCreatesPublishFailedReport() {
|
||||
OutboxRelayFailureReport report =
|
||||
OutboxRelayFailureReport.retryableFailure(
|
||||
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, NEXT_ATTEMPT_AT, CAUSE);
|
||||
|
||||
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
|
||||
assertThat(report.eventId()).isEqualTo("evt-1");
|
||||
assertThat(report.eventType()).isEqualTo("WorkLogReserved");
|
||||
assertThat(report.aggregateId()).isEqualTo("agg-1");
|
||||
assertThat(report.correlationId()).isEqualTo("corr-1");
|
||||
assertThat(report.attemptCount()).isEqualTo(2);
|
||||
assertThat(report.nextAttemptAt()).isEqualTo(NEXT_ATTEMPT_AT);
|
||||
assertThat(report.cause()).isSameAs(CAUSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadLetterFactoryCreatesTerminalReportWithoutRetryTime() {
|
||||
OutboxRelayFailureReport report =
|
||||
OutboxRelayFailureReport.deadLetter(
|
||||
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 3, CAUSE);
|
||||
|
||||
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
|
||||
assertThat(report.nextAttemptAt()).isNull();
|
||||
assertThat(report.cause()).isSameAs(CAUSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedCode() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboxRelayFailureReport(
|
||||
OperationalError.INTERNAL_ERROR,
|
||||
"evt-1",
|
||||
"Event",
|
||||
"agg-1",
|
||||
"corr-1",
|
||||
1,
|
||||
null,
|
||||
CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("code");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankIdentifiersAndEventType() {
|
||||
assertThatThrownBy(
|
||||
() -> OutboxRelayFailureReport.deadLetter(" ", "Event", "agg-1", "corr-1", 1, CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("eventId");
|
||||
assertThatThrownBy(
|
||||
() -> OutboxRelayFailureReport.deadLetter("evt-1", "", "agg-1", "corr-1", 1, CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("eventType");
|
||||
assertThatThrownBy(
|
||||
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "\t", "corr-1", 1, CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("aggregateId");
|
||||
assertThatThrownBy(
|
||||
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "\n", 1, CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("correlationId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAttemptCountBelowOne() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 0, CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("attemptCount");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryableFailureRequiresNextAttemptAt() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboxRelayFailureReport(
|
||||
OperationalError.OUTBOX_PUBLISH_FAILED,
|
||||
"evt-1",
|
||||
"Event",
|
||||
"agg-1",
|
||||
"corr-1",
|
||||
1,
|
||||
null,
|
||||
CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nextAttemptAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadLetterForbidsNextAttemptAt() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboxRelayFailureReport(
|
||||
OperationalError.OUTBOX_DEAD_LETTER,
|
||||
"evt-1",
|
||||
"Event",
|
||||
"agg-1",
|
||||
"corr-1",
|
||||
1,
|
||||
NEXT_ATTEMPT_AT,
|
||||
CAUSE))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nextAttemptAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void causeIsRequired() {
|
||||
assertThatThrownBy(
|
||||
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 1, null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("cause");
|
||||
}
|
||||
}
|
||||
+185
-4
@@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test;
|
||||
* <li>Successful transition: IN_FLIGHT → PUBLISHED
|
||||
* <li>Transient failure → FAILED + backoff window
|
||||
* <li>3 attempt exhaustion → DEAD
|
||||
* <li>Failure is NOT swallowed (status transition + ERROR log required)
|
||||
* <li>Failure is NOT swallowed (status transition + typed report attempt required)
|
||||
* <li>Events processed in {@code occurredAt} ascending order
|
||||
* </ul>
|
||||
*/
|
||||
@@ -38,6 +38,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
|
||||
private FakeOutboxStorePort store;
|
||||
private FakeOutboxMessagePublishPort publishPort;
|
||||
private RecordingFailureReporter reporter;
|
||||
private FakeTransactionPort tx;
|
||||
private OutboxBackoffPolicy backoffPolicy;
|
||||
private Clock clock;
|
||||
@@ -47,13 +48,14 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
void setUp() {
|
||||
store = new FakeOutboxStorePort();
|
||||
publishPort = new FakeOutboxMessagePublishPort();
|
||||
reporter = new RecordingFailureReporter();
|
||||
tx = new FakeTransactionPort();
|
||||
clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
// Use fixed random for determinism: always returns 0.0 jitter (nextDouble() = 0.0)
|
||||
backoffPolicy = new OutboxBackoffPolicy(new ZeroRandom());
|
||||
useCase =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
store, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
|
||||
store, publishPort, reporter, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
|
||||
}
|
||||
|
||||
// ---- success path ----
|
||||
@@ -71,6 +73,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
.isEqualTo(OutboxRelayResult.Outcome.PUBLISHED);
|
||||
assertThat(store.publishedEvents).containsExactly("evt-1");
|
||||
assertThat(publishPort.publishedEvents).containsExactly("evt-1");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,6 +109,16 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
assertThat(nextAttemptAt).isAfter(NOW);
|
||||
// Not yet DEAD (attempt 1 < maxAttempts 3)
|
||||
assertThat(store.deadEvents).doesNotContain("evt-fail");
|
||||
assertThat(reporter.reports)
|
||||
.containsExactly(
|
||||
OutboxRelayFailureReport.retryableFailure(
|
||||
"evt-fail",
|
||||
"UserCreated",
|
||||
"agg-1",
|
||||
"corr-evt-fail",
|
||||
1,
|
||||
nextAttemptAt,
|
||||
publishPort.failureOn("evt-fail")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,6 +133,15 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.DEAD);
|
||||
assertThat(store.deadEvents).contains("evt-dead");
|
||||
assertThat(store.failedEvents).doesNotContainKey("evt-dead");
|
||||
assertThat(reporter.reports)
|
||||
.containsExactly(
|
||||
OutboxRelayFailureReport.deadLetter(
|
||||
"evt-dead",
|
||||
"UserCreated",
|
||||
"agg-1",
|
||||
"corr-evt-dead",
|
||||
3,
|
||||
publishPort.failureOn("evt-dead")));
|
||||
}
|
||||
|
||||
// ---- failure NOT swallowed ----
|
||||
@@ -207,7 +229,14 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
|
||||
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
|
||||
throwingStore,
|
||||
publishPort,
|
||||
reporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
// The exception must propagate — handle() must throw.
|
||||
assertThatThrownBy(
|
||||
@@ -225,6 +254,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
assertThat(throwingStore.deadEvents)
|
||||
.as("markDead must NOT be called when only markPublished fails")
|
||||
.doesNotContain("evt-store-fail");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,7 +278,14 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
|
||||
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
|
||||
throwingStore,
|
||||
publishPort,
|
||||
reporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
|
||||
@@ -266,6 +303,110 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
// No FAILED or DEAD misclassification for the second event either.
|
||||
assertThat(throwingStore.failedEvents).doesNotContainKey("evt-second");
|
||||
assertThat(throwingStore.deadEvents).doesNotContain("evt-second");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markFailedFailurePropagatesAndEmitsNoReport() {
|
||||
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
ThrowingTransitionStorePort throwingStore =
|
||||
new ThrowingTransitionStorePort(new RuntimeException("markFailed failed"), null);
|
||||
throwingStore.addClaimable(event);
|
||||
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
|
||||
PublishPendingOutboxEventsUseCase throwingUseCase =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
throwingStore,
|
||||
publishPort,
|
||||
reporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE))
|
||||
.hasMessage("markFailed failed");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markDeadFailurePropagatesAndEmitsNoReport() {
|
||||
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
|
||||
ThrowingTransitionStorePort throwingStore =
|
||||
new ThrowingTransitionStorePort(null, new RuntimeException("markDead failed"));
|
||||
throwingStore.addClaimable(event);
|
||||
publishPort.failOn("evt-dead", new RuntimeException("broker down"));
|
||||
PublishPendingOutboxEventsUseCase throwingUseCase =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
throwingStore,
|
||||
publishPort,
|
||||
reporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE))
|
||||
.hasMessage("markDead failed");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwingReporterPreservesFailedOutcomeAndRelayContinues() {
|
||||
OutboxEvent failed = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
|
||||
OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
store.addClaimable(failed);
|
||||
store.addClaimable(succeeded);
|
||||
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
|
||||
OutboxRelayFailureReportPort throwingReporter =
|
||||
ignored -> {
|
||||
throw new RuntimeException("reporter failed");
|
||||
};
|
||||
PublishPendingOutboxEventsUseCase useCaseWithThrowingReporter =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
store,
|
||||
publishPort,
|
||||
throwingReporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
OutboxRelayResult result =
|
||||
useCaseWithThrowingReporter.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
|
||||
assertThat(result.outcomes())
|
||||
.extracting(OutboxRelayResult.EventOutcome::outcome)
|
||||
.containsExactly(OutboxRelayResult.Outcome.FAILED, OutboxRelayResult.Outcome.PUBLISHED);
|
||||
assertThat(store.failedEvents).containsKey("evt-fail");
|
||||
assertThat(store.publishedEvents).containsExactly("evt-ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedReportDataPreservesFailedAndDeadOutcomesAndRelayContinues() {
|
||||
OutboxEvent failed = makeEvent("evt-fail", "UserCreated", " ", NOW.minusSeconds(180), 1);
|
||||
OutboxEvent dead = makeEvent("evt-dead", "UserDeleted", " ", NOW.minusSeconds(120), 3);
|
||||
OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
store.addClaimable(failed);
|
||||
store.addClaimable(dead);
|
||||
store.addClaimable(succeeded);
|
||||
publishPort.failOn("evt-fail", new RuntimeException("transient broker failure"));
|
||||
publishPort.failOn("evt-dead", new RuntimeException("persistent broker failure"));
|
||||
|
||||
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
|
||||
assertThat(result.outcomes())
|
||||
.extracting(OutboxRelayResult.EventOutcome::outcome)
|
||||
.containsExactly(
|
||||
OutboxRelayResult.Outcome.FAILED,
|
||||
OutboxRelayResult.Outcome.DEAD,
|
||||
OutboxRelayResult.Outcome.PUBLISHED);
|
||||
assertThat(store.failedEvents).containsKey("evt-fail");
|
||||
assertThat(store.deadEvents).containsExactly("evt-dead");
|
||||
assertThat(store.publishedEvents).containsExactly("evt-ok");
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
}
|
||||
|
||||
// ---- helper ----
|
||||
@@ -346,6 +487,33 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
}
|
||||
}
|
||||
|
||||
static final class ThrowingTransitionStorePort extends FakeOutboxStorePort {
|
||||
private final RuntimeException markFailedException;
|
||||
private final RuntimeException markDeadException;
|
||||
|
||||
ThrowingTransitionStorePort(
|
||||
RuntimeException markFailedException, RuntimeException markDeadException) {
|
||||
this.markFailedException = markFailedException;
|
||||
this.markDeadException = markDeadException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markFailed(String eventId, Instant nextAttemptAt) {
|
||||
if (markFailedException != null) {
|
||||
throw markFailedException;
|
||||
}
|
||||
super.markFailed(eventId, nextAttemptAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDead(String eventId) {
|
||||
if (markDeadException != null) {
|
||||
throw markDeadException;
|
||||
}
|
||||
super.markDead(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
static final class FakeOutboxMessagePublishPort implements OutboxMessagePublishPort {
|
||||
final List<String> publishedEvents = new ArrayList<>();
|
||||
private final Map<String, RuntimeException> failureMap = new LinkedHashMap<>();
|
||||
@@ -354,6 +522,10 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
failureMap.put(eventId, ex);
|
||||
}
|
||||
|
||||
RuntimeException failureOn(String eventId) {
|
||||
return failureMap.get(eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(OutboxEvent event) {
|
||||
if (failureMap.containsKey(event.eventId())) {
|
||||
@@ -363,6 +535,15 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingFailureReporter implements OutboxRelayFailureReportPort {
|
||||
final List<OutboxRelayFailureReport> reports = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void report(OutboxRelayFailureReport report) {
|
||||
reports.add(report);
|
||||
}
|
||||
}
|
||||
|
||||
static final class FakeTransactionPort implements TransactionPort {
|
||||
@Override
|
||||
public <T> T inWrite(Supplier<T> action) {
|
||||
|
||||
Reference in New Issue
Block a user