feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
messaging-superpowers-package 설계서/계획서 기반 구현. registry를 19 → 43 leaf로 확장하고 src/messaging 아래 24개 leaf를 등록. - core-api: M1 publish/consume + M2 batch·delayed·pause-resume - policy/transport-spi: 재시도 결정, DLQ orchestration, admission control, lifecycle - kafka·rabbit(Stable): contiguous commit, confirm/return 상관, 배치, 보안 설정 - pulsar·nats(Experimental): 기본 비활성, live 인증 없음을 코드로 기록 - outbox/inbox/claim-check: 트랜잭션 결합, lease, 무결성 검증 - admin: plan → approve → execute를 타입으로 강제 - 문서 9종, infra compose 7종, JMH 벤치마크 3종 검증: 아키텍처 게이트 3종 통과, 24개 leaf 전부 check 통과, messaging 테스트 604개 통과/0 실패. 미완: 계획서가 요구한 실 브로커 IT 40개 중 7개만 작성. Rabbit 13 / Outbox 6 / Inbox 4 / NATS·Pulsar·Share 5 / testkit 2 / starter·admin 3, 그리고 TLS·ACL 2개가 남음.
This commit is contained in:
+49
-1
@@ -431,7 +431,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (project.path in [':domain-core', ':application-core', ':shared-contract']) {
|
||||
// The messaging platform leaves own a broker-neutral public contract. Keeping their test
|
||||
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring
|
||||
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring
|
||||
// test context add it in their own build file.
|
||||
if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
|
||||
project.path.startsWith(':messaging:')) {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testImplementation 'org.assertj:assertj-core'
|
||||
} else {
|
||||
@@ -444,6 +449,49 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||||
errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker
|
||||
}
|
||||
|
||||
// The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source
|
||||
// set rather than a plugin because the benchmarks are compiled and reviewed on every build but
|
||||
// only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark that
|
||||
// runs in CI is a flaky test measuring the build agent.
|
||||
if (project.path in [':messaging:messaging-kafka',
|
||||
':messaging:messaging-rabbit',
|
||||
':messaging:messaging-testkit']) {
|
||||
sourceSets {
|
||||
jmh {
|
||||
compileClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
|
||||
}
|
||||
}
|
||||
configurations {
|
||||
jmhImplementation.extendsFrom implementation, testImplementation
|
||||
jmhRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly
|
||||
}
|
||||
dependencies {
|
||||
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
|
||||
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
|
||||
// ErrorProne's -Werror would reject JMH's generated sources, which the platform does
|
||||
// not own and cannot fix.
|
||||
jmhAnnotationProcessor 'com.google.errorprone:error_prone_core:2.49.0'
|
||||
}
|
||||
tasks.named('compileJmhJava') {
|
||||
options.errorprone.enabled = false
|
||||
options.compilerArgs.removeAll { it == '-Werror' }
|
||||
}
|
||||
// JMH's annotation processor emits the generated harness into this source set, and its
|
||||
// generated code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats
|
||||
// dead-code elimination). Analysing code the platform neither wrote nor can fix would make
|
||||
// the gate unactionable, so the jmh source set is excluded from the bug and style checks.
|
||||
// The benchmarks themselves are still compiled, which is what catches a real breakage.
|
||||
tasks.named('spotbugsJmh') { enabled = false }
|
||||
tasks.named('checkstyleJmh') { enabled = false }
|
||||
tasks.register('jmh', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Runs the JMH benchmarks in this leaf.'
|
||||
classpath = sourceSets.jmh.runtimeClasspath
|
||||
mainClass = 'org.openjdk.jmh.Main'
|
||||
}
|
||||
}
|
||||
|
||||
// feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky
|
||||
// quarantine bucket so a quarantined test can never block merge. Quarantined tests carry
|
||||
// JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking)
|
||||
|
||||
@@ -252,6 +252,285 @@
|
||||
"runtime_memberships": [
|
||||
"sample-portfolio"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "messaging-core-api",
|
||||
"gradle_path": ":messaging:messaging-core-api",
|
||||
"source_path": "src/messaging/messaging-core-api",
|
||||
"allowed_dependencies": [],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-schema-api",
|
||||
"gradle_path": ":messaging:messaging-schema-api",
|
||||
"source_path": "src/messaging/messaging-schema-api",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-schema-json",
|
||||
"gradle_path": ":messaging:messaging-schema-json",
|
||||
"source_path": "src/messaging/messaging-schema-json",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-schema-avro",
|
||||
"gradle_path": ":messaging:messaging-schema-avro",
|
||||
"source_path": "src/messaging/messaging-schema-avro",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-schema-protobuf",
|
||||
"gradle_path": ":messaging:messaging-schema-protobuf",
|
||||
"source_path": "src/messaging/messaging-schema-protobuf",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-cloudevents",
|
||||
"gradle_path": ":messaging:messaging-cloudevents",
|
||||
"source_path": "src/messaging/messaging-cloudevents",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-policy",
|
||||
"gradle_path": ":messaging:messaging-policy",
|
||||
"source_path": "src/messaging/messaging-policy",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-transport-spi",
|
||||
"gradle_path": ":messaging:messaging-transport-spi",
|
||||
"source_path": "src/messaging/messaging-transport-spi",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-observability",
|
||||
"gradle_path": ":messaging:messaging-observability",
|
||||
"source_path": "src/messaging/messaging-observability",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-security",
|
||||
"gradle_path": ":messaging:messaging-security",
|
||||
"source_path": "src/messaging/messaging-security",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-kafka",
|
||||
"gradle_path": ":messaging:messaging-kafka",
|
||||
"source_path": "src/messaging/messaging-kafka",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-observability",
|
||||
"messaging-security",
|
||||
"messaging-admin-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-kafka-share-experimental",
|
||||
"gradle_path": ":messaging:messaging-kafka-share-experimental",
|
||||
"source_path": "src/messaging/messaging-kafka-share-experimental",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-kafka"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-rabbit",
|
||||
"gradle_path": ":messaging:messaging-rabbit",
|
||||
"source_path": "src/messaging/messaging-rabbit",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-observability",
|
||||
"messaging-security",
|
||||
"messaging-admin-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-reliability-api",
|
||||
"gradle_path": ":messaging:messaging-reliability-api",
|
||||
"source_path": "src/messaging/messaging-reliability-api",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-outbox-jpa",
|
||||
"gradle_path": ":messaging:messaging-outbox-jpa",
|
||||
"source_path": "src/messaging/messaging-outbox-jpa",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-reliability-api",
|
||||
"messaging-policy",
|
||||
"messaging-observability"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-inbox-jpa",
|
||||
"gradle_path": ":messaging:messaging-inbox-jpa",
|
||||
"source_path": "src/messaging/messaging-inbox-jpa",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-reliability-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-claim-check",
|
||||
"gradle_path": ":messaging:messaging-claim-check",
|
||||
"source_path": "src/messaging/messaging-claim-check",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-reliability-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-admin-api",
|
||||
"gradle_path": ":messaging:messaging-admin-api",
|
||||
"source_path": "src/messaging/messaging-admin-api",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-admin-runtime",
|
||||
"gradle_path": ":messaging:messaging-admin-runtime",
|
||||
"source_path": "src/messaging/messaging-admin-runtime",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-policy",
|
||||
"messaging-admin-api",
|
||||
"messaging-transport-spi",
|
||||
"messaging-security",
|
||||
"messaging-observability"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-pulsar-experimental",
|
||||
"gradle_path": ":messaging:messaging-pulsar-experimental",
|
||||
"source_path": "src/messaging/messaging-pulsar-experimental",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-observability",
|
||||
"messaging-security",
|
||||
"messaging-admin-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-nats-experimental",
|
||||
"gradle_path": ":messaging:messaging-nats-experimental",
|
||||
"source_path": "src/messaging/messaging-nats-experimental",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-observability",
|
||||
"messaging-security",
|
||||
"messaging-admin-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-spring-cloud-stream-bridge",
|
||||
"gradle_path": ":messaging:messaging-spring-cloud-stream-bridge",
|
||||
"source_path": "src/messaging/messaging-spring-cloud-stream-bridge",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-spring-boot-starter",
|
||||
"gradle_path": ":messaging:messaging-spring-boot-starter",
|
||||
"source_path": "src/messaging/messaging-spring-boot-starter",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-schema-json",
|
||||
"messaging-cloudevents",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi",
|
||||
"messaging-observability",
|
||||
"messaging-security",
|
||||
"messaging-kafka",
|
||||
"messaging-rabbit",
|
||||
"messaging-reliability-api",
|
||||
"messaging-outbox-jpa",
|
||||
"messaging-inbox-jpa",
|
||||
"messaging-claim-check",
|
||||
"messaging-admin-api",
|
||||
"messaging-admin-runtime"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "messaging-testkit",
|
||||
"gradle_path": ":messaging:messaging-testkit",
|
||||
"source_path": "src/messaging/messaging-testkit",
|
||||
"allowed_dependencies": [
|
||||
"messaging-core-api",
|
||||
"messaging-schema-api",
|
||||
"messaging-policy",
|
||||
"messaging-transport-spi"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,4 +43,24 @@
|
||||
<Source name="DefaultTypingFixture.java"/>
|
||||
</Match>
|
||||
|
||||
<!-- The messaging codec retention test measures whether a round trip retains per-message state.
|
||||
Measuring retained memory requires forcing a collection first and again at the end;
|
||||
without it the "after" reading is dominated by garbage that simply had not been collected
|
||||
yet and the test could never distinguish a leak from ordinary allocation. Scoped to the
|
||||
one test class, so an explicit gc anywhere else stays reportable. -->
|
||||
<Match>
|
||||
<Bug pattern="DM_GC"/>
|
||||
<Source name="PlatformOverheadPerformanceTest.java"/>
|
||||
</Match>
|
||||
|
||||
<!-- The outbox and inbox container tests apply the real shipped Flyway migration by reading it
|
||||
from the classpath and executing it, which is the whole point: a test that re-declared the
|
||||
schema inline would certify a schema nothing deploys. The SQL is a build artifact, not
|
||||
input, and the database is a throwaway container. Scoped to these two classes so any other
|
||||
dynamic SQL stays reportable. -->
|
||||
<Match>
|
||||
<Bug pattern="SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE"/>
|
||||
<Source name="~(Inbox|Outbox)PostgresIT.java"/>
|
||||
</Match>
|
||||
|
||||
</FindBugsFilter>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
api project(':messaging:messaging-policy')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.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.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.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.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.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
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=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.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The approval that authorises a destructive messaging operation.
|
||||
*
|
||||
* <p>Approvals expire. An open-ended approval becomes a standing permission, which is the same
|
||||
* thing as no approval at all — the window is what keeps "we approved a redrive last quarter" from
|
||||
* authorising one today.
|
||||
*
|
||||
* @param ticket the change reference
|
||||
* @param approvedBy the approver's identity
|
||||
* @param approvedAt when the approval was granted
|
||||
* @param validUntil when the approval stops authorising anything
|
||||
*/
|
||||
public record AdminApproval(
|
||||
String ticket, String approvedBy, Instant approvedAt, Instant validUntil) {
|
||||
|
||||
public AdminApproval {
|
||||
Objects.requireNonNull(approvedAt, "approvedAt must not be null");
|
||||
Objects.requireNonNull(validUntil, "validUntil must not be null");
|
||||
if (ticket == null || ticket.isBlank()) {
|
||||
throw new IllegalArgumentException("ticket must not be blank");
|
||||
}
|
||||
if (approvedBy == null || approvedBy.isBlank()) {
|
||||
throw new IllegalArgumentException("approvedBy must not be blank");
|
||||
}
|
||||
if (validUntil.isBefore(approvedAt)) {
|
||||
throw new IllegalArgumentException("an approval cannot expire before it was granted");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the approval still authorises an operation.
|
||||
*
|
||||
* @param now the current instant
|
||||
* @return true while inside the window
|
||||
*/
|
||||
public boolean isValidAt(Instant now) {
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
return !now.isBefore(approvedAt) && now.isBefore(validUntil);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A redrive plan that a named human has approved.
|
||||
*
|
||||
* <p>Carries {@code loopAcknowledged} separately from the approval itself. Approving a redrive of
|
||||
* 900 parked messages and approving a redrive that will re-fail 400 of them are different
|
||||
* decisions, and the second one needs the approver to have seen the number — so the plan cannot
|
||||
* execute on a loop-risking set unless that was acknowledged explicitly.
|
||||
*
|
||||
* @param plan the plan that was approved
|
||||
* @param approval the approval authorising it
|
||||
* @param loopAcknowledged whether the approver accepted the previously-redriven candidates
|
||||
*/
|
||||
public record ApprovedRedrivePlan(
|
||||
RedrivePlan plan, AdminApproval approval, boolean loopAcknowledged) {
|
||||
|
||||
public ApprovedRedrivePlan {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
Objects.requireNonNull(approval, "approval must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses execution when the approval, the topology, or the loop risk no longer permits it.
|
||||
*
|
||||
* @param now the current instant
|
||||
* @param currentTopologyVersion the topology version at execution time
|
||||
* @throws MessageAuthorizationException when the plan may not execute
|
||||
*/
|
||||
public void requireExecutable(Instant now, String currentTopologyVersion) {
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
Objects.requireNonNull(currentTopologyVersion, "currentTopologyVersion must not be null");
|
||||
|
||||
if (!approval.isValidAt(now)) {
|
||||
throw new MessageAuthorizationException(
|
||||
"APPROVAL_EXPIRED", "approval %s is not valid at %s".formatted(approval.ticket(), now));
|
||||
}
|
||||
if (!plan.topologyVersion().equals(currentTopologyVersion)) {
|
||||
throw new MessageAuthorizationException(
|
||||
"TOPOLOGY_CHANGED_SINCE_APPROVAL",
|
||||
"the plan was approved against topology %s but the broker is now at %s"
|
||||
.formatted(plan.topologyVersion(), currentTopologyVersion));
|
||||
}
|
||||
if (plan.risksALoop() && !loopAcknowledged) {
|
||||
throw new MessageAuthorizationException(
|
||||
"REDRIVE_LOOP_NOT_ACKNOWLEDGED",
|
||||
"%d of the %d candidates already failed a previous redrive; re-running them without "
|
||||
.formatted(plan.alreadyRedrivenCandidates(), plan.candidates())
|
||||
+ "fixing the cause produces a loop that looks like progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A replay plan that a named human has approved.
|
||||
*
|
||||
* <p>A distinct type from {@link ReplayPlan} rather than a boolean on it. The execute method takes
|
||||
* this type, so an unapproved plan cannot reach it — the authorisation is enforced by the compiler
|
||||
* instead of by a runtime check somebody can forget to write.
|
||||
*
|
||||
* @param plan the plan that was approved
|
||||
* @param approval the approval authorising it
|
||||
*/
|
||||
public record ApprovedReplayPlan(ReplayPlan plan, AdminApproval approval) {
|
||||
|
||||
public ApprovedReplayPlan {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
Objects.requireNonNull(approval, "approval must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses execution when the approval or the plan no longer applies.
|
||||
*
|
||||
* @param now the current instant
|
||||
* @param currentTopologyVersion the topology version at execution time
|
||||
* @throws MessageAuthorizationException when the approval has expired or the topology moved
|
||||
*/
|
||||
public void requireExecutable(Instant now, String currentTopologyVersion) {
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
Objects.requireNonNull(currentTopologyVersion, "currentTopologyVersion must not be null");
|
||||
|
||||
if (!approval.isValidAt(now)) {
|
||||
throw new MessageAuthorizationException(
|
||||
"APPROVAL_EXPIRED", "approval %s is not valid at %s".formatted(approval.ticket(), now));
|
||||
}
|
||||
if (!plan.topologyVersion().equals(currentTopologyVersion)) {
|
||||
// Every number in the plan was computed against the old topology, so the approver agreed to
|
||||
// an impact estimate that no longer describes what would happen.
|
||||
throw new MessageAuthorizationException(
|
||||
"TOPOLOGY_CHANGED_SINCE_APPROVAL",
|
||||
"the plan was approved against topology %s but the broker is now at %s; the estimated "
|
||||
.formatted(plan.topologyVersion(), currentTopologyVersion)
|
||||
+ "impact no longer applies and the plan must be rebuilt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the broker actually reports for one destination.
|
||||
*
|
||||
* <p>The counterpart to {@link TopologyManifest}: the manifest is what was declared, this is what
|
||||
* exists. Kept as a separate type rather than reusing the manifest so that a comparison cannot
|
||||
* accidentally compare a manifest with itself and report success.
|
||||
*
|
||||
* @param physicalName the broker-side name
|
||||
* @param partitions the observed partition count
|
||||
* @param replicationFactor the observed replication factor
|
||||
* @param configuration the observed configuration entries
|
||||
* @param exists whether the destination is present at all
|
||||
*/
|
||||
public record DestinationTopology(
|
||||
String physicalName,
|
||||
int partitions,
|
||||
int replicationFactor,
|
||||
Map<String, String> configuration,
|
||||
boolean exists) {
|
||||
|
||||
public DestinationTopology {
|
||||
Objects.requireNonNull(configuration, "configuration must not be null");
|
||||
if (physicalName == null || physicalName.isBlank()) {
|
||||
throw new IllegalArgumentException("physicalName must not be blank");
|
||||
}
|
||||
if (exists && partitions < 1) {
|
||||
throw new IllegalArgumentException("an existing destination has at least one partition");
|
||||
}
|
||||
configuration = Map.copyOf(configuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the topology for a destination the broker does not have.
|
||||
*
|
||||
* @param physicalName the broker-side name that was looked up
|
||||
* @return the absent topology
|
||||
*/
|
||||
public static DestinationTopology absent(String physicalName) {
|
||||
return new DestinationTopology(physicalName, 0, 0, Map.of(), false);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
/**
|
||||
* The operations that cannot be undone.
|
||||
*
|
||||
* <p>Enumerated so the guard is exhaustive rather than a list of {@code if} statements that a new
|
||||
* operation can quietly avoid.
|
||||
*/
|
||||
public enum DestructiveOperation {
|
||||
|
||||
/** Re-read a destination from an earlier position. */
|
||||
REPLAY,
|
||||
|
||||
/** Move messages from a dead letter destination back to their source. */
|
||||
REDRIVE,
|
||||
|
||||
/** Move a consumer group's committed position. */
|
||||
OFFSET_RESET,
|
||||
|
||||
/** Discard the contents of a destination. */
|
||||
PURGE,
|
||||
|
||||
/** Remove a destination entirely. */
|
||||
DELETE_DESTINATION
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The single gate every destructive messaging operation passes through.
|
||||
*
|
||||
* <p>Three conditions, all required. The caller must hold the admin credential — an application
|
||||
* runtime does not, by construction. The approval must be present and inside its validity window.
|
||||
* And a dry run is always permitted, because the way to make operators plan before they act is to
|
||||
* make planning free.
|
||||
*
|
||||
* <p>Centralised so that adding a new destructive operation means adding an enum constant, not
|
||||
* remembering to re-implement the checks.
|
||||
*/
|
||||
public final class DestructiveOperationGuard {
|
||||
|
||||
private final boolean adminCredentialPresent;
|
||||
|
||||
/**
|
||||
* Creates a guard for a runtime.
|
||||
*
|
||||
* @param adminCredentialPresent whether this runtime holds the admin credential
|
||||
*/
|
||||
public DestructiveOperationGuard(boolean adminCredentialPresent) {
|
||||
this.adminCredentialPresent = adminCredentialPresent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorises one destructive operation.
|
||||
*
|
||||
* @param operation the operation
|
||||
* @param destination the destination affected
|
||||
* @param approval the approval, when one was supplied
|
||||
* @param dryRun whether this is a plan-only run
|
||||
* @param now the current instant
|
||||
* @throws MessageAuthorizationException when the operation is not authorised
|
||||
*/
|
||||
public void authorize(
|
||||
DestructiveOperation operation,
|
||||
DestinationName destination,
|
||||
Optional<AdminApproval> approval,
|
||||
boolean dryRun,
|
||||
Instant now) {
|
||||
Objects.requireNonNull(operation, "operation must not be null");
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(approval, "approval must not be null");
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
if (!adminCredentialPresent) {
|
||||
throw new MessageAuthorizationException(
|
||||
"ADMIN_CREDENTIAL_REQUIRED",
|
||||
operation + " on " + destination.value() + " requires the admin credential");
|
||||
}
|
||||
AdminApproval granted =
|
||||
approval.orElseThrow(
|
||||
() ->
|
||||
new MessageAuthorizationException(
|
||||
"APPROVAL_REQUIRED",
|
||||
operation + " on " + destination.value() + " requires an approval"));
|
||||
if (!granted.isValidAt(now)) {
|
||||
throw new MessageAuthorizationException(
|
||||
"APPROVAL_EXPIRED", "approval " + granted.ticket() + " is outside its validity window");
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What a redrive would do, produced before anything is moved.
|
||||
*
|
||||
* <p>{@code alreadyRedrivenCandidates} is the number that matters most. A message with a non-zero
|
||||
* redrive count has been sent back to its source before and failed again; redriving it a second
|
||||
* time without fixing the cause produces a loop that looks like progress in every dashboard.
|
||||
* Surfacing the count at plan time is what lets an operator notice before starting it.
|
||||
*
|
||||
* @param request the request this plan was built from
|
||||
* @param candidates how many messages are eligible
|
||||
* @param alreadyRedrivenCandidates how many of those have been redriven before
|
||||
* @param plannedAt when the plan was produced
|
||||
* @param topologyVersion the topology the estimate was computed against
|
||||
*/
|
||||
public record RedrivePlan(
|
||||
RedriveRequest request,
|
||||
int candidates,
|
||||
int alreadyRedrivenCandidates,
|
||||
Instant plannedAt,
|
||||
String topologyVersion) {
|
||||
|
||||
public RedrivePlan {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
Objects.requireNonNull(plannedAt, "plannedAt must not be null");
|
||||
if (candidates < 0) {
|
||||
throw new IllegalArgumentException("candidates must not be negative");
|
||||
}
|
||||
if (alreadyRedrivenCandidates < 0 || alreadyRedrivenCandidates > candidates) {
|
||||
throw new IllegalArgumentException(
|
||||
"alreadyRedrivenCandidates must be between 0 and the candidate count");
|
||||
}
|
||||
if (topologyVersion == null || topologyVersion.isBlank()) {
|
||||
throw new IllegalArgumentException("topologyVersion must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this redrive would replay messages that already failed a redrive.
|
||||
*
|
||||
* @return true when any candidate has been redriven before
|
||||
*/
|
||||
public boolean risksALoop() {
|
||||
return alreadyRedrivenCandidates > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a one-line operator-facing summary of the impact.
|
||||
*
|
||||
* @return the sanitized summary
|
||||
*/
|
||||
public String describeImpact() {
|
||||
String loop =
|
||||
risksALoop()
|
||||
? ", %d of which already failed a previous redrive".formatted(alreadyRedrivenCandidates)
|
||||
: "";
|
||||
return "redrive %d messages from %s to %s%s"
|
||||
.formatted(candidates, request.source().value(), request.target().value(), loop);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A request to move messages from a dead letter destination back to their source.
|
||||
*
|
||||
* <p>The redrive id is a separate identifier from the message id, and both travel with the message.
|
||||
* Reusing the message id as the operation id would make "this message was redriven" and "this
|
||||
* message is a different message" indistinguishable, and an operator could not tell a redrive loop
|
||||
* from ordinary traffic.
|
||||
*
|
||||
* @param redriveId the operation identity
|
||||
* @param source the dead letter destination to drain
|
||||
* @param target the destination to publish back to
|
||||
* @param batchSize how many messages to move per pass
|
||||
* @param dryRun whether to plan without moving anything
|
||||
*/
|
||||
public record RedriveRequest(
|
||||
UUID redriveId, DestinationName source, DestinationName target, int batchSize, boolean dryRun) {
|
||||
|
||||
private static final int MAX_BATCH = 100;
|
||||
|
||||
public RedriveRequest {
|
||||
Objects.requireNonNull(redriveId, "redriveId must not be null");
|
||||
Objects.requireNonNull(source, "source must not be null");
|
||||
Objects.requireNonNull(target, "target must not be null");
|
||||
if (source.equals(target)) {
|
||||
throw new IllegalArgumentException("a redrive cannot target its own source");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > MAX_BATCH) {
|
||||
throw new IllegalArgumentException("redrive batch size must be between 1 and " + MAX_BATCH);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* What a redrive actually did.
|
||||
*
|
||||
* <p>{@code stillParked} is not simply {@code candidates - moved}. A message stays parked when its
|
||||
* republish did not confirm, and the redrive deliberately leaves it there rather than settling it —
|
||||
* the DLQ-confirm-before-settle rule applies to a redrive exactly as it does to the original
|
||||
* dead-lettering, because a redrive that settles an unconfirmed republish deletes the last copy.
|
||||
*
|
||||
* @param redriveId the operation identity
|
||||
* @param candidates how many messages were eligible
|
||||
* @param moved how many were republished and settled
|
||||
* @param stillParked how many stayed on the dead letter destination
|
||||
* @param elapsed how long the redrive took
|
||||
* @param dryRun whether nothing was actually moved
|
||||
*/
|
||||
public record RedriveResult(
|
||||
UUID redriveId, int candidates, int moved, int stillParked, Duration elapsed, boolean dryRun) {
|
||||
|
||||
public RedriveResult {
|
||||
Objects.requireNonNull(redriveId, "redriveId must not be null");
|
||||
Objects.requireNonNull(elapsed, "elapsed must not be null");
|
||||
if (candidates < 0 || moved < 0 || stillParked < 0) {
|
||||
throw new IllegalArgumentException("redrive counters must not be negative");
|
||||
}
|
||||
if (moved + stillParked > candidates) {
|
||||
throw new IllegalArgumentException(
|
||||
"a redrive cannot account for more messages than it had candidates");
|
||||
}
|
||||
if (dryRun && moved > 0) {
|
||||
throw new IllegalArgumentException("a dry run must not move anything");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether every candidate was accounted for.
|
||||
*
|
||||
* <p>An unaccounted message is a bug, not a partial success: it was neither republished nor left
|
||||
* parked, which means the redrive lost track of it.
|
||||
*
|
||||
* @return true when moved plus still-parked covers every candidate
|
||||
*/
|
||||
public boolean isFullyAccounted() {
|
||||
return moved + stillParked == candidates;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What a replay would do, produced before anything is read.
|
||||
*
|
||||
* <p>The plan exists so the estimate can be reviewed. "Replay from yesterday" is a sentence; "this
|
||||
* will re-deliver 4.2 million messages into the live consumer group" is a decision, and the only
|
||||
* moment an operator can make it is before the replay starts.
|
||||
*
|
||||
* <p>{@code topologyVersion} is captured here and re-checked at execution. A plan approved against
|
||||
* one topology and executed against another is estimating a different thing entirely — a partition
|
||||
* count that changed in between invalidates every number in this record.
|
||||
*
|
||||
* @param request the request this plan was built from
|
||||
* @param estimatedMessages how many messages the window covers
|
||||
* @param plannedAt when the plan was produced
|
||||
* @param topologyVersion the topology the estimate was computed against
|
||||
* @param targetsLiveConsumerGroup whether the replay would feed the live group rather than an
|
||||
* isolated one
|
||||
*/
|
||||
public record ReplayPlan(
|
||||
ReplayRequest request,
|
||||
long estimatedMessages,
|
||||
Instant plannedAt,
|
||||
String topologyVersion,
|
||||
boolean targetsLiveConsumerGroup) {
|
||||
|
||||
public ReplayPlan {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
Objects.requireNonNull(plannedAt, "plannedAt must not be null");
|
||||
if (estimatedMessages < 0) {
|
||||
throw new IllegalArgumentException("estimatedMessages must not be negative");
|
||||
}
|
||||
if (topologyVersion == null || topologyVersion.isBlank()) {
|
||||
throw new IllegalArgumentException("topologyVersion must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a one-line operator-facing summary of the impact.
|
||||
*
|
||||
* @return the sanitized summary
|
||||
*/
|
||||
public String describeImpact() {
|
||||
return "replay %s from %s: about %d messages into %s"
|
||||
.formatted(
|
||||
request.destination().value(),
|
||||
request.from(),
|
||||
estimatedMessages,
|
||||
targetsLiveConsumerGroup ? "the LIVE consumer group" : "an isolated group");
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A request to re-read a destination from an earlier position.
|
||||
*
|
||||
* @param replayId the operation identity
|
||||
* @param destination the destination to replay
|
||||
* @param from the replay start point
|
||||
* @param to the replay end point, when bounded
|
||||
* @param isolatedConsumerGroup whether to replay into a throwaway group
|
||||
* @param dryRun whether to plan without reading anything
|
||||
*/
|
||||
public record ReplayRequest(
|
||||
UUID replayId,
|
||||
DestinationName destination,
|
||||
Instant from,
|
||||
Optional<Instant> to,
|
||||
boolean isolatedConsumerGroup,
|
||||
boolean dryRun) {
|
||||
|
||||
public ReplayRequest {
|
||||
Objects.requireNonNull(replayId, "replayId must not be null");
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(from, "from must not be null");
|
||||
Objects.requireNonNull(to, "to must not be null");
|
||||
if (to.filter(end -> end.isBefore(from)).isPresent()) {
|
||||
throw new IllegalArgumentException("replay window ends before it starts");
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* What a replay actually did.
|
||||
*
|
||||
* <p>Reports the delivered count against the plan's estimate. They routinely differ — retention may
|
||||
* have expired part of the window, or the stream may have grown while the plan was being approved —
|
||||
* and the difference is the operator's signal that the replay covered something other than what was
|
||||
* approved.
|
||||
*
|
||||
* @param replayId the operation identity
|
||||
* @param estimatedMessages what the plan predicted
|
||||
* @param deliveredMessages what was actually re-delivered
|
||||
* @param elapsed how long the replay took
|
||||
* @param completed whether the whole window was covered
|
||||
* @param dryRun whether nothing was actually read
|
||||
*/
|
||||
public record ReplayResult(
|
||||
UUID replayId,
|
||||
long estimatedMessages,
|
||||
long deliveredMessages,
|
||||
Duration elapsed,
|
||||
boolean completed,
|
||||
boolean dryRun) {
|
||||
|
||||
public ReplayResult {
|
||||
Objects.requireNonNull(replayId, "replayId must not be null");
|
||||
Objects.requireNonNull(elapsed, "elapsed must not be null");
|
||||
if (estimatedMessages < 0 || deliveredMessages < 0) {
|
||||
throw new IllegalArgumentException("replay counters must not be negative");
|
||||
}
|
||||
if (dryRun && deliveredMessages > 0) {
|
||||
throw new IllegalArgumentException("a dry run must not deliver anything");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the replay covered materially less than was approved.
|
||||
*
|
||||
* @return true when fewer than 90% of the estimated messages were delivered
|
||||
*/
|
||||
public boolean fellShortOfTheEstimate() {
|
||||
return completed && estimatedMessages > 0 && deliveredMessages * 10 < estimatedMessages * 9;
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One discrepancy between a declared topology and what the broker actually has.
|
||||
*
|
||||
* <p>Severity is part of the finding because the two kinds behave differently at startup. A {@link
|
||||
* Severity#BLOCKING} issue means the destination cannot deliver its declared guarantee —
|
||||
* replication factor 1 on a destination promising durability is not a warning, it is a promise the
|
||||
* platform cannot keep — so the context refuses to start. A {@link Severity#ADVISORY} issue is a
|
||||
* drift worth reporting that does not break a guarantee.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param attribute the topology attribute that differs
|
||||
* @param declared what the manifest declared
|
||||
* @param actual what the broker reported
|
||||
* @param severity how the platform should react
|
||||
*/
|
||||
public record TopologyIssue(
|
||||
String destination, String attribute, String declared, String actual, Severity severity) {
|
||||
|
||||
/** How the platform reacts to a topology discrepancy. */
|
||||
public enum Severity {
|
||||
/** The destination cannot deliver a declared guarantee; startup must fail. */
|
||||
BLOCKING,
|
||||
/** Drift worth reporting that does not break a guarantee. */
|
||||
ADVISORY
|
||||
}
|
||||
|
||||
public TopologyIssue {
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
requireText(destination, "destination");
|
||||
requireText(attribute, "attribute");
|
||||
requireText(declared, "declared");
|
||||
requireText(actual, "actual");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blocking issue.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param attribute the differing attribute
|
||||
* @param declared the declared value
|
||||
* @param actual the observed value
|
||||
* @return the issue
|
||||
*/
|
||||
public static TopologyIssue blocking(
|
||||
String destination, String attribute, String declared, String actual) {
|
||||
return new TopologyIssue(destination, attribute, declared, actual, Severity.BLOCKING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an advisory issue.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param attribute the differing attribute
|
||||
* @param declared the declared value
|
||||
* @param actual the observed value
|
||||
* @return the issue
|
||||
*/
|
||||
public static TopologyIssue advisory(
|
||||
String destination, String attribute, String declared, String actual) {
|
||||
return new TopologyIssue(destination, attribute, declared, actual, Severity.ADVISORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a one-line operator-facing description.
|
||||
*
|
||||
* @return the sanitized description
|
||||
*/
|
||||
public String describe() {
|
||||
return "%s: %s declared %s but the broker has %s"
|
||||
.formatted(destination, attribute, declared, actual);
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessagingConfigurationException;
|
||||
|
||||
/**
|
||||
* Whether the application may create broker topology, or only check it.
|
||||
*
|
||||
* <p>{@link #VALIDATE_ONLY} in production, always. An application that auto-creates topology will
|
||||
* auto-create it after a configuration typo too, and the topic it makes is indistinguishable from a
|
||||
* real one — same broker, same client, same metrics — while carrying the broker's default partition
|
||||
* count and replication factor instead of the ones the destination needs. The failure surfaces
|
||||
* weeks later as data loss on a partition that was never replicated.
|
||||
*/
|
||||
public enum TopologyManagementMode {
|
||||
|
||||
/** Compare the declared topology against the broker and fail on a mismatch. */
|
||||
VALIDATE_ONLY,
|
||||
|
||||
/** Create missing topology. Permitted outside production only. */
|
||||
CREATE_IF_MISSING;
|
||||
|
||||
/**
|
||||
* Refuses auto-creation on a production runtime.
|
||||
*
|
||||
* @param production whether this runtime is production
|
||||
* @throws MessagingConfigurationException when auto-creation is configured in production
|
||||
*/
|
||||
public void requireSafeFor(boolean production) {
|
||||
if (production && this == CREATE_IF_MISSING) {
|
||||
throw new MessagingConfigurationException(
|
||||
"AUTO_CREATE_IN_PRODUCTION",
|
||||
"topology auto-creation is not permitted in production: a mistyped destination would be "
|
||||
+ "created with the broker's default partition count and replication factor, and "
|
||||
+ "would look exactly like a correctly provisioned one");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The declared shape of a destination's broker topology.
|
||||
*
|
||||
* <p>Production topology is created by infrastructure code and only <em>validated</em> by the
|
||||
* application. An application that creates topology on startup will happily create it in the wrong
|
||||
* place after a configuration mistake, and the resulting topic looks exactly like a real one.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param physicalName the broker-side name
|
||||
* @param partitions the expected partition count, where the broker has partitions
|
||||
* @param replicationFactor the expected replication factor
|
||||
* @param requiredConfiguration configuration entries that must match exactly
|
||||
*/
|
||||
public record TopologyManifest(
|
||||
String destination,
|
||||
String physicalName,
|
||||
int partitions,
|
||||
int replicationFactor,
|
||||
Map<String, String> requiredConfiguration) {
|
||||
|
||||
public TopologyManifest {
|
||||
Objects.requireNonNull(requiredConfiguration, "requiredConfiguration must not be null");
|
||||
if (destination == null || destination.isBlank()) {
|
||||
throw new IllegalArgumentException("destination must not be blank");
|
||||
}
|
||||
if (physicalName == null || physicalName.isBlank()) {
|
||||
throw new IllegalArgumentException("physicalName must not be blank");
|
||||
}
|
||||
if (partitions < 1) {
|
||||
throw new IllegalArgumentException("partitions must be at least 1");
|
||||
}
|
||||
if (replicationFactor < 1) {
|
||||
throw new IllegalArgumentException("replicationFactor must be at least 1");
|
||||
}
|
||||
requiredConfiguration = Map.copyOf(requiredConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares this manifest against what the broker actually reports.
|
||||
*
|
||||
* @param actualPartitions the observed partition count
|
||||
* @param actualReplicationFactor the observed replication factor
|
||||
* @param actualConfiguration the observed configuration
|
||||
* @return the differences, empty when the topology matches
|
||||
*/
|
||||
public List<String> differencesFrom(
|
||||
int actualPartitions, int actualReplicationFactor, Map<String, String> actualConfiguration) {
|
||||
Objects.requireNonNull(actualConfiguration, "actualConfiguration must not be null");
|
||||
List<String> differences = new java.util.ArrayList<>();
|
||||
|
||||
if (actualPartitions != partitions) {
|
||||
differences.add("partitions expected " + partitions + " but found " + actualPartitions);
|
||||
}
|
||||
if (actualReplicationFactor != replicationFactor) {
|
||||
differences.add(
|
||||
"replicationFactor expected "
|
||||
+ replicationFactor
|
||||
+ " but found "
|
||||
+ actualReplicationFactor);
|
||||
}
|
||||
requiredConfiguration.forEach(
|
||||
(key, expected) -> {
|
||||
String actual = actualConfiguration.get(key);
|
||||
if (!expected.equals(actual)) {
|
||||
differences.add(key + " expected " + expected + " but found " + actual);
|
||||
}
|
||||
});
|
||||
return List.copyOf(differences);
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessagingConfigurationException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The outcome of comparing every declared topology against the broker.
|
||||
*
|
||||
* <p>Reports both severities together rather than failing on the first blocking issue. An operator
|
||||
* fixing a topology wants the whole list — fixing one attribute, redeploying, and discovering the
|
||||
* next one is how a ten-minute fix becomes an afternoon.
|
||||
*
|
||||
* @param issues every discrepancy found
|
||||
* @param destinationsChecked how many destinations were compared
|
||||
*/
|
||||
public record TopologyValidationReport(List<TopologyIssue> issues, int destinationsChecked) {
|
||||
|
||||
public TopologyValidationReport {
|
||||
Objects.requireNonNull(issues, "issues must not be null");
|
||||
if (destinationsChecked < 0) {
|
||||
throw new IllegalArgumentException("destinationsChecked must not be negative");
|
||||
}
|
||||
issues = List.copyOf(issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a report with nothing to fix.
|
||||
*
|
||||
* @param destinationsChecked how many destinations were compared
|
||||
* @return the clean report
|
||||
*/
|
||||
public static TopologyValidationReport clean(int destinationsChecked) {
|
||||
return new TopologyValidationReport(List.of(), destinationsChecked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issues that must fail startup.
|
||||
*
|
||||
* @return the blocking issues
|
||||
*/
|
||||
public List<TopologyIssue> blocking() {
|
||||
return issues.stream()
|
||||
.filter(issue -> issue.severity() == TopologyIssue.Severity.BLOCKING)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issues worth reporting that do not break a guarantee.
|
||||
*
|
||||
* @return the advisory issues
|
||||
*/
|
||||
public List<TopologyIssue> advisory() {
|
||||
return issues.stream()
|
||||
.filter(issue -> issue.severity() == TopologyIssue.Severity.ADVISORY)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the topology may be used.
|
||||
*
|
||||
* @return true when nothing blocking was found
|
||||
*/
|
||||
public boolean isAcceptable() {
|
||||
return blocking().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails startup when any blocking issue was found.
|
||||
*
|
||||
* @throws MessagingConfigurationException listing every blocking issue
|
||||
*/
|
||||
public void requireAcceptable() {
|
||||
List<TopologyIssue> blocking = blocking();
|
||||
if (blocking.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
throw new MessagingConfigurationException(
|
||||
"TOPOLOGY_MISMATCH",
|
||||
"the broker topology cannot deliver the declared guarantees: "
|
||||
+ String.join("; ", blocking.stream().map(TopologyIssue::describe).toList()));
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package dev.caskeleton.messaging.admin;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DestructiveOperationGuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-10T09:15:00Z");
|
||||
private static final DestinationName ORDERS = new DestinationName("order-events");
|
||||
|
||||
private static final AdminApproval VALID =
|
||||
new AdminApproval("CHG-1001", "operator", NOW.minusSeconds(60), NOW.plusSeconds(3600));
|
||||
|
||||
@Test
|
||||
void anApplicationRuntimeCannotRedrive() {
|
||||
DestructiveOperationGuard guard = new DestructiveOperationGuard(false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
guard.authorize(
|
||||
DestructiveOperation.REDRIVE, ORDERS, Optional.of(VALID), false, NOW))
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("admin credential");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAdminRuntimeStillNeedsAnApproval() {
|
||||
DestructiveOperationGuard guard = new DestructiveOperationGuard(true);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> guard.authorize(DestructiveOperation.PURGE, ORDERS, Optional.empty(), false, NOW))
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("approval");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExpiredApprovalDoesNotAuthorise() {
|
||||
DestructiveOperationGuard guard = new DestructiveOperationGuard(true);
|
||||
AdminApproval expired =
|
||||
new AdminApproval("CHG-1000", "operator", NOW.minusSeconds(7200), NOW.minusSeconds(60));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
guard.authorize(
|
||||
DestructiveOperation.OFFSET_RESET, ORDERS, Optional.of(expired), false, NOW))
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("validity window");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDryRunIsAlwaysPermitted() {
|
||||
DestructiveOperationGuard guard = new DestructiveOperationGuard(false);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
guard.authorize(
|
||||
DestructiveOperation.DELETE_DESTINATION, ORDERS, Optional.empty(), true, NOW))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anApprovedAdminOperationIsAuthorised() {
|
||||
DestructiveOperationGuard guard = new DestructiveOperationGuard(true);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
guard.authorize(
|
||||
DestructiveOperation.REPLAY, ORDERS, Optional.of(VALID), false, NOW))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRedriveCannotTargetItsOwnSource() {
|
||||
assertThatThrownBy(() -> new RedriveRequest(UUID.randomUUID(), ORDERS, ORDERS, 100, false))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRedriveBatchIsBoundedSoOneOperationCannotFloodTheSource() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedriveRequest(
|
||||
UUID.randomUUID(), new DestinationName("order-events-dlq"), ORDERS, 101, false))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTopologyManifestReportsEveryDifference() {
|
||||
TopologyManifest manifest =
|
||||
new TopologyManifest(
|
||||
"order-events", "order.events.v1", 6, 3, Map.of("min.insync.replicas", "2"));
|
||||
|
||||
assertThat(manifest.differencesFrom(3, 3, Map.of("min.insync.replicas", "1")))
|
||||
.hasSize(2)
|
||||
.anySatisfy(difference -> assertThat(difference).contains("partitions"))
|
||||
.anySatisfy(difference -> assertThat(difference).contains("min.insync.replicas"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMatchingTopologyReportsNoDifferences() {
|
||||
TopologyManifest manifest =
|
||||
new TopologyManifest(
|
||||
"order-events", "order.events.v1", 6, 3, Map.of("min.insync.replicas", "2"));
|
||||
|
||||
assertThat(manifest.differencesFrom(6, 3, Map.of("min.insync.replicas", "2"))).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
api project(':messaging:messaging-policy')
|
||||
api project(':messaging:messaging-admin-api')
|
||||
api project(':messaging:messaging-transport-spi')
|
||||
api project(':messaging:messaging-security')
|
||||
api project(':messaging:messaging-observability')
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,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.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.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.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.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
|
||||
io.micrometer:micrometer-commons:1.16.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=runtimeClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=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.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=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.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Remembers which approvals have already been executed.
|
||||
*
|
||||
* <p>An approval authorises one execution, not a standing permission. Without this, re-running the
|
||||
* same approved redrive twice is a single command-history arrow-up away — and the second run
|
||||
* republishes messages the first one already moved, which on a destination without an inbox is
|
||||
* indistinguishable from a duplicate storm.
|
||||
*
|
||||
* <p>Claiming is atomic and returns the previous claim rather than a boolean, so a duplicate
|
||||
* attempt can tell the operator <em>when</em> it ran and by which operation id instead of just
|
||||
* refusing.
|
||||
*/
|
||||
public final class AdminOperationIdempotencyStore {
|
||||
|
||||
private final Map<String, Claim> claims = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* One recorded execution of an approval.
|
||||
*
|
||||
* @param approvalTicket the approval that was executed
|
||||
* @param operationId the operation identity that claimed it
|
||||
* @param executedAt when it ran
|
||||
*/
|
||||
public record Claim(String approvalTicket, String operationId, Instant executedAt) {
|
||||
|
||||
public Claim {
|
||||
Objects.requireNonNull(executedAt, "executedAt must not be null");
|
||||
if (approvalTicket == null || approvalTicket.isBlank()) {
|
||||
throw new IllegalArgumentException("approvalTicket must not be blank");
|
||||
}
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
throw new IllegalArgumentException("operationId must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims an approval for execution.
|
||||
*
|
||||
* @param approvalTicket the approval to claim
|
||||
* @param operationId the operation attempting it
|
||||
* @param now the current instant
|
||||
* @return empty when the claim succeeded; the existing claim when it was already executed
|
||||
*/
|
||||
public Optional<Claim> claim(String approvalTicket, String operationId, Instant now) {
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
Claim candidate = new Claim(approvalTicket, operationId, now);
|
||||
Claim existing = claims.putIfAbsent(approvalTicket, candidate);
|
||||
return Optional.ofNullable(existing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the recorded execution of an approval.
|
||||
*
|
||||
* @param approvalTicket the approval
|
||||
* @return the claim, when the approval has been executed
|
||||
*/
|
||||
public Optional<Claim> find(String approvalTicket) {
|
||||
return Optional.ofNullable(claims.get(approvalTicket));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many approvals have been executed.
|
||||
*
|
||||
* @return the claim count
|
||||
*/
|
||||
public int size() {
|
||||
return claims.size();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.DestinationTopology;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads the broker's current topology.
|
||||
*
|
||||
* <p>Read-only by construction. The inspector is what the application's own credential uses, and
|
||||
* that credential holds no destructive grant, so the type exposes no way to create, alter, or
|
||||
* delete — an application cannot reach a destructive operation even by mistake because there is no
|
||||
* method to reach.
|
||||
*/
|
||||
public interface BrokerTopologyInspector {
|
||||
|
||||
/**
|
||||
* Describes one destination.
|
||||
*
|
||||
* @param physicalName the broker-side name
|
||||
* @return the observed topology, absent when the broker has no such destination
|
||||
*/
|
||||
Optional<DestinationTopology> describe(String physicalName);
|
||||
|
||||
/**
|
||||
* Returns an opaque version for the broker's current topology.
|
||||
*
|
||||
* <p>Used to invalidate an approved plan whose impact estimate was computed against an earlier
|
||||
* shape. Any value that changes when the topology changes is sufficient; it is never parsed.
|
||||
*
|
||||
* @return the topology version
|
||||
*/
|
||||
String topologyVersion();
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.DestinationTopology;
|
||||
import dev.caskeleton.messaging.admin.TopologyIssue;
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import dev.caskeleton.messaging.admin.TopologyValidationReport;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Validates every declared topology in one pass and reports the whole result.
|
||||
*
|
||||
* <p>Collects all issues rather than stopping at the first blocking one. An operator fixing a
|
||||
* topology mismatch wants the complete list — discovering the next problem only after a redeploy
|
||||
* turns one fix into a sequence of them, and each redeploy is another restart of a production
|
||||
* service.
|
||||
*/
|
||||
public final class CompositeTopologyValidator {
|
||||
|
||||
private final BrokerTopologyInspector inspector;
|
||||
private final TopologyValidator validator = new TopologyValidator();
|
||||
|
||||
/**
|
||||
* Creates a validator over a broker inspector.
|
||||
*
|
||||
* @param inspector reads the broker's current topology
|
||||
*/
|
||||
public CompositeTopologyValidator(BrokerTopologyInspector inspector) {
|
||||
this.inspector = Objects.requireNonNull(inspector, "inspector must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares every manifest against the broker.
|
||||
*
|
||||
* @param manifests the declared topologies
|
||||
* @return the complete report
|
||||
*/
|
||||
public TopologyValidationReport validate(List<TopologyManifest> manifests) {
|
||||
Objects.requireNonNull(manifests, "manifests must not be null");
|
||||
|
||||
List<TopologyIssue> issues = new ArrayList<>();
|
||||
for (TopologyManifest manifest : manifests) {
|
||||
DestinationTopology observed =
|
||||
inspector
|
||||
.describe(manifest.physicalName())
|
||||
.orElseGet(() -> DestinationTopology.absent(manifest.physicalName()));
|
||||
issues.addAll(validator.compare(manifest, observed));
|
||||
}
|
||||
return new TopologyValidationReport(issues, manifests.size());
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.ApprovedRedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.ApprovedReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.RedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.RedriveRequest;
|
||||
import dev.caskeleton.messaging.admin.RedriveResult;
|
||||
import dev.caskeleton.messaging.admin.ReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.ReplayRequest;
|
||||
import dev.caskeleton.messaging.admin.ReplayResult;
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import dev.caskeleton.messaging.admin.TopologyValidationReport;
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Wires plan, approval, and execution together for the non-destructive admin operations.
|
||||
*
|
||||
* <p>Execution runs four checks, in this order, and the order is the point.
|
||||
*
|
||||
* <ol>
|
||||
* <li>The approval is still inside its window.
|
||||
* <li>The topology has not changed since the plan was approved.
|
||||
* <li>The approval has not already been executed.
|
||||
* <li>Only then does anything move.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The idempotency claim comes <em>before</em> the work rather than after it. Claiming afterwards
|
||||
* leaves a window where a second execution starts while the first is still running, which is
|
||||
* precisely the double-redrive this store exists to prevent.
|
||||
*/
|
||||
public final class DefaultMessagingAdminService implements MessagingAdminService {
|
||||
|
||||
private final CompositeTopologyValidator topologyValidator;
|
||||
private final BrokerTopologyInspector inspector;
|
||||
private final AdminOperationIdempotencyStore idempotency;
|
||||
private final ReplayService replayService;
|
||||
private final RedriveService redriveService;
|
||||
private final Supplier<List<TopologyManifest>> manifests;
|
||||
private final ReplayEstimator replayEstimator;
|
||||
private final RedriveEstimator redriveEstimator;
|
||||
private final Supplier<Instant> clock;
|
||||
|
||||
/**
|
||||
* Creates the admin service.
|
||||
*
|
||||
* @param topologyValidator compares declared topology against the broker
|
||||
* @param inspector reads the broker's topology version
|
||||
* @param idempotency records which approvals have been executed
|
||||
* @param replayService performs replays
|
||||
* @param redriveService performs redrives
|
||||
* @param manifests supplies the declared topologies
|
||||
* @param replayEstimator estimates a replay's message count
|
||||
* @param redriveEstimator estimates a redrive's candidates
|
||||
* @param clock supplies the current instant
|
||||
*/
|
||||
public DefaultMessagingAdminService(
|
||||
CompositeTopologyValidator topologyValidator,
|
||||
BrokerTopologyInspector inspector,
|
||||
AdminOperationIdempotencyStore idempotency,
|
||||
ReplayService replayService,
|
||||
RedriveService redriveService,
|
||||
Supplier<List<TopologyManifest>> manifests,
|
||||
ReplayEstimator replayEstimator,
|
||||
RedriveEstimator redriveEstimator,
|
||||
Supplier<Instant> clock) {
|
||||
this.topologyValidator =
|
||||
Objects.requireNonNull(topologyValidator, "topologyValidator required");
|
||||
this.inspector = Objects.requireNonNull(inspector, "inspector must not be null");
|
||||
this.idempotency = Objects.requireNonNull(idempotency, "idempotency must not be null");
|
||||
this.replayService = Objects.requireNonNull(replayService, "replayService must not be null");
|
||||
this.redriveService = Objects.requireNonNull(redriveService, "redriveService must not be null");
|
||||
this.manifests = Objects.requireNonNull(manifests, "manifests must not be null");
|
||||
this.replayEstimator = Objects.requireNonNull(replayEstimator, "replayEstimator required");
|
||||
this.redriveEstimator = Objects.requireNonNull(redriveEstimator, "redriveEstimator required");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TopologyValidationReport validateTopology() {
|
||||
return topologyValidator.validate(manifests.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReplayPlan planReplay(ReplayRequest request) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
return new ReplayPlan(
|
||||
request,
|
||||
replayEstimator.estimate(request),
|
||||
clock.get(),
|
||||
inspector.topologyVersion(),
|
||||
!request.isolatedConsumerGroup());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReplayResult executeReplay(ApprovedReplayPlan plan) {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
Instant now = clock.get();
|
||||
ReplayRequest request = plan.plan().request();
|
||||
|
||||
plan.requireExecutable(now, inspector.topologyVersion());
|
||||
claimOrRefuse(plan.approval().ticket(), request.replayId().toString(), now);
|
||||
|
||||
Instant startedAt = clock.get();
|
||||
ReplayReport report =
|
||||
replayService.replay(
|
||||
request, Optional.of(plan.approval()), plan.approval().approvedBy(), now);
|
||||
|
||||
return new ReplayResult(
|
||||
request.replayId(),
|
||||
plan.plan().estimatedMessages(),
|
||||
report.replayed(),
|
||||
Duration.between(startedAt, clock.get()),
|
||||
true,
|
||||
report.dryRun());
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedrivePlan planRedrive(RedriveRequest request) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
RedriveEstimate estimate = redriveEstimator.estimate(request);
|
||||
return new RedrivePlan(
|
||||
request,
|
||||
estimate.candidates(),
|
||||
estimate.alreadyRedriven(),
|
||||
clock.get(),
|
||||
inspector.topologyVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedriveResult executeRedrive(ApprovedRedrivePlan plan) {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
Instant now = clock.get();
|
||||
RedriveRequest request = plan.plan().request();
|
||||
|
||||
plan.requireExecutable(now, inspector.topologyVersion());
|
||||
claimOrRefuse(plan.approval().ticket(), request.redriveId().toString(), now);
|
||||
|
||||
Instant startedAt = clock.get();
|
||||
RedriveReport report =
|
||||
redriveService.redrive(
|
||||
request, Optional.of(plan.approval()), plan.approval().approvedBy(), now);
|
||||
|
||||
return new RedriveResult(
|
||||
request.redriveId(),
|
||||
report.candidates(),
|
||||
report.moved(),
|
||||
report.failed(),
|
||||
Duration.between(startedAt, clock.get()),
|
||||
report.dryRun());
|
||||
}
|
||||
|
||||
private void claimOrRefuse(String approvalTicket, String operationId, Instant now) {
|
||||
idempotency
|
||||
.claim(approvalTicket, operationId, now)
|
||||
.ifPresent(
|
||||
existing -> {
|
||||
throw new MessageAuthorizationException(
|
||||
"APPROVAL_ALREADY_EXECUTED",
|
||||
"approval %s was already executed at %s by operation %s; an approval authorises "
|
||||
.formatted(approvalTicket, existing.executedAt(), existing.operationId())
|
||||
+ "one execution, not a standing permission");
|
||||
});
|
||||
}
|
||||
|
||||
/** Estimates how many messages a replay would re-deliver. */
|
||||
@FunctionalInterface
|
||||
public interface ReplayEstimator {
|
||||
|
||||
/**
|
||||
* Estimates a replay's message count without reading anything.
|
||||
*
|
||||
* @param request the replay request
|
||||
* @return the estimated count
|
||||
*/
|
||||
long estimate(ReplayRequest request);
|
||||
}
|
||||
|
||||
/** How many messages a redrive would move, and how many already failed one. */
|
||||
record RedriveEstimate(int candidates, int alreadyRedriven) {}
|
||||
|
||||
/** Estimates a redrive's candidates. */
|
||||
@FunctionalInterface
|
||||
public interface RedriveEstimator {
|
||||
|
||||
/**
|
||||
* Estimates a redrive's candidates without moving anything.
|
||||
*
|
||||
* @param request the redrive request
|
||||
* @return the estimate
|
||||
*/
|
||||
RedriveEstimate estimate(RedriveRequest request);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.AdminApproval;
|
||||
import dev.caskeleton.messaging.admin.DestructiveOperation;
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The operations that destroy data an application cannot recreate.
|
||||
*
|
||||
* <p>A separate interface from {@link MessagingAdminService}, and no bean for it is ever registered
|
||||
* in an application runtime. The separation is the control: an application that never receives this
|
||||
* type cannot purge a topic even if every other guard is bypassed, because the method does not
|
||||
* exist on anything it holds.
|
||||
*
|
||||
* <p>Each operation takes an {@link Approved} argument rather than an approval parameter, so the
|
||||
* authorisation cannot be forgotten at a call site — there is no way to call these without one.
|
||||
*/
|
||||
public interface DestructiveMessagingAdmin {
|
||||
|
||||
/** An authorised destructive request. */
|
||||
record Approved(
|
||||
DestructiveOperation operation,
|
||||
DestinationName destination,
|
||||
AdminApproval approval,
|
||||
long estimatedMessagesAffected) {
|
||||
|
||||
public Approved {
|
||||
Objects.requireNonNull(operation, "operation must not be null");
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(approval, "approval must not be null");
|
||||
if (estimatedMessagesAffected < 0) {
|
||||
throw new IllegalArgumentException("estimatedMessagesAffected must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a destructive operation did. */
|
||||
record DestructiveResult(
|
||||
DestructiveOperation operation,
|
||||
DestinationName destination,
|
||||
long messagesAffected,
|
||||
Duration elapsed,
|
||||
Instant executedAt) {
|
||||
|
||||
public DestructiveResult {
|
||||
Objects.requireNonNull(operation, "operation must not be null");
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(elapsed, "elapsed must not be null");
|
||||
Objects.requireNonNull(executedAt, "executedAt must not be null");
|
||||
if (messagesAffected < 0) {
|
||||
throw new IllegalArgumentException("messagesAffected must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a consumer group's committed position.
|
||||
*
|
||||
* @param request the authorised request
|
||||
* @return what the reset did
|
||||
*/
|
||||
DestructiveResult resetOffset(Approved request);
|
||||
|
||||
/**
|
||||
* Discards the messages a destination currently holds.
|
||||
*
|
||||
* @param request the authorised request
|
||||
* @return what the purge did
|
||||
*/
|
||||
DestructiveResult purge(Approved request);
|
||||
|
||||
/**
|
||||
* Removes a destination entirely.
|
||||
*
|
||||
* @param request the authorised request
|
||||
* @return what the deletion did
|
||||
*/
|
||||
DestructiveResult deleteDestination(Approved request);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.ApprovedRedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.ApprovedReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.RedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.RedriveRequest;
|
||||
import dev.caskeleton.messaging.admin.RedriveResult;
|
||||
import dev.caskeleton.messaging.admin.ReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.ReplayRequest;
|
||||
import dev.caskeleton.messaging.admin.ReplayResult;
|
||||
import dev.caskeleton.messaging.admin.TopologyValidationReport;
|
||||
|
||||
/**
|
||||
* The non-destructive half of the admin plane.
|
||||
*
|
||||
* <p>Every mutating operation is split into plan and execute, and the execute methods take an
|
||||
* {@code Approved*} type. A caller cannot execute something it has not planned, because it has no
|
||||
* way to construct the argument — the plan/approve/execute sequence is enforced by the types rather
|
||||
* than by a runtime check.
|
||||
*
|
||||
* <p>Destructive operations live in {@link DestructiveMessagingAdmin}, a separate interface that an
|
||||
* application runtime never receives a bean for. Splitting them means a compromised handler that
|
||||
* somehow reaches this service still has no method that deletes anything.
|
||||
*/
|
||||
public interface MessagingAdminService {
|
||||
|
||||
/**
|
||||
* Compares every declared topology against the broker.
|
||||
*
|
||||
* @return the discrepancies found
|
||||
*/
|
||||
TopologyValidationReport validateTopology();
|
||||
|
||||
/**
|
||||
* Estimates what a replay would do, without reading anything.
|
||||
*
|
||||
* @param request the replay request
|
||||
* @return the plan, including its impact estimate
|
||||
*/
|
||||
ReplayPlan planReplay(ReplayRequest request);
|
||||
|
||||
/**
|
||||
* Executes an approved replay.
|
||||
*
|
||||
* @param plan the approved plan
|
||||
* @return what the replay actually did
|
||||
*/
|
||||
ReplayResult executeReplay(ApprovedReplayPlan plan);
|
||||
|
||||
/**
|
||||
* Estimates what a redrive would do, without moving anything.
|
||||
*
|
||||
* @param request the redrive request
|
||||
* @return the plan, including how many candidates already failed a redrive
|
||||
*/
|
||||
RedrivePlan planRedrive(RedriveRequest request);
|
||||
|
||||
/**
|
||||
* Executes an approved redrive.
|
||||
*
|
||||
* @param plan the approved plan
|
||||
* @return what the redrive actually did
|
||||
*/
|
||||
RedriveResult executeRedrive(ApprovedRedrivePlan plan);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
/**
|
||||
* What one redrive pass did.
|
||||
*
|
||||
* @param candidates how many messages were eligible
|
||||
* @param moved how many were republished and settled
|
||||
* @param failed how many did not confirm and stay parked
|
||||
* @param dryRun whether this was a plan-only run
|
||||
*/
|
||||
public record RedriveReport(int candidates, int moved, int failed, boolean dryRun) {
|
||||
|
||||
public RedriveReport {
|
||||
if (candidates < 0 || moved < 0 || failed < 0) {
|
||||
throw new IllegalArgumentException("redrive counters must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.AdminApproval;
|
||||
import dev.caskeleton.messaging.admin.DestructiveOperation;
|
||||
import dev.caskeleton.messaging.admin.DestructiveOperationGuard;
|
||||
import dev.caskeleton.messaging.admin.RedriveRequest;
|
||||
import dev.caskeleton.messaging.api.MessageId;
|
||||
import dev.caskeleton.messaging.api.publish.PublishCompletion;
|
||||
import dev.caskeleton.messaging.api.publish.PublishResult;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Moves messages from a dead letter destination back to their source.
|
||||
*
|
||||
* <p>A redrive is a publish followed by a settlement, in that order, exactly like dead lettering in
|
||||
* reverse. A message whose republish did not confirm stays in the dead letter destination: losing
|
||||
* it on the way back would be the one outcome worse than leaving it parked.
|
||||
*
|
||||
* <p>The redrive id and a redrive counter travel with each message. Without them a message that
|
||||
* fails again is indistinguishable from a new one, and a redrive loop is invisible until the dead
|
||||
* letter destination is full.
|
||||
*/
|
||||
public final class RedriveService {
|
||||
|
||||
private final DestructiveOperationGuard guard;
|
||||
private final RedriveSource source;
|
||||
private final RedrivePublisher publisher;
|
||||
private final AuditSink audit;
|
||||
|
||||
/**
|
||||
* Creates a redrive service.
|
||||
*
|
||||
* @param guard the destructive operation guard
|
||||
* @param source reads and settles dead letter messages
|
||||
* @param publisher republishes to the target destination
|
||||
* @param audit records the operation
|
||||
*/
|
||||
public RedriveService(
|
||||
DestructiveOperationGuard guard,
|
||||
RedriveSource source,
|
||||
RedrivePublisher publisher,
|
||||
AuditSink audit) {
|
||||
this.guard = Objects.requireNonNull(guard, "guard must not be null");
|
||||
this.source = Objects.requireNonNull(source, "source must not be null");
|
||||
this.publisher = Objects.requireNonNull(publisher, "publisher must not be null");
|
||||
this.audit = Objects.requireNonNull(audit, "audit must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one redrive pass.
|
||||
*
|
||||
* @param request what to move
|
||||
* @param approval the approval, when one was supplied
|
||||
* @param subject the operator identity
|
||||
* @param now the current instant
|
||||
* @return what the pass did
|
||||
*/
|
||||
public RedriveReport redrive(
|
||||
RedriveRequest request, Optional<AdminApproval> approval, String subject, Instant now) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
guard.authorize(
|
||||
DestructiveOperation.REDRIVE, request.source(), approval, request.dryRun(), now);
|
||||
|
||||
List<MessageId> candidates = source.peek(request.source(), request.batchSize());
|
||||
if (request.dryRun()) {
|
||||
return new RedriveReport(candidates.size(), 0, 0, true);
|
||||
}
|
||||
|
||||
List<MessageId> moved = new ArrayList<>();
|
||||
int failed = 0;
|
||||
for (MessageId messageId : candidates) {
|
||||
PublishResult result = publisher.republish(messageId, request.target(), request.redriveId());
|
||||
if (result.completion() == PublishCompletion.CONFIRMED) {
|
||||
source.settle(request.source(), messageId);
|
||||
moved.add(messageId);
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
audit.record(
|
||||
new dev.caskeleton.messaging.observation.MessagingAuditEvent(
|
||||
"REDRIVE",
|
||||
subject,
|
||||
request.source().value(),
|
||||
approval.map(AdminApproval::ticket).orElse("dry-run"),
|
||||
now,
|
||||
java.util.Map.of(
|
||||
"redriveId", request.redriveId().toString(),
|
||||
"moved", Integer.toString(moved.size()),
|
||||
"failed", Integer.toString(failed))));
|
||||
|
||||
return new RedriveReport(candidates.size(), moved.size(), failed, false);
|
||||
}
|
||||
|
||||
/** Reads and settles messages on a dead letter destination. */
|
||||
public interface RedriveSource {
|
||||
|
||||
/**
|
||||
* Returns the next candidates without settling them.
|
||||
*
|
||||
* @param destination the dead letter destination
|
||||
* @param batchSize how many to return
|
||||
* @return the candidate identities
|
||||
*/
|
||||
List<MessageId> peek(
|
||||
dev.caskeleton.messaging.api.destination.DestinationName destination, int batchSize);
|
||||
|
||||
/**
|
||||
* Settles a message that has been successfully republished.
|
||||
*
|
||||
* @param destination the dead letter destination
|
||||
* @param messageId the message identity
|
||||
*/
|
||||
void settle(
|
||||
dev.caskeleton.messaging.api.destination.DestinationName destination, MessageId messageId);
|
||||
}
|
||||
|
||||
/** Republishes a dead lettered message to its target destination. */
|
||||
@FunctionalInterface
|
||||
public interface RedrivePublisher {
|
||||
|
||||
/**
|
||||
* Republishes one message under its original identity.
|
||||
*
|
||||
* @param messageId the message identity
|
||||
* @param target the destination to publish back to
|
||||
* @param redriveId the operation identity stamped on the message
|
||||
* @return the publish outcome
|
||||
*/
|
||||
PublishResult republish(
|
||||
MessageId messageId,
|
||||
dev.caskeleton.messaging.api.destination.DestinationName target,
|
||||
java.util.UUID redriveId);
|
||||
}
|
||||
|
||||
/** Records privileged operations. */
|
||||
@FunctionalInterface
|
||||
public interface AuditSink {
|
||||
|
||||
/**
|
||||
* Records one audit event.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* What one replay did.
|
||||
*
|
||||
* @param replayId the operation identity
|
||||
* @param replayed how many messages were re-read
|
||||
* @param dryRun whether this was a plan-only run
|
||||
*/
|
||||
public record ReplayReport(UUID replayId, long replayed, boolean dryRun) {
|
||||
|
||||
public ReplayReport {
|
||||
Objects.requireNonNull(replayId, "replayId must not be null");
|
||||
if (replayed < 0) {
|
||||
throw new IllegalArgumentException("replayed must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.AdminApproval;
|
||||
import dev.caskeleton.messaging.admin.DestructiveOperation;
|
||||
import dev.caskeleton.messaging.admin.DestructiveOperationGuard;
|
||||
import dev.caskeleton.messaging.admin.ReplayRequest;
|
||||
import dev.caskeleton.messaging.observation.MessagingAuditEvent;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Re-reads a destination from an earlier position.
|
||||
*
|
||||
* <p>An isolated replay reads alongside the live consumer and needs no approval, because it changes
|
||||
* nothing: a throwaway group has its own offsets. Replaying into an existing production group is a
|
||||
* different operation entirely — it rewinds a live consumer and reprocesses everything since — so
|
||||
* it goes through the destructive guard.
|
||||
*
|
||||
* <p>Making the safe form free and the destructive form approved is what keeps operators from
|
||||
* reaching for the destructive one out of convenience.
|
||||
*/
|
||||
public final class ReplayService {
|
||||
|
||||
private final DestructiveOperationGuard guard;
|
||||
private final ReplayExecutor executor;
|
||||
private final RedriveService.AuditSink audit;
|
||||
|
||||
/**
|
||||
* Creates a replay service.
|
||||
*
|
||||
* @param guard the destructive operation guard
|
||||
* @param executor performs the replay
|
||||
* @param audit records the operation
|
||||
*/
|
||||
public ReplayService(
|
||||
DestructiveOperationGuard guard, ReplayExecutor executor, RedriveService.AuditSink audit) {
|
||||
this.guard = Objects.requireNonNull(guard, "guard must not be null");
|
||||
this.executor = Objects.requireNonNull(executor, "executor must not be null");
|
||||
this.audit = Objects.requireNonNull(audit, "audit must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one replay.
|
||||
*
|
||||
* @param request what to replay
|
||||
* @param approval the approval, when one was supplied
|
||||
* @param subject the operator identity
|
||||
* @param now the current instant
|
||||
* @return what the replay did
|
||||
*/
|
||||
public ReplayReport replay(
|
||||
ReplayRequest request, Optional<AdminApproval> approval, String subject, Instant now) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
Objects.requireNonNull(approval, "approval must not be null");
|
||||
|
||||
boolean needsApproval = !request.isolatedConsumerGroup();
|
||||
guard.authorize(
|
||||
DestructiveOperation.REPLAY,
|
||||
request.destination(),
|
||||
approval,
|
||||
request.dryRun() || !needsApproval,
|
||||
now);
|
||||
|
||||
if (request.dryRun()) {
|
||||
return new ReplayReport(request.replayId(), 0, true);
|
||||
}
|
||||
|
||||
long replayed = executor.replay(request);
|
||||
|
||||
audit.record(
|
||||
new MessagingAuditEvent(
|
||||
"REPLAY",
|
||||
subject,
|
||||
request.destination().value(),
|
||||
approval.map(AdminApproval::ticket).orElse("isolated"),
|
||||
now,
|
||||
Map.of(
|
||||
"replayId", request.replayId().toString(),
|
||||
"isolated", Boolean.toString(request.isolatedConsumerGroup()),
|
||||
"replayed", Long.toString(replayed))));
|
||||
|
||||
return new ReplayReport(request.replayId(), replayed, false);
|
||||
}
|
||||
|
||||
/** Performs the replay against a broker. */
|
||||
@FunctionalInterface
|
||||
public interface ReplayExecutor {
|
||||
|
||||
/**
|
||||
* Replays a destination and returns how many messages were re-read.
|
||||
*
|
||||
* @param request the replay request
|
||||
* @return the replayed message count
|
||||
*/
|
||||
long replay(ReplayRequest request);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import dev.caskeleton.messaging.api.error.MessageTopologyException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Validates declared topology against what the broker actually has.
|
||||
*
|
||||
* <p>Validate-only, and it fails startup rather than logging. A partition count that silently
|
||||
* differs from the manifest changes the ordering guarantee the destination advertises, and a
|
||||
* missing {@code min.insync.replicas} changes what {@code acks=all} actually means — both are the
|
||||
* kind of drift that is invisible until the incident.
|
||||
*/
|
||||
public final class TopologyValidationRuntime {
|
||||
|
||||
private final TopologyReader reader;
|
||||
|
||||
/**
|
||||
* Creates a validator over a broker reader.
|
||||
*
|
||||
* @param reader reads the observed topology
|
||||
*/
|
||||
public TopologyValidationRuntime(TopologyReader reader) {
|
||||
this.reader = Objects.requireNonNull(reader, "reader must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates every manifest, reporting all differences at once.
|
||||
*
|
||||
* @param manifests the declared topology
|
||||
* @throws MessageTopologyException when the broker does not match
|
||||
*/
|
||||
public void validate(List<TopologyManifest> manifests) {
|
||||
Objects.requireNonNull(manifests, "manifests must not be null");
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
for (TopologyManifest manifest : manifests) {
|
||||
ObservedTopology observed = reader.read(manifest.physicalName());
|
||||
if (observed == null) {
|
||||
problems.add(manifest.physicalName() + " does not exist");
|
||||
continue;
|
||||
}
|
||||
manifest
|
||||
.differencesFrom(
|
||||
observed.partitions(), observed.replicationFactor(), observed.configuration())
|
||||
.forEach(difference -> problems.add(manifest.physicalName() + ": " + difference));
|
||||
}
|
||||
|
||||
if (!problems.isEmpty()) {
|
||||
throw new MessageTopologyException(
|
||||
"TOPOLOGY_MISMATCH",
|
||||
"broker topology does not match the manifest: " + String.join("; ", problems));
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the observed topology for a physical destination. */
|
||||
@FunctionalInterface
|
||||
public interface TopologyReader {
|
||||
|
||||
/**
|
||||
* Reads one destination's topology.
|
||||
*
|
||||
* @param physicalName the broker-side name
|
||||
* @return the observed topology, or null when it does not exist
|
||||
*/
|
||||
ObservedTopology read(String physicalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the broker reports for a destination.
|
||||
*
|
||||
* @param partitions the observed partition count
|
||||
* @param replicationFactor the observed replication factor
|
||||
* @param configuration the observed configuration
|
||||
*/
|
||||
public record ObservedTopology(
|
||||
int partitions, int replicationFactor, Map<String, String> configuration) {
|
||||
|
||||
public ObservedTopology {
|
||||
Objects.requireNonNull(configuration, "configuration must not be null");
|
||||
configuration = Map.copyOf(configuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import dev.caskeleton.messaging.admin.DestinationTopology;
|
||||
import dev.caskeleton.messaging.admin.TopologyIssue;
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Compares one declared topology against what the broker reports.
|
||||
*
|
||||
* <p>Which discrepancies block is a judgement encoded here rather than left to configuration.
|
||||
* Replication factor and absence are blocking because a destination that is missing or unreplicated
|
||||
* cannot deliver the durability its profile promises. A partition count that is <em>higher</em>
|
||||
* than declared is advisory rather than blocking: extra partitions do not break durability, and
|
||||
* someone scaling a topic up deliberately should not be met with a refusal to start.
|
||||
*
|
||||
* <p>A partition count that is <em>lower</em> is blocking, because it silently reduces the
|
||||
* concurrency the destination was sized for and, on a keyed topic, changes which key lands where.
|
||||
*/
|
||||
public final class TopologyValidator {
|
||||
|
||||
/**
|
||||
* Compares a manifest against observed topology.
|
||||
*
|
||||
* @param manifest what was declared
|
||||
* @param observed what the broker reports
|
||||
* @return the discrepancies found, empty when they agree
|
||||
*/
|
||||
public List<TopologyIssue> compare(TopologyManifest manifest, DestinationTopology observed) {
|
||||
Objects.requireNonNull(manifest, "manifest must not be null");
|
||||
Objects.requireNonNull(observed, "observed must not be null");
|
||||
|
||||
List<TopologyIssue> issues = new ArrayList<>();
|
||||
String destination = manifest.destination();
|
||||
|
||||
if (!observed.exists()) {
|
||||
issues.add(
|
||||
TopologyIssue.blocking(destination, "existence", manifest.physicalName(), "absent"));
|
||||
return List.copyOf(issues);
|
||||
}
|
||||
|
||||
if (!manifest.physicalName().equals(observed.physicalName())) {
|
||||
issues.add(
|
||||
TopologyIssue.blocking(
|
||||
destination, "physicalName", manifest.physicalName(), observed.physicalName()));
|
||||
}
|
||||
|
||||
if (observed.partitions() < manifest.partitions()) {
|
||||
issues.add(
|
||||
TopologyIssue.blocking(
|
||||
destination,
|
||||
"partitions",
|
||||
Integer.toString(manifest.partitions()),
|
||||
Integer.toString(observed.partitions())));
|
||||
} else if (observed.partitions() > manifest.partitions()) {
|
||||
// Scaling a topic up is a legitimate operation; refusing to start would punish it.
|
||||
issues.add(
|
||||
TopologyIssue.advisory(
|
||||
destination,
|
||||
"partitions",
|
||||
Integer.toString(manifest.partitions()),
|
||||
Integer.toString(observed.partitions())));
|
||||
}
|
||||
|
||||
if (observed.replicationFactor() < manifest.replicationFactor()) {
|
||||
issues.add(
|
||||
TopologyIssue.blocking(
|
||||
destination,
|
||||
"replicationFactor",
|
||||
Integer.toString(manifest.replicationFactor()),
|
||||
Integer.toString(observed.replicationFactor())));
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> required : manifest.requiredConfiguration().entrySet()) {
|
||||
String actual = observed.configuration().get(required.getKey());
|
||||
if (!required.getValue().equals(actual)) {
|
||||
issues.add(
|
||||
TopologyIssue.blocking(
|
||||
destination,
|
||||
required.getKey(),
|
||||
required.getValue(),
|
||||
actual == null ? "unset" : actual));
|
||||
}
|
||||
}
|
||||
|
||||
return List.copyOf(issues);
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.admin.AdminApproval;
|
||||
import dev.caskeleton.messaging.admin.ApprovedRedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.ApprovedReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.RedrivePlan;
|
||||
import dev.caskeleton.messaging.admin.RedriveRequest;
|
||||
import dev.caskeleton.messaging.admin.RedriveResult;
|
||||
import dev.caskeleton.messaging.admin.ReplayPlan;
|
||||
import dev.caskeleton.messaging.admin.ReplayRequest;
|
||||
import dev.caskeleton.messaging.admin.ReplayResult;
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import dev.caskeleton.messaging.api.error.MessageAuthorizationException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ApprovedPlanExecutionTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-10T09:00:00Z");
|
||||
private static final UUID OPERATION_ID = UUID.fromString("0199aaaa-bbbb-7ccc-8ddd-eeeeffff0000");
|
||||
|
||||
private static AdminApproval approval() {
|
||||
return new AdminApproval("CHG-1042", "ops@example.com", NOW, NOW.plus(Duration.ofHours(2)));
|
||||
}
|
||||
|
||||
private static ReplayRequest replayRequest() {
|
||||
return new ReplayRequest(
|
||||
OPERATION_ID,
|
||||
new DestinationName("orders.v1"),
|
||||
NOW.minus(Duration.ofDays(1)),
|
||||
Optional.empty(),
|
||||
true,
|
||||
false);
|
||||
}
|
||||
|
||||
private static ReplayPlan replayPlan(String topologyVersion) {
|
||||
return new ReplayPlan(replayRequest(), 4_200_000, NOW, topologyVersion, false);
|
||||
}
|
||||
|
||||
private static RedriveRequest redriveRequest() {
|
||||
return new RedriveRequest(
|
||||
OPERATION_ID,
|
||||
new DestinationName("orders.v1.dlq"),
|
||||
new DestinationName("orders.v1"),
|
||||
50,
|
||||
false);
|
||||
}
|
||||
|
||||
private static RedrivePlan redrivePlan(int candidates, int alreadyRedriven) {
|
||||
return new RedrivePlan(redriveRequest(), candidates, alreadyRedriven, NOW, "v1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExpiredApprovalCannotExecute() {
|
||||
ApprovedReplayPlan approved = new ApprovedReplayPlan(replayPlan("v1"), approval());
|
||||
|
||||
assertThatThrownBy(() -> approved.requireExecutable(NOW.plus(Duration.ofDays(1)), "v1"))
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("CHG-1042");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTopologyChangeSinceApprovalInvalidatesThePlan() {
|
||||
ApprovedReplayPlan approved = new ApprovedReplayPlan(replayPlan("v1"), approval());
|
||||
|
||||
assertThatThrownBy(() -> approved.requireExecutable(NOW, "v2"))
|
||||
.as("every number in the plan was computed against the old topology")
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("rebuilt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidApprovalOnUnchangedTopologyExecutes() {
|
||||
assertThatCode(
|
||||
() -> new ApprovedReplayPlan(replayPlan("v1"), approval()).requireExecutable(NOW, "v1"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRedriveThatWouldLoopNeedsThatAcknowledgedExplicitly() {
|
||||
ApprovedRedrivePlan approved =
|
||||
new ApprovedRedrivePlan(redrivePlan(900, 400), approval(), false);
|
||||
|
||||
assertThatThrownBy(() -> approved.requireExecutable(NOW, "v1"))
|
||||
.isInstanceOf(MessageAuthorizationException.class)
|
||||
.hasMessageContaining("looks like progress");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAcknowledgedLoopMayProceed() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new ApprovedRedrivePlan(redrivePlan(900, 400), approval(), true)
|
||||
.requireExecutable(NOW, "v1"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRedriveWithNoPreviouslyRedrivenCandidatesNeedsNoAcknowledgement() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new ApprovedRedrivePlan(redrivePlan(900, 0), approval(), false)
|
||||
.requireExecutable(NOW, "v1"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePlanDescribesItsImpactBeforeAnythingRuns() {
|
||||
assertThat(replayPlan("v1").describeImpact()).contains("4200000").contains("isolated group");
|
||||
assertThat(redrivePlan(900, 400).describeImpact())
|
||||
.contains("already failed a previous redrive");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReplayIntoTheLiveGroupSaysSoInCapitals() {
|
||||
ReplayPlan live =
|
||||
new ReplayPlan(
|
||||
new ReplayRequest(
|
||||
OPERATION_ID,
|
||||
new DestinationName("orders.v1"),
|
||||
NOW.minus(Duration.ofDays(1)),
|
||||
Optional.empty(),
|
||||
false,
|
||||
false),
|
||||
4_200_000,
|
||||
NOW,
|
||||
"v1",
|
||||
true);
|
||||
|
||||
assertThat(live.describeImpact())
|
||||
.as("re-delivering millions of messages into the live group is the decision to flag")
|
||||
.contains("LIVE consumer group");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anApprovalAuthorisesOneExecutionNotAStandingPermission() {
|
||||
AdminOperationIdempotencyStore store = new AdminOperationIdempotencyStore();
|
||||
|
||||
assertThat(store.claim("CHG-1042", "op-1", NOW)).isEmpty();
|
||||
assertThat(store.claim("CHG-1042", "op-2", NOW.plus(Duration.ofMinutes(5))))
|
||||
.as("an arrow-up in the shell must not re-run an approved redrive")
|
||||
.hasValueSatisfying(existing -> assertThat(existing.operationId()).isEqualTo("op-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDifferentApprovalIsClaimedIndependently() {
|
||||
AdminOperationIdempotencyStore store = new AdminOperationIdempotencyStore();
|
||||
store.claim("CHG-1042", "op-1", NOW);
|
||||
|
||||
assertThat(store.claim("CHG-1043", "op-2", NOW)).isEmpty();
|
||||
assertThat(store.size()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReplayResultFlagsAShortfallAgainstTheApprovedEstimate() {
|
||||
ReplayResult result =
|
||||
new ReplayResult(OPERATION_ID, 1_000_000, 100_000, Duration.ofMinutes(3), true, false);
|
||||
|
||||
assertThat(result.fellShortOfTheEstimate())
|
||||
.as("retention may have expired part of the approved window")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRedriveResultMustAccountForEveryCandidate() {
|
||||
RedriveResult accounted =
|
||||
new RedriveResult(OPERATION_ID, 100, 80, 20, Duration.ofSeconds(4), false);
|
||||
RedriveResult unaccounted =
|
||||
new RedriveResult(OPERATION_ID, 100, 80, 10, Duration.ofSeconds(4), false);
|
||||
|
||||
assertThat(accounted.isFullyAccounted()).isTrue();
|
||||
assertThat(unaccounted.isFullyAccounted())
|
||||
.as("a message that was neither moved nor left parked has been lost track of")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDryRunCannotClaimToHaveMovedAnything() {
|
||||
assertThatThrownBy(
|
||||
() -> new RedriveResult(OPERATION_ID, 100, 5, 0, Duration.ofSeconds(1), true))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import dev.caskeleton.messaging.api.error.MessageTopologyException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TopologyValidationRuntimeTest {
|
||||
|
||||
private static final TopologyManifest ORDERS =
|
||||
new TopologyManifest(
|
||||
"order-events", "order.events.v1", 6, 3, Map.of("min.insync.replicas", "2"));
|
||||
|
||||
@Test
|
||||
void aMatchingTopologyValidates() {
|
||||
TopologyValidationRuntime runtime =
|
||||
new TopologyValidationRuntime(
|
||||
name ->
|
||||
new TopologyValidationRuntime.ObservedTopology(
|
||||
6, 3, Map.of("min.insync.replicas", "2")));
|
||||
|
||||
assertThatCode(() -> runtime.validate(List.of(ORDERS))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingDestinationFailsStartup() {
|
||||
TopologyValidationRuntime runtime = new TopologyValidationRuntime(name -> null);
|
||||
|
||||
assertThatThrownBy(() -> runtime.validate(List.of(ORDERS)))
|
||||
.isInstanceOf(MessageTopologyException.class)
|
||||
.hasMessageContaining("does not exist");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWeakenedReplicationSettingFailsStartup() {
|
||||
TopologyValidationRuntime runtime =
|
||||
new TopologyValidationRuntime(
|
||||
name ->
|
||||
new TopologyValidationRuntime.ObservedTopology(
|
||||
6, 3, Map.of("min.insync.replicas", "1")));
|
||||
|
||||
assertThatThrownBy(() -> runtime.validate(List.of(ORDERS)))
|
||||
.isInstanceOf(MessageTopologyException.class)
|
||||
.hasMessageContaining("min.insync.replicas");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyDifferenceIsReportedAtOnce() {
|
||||
TopologyValidationRuntime runtime =
|
||||
new TopologyValidationRuntime(
|
||||
name ->
|
||||
new TopologyValidationRuntime.ObservedTopology(
|
||||
3, 1, Map.of("min.insync.replicas", "1")));
|
||||
|
||||
assertThatThrownBy(() -> runtime.validate(List.of(ORDERS)))
|
||||
.isInstanceOf(MessageTopologyException.class)
|
||||
.hasMessageContaining("partitions")
|
||||
.hasMessageContaining("replicationFactor")
|
||||
.hasMessageContaining("min.insync.replicas");
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package dev.caskeleton.messaging.admin.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.admin.DestinationTopology;
|
||||
import dev.caskeleton.messaging.admin.TopologyIssue;
|
||||
import dev.caskeleton.messaging.admin.TopologyManagementMode;
|
||||
import dev.caskeleton.messaging.admin.TopologyManifest;
|
||||
import dev.caskeleton.messaging.admin.TopologyValidationReport;
|
||||
import dev.caskeleton.messaging.api.error.MessagingConfigurationException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TopologyValidatorTest {
|
||||
|
||||
private final TopologyValidator validator = new TopologyValidator();
|
||||
|
||||
private static TopologyManifest manifest() {
|
||||
return new TopologyManifest(
|
||||
"orders.v1", "orders-v1", 12, 3, Map.of("min.insync.replicas", "2"));
|
||||
}
|
||||
|
||||
private static DestinationTopology observed(
|
||||
int partitions, int replication, Map<String, String> config) {
|
||||
return new DestinationTopology("orders-v1", partitions, replication, config, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgreeingTopologyProducesNoIssues() {
|
||||
assertThat(validator.compare(manifest(), observed(12, 3, Map.of("min.insync.replicas", "2"))))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAbsentDestinationBlocksStartup() {
|
||||
List<TopologyIssue> issues =
|
||||
validator.compare(manifest(), DestinationTopology.absent("orders-v1"));
|
||||
|
||||
assertThat(issues)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
issue -> {
|
||||
assertThat(issue.attribute()).isEqualTo("existence");
|
||||
assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.BLOCKING);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooFewPartitionsBlocksBecauseKeysWouldLandDifferently() {
|
||||
List<TopologyIssue> issues =
|
||||
validator.compare(manifest(), observed(6, 3, Map.of("min.insync.replicas", "2")));
|
||||
|
||||
assertThat(issues)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
issue -> assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.BLOCKING));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extraPartitionsAreAdvisoryBecauseScalingUpIsLegitimate() {
|
||||
List<TopologyIssue> issues =
|
||||
validator.compare(manifest(), observed(24, 3, Map.of("min.insync.replicas", "2")));
|
||||
|
||||
assertThat(issues)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
issue -> assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.ADVISORY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooLittleReplicationBlocksBecauseDurabilityIsPromised() {
|
||||
List<TopologyIssue> issues =
|
||||
validator.compare(manifest(), observed(12, 1, Map.of("min.insync.replicas", "2")));
|
||||
|
||||
assertThat(issues)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
issue -> {
|
||||
assertThat(issue.attribute()).isEqualTo("replicationFactor");
|
||||
assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.BLOCKING);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingRequiredConfigurationEntryBlocks() {
|
||||
List<TopologyIssue> issues = validator.compare(manifest(), observed(12, 3, Map.of()));
|
||||
|
||||
assertThat(issues)
|
||||
.singleElement()
|
||||
.satisfies(issue -> assertThat(issue.actual()).isEqualTo("unset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyIssueIsCollectedRatherThanFailingOnTheFirst() {
|
||||
List<TopologyIssue> issues = validator.compare(manifest(), observed(6, 1, Map.of()));
|
||||
|
||||
assertThat(issues)
|
||||
.as("discovering the next problem only after a redeploy turns one fix into several")
|
||||
.hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReportRefusesStartupWhenAnythingBlocks() {
|
||||
TopologyValidationReport report =
|
||||
new TopologyValidationReport(
|
||||
validator.compare(manifest(), observed(12, 1, Map.of("min.insync.replicas", "2"))), 1);
|
||||
|
||||
assertThat(report.isAcceptable()).isFalse();
|
||||
assertThatThrownBy(report::requireAcceptable)
|
||||
.isInstanceOf(MessagingConfigurationException.class)
|
||||
.hasMessageContaining("replicationFactor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAdvisoryOnlyReportStillStarts() {
|
||||
TopologyValidationReport report =
|
||||
new TopologyValidationReport(
|
||||
validator.compare(manifest(), observed(24, 3, Map.of("min.insync.replicas", "2"))), 1);
|
||||
|
||||
assertThat(report.isAcceptable()).isTrue();
|
||||
assertThat(report.advisory()).hasSize(1);
|
||||
assertThatCode(report::requireAcceptable).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCompositeValidatorChecksEveryManifest() {
|
||||
CompositeTopologyValidator composite =
|
||||
new CompositeTopologyValidator(
|
||||
new BrokerTopologyInspector() {
|
||||
@Override
|
||||
public Optional<DestinationTopology> describe(String physicalName) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String topologyVersion() {
|
||||
return "v1";
|
||||
}
|
||||
});
|
||||
|
||||
TopologyValidationReport report =
|
||||
composite.validate(
|
||||
List.of(
|
||||
manifest(), new TopologyManifest("payments.v1", "payments-v1", 3, 3, Map.of())));
|
||||
|
||||
assertThat(report.destinationsChecked()).isEqualTo(2);
|
||||
assertThat(report.blocking()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCreationIsRefusedInProduction() {
|
||||
assertThatThrownBy(() -> TopologyManagementMode.CREATE_IF_MISSING.requireSafeFor(true))
|
||||
.as("a mistyped destination would be created and look exactly like a real one")
|
||||
.isInstanceOf(MessagingConfigurationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCreationIsAllowedOutsideProduction() {
|
||||
assertThatCode(() -> TopologyManagementMode.CREATE_IF_MISSING.requireSafeFor(false))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOnlyIsAlwaysSafe() {
|
||||
assertThatCode(() -> TopologyManagementMode.VALIDATE_ONLY.requireSafeFor(true))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
api project(':messaging:messaging-reliability-api')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.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.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.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.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.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
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=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.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.FailureCategory;
|
||||
import dev.caskeleton.messaging.api.error.FailureDescriptor;
|
||||
import dev.caskeleton.messaging.api.error.MessagingException;
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The stored payload does not match the reference the message carried.
|
||||
*
|
||||
* <p>Not retryable. A digest mismatch means the object at that key is not the object the producer
|
||||
* wrote — the key was reused, the object was overwritten, or something truncated it — and fetching
|
||||
* it again returns the same wrong bytes. Retrying would only delay the dead-letter.
|
||||
*
|
||||
* <p>Deliberately distinct from "the object is gone". An expired claim check is an operational
|
||||
* problem with a known cause and a known fix; a digest mismatch means something wrote data nobody
|
||||
* expected, and the two must not be diagnosed as one.
|
||||
*/
|
||||
public class ClaimCheckIntegrityException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.POISON_MESSAGE;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public ClaimCheckIntegrityException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, false, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public ClaimCheckIntegrityException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Verifies a fetched claim check payload before it is handed to a codec.
|
||||
*
|
||||
* <p>A claim check turns one message into two systems that can drift. The payload store has its own
|
||||
* retention, its own replication, and its own access control, and none of them are coordinated with
|
||||
* the broker's. So a consumer that fetches bytes and decodes them without checking is trusting
|
||||
* something the message never proved.
|
||||
*
|
||||
* <p>Both checks fail closed. An expired reference is reported before the fetch, because a
|
||||
* not-found from the store is ambiguous between "reaped" and "never written". A digest mismatch is
|
||||
* reported as validation rather than deserialization, because the bytes are not corrupt JSON — they
|
||||
* are the wrong bytes.
|
||||
*/
|
||||
public final class ClaimCheckIntegrityGuard {
|
||||
|
||||
/**
|
||||
* Verifies a payload against its reference.
|
||||
*
|
||||
* @param reference the claim check reference
|
||||
* @param payload the bytes fetched from the store
|
||||
* @param now the current instant
|
||||
* @return the verified payload
|
||||
* @throws MessageValidationException when the reference expired or the digest does not match
|
||||
*/
|
||||
public byte[] verify(ClaimCheckReference reference, byte[] payload, Instant now) {
|
||||
Objects.requireNonNull(reference, "reference must not be null");
|
||||
Objects.requireNonNull(payload, "payload must not be null");
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
|
||||
if (reference.isExpired(now)) {
|
||||
throw new MessageValidationException(
|
||||
"CLAIM_CHECK_EXPIRED",
|
||||
"the claim check payload retention expired at " + reference.expiresAt());
|
||||
}
|
||||
if (payload.length != reference.sizeBytes()) {
|
||||
throw new MessageValidationException(
|
||||
"CLAIM_CHECK_SIZE_MISMATCH",
|
||||
"expected " + reference.sizeBytes() + " bytes but read " + payload.length);
|
||||
}
|
||||
String actual = sha256(payload);
|
||||
if (!actual.equals(reference.sha256())) {
|
||||
throw new MessageValidationException(
|
||||
"CLAIM_CHECK_DIGEST_MISMATCH", "the fetched payload does not match its reference digest");
|
||||
}
|
||||
return payload.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lowercase hex SHA-256 of a payload.
|
||||
*
|
||||
* @param payload the bytes to digest
|
||||
* @return the digest
|
||||
*/
|
||||
public static String sha256(byte[] payload) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessagingConfigurationException;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* When a payload is offloaded, and how long the object must outlive the message.
|
||||
*
|
||||
* <p>The retention rule is the one that matters. A claim check object deleted while its message is
|
||||
* still deliverable turns a large message into an undeliverable one — the consumer fetches, gets
|
||||
* nothing, and the message dead-letters for a reason that has nothing to do with the message. So
|
||||
* retention must exceed the broker's own retention plus the full retry and dead-letter window, and
|
||||
* the constructor refuses a configuration where it does not.
|
||||
*
|
||||
* <p>The threshold is separate from the destination's payload limit. Offloading starts well below
|
||||
* the limit, because the limit is where the broker refuses the message and the threshold is where
|
||||
* carrying it inline stops being a good idea.
|
||||
*
|
||||
* @param thresholdBytes the encoded size above which a payload is offloaded
|
||||
* @param retention how long the stored object must remain readable
|
||||
* @param brokerRetention how long the broker keeps the message
|
||||
* @param maxRedeliveryWindow the longest retry and dead-letter path a message can take
|
||||
*/
|
||||
public record ClaimCheckPolicy(
|
||||
int thresholdBytes,
|
||||
Duration retention,
|
||||
Duration brokerRetention,
|
||||
Duration maxRedeliveryWindow) {
|
||||
|
||||
/** The default offload threshold: a quarter of the portable payload limit. */
|
||||
public static final int DEFAULT_THRESHOLD_BYTES = 262_144;
|
||||
|
||||
public ClaimCheckPolicy {
|
||||
Objects.requireNonNull(retention, "retention must not be null");
|
||||
Objects.requireNonNull(brokerRetention, "brokerRetention must not be null");
|
||||
Objects.requireNonNull(maxRedeliveryWindow, "maxRedeliveryWindow must not be null");
|
||||
if (thresholdBytes < 1) {
|
||||
throw new IllegalArgumentException("thresholdBytes must be positive");
|
||||
}
|
||||
requirePositive(retention, "retention");
|
||||
requirePositive(brokerRetention, "brokerRetention");
|
||||
requirePositive(maxRedeliveryWindow, "maxRedeliveryWindow");
|
||||
|
||||
Duration required = brokerRetention.plus(maxRedeliveryWindow);
|
||||
if (retention.compareTo(required) < 0) {
|
||||
throw new MessagingConfigurationException(
|
||||
"CLAIM_CHECK_RETENTION_TOO_SHORT",
|
||||
"claim check retention of %s is below the %s the message can remain deliverable; the "
|
||||
.formatted(retention, required)
|
||||
+ "object would be reaped while a consumer can still be handed its message");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a policy for a broker retaining one day with a one-day retry path.
|
||||
*
|
||||
* @return the default policy
|
||||
*/
|
||||
public static ClaimCheckPolicy defaults() {
|
||||
return new ClaimCheckPolicy(
|
||||
DEFAULT_THRESHOLD_BYTES, Duration.ofDays(3), Duration.ofDays(1), Duration.ofDays(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a payload of this size is offloaded.
|
||||
*
|
||||
* @param payloadBytes the encoded payload size
|
||||
* @return true when the payload travels by reference
|
||||
*/
|
||||
public boolean shouldOffload(int payloadBytes) {
|
||||
return payloadBytes > thresholdBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shortest retention this deployment allows.
|
||||
*
|
||||
* @return the minimum safe retention
|
||||
*/
|
||||
public Duration requiredRetention() {
|
||||
return brokerRetention.plus(maxRedeliveryWindow);
|
||||
}
|
||||
|
||||
private static void requirePositive(Duration value, String field) {
|
||||
if (value.isNegative() || value.isZero()) {
|
||||
throw new IllegalArgumentException(field + " must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decides whether a payload travels inline or by reference, and stores it when it does not.
|
||||
*
|
||||
* <p>The object is written <em>before</em> the message is published, and that order is the whole
|
||||
* design. Publishing first would let a consumer receive a reference to an object that does not
|
||||
* exist yet — a race that is rare in a test and routine under load, because the broker hop is
|
||||
* faster than the object store write.
|
||||
*
|
||||
* <p>Nothing here deletes on failure. If the publish is rejected the object is left behind, and the
|
||||
* retention sweep reclaims it; deleting eagerly would delete the object out from under a publish
|
||||
* that turned out to be ambiguous rather than rejected.
|
||||
*/
|
||||
public final class ClaimCheckPublisher {
|
||||
|
||||
private final ClaimCheckStore store;
|
||||
private final ClaimCheckPolicy policy;
|
||||
|
||||
/**
|
||||
* Creates a claim check publisher.
|
||||
*
|
||||
* @param store the payload store
|
||||
* @param policy the offload threshold and retention
|
||||
*/
|
||||
public ClaimCheckPublisher(ClaimCheckStore store, ClaimCheckPolicy policy) {
|
||||
this.store = Objects.requireNonNull(store, "store must not be null");
|
||||
this.policy = Objects.requireNonNull(policy, "policy must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads a payload when the policy calls for it.
|
||||
*
|
||||
* @param payload the encoded payload bytes
|
||||
* @return the outcome, carrying either the inline payload or the stored reference
|
||||
*/
|
||||
public Offloaded offload(byte[] payload) {
|
||||
Objects.requireNonNull(payload, "payload must not be null");
|
||||
|
||||
if (!policy.shouldOffload(payload.length)) {
|
||||
return new Offloaded(payload.clone(), Optional.empty());
|
||||
}
|
||||
ClaimCheckReference reference = store.put(payload, policy.retention());
|
||||
// The published message carries no payload bytes at all, only the reference. Carrying both
|
||||
// would double the transfer for no benefit and let the two disagree.
|
||||
return new Offloaded(new byte[0], Optional.of(reference));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the policy this publisher applies.
|
||||
*
|
||||
* @return the claim check policy
|
||||
*/
|
||||
public ClaimCheckPolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a payload became after the offload decision.
|
||||
*
|
||||
* @param payload the payload to publish, empty when offloaded
|
||||
* @param reference the stored object's reference, present when offloaded
|
||||
*/
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
public record Offloaded(byte[] payload, Optional<ClaimCheckReference> reference) {
|
||||
|
||||
public Offloaded {
|
||||
Objects.requireNonNull(payload, "payload must not be null");
|
||||
Objects.requireNonNull(reference, "reference must not be null");
|
||||
payload = payload.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] payload() {
|
||||
return payload.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the payload travels by reference.
|
||||
*
|
||||
* @return true when the payload was offloaded
|
||||
*/
|
||||
public boolean isOffloaded() {
|
||||
return reference.isPresent();
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Fetches an offloaded payload and verifies it before a handler ever sees it.
|
||||
*
|
||||
* <p>Verification is not optional and cannot be skipped by a caller. An object store key is a
|
||||
* string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket,
|
||||
* or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is
|
||||
* the only thing standing between that and a handler acting on someone else's data.
|
||||
*
|
||||
* <p>Failures are classified rather than merged. An expired reference is an operational problem
|
||||
* whose fix is a retention change; a digest mismatch means something wrote data nobody expected.
|
||||
* Both dead-letter the message, but an operator seeing one code should not have to guess which
|
||||
* happened.
|
||||
*/
|
||||
public final class ClaimCheckResolver {
|
||||
|
||||
private final ClaimCheckStore store;
|
||||
private final ClaimCheckIntegrityGuard guard = new ClaimCheckIntegrityGuard();
|
||||
|
||||
/**
|
||||
* Creates a resolver.
|
||||
*
|
||||
* @param store the payload store
|
||||
*/
|
||||
public ClaimCheckResolver(ClaimCheckStore store) {
|
||||
this.store = Objects.requireNonNull(store, "store must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload a message carries, fetching it when it travels by reference.
|
||||
*
|
||||
* @param inline the payload bytes the message carried, empty when it travels by reference
|
||||
* @param reference the claim check reference, when the message carried one
|
||||
* @param now the current instant
|
||||
* @return the payload the handler should see
|
||||
* @throws ClaimCheckIntegrityException when the stored object is not the one referenced
|
||||
* @throws MessageValidationException when the reference has expired
|
||||
*/
|
||||
public byte[] resolve(byte[] inline, Optional<ClaimCheckReference> reference, Instant now) {
|
||||
Objects.requireNonNull(inline, "inline must not be null");
|
||||
Objects.requireNonNull(reference, "reference must not be null");
|
||||
Objects.requireNonNull(now, "now must not be null");
|
||||
|
||||
if (reference.isEmpty()) {
|
||||
return inline.clone();
|
||||
}
|
||||
ClaimCheckReference claimCheck = reference.get();
|
||||
|
||||
if (claimCheck.isExpired(now)) {
|
||||
// Checked before fetching. A store that still returns the object past its retention would
|
||||
// otherwise hide a misconfiguration until the day the sweep caught up.
|
||||
throw new MessageValidationException(
|
||||
"CLAIM_CHECK_EXPIRED",
|
||||
"the claim check retention expired at %s; the object may already be reaped"
|
||||
.formatted(claimCheck.expiresAt()));
|
||||
}
|
||||
|
||||
return verify(claimCheck, fetch(claimCheck), now);
|
||||
}
|
||||
|
||||
private byte[] fetch(ClaimCheckReference reference) {
|
||||
byte[] fetched = store.get(reference);
|
||||
if (fetched == null) {
|
||||
throw new MessageValidationException(
|
||||
"CLAIM_CHECK_NOT_FOUND",
|
||||
"no object exists at the referenced key; it was either reaped early or never written");
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
private byte[] verify(ClaimCheckReference reference, byte[] fetched, Instant now) {
|
||||
try {
|
||||
return guard.verify(reference, fetched, now);
|
||||
} catch (MessageValidationException validation) {
|
||||
// A size or digest mismatch is a poison message, not a validation failure to be retried:
|
||||
// fetching the same key again returns the same wrong bytes.
|
||||
if (validation.failure().code().endsWith("_MISMATCH")) {
|
||||
throw new ClaimCheckIntegrityException(
|
||||
validation.failure().code(), validation.failure().sanitizedMessage());
|
||||
}
|
||||
throw validation;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Stores and retrieves payloads that are too large to travel through the broker. */
|
||||
public interface ClaimCheckStore {
|
||||
|
||||
/**
|
||||
* Stores a payload and returns its reference.
|
||||
*
|
||||
* @param payload the bytes to store
|
||||
* @param retention how long the object must remain readable
|
||||
* @return the reference to publish in place of the payload
|
||||
*/
|
||||
ClaimCheckReference put(byte[] payload, Duration retention);
|
||||
|
||||
/**
|
||||
* Fetches a payload, verifying it against its reference.
|
||||
*
|
||||
* @param reference the claim check reference
|
||||
* @return the stored bytes
|
||||
*/
|
||||
byte[] get(ClaimCheckReference reference);
|
||||
|
||||
/**
|
||||
* Deletes a stored payload.
|
||||
*
|
||||
* @param reference the claim check reference
|
||||
*/
|
||||
void delete(ClaimCheckReference reference);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ClaimCheckIntegrityGuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-10T09:15:00Z");
|
||||
private static final byte[] PAYLOAD = "a large document".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ClaimCheckIntegrityGuard guard = new ClaimCheckIntegrityGuard();
|
||||
|
||||
@Test
|
||||
void acceptsAPayloadMatchingItsReference() {
|
||||
ClaimCheckReference reference =
|
||||
reference(PAYLOAD.length, ClaimCheckIntegrityGuard.sha256(PAYLOAD));
|
||||
|
||||
assertThat(guard.verify(reference, PAYLOAD, NOW)).isEqualTo(PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPayloadWhoseDigestDoesNotMatch() {
|
||||
ClaimCheckReference reference =
|
||||
reference(
|
||||
PAYLOAD.length,
|
||||
ClaimCheckIntegrityGuard.sha256(
|
||||
"a different document".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertThatThrownBy(() -> guard.verify(reference, PAYLOAD, NOW))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("digest");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsATruncatedPayloadBeforeHashingIt() {
|
||||
ClaimCheckReference reference =
|
||||
reference(PAYLOAD.length + 10, ClaimCheckIntegrityGuard.sha256(PAYLOAD));
|
||||
|
||||
assertThatThrownBy(() -> guard.verify(reference, PAYLOAD, NOW))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnExpiredReferenceBeforeTheFetchIsTrusted() {
|
||||
ClaimCheckReference reference =
|
||||
new ClaimCheckReference(
|
||||
"payloads/o-1",
|
||||
PAYLOAD.length,
|
||||
ClaimCheckIntegrityGuard.sha256(PAYLOAD),
|
||||
NOW.minusSeconds(1));
|
||||
|
||||
assertThatThrownBy(() -> guard.verify(reference, PAYLOAD, NOW))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("retention");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReferenceRequiresALowercaseHexDigest() {
|
||||
assertThatThrownBy(
|
||||
() -> new ClaimCheckReference("payloads/o-1", 5, "NOT-A-DIGEST", NOW.plusSeconds(60)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theVerifiedPayloadIsACopy() {
|
||||
ClaimCheckReference reference =
|
||||
reference(PAYLOAD.length, ClaimCheckIntegrityGuard.sha256(PAYLOAD));
|
||||
|
||||
byte[] verified = guard.verify(reference, PAYLOAD, NOW);
|
||||
verified[0] = 'z';
|
||||
|
||||
assertThatCode(() -> guard.verify(reference, PAYLOAD, NOW)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static ClaimCheckReference reference(long sizeBytes, String sha256) {
|
||||
return new ClaimCheckReference("payloads/o-1", sizeBytes, sha256, NOW.plusSeconds(3600));
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.reliability.ClaimCheckReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ClaimCheckResolverTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-10T09:15:00Z");
|
||||
private static final byte[] PAYLOAD = "{\"orderId\":\"o-1\"}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
/** An in-memory store that can be made to return the wrong bytes on purpose. */
|
||||
private static final class FakeStore implements ClaimCheckStore {
|
||||
|
||||
private final Map<String, byte[]> objects = new HashMap<>();
|
||||
private int puts;
|
||||
|
||||
@Override
|
||||
public ClaimCheckReference put(byte[] payload, Duration retention) {
|
||||
puts++;
|
||||
String key = "claim/" + puts;
|
||||
objects.put(key, payload.clone());
|
||||
return new ClaimCheckReference(
|
||||
key, payload.length, ClaimCheckIntegrityGuard.sha256(payload), NOW.plus(retention));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(ClaimCheckReference reference) {
|
||||
return objects.get(reference.storageKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(ClaimCheckReference reference) {
|
||||
objects.remove(reference.storageKey());
|
||||
}
|
||||
|
||||
void overwrite(String key, byte[] replacement) {
|
||||
objects.put(key, replacement);
|
||||
}
|
||||
|
||||
int puts() {
|
||||
return puts;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] filled(int size) {
|
||||
byte[] payload = new byte[size];
|
||||
java.util.Arrays.fill(payload, (byte) 'x');
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSmallPayloadTravelsInlineAndIsNeverStored() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckPublisher publisher = new ClaimCheckPublisher(store, ClaimCheckPolicy.defaults());
|
||||
|
||||
ClaimCheckPublisher.Offloaded offloaded = publisher.offload(PAYLOAD);
|
||||
|
||||
assertThat(offloaded.isOffloaded()).isFalse();
|
||||
assertThat(store.puts()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLargePayloadIsStoredAndTheMessageCarriesNoBytes() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckPublisher publisher = new ClaimCheckPublisher(store, ClaimCheckPolicy.defaults());
|
||||
|
||||
ClaimCheckPublisher.Offloaded offloaded =
|
||||
publisher.offload(filled(ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES + 1));
|
||||
|
||||
assertThat(offloaded.isOffloaded()).isTrue();
|
||||
assertThat(offloaded.payload().length)
|
||||
.as("carrying both would double the transfer and let the two disagree")
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOffloadedPayloadRoundTripsThroughTheStore() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckPolicy policy =
|
||||
new ClaimCheckPolicy(8, Duration.ofDays(3), Duration.ofDays(1), Duration.ofDays(1));
|
||||
ClaimCheckPublisher.Offloaded offloaded =
|
||||
new ClaimCheckPublisher(store, policy).offload(PAYLOAD);
|
||||
|
||||
byte[] resolved =
|
||||
new ClaimCheckResolver(store).resolve(offloaded.payload(), offloaded.reference(), NOW);
|
||||
|
||||
assertThat(resolved).isEqualTo(PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInlinePayloadIsReturnedWithoutTouchingTheStore() {
|
||||
FakeStore store = new FakeStore();
|
||||
|
||||
byte[] resolved = new ClaimCheckResolver(store).resolve(PAYLOAD, Optional.empty(), NOW);
|
||||
|
||||
assertThat(resolved).isEqualTo(PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectThatWasSwappedUnderTheReferenceIsAPoisonMessage() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckPolicy policy =
|
||||
new ClaimCheckPolicy(8, Duration.ofDays(3), Duration.ofDays(1), Duration.ofDays(1));
|
||||
ClaimCheckPublisher.Offloaded offloaded =
|
||||
new ClaimCheckPublisher(store, policy).offload(PAYLOAD);
|
||||
store.overwrite(
|
||||
offloaded.reference().orElseThrow().storageKey(),
|
||||
"{\"orderId\":\"SOMEONE-ELSE\"}".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ClaimCheckResolver(store)
|
||||
.resolve(offloaded.payload(), offloaded.reference(), NOW))
|
||||
.as("the digest is the only thing between a swapped object and the wrong data")
|
||||
.isInstanceOf(ClaimCheckIntegrityException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIntegrityFailureIsNotRetryableBecauseTheKeyReturnsTheSameBytes() {
|
||||
ClaimCheckIntegrityException failure =
|
||||
new ClaimCheckIntegrityException("CLAIM_CHECK_DIGEST_MISMATCH", "swapped");
|
||||
|
||||
assertThat(failure.failure().retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void amissingObjectIsReportedSeparatelyFromASwappedOne() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckPolicy policy =
|
||||
new ClaimCheckPolicy(8, Duration.ofDays(3), Duration.ofDays(1), Duration.ofDays(1));
|
||||
ClaimCheckPublisher.Offloaded offloaded =
|
||||
new ClaimCheckPublisher(store, policy).offload(PAYLOAD);
|
||||
store.delete(offloaded.reference().orElseThrow());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ClaimCheckResolver(store)
|
||||
.resolve(offloaded.payload(), offloaded.reference(), NOW))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("never written");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExpiredReferenceIsRefusedBeforeTheStoreIsEvenAsked() {
|
||||
FakeStore store = new FakeStore();
|
||||
ClaimCheckReference expired =
|
||||
new ClaimCheckReference(
|
||||
"claim/1",
|
||||
PAYLOAD.length,
|
||||
ClaimCheckIntegrityGuard.sha256(PAYLOAD),
|
||||
NOW.minus(Duration.ofHours(1)));
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> new ClaimCheckResolver(store).resolve(new byte[0], Optional.of(expired), NOW))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("expired");
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.messaging.claimcheck;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.MessagingConfigurationException;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ClaimCheckRetentionValidatorTest {
|
||||
|
||||
@Test
|
||||
void retentionShorterThanTheMessageLifetimeIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ClaimCheckPolicy(
|
||||
1024, Duration.ofHours(6), Duration.ofDays(1), Duration.ofDays(1)))
|
||||
.as("the object would be reaped while a consumer can still be handed its message")
|
||||
.isInstanceOf(MessagingConfigurationException.class)
|
||||
.hasMessageContaining("reaped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retentionCoveringBrokerRetentionPlusTheRetryPathIsAccepted() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new ClaimCheckPolicy(
|
||||
1024, Duration.ofDays(2), Duration.ofDays(1), Duration.ofDays(1)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRequiredRetentionIsBrokerRetentionPlusTheRedeliveryWindow() {
|
||||
ClaimCheckPolicy policy =
|
||||
new ClaimCheckPolicy(1024, Duration.ofDays(5), Duration.ofDays(2), Duration.ofDays(1));
|
||||
|
||||
assertThat(policy.requiredRetention()).isEqualTo(Duration.ofDays(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDefaultsSatisfyTheirOwnRule() {
|
||||
assertThatCode(ClaimCheckPolicy::defaults).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/** The portable payload limit the platform documents, restated here so the two cannot drift. */
|
||||
private static final int PORTABLE_PAYLOAD_LIMIT_BYTES = 1_048_576;
|
||||
|
||||
@Test
|
||||
void theOffloadThresholdSitsWellBelowThePortablePayloadLimit() {
|
||||
assertThat(ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES)
|
||||
.as(
|
||||
"offloading starts where carrying inline stops being wise, not where the broker refuses")
|
||||
.isLessThan(PORTABLE_PAYLOAD_LIMIT_BYTES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPayloadAtTheThresholdStillTravelsInline() {
|
||||
ClaimCheckPolicy policy = ClaimCheckPolicy.defaults();
|
||||
|
||||
assertThat(policy.shouldOffload(ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES)).isFalse();
|
||||
assertThat(policy.shouldOffload(ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES + 1)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNonPositiveDurationIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> new ClaimCheckPolicy(1024, Duration.ZERO, Duration.ofDays(1), Duration.ofDays(1)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNonPositiveThresholdIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ClaimCheckPolicy(0, Duration.ofDays(3), Duration.ofDays(1), Duration.ofDays(1)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
api project(':messaging:messaging-schema-api')
|
||||
|
||||
implementation 'io.cloudevents:cloudevents-api:4.0.1'
|
||||
implementation 'io.cloudevents:cloudevents-core:4.0.1'
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.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.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.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.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.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.cloudevents:cloudevents-api:4.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.cloudevents:cloudevents-core:4.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=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.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.messaging.cloudevents;
|
||||
|
||||
/**
|
||||
* The CloudEvents extension attribute names this profile writes.
|
||||
*
|
||||
* <p>CloudEvents requires extension names to be lowercase alphanumeric, which is why these are not
|
||||
* simply the envelope field names.
|
||||
*/
|
||||
public final class CloudEventExtensions {
|
||||
|
||||
/** Carries the envelope's correlation id. */
|
||||
public static final String CORRELATION_ID = "correlationid";
|
||||
|
||||
/** Carries the envelope's causation id. */
|
||||
public static final String CAUSATION_ID = "causationid";
|
||||
|
||||
/** Carries the envelope's schema version. */
|
||||
public static final String SCHEMA_VERSION = "schemaversion";
|
||||
|
||||
/** Carries the envelope's tenant identity. */
|
||||
public static final String TENANT_CONTEXT = "tenantcontext";
|
||||
|
||||
private CloudEventExtensions() {}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.messaging.cloudevents;
|
||||
|
||||
import dev.caskeleton.messaging.api.MessageEnvelope;
|
||||
import dev.caskeleton.messaging.schema.EncodedMessage;
|
||||
import io.cloudevents.CloudEvent;
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* Maps between the platform envelope and CloudEvents 1.0.2.
|
||||
*
|
||||
* <p>Offered for domain and integration events only. Commands and work items are not forced through
|
||||
* CloudEvents: they are internal contracts where the interoperability the specification buys does
|
||||
* not pay for the attributes it requires.
|
||||
*/
|
||||
public interface CloudEventMapper {
|
||||
|
||||
/**
|
||||
* Converts an envelope to a CloudEvent.
|
||||
*
|
||||
* @param envelope the envelope carrying an already-encoded payload
|
||||
* @param source the event source URI
|
||||
* @return the CloudEvent
|
||||
*/
|
||||
CloudEvent toCloudEvent(MessageEnvelope<?> envelope, URI source);
|
||||
|
||||
/**
|
||||
* Converts a CloudEvent back to an envelope.
|
||||
*
|
||||
* @param event the CloudEvent
|
||||
* @return an envelope whose payload is the still-encoded data
|
||||
*/
|
||||
MessageEnvelope<EncodedMessage> fromCloudEvent(CloudEvent event);
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package dev.caskeleton.messaging.cloudevents;
|
||||
|
||||
import dev.caskeleton.messaging.api.CausationId;
|
||||
import dev.caskeleton.messaging.api.ContentType;
|
||||
import dev.caskeleton.messaging.api.CorrelationId;
|
||||
import dev.caskeleton.messaging.api.MessageEnvelope;
|
||||
import dev.caskeleton.messaging.api.MessageId;
|
||||
import dev.caskeleton.messaging.api.MessageType;
|
||||
import dev.caskeleton.messaging.api.ProducerId;
|
||||
import dev.caskeleton.messaging.api.SchemaVersion;
|
||||
import dev.caskeleton.messaging.api.TenantContext;
|
||||
import dev.caskeleton.messaging.api.TraceContext;
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.api.header.MessageHeaders;
|
||||
import dev.caskeleton.messaging.schema.EncodedMessage;
|
||||
import io.cloudevents.CloudEvent;
|
||||
import io.cloudevents.CloudEventData;
|
||||
import io.cloudevents.core.builder.CloudEventBuilder;
|
||||
import io.cloudevents.core.data.BytesCloudEventData;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The CloudEvents 1.0.2 compatible profile.
|
||||
*
|
||||
* <p>Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather
|
||||
* than defaulted to the production instant, because {@code time} is read downstream as when the
|
||||
* fact happened, not when the platform got around to serialising it. And an event with no data maps
|
||||
* to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and
|
||||
* inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.
|
||||
*/
|
||||
public final class DefaultCloudEventMapper implements CloudEventMapper {
|
||||
|
||||
private static final String SPEC_CONTENT_TYPE_FALLBACK = "application/json";
|
||||
|
||||
@Override
|
||||
public CloudEvent toCloudEvent(MessageEnvelope<?> envelope, URI source) {
|
||||
Objects.requireNonNull(envelope, "envelope must not be null");
|
||||
Objects.requireNonNull(source, "source must not be null");
|
||||
|
||||
Instant occurredAt =
|
||||
envelope
|
||||
.occurredAt()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new MessageValidationException(
|
||||
"CLOUDEVENT_TIME_REQUIRED",
|
||||
"an event mapped to CloudEvents requires occurredAt"));
|
||||
|
||||
CloudEventBuilder builder =
|
||||
CloudEventBuilder.v1()
|
||||
.withId(envelope.messageId().value().toString())
|
||||
.withSource(source)
|
||||
.withType(envelope.messageType().value())
|
||||
.withTime(OffsetDateTime.ofInstant(occurredAt, ZoneOffset.UTC))
|
||||
.withDataContentType(envelope.contentType().value())
|
||||
.withExtension(
|
||||
CloudEventExtensions.SCHEMA_VERSION,
|
||||
Integer.toString(envelope.schemaVersion().value()));
|
||||
|
||||
envelope
|
||||
.correlationId()
|
||||
.ifPresent(
|
||||
value -> builder.withExtension(CloudEventExtensions.CORRELATION_ID, value.value()));
|
||||
envelope
|
||||
.causationId()
|
||||
.ifPresent(
|
||||
value ->
|
||||
builder.withExtension(
|
||||
CloudEventExtensions.CAUSATION_ID, value.value().value().toString()));
|
||||
envelope
|
||||
.tenantContext()
|
||||
.ifPresent(
|
||||
value -> builder.withExtension(CloudEventExtensions.TENANT_CONTEXT, value.tenantId()));
|
||||
|
||||
if (envelope.payload() instanceof EncodedMessage encoded) {
|
||||
encoded
|
||||
.schemaReference()
|
||||
.flatMap(reference -> reference.schemaUri())
|
||||
.ifPresent(builder::withDataSchema);
|
||||
builder.withData(BytesCloudEventData.wrap(encoded.bytes()));
|
||||
} else if (envelope.payload() instanceof byte[] bytes) {
|
||||
builder.withData(BytesCloudEventData.wrap(bytes.clone()));
|
||||
} else {
|
||||
throw new MessageValidationException(
|
||||
"CLOUDEVENT_PAYLOAD_NOT_ENCODED",
|
||||
"CloudEvents mapping requires an already-encoded payload");
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageEnvelope<EncodedMessage> fromCloudEvent(CloudEvent event) {
|
||||
Objects.requireNonNull(event, "event must not be null");
|
||||
|
||||
OffsetDateTime time = event.getTime();
|
||||
if (time == null) {
|
||||
throw new MessageValidationException(
|
||||
"CLOUDEVENT_TIME_REQUIRED", "a CloudEvent mapped to an envelope requires time");
|
||||
}
|
||||
|
||||
ContentType contentType =
|
||||
new ContentType(
|
||||
Optional.ofNullable(event.getDataContentType()).orElse(SPEC_CONTENT_TYPE_FALLBACK));
|
||||
CloudEventData data = event.getData();
|
||||
byte[] bytes = data == null ? new byte[0] : data.toBytes();
|
||||
|
||||
Instant occurredAt = time.toInstant();
|
||||
return new MessageEnvelope<>(
|
||||
new MessageId(UUID.fromString(event.getId())),
|
||||
new MessageType(event.getType()),
|
||||
new SchemaVersion(intExtension(event, CloudEventExtensions.SCHEMA_VERSION)),
|
||||
occurredAt,
|
||||
Optional.of(occurredAt),
|
||||
new ProducerId(producerFrom(event.getSource())),
|
||||
stringExtension(event, CloudEventExtensions.CORRELATION_ID).map(CorrelationId::new),
|
||||
stringExtension(event, CloudEventExtensions.CAUSATION_ID)
|
||||
.map(value -> new CausationId(new MessageId(UUID.fromString(value)))),
|
||||
contentType,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
stringExtension(event, CloudEventExtensions.TENANT_CONTEXT).map(TenantContext::new),
|
||||
TraceContext.none(),
|
||||
MessageHeaders.empty(),
|
||||
new EncodedMessage(bytes, contentType, Optional.empty()));
|
||||
}
|
||||
|
||||
private static Optional<String> stringExtension(CloudEvent event, String name) {
|
||||
return Optional.ofNullable(event.getExtension(name)).map(Object::toString);
|
||||
}
|
||||
|
||||
private static int intExtension(CloudEvent event, String name) {
|
||||
return stringExtension(event, name)
|
||||
.map(
|
||||
value -> {
|
||||
try {
|
||||
return Integer.valueOf(value);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new MessageValidationException(
|
||||
"CLOUDEVENT_SCHEMA_VERSION_INVALID",
|
||||
"schemaversion extension is not an integer",
|
||||
exception);
|
||||
}
|
||||
})
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new MessageValidationException(
|
||||
"CLOUDEVENT_SCHEMA_VERSION_REQUIRED",
|
||||
"schemaversion extension is required by this profile"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a bounded producer id from the source URI.
|
||||
*
|
||||
* <p>The last path or scheme-specific segment is used so that a long URI does not become an
|
||||
* unbounded producer name, which would leak straight into metric tags.
|
||||
*/
|
||||
private static String producerFrom(URI source) {
|
||||
String text = source.toString();
|
||||
int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));
|
||||
String candidate =
|
||||
separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;
|
||||
return candidate.isBlank() ? "unknown" : candidate;
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package dev.caskeleton.messaging.cloudevents;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.messaging.api.ContentType;
|
||||
import dev.caskeleton.messaging.api.CorrelationId;
|
||||
import dev.caskeleton.messaging.api.MessageEnvelope;
|
||||
import dev.caskeleton.messaging.api.MessageId;
|
||||
import dev.caskeleton.messaging.api.MessageType;
|
||||
import dev.caskeleton.messaging.api.ProducerId;
|
||||
import dev.caskeleton.messaging.api.SchemaVersion;
|
||||
import dev.caskeleton.messaging.api.TenantContext;
|
||||
import dev.caskeleton.messaging.api.TraceContext;
|
||||
import dev.caskeleton.messaging.api.error.MessageValidationException;
|
||||
import dev.caskeleton.messaging.api.header.MessageHeaders;
|
||||
import dev.caskeleton.messaging.schema.EncodedMessage;
|
||||
import io.cloudevents.CloudEvent;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CloudEventMappingTest {
|
||||
|
||||
private static final URI SOURCE = URI.create("urn:service:order-api");
|
||||
|
||||
private final DefaultCloudEventMapper mapper = new DefaultCloudEventMapper();
|
||||
|
||||
@Test
|
||||
void mapsLogicalIdentityAndExtensions() {
|
||||
MessageEnvelope<EncodedMessage> envelope = CloudEventFixture.orderCreatedEnvelope();
|
||||
|
||||
CloudEvent event = mapper.toCloudEvent(envelope, SOURCE);
|
||||
|
||||
assertThat(event.getId()).isEqualTo(envelope.messageId().value().toString());
|
||||
assertThat(event.getType()).isEqualTo("order.created");
|
||||
assertThat(event.getExtension(CloudEventExtensions.SCHEMA_VERSION)).isEqualTo("1");
|
||||
assertThat(event.getSource()).isEqualTo(SOURCE);
|
||||
assertThat(event.getDataContentType()).isEqualTo("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsCorrelationAndTenantAsExtensions() {
|
||||
CloudEvent event = mapper.toCloudEvent(CloudEventFixture.orderCreatedEnvelope(), SOURCE);
|
||||
|
||||
assertThat(event.getExtension(CloudEventExtensions.CORRELATION_ID)).isEqualTo("wf-1");
|
||||
assertThat(event.getExtension(CloudEventExtensions.TENANT_CONTEXT)).isEqualTo("acme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsOccurredAtToEventTime() {
|
||||
CloudEvent event = mapper.toCloudEvent(CloudEventFixture.orderCreatedEnvelope(), SOURCE);
|
||||
|
||||
assertThat(event.getTime()).isNotNull();
|
||||
assertThat(event.getTime().toInstant()).isEqualTo(Instant.parse("2026-08-10T09:15:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEventEnvelopeWithoutOccurredAt() {
|
||||
MessageEnvelope<EncodedMessage> withoutOccurredAt =
|
||||
CloudEventFixture.envelope(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> mapper.toCloudEvent(withoutOccurredAt, SOURCE))
|
||||
.isInstanceOf(MessageValidationException.class)
|
||||
.hasMessageContaining("occurredAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsBackToAnEnvelopeWithoutInventingATombstone() {
|
||||
MessageEnvelope<EncodedMessage> original = CloudEventFixture.orderCreatedEnvelope();
|
||||
|
||||
MessageEnvelope<EncodedMessage> restored =
|
||||
mapper.fromCloudEvent(mapper.toCloudEvent(original, SOURCE));
|
||||
|
||||
assertThat(restored.messageId()).isEqualTo(original.messageId());
|
||||
assertThat(restored.messageType()).isEqualTo(original.messageType());
|
||||
assertThat(restored.schemaVersion()).isEqualTo(original.schemaVersion());
|
||||
assertThat(restored.correlationId()).contains(new CorrelationId("wf-1"));
|
||||
assertThat(restored.tenantContext()).contains(new TenantContext("acme"));
|
||||
assertThat(restored.payload().bytes()).isEqualTo(original.payload().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue() {
|
||||
CloudEvent noData =
|
||||
io.cloudevents.core.builder.CloudEventBuilder.v1()
|
||||
.withId(UUID.randomUUID().toString())
|
||||
.withSource(SOURCE)
|
||||
.withType("order.created")
|
||||
.withTime(java.time.OffsetDateTime.parse("2026-08-10T09:15:00Z"))
|
||||
.withDataContentType("application/json")
|
||||
.withExtension(CloudEventExtensions.SCHEMA_VERSION, "1")
|
||||
.build();
|
||||
|
||||
MessageEnvelope<EncodedMessage> restored = mapper.fromCloudEvent(noData);
|
||||
|
||||
assertThat(restored.payload()).isNotNull();
|
||||
assertThat(restored.payload().size()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnencodedPayload() {
|
||||
MessageEnvelope<String> unencoded =
|
||||
new MessageEnvelope<>(
|
||||
MessageId.newId(),
|
||||
new MessageType("order.created"),
|
||||
new SchemaVersion(1),
|
||||
Instant.parse("2026-08-10T09:15:00Z"),
|
||||
Optional.of(Instant.parse("2026-08-10T09:15:00Z")),
|
||||
new ProducerId("order-api"),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
ContentType.JSON,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
TraceContext.none(),
|
||||
MessageHeaders.empty(),
|
||||
"not encoded");
|
||||
|
||||
assertThatThrownBy(() -> mapper.toCloudEvent(unencoded, SOURCE))
|
||||
.isInstanceOf(MessageValidationException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds CloudEvents mapping fixtures. */
|
||||
final class CloudEventFixture {
|
||||
|
||||
private static final MessageId FIXED_ID =
|
||||
new MessageId(UUID.fromString("0190f4aa-0000-7000-8000-000000000001"));
|
||||
|
||||
private CloudEventFixture() {}
|
||||
|
||||
static MessageEnvelope<EncodedMessage> orderCreatedEnvelope() {
|
||||
return envelope(Optional.of(Instant.parse("2026-08-10T09:15:00Z")));
|
||||
}
|
||||
|
||||
static MessageEnvelope<EncodedMessage> envelope(Optional<Instant> occurredAt) {
|
||||
byte[] payload = "{\"orderId\":\"o-1\"}".getBytes(StandardCharsets.UTF_8);
|
||||
return new MessageEnvelope<>(
|
||||
FIXED_ID,
|
||||
new MessageType("order.created"),
|
||||
new SchemaVersion(1),
|
||||
Instant.parse("2026-08-10T09:15:01Z"),
|
||||
occurredAt,
|
||||
new ProducerId("order-api"),
|
||||
Optional.of(new CorrelationId("wf-1")),
|
||||
Optional.empty(),
|
||||
ContentType.JSON,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(new TenantContext("acme")),
|
||||
TraceContext.none(),
|
||||
MessageHeaders.empty(),
|
||||
new EncodedMessage(payload, ContentType.JSON, Optional.empty()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.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.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.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.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.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
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=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.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=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.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Identity of the message that directly caused this one.
|
||||
*
|
||||
* <p>Unlike {@link CorrelationId}, which spans a whole workflow, this points at exactly one
|
||||
* predecessor and forms the causal edge used when reconstructing a flow.
|
||||
*
|
||||
* @param value the predecessor message identity
|
||||
*/
|
||||
public record CausationId(MessageId value) {
|
||||
|
||||
public CausationId {
|
||||
Objects.requireNonNull(value, "causationId value must not be null");
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Media type of an encoded payload, resolved through the codec registry.
|
||||
*
|
||||
* @param value the media type, for example {@code application/json}
|
||||
*/
|
||||
public record ContentType(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 160;
|
||||
|
||||
/** The Stable default codec's content type. */
|
||||
public static final ContentType JSON = new ContentType("application/json");
|
||||
|
||||
/** Avro binary content type. */
|
||||
public static final ContentType AVRO = new ContentType("application/avro");
|
||||
|
||||
/** Protobuf binary content type. */
|
||||
public static final ContentType PROTOBUF = new ContentType("application/x-protobuf");
|
||||
|
||||
/** Opaque bytes, only reachable through the M2 raw codec. */
|
||||
public static final ContentType OCTET_STREAM = new ContentType("application/octet-stream");
|
||||
|
||||
public ContentType {
|
||||
if (value == null || value.isBlank() || value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("contentType must contain 1 to 160 characters");
|
||||
}
|
||||
if (value.indexOf('/') < 0) {
|
||||
throw new IllegalArgumentException("contentType must be a media type: " + value);
|
||||
}
|
||||
value = value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
/**
|
||||
* Workflow-scoped correlation value shared by every message of one business flow.
|
||||
*
|
||||
* @param value the correlation value, 1 to 160 characters
|
||||
*/
|
||||
public record CorrelationId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 160;
|
||||
|
||||
public CorrelationId {
|
||||
if (value == null || value.isBlank() || value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("correlationId must contain 1 to 160 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import dev.caskeleton.messaging.api.header.MessageHeaders;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The platform's unit of transfer: identity, provenance, routing intent, and payload.
|
||||
*
|
||||
* <p>The payload is never null. A null Kafka value is a tombstone, which is a distinct
|
||||
* broker-native operation with different retention semantics, so generalising it into "an envelope
|
||||
* with no payload" would silently turn a delete into an event on brokers that have no such concept.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
* @param messageId logical identity, preserved across retry, DLQ, and redrive
|
||||
* @param messageType stable catalog type
|
||||
* @param schemaVersion payload schema revision
|
||||
* @param producedAt instant the platform created this envelope
|
||||
* @param occurredAt instant the business fact occurred; required for events
|
||||
* @param producer logical producing service
|
||||
* @param correlationId workflow correlation
|
||||
* @param causationId directly causing message
|
||||
* @param contentType codec media type
|
||||
* @param partitionKey distribution key
|
||||
* @param orderingKey ordering key
|
||||
* @param tenantContext bounded tenant identity
|
||||
* @param traceContext trace propagation values
|
||||
* @param headers bounded application headers
|
||||
* @param payload the non-null payload
|
||||
*/
|
||||
public record MessageEnvelope<T>(
|
||||
MessageId messageId,
|
||||
MessageType messageType,
|
||||
SchemaVersion schemaVersion,
|
||||
Instant producedAt,
|
||||
Optional<Instant> occurredAt,
|
||||
ProducerId producer,
|
||||
Optional<CorrelationId> correlationId,
|
||||
Optional<CausationId> causationId,
|
||||
ContentType contentType,
|
||||
Optional<String> partitionKey,
|
||||
Optional<String> orderingKey,
|
||||
Optional<TenantContext> tenantContext,
|
||||
TraceContext traceContext,
|
||||
MessageHeaders headers,
|
||||
T payload) {
|
||||
|
||||
public MessageEnvelope {
|
||||
Objects.requireNonNull(messageId, "messageId must not be null");
|
||||
Objects.requireNonNull(messageType, "messageType must not be null");
|
||||
Objects.requireNonNull(schemaVersion, "schemaVersion must not be null");
|
||||
Objects.requireNonNull(producedAt, "producedAt must not be null");
|
||||
Objects.requireNonNull(occurredAt, "occurredAt must not be null");
|
||||
Objects.requireNonNull(producer, "producer must not be null");
|
||||
Objects.requireNonNull(correlationId, "correlationId must not be null");
|
||||
Objects.requireNonNull(causationId, "causationId must not be null");
|
||||
Objects.requireNonNull(contentType, "contentType must not be null");
|
||||
Objects.requireNonNull(partitionKey, "partitionKey must not be null");
|
||||
Objects.requireNonNull(orderingKey, "orderingKey must not be null");
|
||||
Objects.requireNonNull(tenantContext, "tenantContext must not be null");
|
||||
Objects.requireNonNull(traceContext, "traceContext must not be null");
|
||||
Objects.requireNonNull(headers, "headers must not be null");
|
||||
Objects.requireNonNull(payload, "payload must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of this envelope carrying a different payload representation.
|
||||
*
|
||||
* <p>Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one
|
||||
* of them must keep {@link #messageId()} intact — which is exactly what this method guarantees by
|
||||
* construction.
|
||||
*
|
||||
* @param <R> the replacement payload type
|
||||
* @param replacement the new payload
|
||||
* @return an envelope with identical identity and metadata
|
||||
*/
|
||||
public <R> MessageEnvelope<R> withPayload(R replacement) {
|
||||
return new MessageEnvelope<>(
|
||||
messageId,
|
||||
messageType,
|
||||
schemaVersion,
|
||||
producedAt,
|
||||
occurredAt,
|
||||
producer,
|
||||
correlationId,
|
||||
causationId,
|
||||
contentType,
|
||||
partitionKey,
|
||||
orderingKey,
|
||||
tenantContext,
|
||||
traceContext,
|
||||
headers,
|
||||
replacement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of this envelope carrying a different content type.
|
||||
*
|
||||
* @param replacement the new content type
|
||||
* @return an envelope with identical identity and payload
|
||||
*/
|
||||
public MessageEnvelope<T> withContentType(ContentType replacement) {
|
||||
return new MessageEnvelope<>(
|
||||
messageId,
|
||||
messageType,
|
||||
schemaVersion,
|
||||
producedAt,
|
||||
occurredAt,
|
||||
producer,
|
||||
correlationId,
|
||||
causationId,
|
||||
Objects.requireNonNull(replacement, "contentType must not be null"),
|
||||
partitionKey,
|
||||
orderingKey,
|
||||
tenantContext,
|
||||
traceContext,
|
||||
headers,
|
||||
payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of this envelope carrying replacement headers.
|
||||
*
|
||||
* @param replacement the new headers
|
||||
* @return an envelope with identical identity and payload
|
||||
*/
|
||||
public MessageEnvelope<T> withHeaders(MessageHeaders replacement) {
|
||||
return new MessageEnvelope<>(
|
||||
messageId,
|
||||
messageType,
|
||||
schemaVersion,
|
||||
producedAt,
|
||||
occurredAt,
|
||||
producer,
|
||||
correlationId,
|
||||
causationId,
|
||||
contentType,
|
||||
partitionKey,
|
||||
orderingKey,
|
||||
tenantContext,
|
||||
traceContext,
|
||||
Objects.requireNonNull(replacement, "headers must not be null"),
|
||||
payload);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Logical identity of a message.
|
||||
*
|
||||
* <p>This value survives publish retry, broker redelivery, retry destinations, dead lettering, and
|
||||
* redrive. A new {@code MessageId} is minted only for a genuinely new business fact or command, so
|
||||
* an Inbox can use it to suppress duplicate side effects.
|
||||
*
|
||||
* @param value the UUIDv7 identity
|
||||
*/
|
||||
public record MessageId(UUID value) {
|
||||
|
||||
public MessageId {
|
||||
Objects.requireNonNull(value, "messageId value must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a new logical message identity.
|
||||
*
|
||||
* @return a fresh time-ordered identity
|
||||
*/
|
||||
public static MessageId newId() {
|
||||
return new MessageId(UuidV7.next());
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
/**
|
||||
* Stable catalog name of a message, such as {@code order.created}.
|
||||
*
|
||||
* <p>Java class names are deliberately not usable as a message type: renaming or repackaging a
|
||||
* class must never change the wire contract.
|
||||
*
|
||||
* @param value the catalog name, 1 to 240 characters
|
||||
*/
|
||||
public record MessageType(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 240;
|
||||
|
||||
public MessageType {
|
||||
if (value == null || value.isBlank() || value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("messageType must contain 1 to 240 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
/**
|
||||
* Logical service identity of the component that produced a message.
|
||||
*
|
||||
* <p>This is a deployment-independent service name, not a host, pod, or connection identity, so
|
||||
* that it stays a bounded value safe for metric tags.
|
||||
*
|
||||
* @param value the service name, 1 to 120 characters
|
||||
*/
|
||||
public record ProducerId(String value) {
|
||||
|
||||
private static final int MAX_LENGTH = 120;
|
||||
|
||||
public ProducerId {
|
||||
if (value == null || value.isBlank() || value.length() > MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("producerId must contain 1 to 120 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
/**
|
||||
* Monotonic schema revision of a {@link MessageType}.
|
||||
*
|
||||
* @param value the revision, starting at 1
|
||||
*/
|
||||
public record SchemaVersion(int value) {
|
||||
|
||||
public SchemaVersion {
|
||||
if (value < 1) {
|
||||
throw new IllegalArgumentException("schemaVersion must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Bounded tenant identity carried with a message.
|
||||
*
|
||||
* <p>The value is deliberately constrained to a short slug. Tenant identity is one of the few
|
||||
* envelope fields that observability code is tempted to use as a metric label, and an unbounded
|
||||
* tenant id turns that into a cardinality explosion.
|
||||
*
|
||||
* @param tenantId the tenant slug
|
||||
*/
|
||||
public record TenantContext(String tenantId) {
|
||||
|
||||
private static final Pattern VALID = Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}");
|
||||
|
||||
public TenantContext {
|
||||
if (tenantId == null || !VALID.matcher(tenantId).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"tenantId must match [a-z0-9][a-z0-9._-]{0,63}: " + tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* W3C trace propagation values carried with a message.
|
||||
*
|
||||
* <p>The platform creates and propagates these; handlers do not set them. Keeping them on the
|
||||
* envelope rather than only in headers means a trace survives an Outbox round trip through the
|
||||
* database, where broker headers do not exist yet.
|
||||
*
|
||||
* @param traceparent the {@code traceparent} value when a trace is active
|
||||
* @param tracestate the {@code tracestate} value when present
|
||||
* @param baggage the {@code baggage} value when present
|
||||
*/
|
||||
public record TraceContext(
|
||||
Optional<String> traceparent, Optional<String> tracestate, Optional<String> baggage) {
|
||||
|
||||
public TraceContext {
|
||||
Objects.requireNonNull(traceparent, "traceparent must not be null");
|
||||
Objects.requireNonNull(tracestate, "tracestate must not be null");
|
||||
Objects.requireNonNull(baggage, "baggage must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a context with no active trace.
|
||||
*
|
||||
* @return an empty trace context
|
||||
*/
|
||||
public static TraceContext none() {
|
||||
return new TraceContext(Optional.empty(), Optional.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a context carrying only a trace parent.
|
||||
*
|
||||
* @param traceparent the {@code traceparent} value
|
||||
* @return a trace context
|
||||
*/
|
||||
public static TraceContext of(String traceparent) {
|
||||
return new TraceContext(Optional.of(traceparent), Optional.empty(), Optional.empty());
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.messaging.api;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Generates RFC 9562 UUIDv7 values.
|
||||
*
|
||||
* <p>The platform needs message identifiers that sort by creation time so that outbox scans, DLQ
|
||||
* listings, and broker partitions stay locality-friendly, while still being globally unique. The 12
|
||||
* bit {@code rand_a} field is used as a monotonic intra-millisecond counter instead of random bits:
|
||||
* two calls in the same millisecond are then still strictly ordered, which is what makes "same
|
||||
* logical message keeps the same id" auditable across a retry.
|
||||
*/
|
||||
public final class UuidV7 {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/** Packs the 48-bit millisecond timestamp in the high bits and the 12-bit counter in the low. */
|
||||
private static final AtomicLong STATE = new AtomicLong();
|
||||
|
||||
private static final long COUNTER_BITS = 12L;
|
||||
private static final long COUNTER_MASK = 0xFFFL;
|
||||
private static final long VERSION_7 = 0x7L;
|
||||
private static final long VARIANT_RFC9562 = 0x8000_0000_0000_0000L;
|
||||
private static final long RANDOM_B_MASK = 0x3FFF_FFFF_FFFF_FFFFL;
|
||||
|
||||
private UuidV7() {}
|
||||
|
||||
/**
|
||||
* Returns the next monotonically increasing UUIDv7.
|
||||
*
|
||||
* @return a version 7, variant 2 UUID
|
||||
*/
|
||||
public static UUID next() {
|
||||
long state = STATE.updateAndGet(UuidV7::advance);
|
||||
long timestamp = state >>> COUNTER_BITS;
|
||||
long counter = state & COUNTER_MASK;
|
||||
|
||||
long mostSignificantBits = (timestamp << 16) | (VERSION_7 << COUNTER_BITS) | counter;
|
||||
long leastSignificantBits = (RANDOM.nextLong() & RANDOM_B_MASK) | VARIANT_RFC9562;
|
||||
return new UUID(mostSignificantBits, leastSignificantBits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the packed state.
|
||||
*
|
||||
* <p>When the clock moved forward the counter restarts at zero. Otherwise the packed value is
|
||||
* simply incremented: that bumps the counter and, once the 12-bit counter overflows, carries into
|
||||
* the timestamp field. A backwards clock step therefore never produces a duplicate or a
|
||||
* descending id, it only borrows from the future.
|
||||
*/
|
||||
private static long advance(long previous) {
|
||||
long now = System.currentTimeMillis();
|
||||
long previousTimestamp = previous >>> COUNTER_BITS;
|
||||
if (now > previousTimestamp) {
|
||||
return now << COUNTER_BITS;
|
||||
}
|
||||
return previous + 1;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Batch-wide facts about one delivered batch.
|
||||
*
|
||||
* <p>{@code orderingUnit} is what makes a batch safe to hand to an ordered destination. A batch
|
||||
* drawn from two partitions cannot be settled or retried as a unit without reordering one of them,
|
||||
* so an ordered destination requires the batch to name exactly one ordering unit and the runtime
|
||||
* rejects a batch that spans more.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param size the number of deliveries in the batch
|
||||
* @param orderingUnit the single partition or queue the batch was drawn from, when it has one
|
||||
* @param settlableAsBatch whether the broker can settle the whole batch in one operation
|
||||
* @param receivedAt when the consumer assembled the batch
|
||||
*/
|
||||
public record BatchDeliveryMetadata(
|
||||
DestinationName destination,
|
||||
int size,
|
||||
Optional<String> orderingUnit,
|
||||
boolean settlableAsBatch,
|
||||
Instant receivedAt) {
|
||||
|
||||
public BatchDeliveryMetadata {
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(orderingUnit, "orderingUnit must not be null");
|
||||
Objects.requireNonNull(receivedAt, "receivedAt must not be null");
|
||||
if (size < 1) {
|
||||
throw new IllegalArgumentException("a delivered batch has at least one delivery");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this batch may be handed to a destination with strict ordering.
|
||||
*
|
||||
* @return true when the batch came from exactly one ordering unit
|
||||
*/
|
||||
public boolean isSafeForOrderedDestination() {
|
||||
return orderingUnit.isPresent();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A batch of decoded messages handed to a batch handler.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
* @param deliveries the individual deliveries, in broker order
|
||||
* @param metadata batch-wide facts
|
||||
*/
|
||||
public record BatchMessageDelivery<T>(
|
||||
List<MessageDelivery<T>> deliveries, BatchDeliveryMetadata metadata) {
|
||||
|
||||
public BatchMessageDelivery {
|
||||
Objects.requireNonNull(deliveries, "deliveries must not be null");
|
||||
Objects.requireNonNull(metadata, "metadata must not be null");
|
||||
deliveries = List.copyOf(deliveries);
|
||||
if (deliveries.isEmpty()) {
|
||||
throw new IllegalArgumentException("a delivered batch is never empty");
|
||||
}
|
||||
if (deliveries.size() != metadata.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"metadata size %d does not match %d deliveries"
|
||||
.formatted(metadata.size(), deliveries.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* The M2 batch consume entry point.
|
||||
*
|
||||
* <p>The handler returns one {@link HandleResult} for the whole batch. On a broker that settles
|
||||
* batches atomically the runtime applies that result once; on a broker that settles per message it
|
||||
* applies the same result to each delivery. Either way the handler is not asked to reason about
|
||||
* which settlement mode it is running under.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
*/
|
||||
public interface BatchMessageHandler<T> {
|
||||
|
||||
/**
|
||||
* Handles one delivered batch.
|
||||
*
|
||||
* @param batch the batch to handle
|
||||
* @return a stage completing with the outcome for the whole batch
|
||||
*/
|
||||
CompletionStage<HandleResult> handle(BatchMessageDelivery<T> batch);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Runtime context handed to a handler alongside a delivery.
|
||||
*
|
||||
* <p>{@code shutdownRequested} is visible to handlers on purpose: during a graceful drain the
|
||||
* platform stops creating new retry attempts, and a long-running handler that can wind down early
|
||||
* shortens the drain instead of being cancelled at the deadline.
|
||||
*
|
||||
* @param handlerDeadline the instant after which the handler is considered timed out
|
||||
* @param shutdownRequested whether the consumer has begun draining
|
||||
* @param consumerId the stable, low-cardinality consumer identity
|
||||
*/
|
||||
public record DeliveryContext(
|
||||
Instant handlerDeadline, boolean shutdownRequested, String consumerId) {
|
||||
|
||||
public DeliveryContext {
|
||||
Objects.requireNonNull(handlerDeadline, "handlerDeadline must not be null");
|
||||
if (consumerId == null || consumerId.isBlank()) {
|
||||
throw new IllegalArgumentException("consumerId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the handler deadline has passed.
|
||||
*
|
||||
* @param now the current instant
|
||||
* @return true when the deadline has elapsed
|
||||
*/
|
||||
public boolean isExpired(Instant now) {
|
||||
return !now.isBefore(handlerDeadline);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
/**
|
||||
* Broker-level delivery guarantee offered by the common contract.
|
||||
*
|
||||
* <p>There is deliberately no {@code EXACTLY_ONCE} constant. No broker delivers exactly-once across
|
||||
* an external side effect; what real systems provide is at-least-once delivery combined with an
|
||||
* idempotent or transactional consumer. Naming a guarantee the platform cannot honour would push
|
||||
* that responsibility out of sight, so the enum stops where the evidence stops.
|
||||
*/
|
||||
public enum DeliveryGuarantee {
|
||||
|
||||
/** Delivery may be lost; duplicate suppression is preferred over durability. */
|
||||
AT_MOST_ONCE,
|
||||
|
||||
/** Redelivery is possible; durability is preferred over duplicate suppression. */
|
||||
AT_LEAST_ONCE
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import dev.caskeleton.messaging.api.publish.BrokerPosition;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Transport-side facts about one delivery attempt.
|
||||
*
|
||||
* <p>The first delivery is attempt one, not zero. Off-by-one confusion here directly changes how
|
||||
* many times a poison message is replayed before it is parked, so the counting rule is fixed at the
|
||||
* contract rather than left to each adapter.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param deliveryAttempt the attempt number, starting at one
|
||||
* @param redelivered whether the broker flagged this as a redelivery
|
||||
* @param brokerPosition the broker coordinate when available
|
||||
* @param partitionOrQueue the ordering unit when available
|
||||
* @param consumerGroup the consumer group when applicable
|
||||
* @param receivedAt when the consumer received the delivery
|
||||
*/
|
||||
public record DeliveryMetadata(
|
||||
DestinationName destination,
|
||||
int deliveryAttempt,
|
||||
boolean redelivered,
|
||||
Optional<BrokerPosition> brokerPosition,
|
||||
Optional<String> partitionOrQueue,
|
||||
Optional<String> consumerGroup,
|
||||
Instant receivedAt) {
|
||||
|
||||
public DeliveryMetadata {
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(brokerPosition, "brokerPosition must not be null");
|
||||
Objects.requireNonNull(partitionOrQueue, "partitionOrQueue must not be null");
|
||||
Objects.requireNonNull(consumerGroup, "consumerGroup must not be null");
|
||||
Objects.requireNonNull(receivedAt, "receivedAt must not be null");
|
||||
if (deliveryAttempt < 1) {
|
||||
throw new IllegalArgumentException("deliveryAttempt counts the first delivery as 1");
|
||||
}
|
||||
if (deliveryAttempt == 1 && redelivered) {
|
||||
throw new IllegalArgumentException("the first delivery attempt cannot be a redelivery");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
/** How a handler's external side effect is protected against redelivery. */
|
||||
public enum ExternalSideEffectGuarantee {
|
||||
|
||||
/** Nothing protects the side effect; only valid where replay is harmless. */
|
||||
NONE,
|
||||
|
||||
/** The handler must make the side effect idempotent itself. */
|
||||
IDEMPOTENCY_REQUIRED,
|
||||
|
||||
/** An Inbox row and the side effect commit inside the same database transaction. */
|
||||
INBOX_TRANSACTIONAL
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import dev.caskeleton.messaging.api.error.FailureDescriptor;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What an M1 handler decided about a delivery.
|
||||
*
|
||||
* <p>The handler states an intent; the platform performs the settlement. That split is what keeps
|
||||
* "acknowledge only after the handler succeeded" a platform invariant rather than something each
|
||||
* handler has to remember, and it is why no variant here carries a broker acknowledgement handle.
|
||||
*/
|
||||
public sealed interface HandleResult
|
||||
permits HandleResult.Success, HandleResult.Retry, HandleResult.DeadLetter, HandleResult.Reject {
|
||||
|
||||
/** Processing succeeded; the platform may settle the source. */
|
||||
record Success() implements HandleResult {}
|
||||
|
||||
/**
|
||||
* Processing failed in a way that may succeed later.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
record Retry(FailureDescriptor failure) implements HandleResult {
|
||||
public Retry {
|
||||
Objects.requireNonNull(failure, "failure must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing failed permanently; route to the dead letter destination.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
record DeadLetter(FailureDescriptor failure) implements HandleResult {
|
||||
public DeadLetter {
|
||||
Objects.requireNonNull(failure, "failure must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the message without dead lettering.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
record Reject(FailureDescriptor failure) implements HandleResult {
|
||||
public Reject {
|
||||
Objects.requireNonNull(failure, "failure must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a successful result.
|
||||
*
|
||||
* @return the success variant
|
||||
*/
|
||||
static HandleResult success() {
|
||||
return new Success();
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import dev.caskeleton.messaging.api.MessageEnvelope;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One decoded message handed to a handler.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
* @param message the decoded envelope
|
||||
* @param metadata transport-side delivery facts
|
||||
* @param context runtime context for this attempt
|
||||
*/
|
||||
public record MessageDelivery<T>(
|
||||
MessageEnvelope<T> message, DeliveryMetadata metadata, DeliveryContext context) {
|
||||
|
||||
public MessageDelivery {
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
Objects.requireNonNull(metadata, "metadata must not be null");
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* The M1 typed handler implemented by ordinary business code.
|
||||
*
|
||||
* <p>Handlers state an outcome and never touch broker acknowledgement APIs. Duplicate delivery is a
|
||||
* normal condition, not an error: implementations are expected to be idempotent, or to sit behind
|
||||
* the Inbox.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
*/
|
||||
public interface MessageHandler<T> {
|
||||
|
||||
/**
|
||||
* Handles one delivery.
|
||||
*
|
||||
* @param delivery the decoded message and its metadata
|
||||
* @return a stage completing with the handling outcome
|
||||
*/
|
||||
CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
/**
|
||||
* The real scope inside which message order is preserved.
|
||||
*
|
||||
* <p>There is deliberately no {@code GLOBAL} constant. Ordering is a property of a partition, a key
|
||||
* mapping, or a single consumer — never of a whole destination — and advertising a global scope
|
||||
* would promise something no partitioned broker can keep.
|
||||
*/
|
||||
public enum OrderingScope {
|
||||
|
||||
/** No order is promised. */
|
||||
NONE,
|
||||
|
||||
/** Order holds across the destination, which requires a single ordering unit. */
|
||||
DESTINATION,
|
||||
|
||||
/** Order holds inside one broker partition. */
|
||||
PARTITION,
|
||||
|
||||
/** Order holds for one key while its mapping to an ordering unit is stable. */
|
||||
KEY
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
import dev.caskeleton.messaging.api.destination.DestinationName;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* The M2 consumer flow-control entry point.
|
||||
*
|
||||
* <p>Pausing stops new deliveries; it does not abandon the ones already in flight. A paused
|
||||
* consumer stays a member of its group and keeps its assignment, which is the point: leaving the
|
||||
* group to stop consuming would trigger a rebalance and hand the work to another instance that is
|
||||
* just as overloaded.
|
||||
*/
|
||||
public interface PauseResumeController {
|
||||
|
||||
/**
|
||||
* Stops new deliveries for a destination scope.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param scope the partition, queue, or {@code "*"} for every assigned unit
|
||||
* @return a stage completing once no further deliveries will be dispatched
|
||||
*/
|
||||
CompletionStage<Void> pause(DestinationName destination, String scope);
|
||||
|
||||
/**
|
||||
* Resumes deliveries for a destination scope.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param scope the partition, queue, or {@code "*"} for every assigned unit
|
||||
* @return a stage completing once deliveries may flow again
|
||||
*/
|
||||
CompletionStage<Void> resume(DestinationName destination, String scope);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.messaging.api.delivery;
|
||||
|
||||
/** How duplicate processing is neutralised once a message has been delivered. */
|
||||
public enum ProcessingGuarantee {
|
||||
|
||||
/** The handler suppresses duplicate effects using the message id or a business key. */
|
||||
APPLICATION_IDEMPOTENT,
|
||||
|
||||
/** Atomicity holds only inside the transaction scope the broker itself defines. */
|
||||
BROKER_TRANSACTIONAL
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
/** Resolves the capability snapshot for a logical destination. */
|
||||
public interface CapabilityRegistry {
|
||||
|
||||
/**
|
||||
* Returns the capabilities of a destination.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @return the capability snapshot
|
||||
* @throws dev.caskeleton.messaging.api.error.MessagingConfigurationException when the destination
|
||||
* is not registered
|
||||
*/
|
||||
DestinationCapabilities capabilities(DestinationName destination);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
/**
|
||||
* The publish confirmation a destination profile demands.
|
||||
*
|
||||
* <p>This is the requested level. What the broker actually supplied is reported separately as a
|
||||
* confirmation level on the publish evidence, so a profile asking for replication evidence against
|
||||
* an adapter that can only prove a broker ack fails at startup instead of silently downgrading.
|
||||
*/
|
||||
public enum ConfirmationRequirement {
|
||||
|
||||
/** No confirmation is required; only valid for at-most-once profiles. */
|
||||
NONE,
|
||||
|
||||
/** The broker must acknowledge receipt. */
|
||||
BROKER_ACK,
|
||||
|
||||
/** The broker must acknowledge replication or persistence. */
|
||||
REPLICATION_OR_PERSISTENCE_ACK
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A capability snapshot bound to one logical destination.
|
||||
*
|
||||
* <p>Capabilities are per destination, not per broker: the same Kafka cluster offers keyed ordering
|
||||
* on a partitioned topic and none on a share group, and the same RabbitMQ node offers native dead
|
||||
* lettering only where the queue was declared with one.
|
||||
*
|
||||
* @param destination the logical destination
|
||||
* @param broker the adapter's broker name
|
||||
* @param capabilities what the adapter can prove for this destination
|
||||
*/
|
||||
public record DestinationCapabilities(
|
||||
DestinationName destination, String broker, MessagingCapabilities capabilities) {
|
||||
|
||||
public DestinationCapabilities {
|
||||
Objects.requireNonNull(destination, "destination must not be null");
|
||||
Objects.requireNonNull(capabilities, "capabilities must not be null");
|
||||
if (broker == null || broker.isBlank()) {
|
||||
throw new IllegalArgumentException("broker must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
/**
|
||||
* The interaction pattern a destination implements.
|
||||
*
|
||||
* <p>This drives validation rather than transport: an event stream may keep ordering and replay, a
|
||||
* work queue may not, and a request-reply destination needs a correlation strategy no other kind
|
||||
* requires.
|
||||
*/
|
||||
public enum DestinationKind {
|
||||
|
||||
/** Point-to-point command delivered to exactly one logical handler. */
|
||||
ASYNC_COMMAND,
|
||||
|
||||
/** Fact published inside one bounded context. */
|
||||
DOMAIN_EVENT,
|
||||
|
||||
/** Fact published across bounded contexts under a stable schema contract. */
|
||||
INTEGRATION_EVENT,
|
||||
|
||||
/** Competing consumers draining a shared backlog. */
|
||||
WORK_QUEUE,
|
||||
|
||||
/** Fan-out to independent subscribers. */
|
||||
PUBLISH_SUBSCRIBE,
|
||||
|
||||
/** Retained, replayable, partitioned log. */
|
||||
EVENT_STREAM,
|
||||
|
||||
/** Correlated request and reply, available only as an M2 capability. */
|
||||
REQUEST_REPLY
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Logical name of a destination.
|
||||
*
|
||||
* <p>The pattern excludes {@code :}, {@code /}, and whitespace so that a broker address can never
|
||||
* be smuggled in as a logical name. {@code topic://orders} has to fail here, otherwise the physical
|
||||
* mapping owned by the destination profile could be bypassed from application code.
|
||||
*
|
||||
* @param value the logical name
|
||||
*/
|
||||
public record DestinationName(String value) {
|
||||
|
||||
private static final Pattern VALID = Pattern.compile("[a-z0-9][a-z0-9.-]{0,159}");
|
||||
|
||||
public DestinationName {
|
||||
if (value == null || !VALID.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"destination name must match [a-z0-9][a-z0-9.-]{0,159}: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
import dev.caskeleton.messaging.api.MessageType;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The application-facing handle for a destination.
|
||||
*
|
||||
* <p>It carries a logical name, the catalog message type, and the payload class — never a topic,
|
||||
* exchange, queue, or subject. The physical mapping lives in the destination profile, which is what
|
||||
* lets the same code run against Kafka in production and an in-memory harness in tests.
|
||||
*
|
||||
* @param <T> the payload type
|
||||
* @param name the logical destination name
|
||||
* @param messageType the catalog message type
|
||||
* @param payloadType the payload class
|
||||
*/
|
||||
public record MessageDestination<T>(
|
||||
DestinationName name, MessageType messageType, Class<T> payloadType) {
|
||||
|
||||
public MessageDestination {
|
||||
Objects.requireNonNull(name, "destination name must not be null");
|
||||
Objects.requireNonNull(messageType, "destination messageType must not be null");
|
||||
Objects.requireNonNull(payloadType, "destination payloadType must not be null");
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.messaging.api.destination;
|
||||
|
||||
/**
|
||||
* What a broker adapter can actually prove or do.
|
||||
*
|
||||
* <p>The common API does not assume every broker supports every feature. When a profile asks for
|
||||
* something absent here the platform fails loudly — at startup where possible, otherwise with a
|
||||
* capability exception — rather than quietly degrading, because a silently weakened guarantee is
|
||||
* indistinguishable from a working one until the incident.
|
||||
*
|
||||
* @param brokerAcknowledgement the adapter can prove the broker accepted a publish
|
||||
* @param replicationOrPersistenceEvidence the adapter can prove replication or persistence
|
||||
* @param perMessageSettlement individual messages can be settled
|
||||
* @param batchSettlement batches can be settled as a unit
|
||||
* @param orderedStream the destination preserves order inside an ordering unit
|
||||
* @param keyedOrdering order is preserved per key
|
||||
* @param replay historical messages can be re-read
|
||||
* @param delayedDelivery delivery can be scheduled for a future instant
|
||||
* @param brokerTransaction the broker offers a transaction scope
|
||||
* @param deduplicatedPublish the broker suppresses duplicate publishes of a stable id
|
||||
* @param nativeDeadLetter the broker provides dead lettering itself
|
||||
* @param topologyManagement topology can be inspected or created through the adapter
|
||||
*/
|
||||
public record MessagingCapabilities(
|
||||
boolean brokerAcknowledgement,
|
||||
boolean replicationOrPersistenceEvidence,
|
||||
boolean perMessageSettlement,
|
||||
boolean batchSettlement,
|
||||
boolean orderedStream,
|
||||
boolean keyedOrdering,
|
||||
boolean replay,
|
||||
boolean delayedDelivery,
|
||||
boolean brokerTransaction,
|
||||
boolean deduplicatedPublish,
|
||||
boolean nativeDeadLetter,
|
||||
boolean topologyManagement) {
|
||||
|
||||
/**
|
||||
* Returns a capability set with nothing enabled.
|
||||
*
|
||||
* @return the empty capability set
|
||||
*/
|
||||
public static MessagingCapabilities none() {
|
||||
return new MessagingCapabilities(
|
||||
false, false, false, false, false, false, false, false, false, false, false, false);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
/**
|
||||
* The stable classification a retry engine, DLQ router, and dashboard all agree on.
|
||||
*
|
||||
* <p>Categories exist so that retry decisions are made from a declared class of failure rather than
|
||||
* from exception type matching, which drifts every time a client library is upgraded.
|
||||
*/
|
||||
public enum FailureCategory {
|
||||
|
||||
/** Broker or network fault expected to clear on its own. */
|
||||
TRANSIENT_INFRASTRUCTURE,
|
||||
|
||||
/** The broker or a downstream applied backpressure or a quota. */
|
||||
THROTTLED,
|
||||
|
||||
/** The handler failed in a way that may succeed on redelivery. */
|
||||
PROCESSING_TRANSIENT,
|
||||
|
||||
/** The business rejected the message; redelivery cannot help. */
|
||||
PERMANENT_BUSINESS,
|
||||
|
||||
/** The message repeatedly destroys its consumer and must be parked. */
|
||||
POISON_MESSAGE,
|
||||
|
||||
/** The payload could not be decoded against its schema. */
|
||||
DESERIALIZATION,
|
||||
|
||||
/** Broker authentication failed. */
|
||||
AUTHENTICATION,
|
||||
|
||||
/** Broker authorization denied the operation. */
|
||||
AUTHORIZATION,
|
||||
|
||||
/** The outcome could not be determined. */
|
||||
AMBIGUOUS,
|
||||
|
||||
/** The platform or destination is misconfigured. */
|
||||
CONFIGURATION
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A sanitized, transportable description of a failure.
|
||||
*
|
||||
* <p>This travels in reserved headers to retry destinations and DLQs, so it deliberately holds no
|
||||
* payload, no stack trace, no credential, and no actual message key. Stack traces belong in secure
|
||||
* log storage; a DLQ is read by more people than the log is.
|
||||
*
|
||||
* @param category the stable classification
|
||||
* @param code a stable, machine-readable code
|
||||
* @param retryable whether automatic retry is permitted
|
||||
* @param sanitizedMessage a short operator-facing description
|
||||
* @param exceptionType the originating exception's simple type name, when known
|
||||
*/
|
||||
public record FailureDescriptor(
|
||||
FailureCategory category,
|
||||
String code,
|
||||
boolean retryable,
|
||||
String sanitizedMessage,
|
||||
Optional<String> exceptionType) {
|
||||
|
||||
private static final int MAX_MESSAGE_LENGTH = 512;
|
||||
private static final int MAX_CODE_LENGTH = 120;
|
||||
|
||||
public FailureDescriptor {
|
||||
Objects.requireNonNull(category, "failure category must not be null");
|
||||
Objects.requireNonNull(exceptionType, "exceptionType must not be null");
|
||||
if (code == null || code.isBlank() || code.length() > MAX_CODE_LENGTH) {
|
||||
throw new IllegalArgumentException("failure code must contain 1 to 120 characters");
|
||||
}
|
||||
if (sanitizedMessage == null) {
|
||||
throw new IllegalArgumentException("sanitizedMessage must not be null");
|
||||
}
|
||||
if (sanitizedMessage.length() > MAX_MESSAGE_LENGTH) {
|
||||
sanitizedMessage = sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a descriptor whose retryability follows the category's default.
|
||||
*
|
||||
* @param category the classification
|
||||
* @param code the stable code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @return a descriptor
|
||||
*/
|
||||
public static FailureDescriptor of(
|
||||
FailureCategory category, String code, String sanitizedMessage) {
|
||||
return new FailureDescriptor(
|
||||
category, code, defaultRetryable(category), sanitizedMessage, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a category is a retry candidate by default.
|
||||
*
|
||||
* <p>Deserialization, authentication, authorization, and configuration failures are never retried
|
||||
* automatically: each of them fails identically on every redelivery, so retrying only multiplies
|
||||
* the load while the message is still poison.
|
||||
*
|
||||
* @param category the classification
|
||||
* @return true when automatic retry is allowed by default
|
||||
*/
|
||||
public static boolean defaultRetryable(FailureCategory category) {
|
||||
return switch (category) {
|
||||
case TRANSIENT_INFRASTRUCTURE, THROTTLED, PROCESSING_TRANSIENT -> true;
|
||||
case PERMANENT_BUSINESS,
|
||||
POISON_MESSAGE,
|
||||
DESERIALIZATION,
|
||||
AUTHENTICATION,
|
||||
AUTHORIZATION,
|
||||
AMBIGUOUS,
|
||||
CONFIGURATION ->
|
||||
false;
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Broker authentication failed. */
|
||||
public class MessageAuthenticationException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.AUTHENTICATION;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageAuthenticationException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, false, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageAuthenticationException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
false,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageAuthenticationException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The broker denied the operation for these credentials. */
|
||||
public class MessageAuthorizationException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.AUTHORIZATION;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageAuthorizationException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, false, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageAuthorizationException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
false,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageAuthorizationException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Admission was refused because the platform is already at its in-flight or buffer ceiling.
|
||||
*
|
||||
* <p>Retryable, and deliberately raised rather than absorbed by an unbounded wait. Blocking the
|
||||
* caller until a slot frees turns producer-side saturation into thread exhaustion in the calling
|
||||
* application, which is a far worse failure than a fast rejection the caller can shed or retry.
|
||||
*
|
||||
* <p>Nothing was transmitted when this is thrown, so the message has no ambiguity: the caller may
|
||||
* resubmit it under the same {@code messageId} without risking a duplicate.
|
||||
*/
|
||||
public class MessageBackpressureException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.TRANSIENT_INFRASTRUCTURE;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageBackpressureException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, true, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageBackpressureException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The broker could not be reached. */
|
||||
public class MessageBrokerUnavailableException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.TRANSIENT_INFRASTRUCTURE;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageBrokerUnavailableException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, true, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageBrokerUnavailableException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
true,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageBrokerUnavailableException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The consumer runtime failed while handling a delivery. */
|
||||
public class MessageConsumerException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.PROCESSING_TRANSIENT;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageConsumerException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, true, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageConsumerException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
true,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageConsumerException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Publishing to the dead letter destination failed.
|
||||
*
|
||||
* <p>The source message stays unsettled. Acknowledging a source whose DLQ publish failed would
|
||||
* destroy the only remaining copy.
|
||||
*/
|
||||
public class MessageDeadLetterException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.TRANSIENT_INFRASTRUCTURE;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageDeadLetterException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, true, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageDeadLetterException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
true,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageDeadLetterException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The handler exceeded its timeout.
|
||||
*
|
||||
* <p>Whether the handler committed a side effect before the deadline is unknown, so the delivery is
|
||||
* recorded as possibly duplicated when it is retried.
|
||||
*/
|
||||
public class MessageHandlerTimeoutException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.PROCESSING_TRANSIENT;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageHandlerTimeoutException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, true, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageHandlerTimeoutException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
true,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageHandlerTimeoutException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/** A header was reserved, secret, or exceeded a limit. */
|
||||
public class MessageHeaderRejectedException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.PERMANENT_BUSINESS;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessageHeaderRejectedException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, false, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessageHeaderRejectedException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
false,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessageHeaderRejectedException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.messaging.api.error;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The publish outcome could not be determined.
|
||||
*
|
||||
* <p>The broker may hold the message. Republishing is permitted only under the same logical message
|
||||
* id, so that broker deduplication or a downstream Inbox can collapse the duplicate.
|
||||
*/
|
||||
public class MessagePublishAmbiguousException extends MessagingException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final FailureCategory CATEGORY = FailureCategory.AMBIGUOUS;
|
||||
|
||||
/**
|
||||
* Creates the exception with a stable code and sanitized message.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
*/
|
||||
public MessagePublishAmbiguousException(String code, String sanitizedMessage) {
|
||||
super(new FailureDescriptor(CATEGORY, code, false, sanitizedMessage, Optional.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception with an originating cause.
|
||||
*
|
||||
* @param code the stable failure code
|
||||
* @param sanitizedMessage the operator-facing description
|
||||
* @param cause the originating throwable
|
||||
*/
|
||||
public MessagePublishAmbiguousException(String code, String sanitizedMessage, Throwable cause) {
|
||||
super(
|
||||
new FailureDescriptor(
|
||||
CATEGORY,
|
||||
code,
|
||||
false,
|
||||
sanitizedMessage,
|
||||
Optional.ofNullable(cause).map(value -> value.getClass().getSimpleName())),
|
||||
cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the exception from an explicit descriptor.
|
||||
*
|
||||
* @param failure the sanitized failure description
|
||||
*/
|
||||
public MessagePublishAmbiguousException(FailureDescriptor failure) {
|
||||
super(failure);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user