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:
DongHyeonka
2026-08-14 14:55:38 +09:00
parent 3b5aee50e3
commit d646c2f12f
486 changed files with 40271 additions and 2 deletions
+49 -1
View File
@@ -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)
+279
View File
@@ -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": []
}
]
}
+20
View File
@@ -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
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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()));
}
}
@@ -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
@@ -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();
}
}
@@ -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();
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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");
}
}
}
@@ -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();
}
}
}
@@ -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;
}
}
}
@@ -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);
}
@@ -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));
}
}
@@ -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");
}
}
@@ -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=
@@ -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() {}
}
@@ -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);
}
@@ -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;
}
}
@@ -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
@@ -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");
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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);
}
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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()));
}
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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
}
@@ -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");
}
}
}
@@ -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
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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
}
@@ -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);
}
@@ -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
}
@@ -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);
}
@@ -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
}
@@ -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");
}
}
}
@@ -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
}
@@ -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);
}
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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
}
@@ -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;
};
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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