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
+156
View File
@@ -0,0 +1,156 @@
# 설정 레퍼런스
## Destination profile
```yaml
messaging:
destinations:
order-events:
broker: kafka-primary
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
tier: M1 # M1 | M2 | M3
physical:
topic: order.events.v1
schema:
codec: application/json
compatibility: BACKWARD_TRANSITIVE
message-types: [order.created]
guarantees:
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
ordering: KEY # NONE | DESTINATION | PARTITION | KEY
external-side-effect: INBOX_TRANSACTIONAL
producer:
confirmation: REPLICATION_OR_PERSISTENCE_ACK
timeout: 5s
mandatory-routing: true
idempotent: true
consumer:
group: order-projection
concurrency: 6
max-in-flight-per-ordering-unit: 1
prefetch: 16
handler-timeout: 30s
manual-settlement: false
retry:
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
# | RETRY_DESTINATION | BROKER_DELAYED
max-attempts: 3
initial-delay: 200ms
max-delay: 2s
multiplier: 2.0
jitter: true
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
dlq:
destination: order-events-dlq
max-redrive-count: 1
payload:
max-bytes: 1048576
claim-check-threshold-bytes: 1048576
key-resolver-configured: true
production: true
topology-auto-create: false
```
## 기본값
| 설정 | 기본값 | 근거 |
|---|---:|---|
| logical payload 최대 | 1,048,576 bytes | portability. 초과는 Claim Check |
| global hard 최대 | 8,388,608 bytes | 어떤 destination도 넘을 수 없는 상한 |
| header 총 크기 | 32,768 bytes | |
| header 개수 | 64 | |
| header key | 128 bytes | metric tag 안전 |
| header value | 4,096 bytes | |
| publish timeout | 5s | |
| handler timeout | 30s | |
| graceful shutdown drain | 30s | |
| 일반 destination retry | 0회 | 자동 retry는 opt-in |
| DLQ redrive batch | 100 | 한 번의 작업이 source를 덮치지 않게 |
| Outbox relay batch | 100 | |
| Outbox lease | 30s | |
| Outbox polling | 500ms | |
| metric dimension 상한 | 200 | cardinality 폭발 방지 |
## Broker profile
### Kafka
```yaml
messaging:
brokers:
kafka-primary:
type: kafka
stable: true
production: true
bootstrap-servers: [broker-1:9093, broker-2:9093]
enable-idempotence: true # stable에서 필수
acks: all # stable에서 필수
max-in-flight-requests-per-connection: 5 # 최대 5
delivery-timeout: 30s
enable-auto-commit: false # 항상 금지
tls-enabled: true # production 필수
authentication-enabled: true # production 필수
```
### RabbitMQ
```yaml
messaging:
brokers:
rabbit-primary:
type: rabbitmq
stable: true
production: true
addresses: [rabbit-1:5671]
publisher-confirms: true # stable에서 필수
publisher-returns: true # stable에서 필수
mandatory: true # stable에서 필수
confirm-timeout: 5s
auto-ack: false # 항상 금지
prefetch: 16
quorum-queues: true # durable work queue 필수
tls-enabled: true
authentication-enabled: true
```
## 보안
```yaml
messaging:
security:
kafka-primary:
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
# admin은 application runtime에 설정하지 않는다
hostname-verification: true
access:
publishable: [order-events]
consumable: []
administrable: []
```
## Experimental / Optional
기본값은 전부 `false`다.
```yaml
messaging:
experimental:
kafka-share: false
pulsar: false
nats: false
bridge:
spring-cloud-stream: false
```
## Backpressure
```yaml
messaging:
backpressure:
global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다
```
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
+75
View File
@@ -0,0 +1,75 @@
# 전달 보장
## 왜 `EXACTLY_ONCE`가 없는가
어떤 브로커도 **외부 side effect를 포함한** exactly-once를 제공하지 않는다.
실제로 존재하는 것은 at-least-once 전달 + 멱등하거나 transactional한 consumer의 조합이다.
플랫폼이 지킬 수 없는 이름을 enum에 두면 그 책임이 눈에 보이지 않는 곳으로 밀려난다.
그래서 `DeliveryGuarantee`는 증거가 끝나는 지점에서 멈춘다.
```java
public enum DeliveryGuarantee { AT_MOST_ONCE, AT_LEAST_ONCE }
```
## Publish 결과는 boolean이 아니다
```java
public enum PublishCompletion { CONFIRMED, REJECTED, AMBIGUOUS }
```
`REJECTED``AMBIGUOUS`를 하나의 "실패"로 합치면 중복 주문이 만들어진다.
전자는 broker가 저장하지 않았음이 **확정**되어 포기해도 안전하고, 후자는 그렇지 않다.
| 상황 | 결과 |
|---|---|
| 로컬 validation 실패 | `REJECTED`, `NOT_TRANSMITTED` |
| broker 명시적 reject / nack | `REJECTED` |
| confirm 수신 | `CONFIRMED` |
| Rabbit confirm + unroutable return | `REJECTED`, `UNROUTABLE` |
| bytes 전송 후 connection loss | `AMBIGUOUS` |
| confirm timeout | `AMBIGUOUS` |
| adapter가 판정 불가 | 보수적으로 `AMBIGUOUS` |
`PublishResult` 생성자가 이 규칙을 강제한다. `CONFIRMED`인데 broker acceptance가 없거나,
`AMBIGUOUS`인데 confirmation level을 주장하면 **객체 생성 자체가 실패**한다.
## Ordering
```java
public enum OrderingScope { NONE, DESTINATION, PARTITION, KEY }
```
순서는 partition·key·단일 consumer의 성질이지 destination 전체의 성질이 아니다.
`GLOBAL`이 없는 이유가 이것이다.
`DestinationProfileValidator`가 다음을 거부한다.
- `ordering=KEY`인데 key resolver 없음
- ordered destination인데 `ALLOW_REORDER` retry
- `orderingImpact=PRESERVE`인데 재발행형 retry(`RETRY_DESTINATION`, `BROKER_DELAYED`)
- `ordering=DESTINATION`인데 concurrency > 1
- ordered destination인데 ordering unit당 in-flight > 1
## External side effect
```java
public enum ExternalSideEffectGuarantee { NONE, IDEMPOTENCY_REQUIRED, INBOX_TRANSACTIONAL }
```
`INBOX_TRANSACTIONAL`만이 "DB side effect와 중복 차단이 같은 transaction에서 commit된다"를 의미한다.
Kafka transaction은 **Kafka 안에서만** 원자적이므로 이 값과 함께 설정하면
`KafkaTransactionProfileValidator`가 거부한다. 두 개의 독립적인 commit을 하나로 착각하게 두지 않기 위해서다.
## Consumer settlement 순서
```text
RECEIVED → DECODING → PROCESSING → HANDLER_SUCCEEDED → SETTLEMENT_SENDING
├→ SETTLED
└→ SETTLEMENT_UNKNOWN
```
- handler는 broker ACK API를 호출하지 않는다.
- `Success` 이후에만 source settlement한다.
- `SETTLEMENT_UNKNOWN`은 성공이 아니다. redelivery 가능성을 의미한다.
- `SettlementResult` 생성자가 `SETTLED`인데 `redeliveryPossible=true`인 조합을 거부한다.
+97
View File
@@ -0,0 +1,97 @@
# Experimental 정책
## Stable과 Experimental의 차이
**Stable**은 공통 Contract Suite(`MessagingAdapterContract`)를 변경 없이 통과한 어댑터다.
컴파일되는 어댑터가 아니라, 아래 7가지를 실제로 증명한 어댑터다.
```text
publishesAndConfirms
returnsAmbiguousWhenConfirmIsLost
redeliversWhenSettlementIsLost
preservesMessageIdAcrossRetryAndDlq
keepsSourceUnsettledWhenDlqPublishFails
rejectsOversizedPayloadBeforeTransport
stopsAcceptingNewWorkDuringShutdown
```
**Experimental**은 아직 그 증명이 끝나지 않은 어댑터다.
## 규칙
### 1. 기본 비활성
```yaml
messaging.experimental.kafka-share: false
messaging.experimental.pulsar: false
messaging.experimental.nats: false
```
활성화하지 않으면 validator가 `MessagingCapabilityUnavailableException`을 던진다.
Contract Suite가 아직 증명 중인 어댑터가 누군가의 기본 설정 때문에 load-bearing이 되어서는 안 된다.
### 2. Stable 모듈이 Experimental 모듈에 의존하지 않는다
Gradle 의존 그래프로 강제된다. `messaging-spring-boot-starter``allowed_dependencies`
`messaging-kafka-share-experimental`, `messaging-pulsar-experimental`,
`messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`**없다**.
`verifyCleanArchitectureDependencies`가 위반을 빌드 실패로 만든다.
### 3. Core 계약을 바꾸지 않는다
Experimental 어댑터는 브로커의 차이를 `MessagingCapabilities`로 표현할 뿐,
`messaging-core-api`의 타입을 바꾸지 않는다.
### 4. 없는 기능을 광고하지 않는다
| 어댑터 | 광고하지 않는 것 | 이유 |
|---|---|---|
| Kafka Share Group | orderedStream, keyedOrdering, replay, brokerTransaction | 경쟁 소비자 + 개별 ack는 partition 순서를 유지할 수 없다 |
| Pulsar | brokerTransaction | Pulsar에 있지만 플랫폼 Contract Suite로 증명되지 않았다 |
| Pulsar (Shared) | keyedOrdering | round-robin 분배 |
| NATS JetStream | nativeDeadLetter | delivery limit 초과 시 terminate할 뿐 라우팅하지 않는다 |
| NATS JetStream | keyedOrdering | subject 기반 모델에 per-key 순서가 없다 |
`false`인 capability를 요구하는 profile은 startup에서 실패한다.
조용히 약화되지 않는다.
### 5. 명시적 거부
| 조합 | 결과 |
|---|---|
| Kafka Share Group + ordering != NONE | 거부 |
| Kafka Share Group + pause/resume | `MessagingCapabilityUnavailableException` |
| Pulsar Shared + ordering=KEY | 거부 (Key_Shared 필요) |
| Pulsar + ordering=DESTINATION | 거부 |
| NATS Core + AT_LEAST_ONCE | 거부 (JetStream 필요) |
| NATS ordered consumer + 경쟁 워커 > 1 | 거부 |
| NATS + ordering=KEY | 거부 |
## Spring Cloud Stream bridge
Experimental이 아니라 **Optional**이다. 위험이 다르다.
Stream은 자체 binder 설정을 소유하므로, binding이 destination profile이 모르는
serializer·error handling·acknowledgement mode를 조용히 획득할 수 있다.
따라서 브리지는 **플랫폼 보장에 의존하지 않는 destination에만** 허용한다.
```text
ordering scope 선언 → 거부
retry policy 선언 → 거부
dead letter 선언 → 거부
```
이 셋 중 하나라도 필요하면 native adapter를 쓴다. 거기서만 실제로 강제되기 때문이다.
## 승격 조건
Experimental → Stable로 올리려면 전부 필요하다.
1. `MessagingAdapterContract` 7개 테스트를 변경 없이 통과
2. 장애 주입(연결 끊김, confirm 유실, settlement 유실) 하에서 통과
3. 지원 브로커 버전 범위 명시 및 CI 검증
4. `support-matrix.md`의 capability 표 갱신
5. ADR 작성
6. 기본 활성화 여부에 대한 별도 결정
+113
View File
@@ -0,0 +1,113 @@
# 마이그레이션 가이드
## 기존 Spring Kafka / Spring AMQP 코드에서
### 1. topic 이름을 코드에서 제거한다
```java
// before
kafkaTemplate.send("order.events.v1", key, payload);
// after
publisher.publish(orderEvents, envelope, PublishOptions.defaults());
```
`MessageDestination`은 logical name만 가진다. 물리 매핑은 destination profile이 소유한다.
`DestinationName`의 패턴이 `topic://orders` 같은 값을 거부하므로 우회할 수 없다.
### 2. boolean 성공 판정을 없앤다
```java
// before
try { template.send(...).get(); success(); }
catch (Exception e) { fail(); } // REJECTED와 AMBIGUOUS를 구분하지 못한다
// after
PublishResult result = ...;
switch (result.completion()) {
case CONFIRMED -> success();
case REJECTED -> abandon(); // broker가 저장하지 않음이 확정
case AMBIGUOUS -> retrySameMessageId(result); // broker가 가지고 있을 수 있음
}
```
이 구분이 없으면 confirm 유실 한 번이 중복 주문 하나가 된다.
### 3. auto-commit / auto-ack를 끈다
```yaml
# Kafka
enable.auto.commit: false
# RabbitMQ
auto-ack: false
```
둘 다 validator가 강제로 거부한다. 타이머 기반 commit은 handler가 실행되기도 전에
메시지를 처리 완료로 표시한다.
### 4. handler에서 ack 호출을 제거한다
```java
// before
@KafkaListener(...)
void handle(ConsumerRecord<?,?> record, Acknowledgment ack) {
process(record);
ack.acknowledge(); // 실패 시 순서가 애매해진다
}
// after
CompletionStage<HandleResult> handle(MessageDelivery<OrderCreated> delivery) {
process(delivery.message().payload());
return completedFuture(HandleResult.success());
}
```
settlement는 플랫폼이 수행한다. "성공한 뒤에만 ack"가 각 handler의 기억이 아니라
플랫폼 불변식이 된다.
### 5. 중복을 정상 상황으로 다룬다
at-least-once는 중복을 전제한다. 세 가지 중 하나를 고른다.
| 방식 | 언제 |
|---|---|
| handler 자체 멱등 | 자연 멱등 연산 (upsert 등) |
| Inbox | DB side effect가 있는 경우 |
| Kafka transaction | Kafka → Kafka 파이프라인만 |
`ExternalSideEffectGuarantee`에 선언한다. `INBOX_TRANSACTIONAL`과 Kafka transaction을
동시에 설정하면 거부된다. Kafka transaction은 DB를 포함하지 않는다.
### 6. 큰 payload는 Claim Check로
broker frame 크기를 키우지 않는다. broker 메모리, replication latency,
consumer recovery가 동시에 나빠지고, 유계·검증 가능한 실패가 무계 실패로 바뀐다.
1 MiB 초과는 외부 저장소로 offload하고 digest를 포함한 참조만 발행한다.
## DB 마이그레이션
```text
V1__messaging_outbox.sql
V2__messaging_inbox.sql
```
Outbox row는 business transaction과 같은 transaction에서 쓴다.
Inbox reservation은 handler side effect와 같은 transaction에서 쓴다.
별도 transaction이면 각 패턴이 닫으려던 창이 그대로 열려 있다.
## 단계적 전환
1. **publish만 전환** — 기존 consumer는 그대로. wire format은 reserved header가 추가될 뿐이다.
2. **Outbox 도입** — publish 유실 창을 닫는다.
3. **consume 전환** — handler를 `MessageHandler`로 옮기고 ack 호출을 제거한다.
4. **Inbox 도입** — 중복 side effect를 닫는다.
5. **retry·DLQ 정책 선언** — 이 시점까지 자동 retry는 0회다.
각 단계는 독립적으로 배포 가능하고, 되돌릴 수 있다.
## 되돌릴 수 없는 것
- 한 번 발행된 message type의 wire contract
- 이미 retention 안에 있는 메시지의 schema
- redrive된 메시지의 `messageId` (바뀌지 않는다 — 이것이 의도다)
+113
View File
@@ -0,0 +1,113 @@
# 운영 Runbook
## 배포 전 체크
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyRuntimeModuleMembership --console=plain
./gradlew verifyOneTypePerFile --console=plain
```
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
- ordered destination + reorder 가능 retry
- `ordering=KEY` + key resolver 없음
- payload 상한 > 8,388,608 bytes
- DLQ 자기 참조 / retry 자기 참조
- retry·DLQ 그래프 cycle
- 미등록 retry·DLQ destination
- M1 destination + manual settlement
- `AT_LEAST_ONCE` + confirmation `NONE`
- production profile + topology auto-create
- broker topology가 manifest와 불일치
## 증상별 대응
### publish가 AMBIGUOUS로 쏟아진다
broker confirm 경로 문제다. 실패가 아니다.
1. `PublishEvidence.transmission``MAY_HAVE_BEEN_TRANSMITTED`인지 확인
2. Kafka: `delivery.timeout.ms`, ISR 상태, leader election 확인
3. Rabbit: confirm timeout, channel 상태 확인
4. Outbox를 쓰고 있다면 `status='AMBIGUOUS'` row가 같은 messageId로 재시도 중이다. **정상이다.**
5. consumer 쪽 Inbox가 중복을 흡수하는지 확인
`AMBIGUOUS`를 실패로 취급해 새 messageId로 재발행하지 말 것. 중복이 복구 불가능해진다.
### DLQ가 비어 있는데 메시지가 사라졌다
DLQ publish 실패 시 source는 settlement되지 않는다. 메시지는 source에 남아 재전달된다.
1. `msg.failure-code``DEAD_LETTER_*`인 로그 확인
2. DLQ destination이 실제로 존재하는지 (topology validation)
3. DLQ credential에 publish 권한이 있는지
### consumer lag이 한 partition에서만 증가한다
`ContiguousPartitionOffsetTracker`가 gap에서 멈춘 것이다. 설계된 동작이다.
commit은 **연속** 완료 offset까지만 전진한다. offset 11이 아직 실행 중이면
10과 12가 끝나도 watermark는 10에 머문다. 12를 commit하면 consumer가 죽었을 때 11을 잃는다.
1. 해당 partition의 in-flight를 확인
2. 느린 handler를 찾는다 (`handlerTimeout` 초과 여부)
3. 필요하면 `PAUSE_PARTITION` retry가 걸려 있는지 확인
### 재시도 폭풍
`RetryPolicy.jitter=false`인지 확인한다. jitter 없이는 같은 초에 실패한 모든 consumer가
같은 초에 재시도한다.
### shutdown이 오래 걸린다
`GracefulShutdownCoordinator`가 in-flight를 기다리는 중이다.
- `inFlight()`가 0이 되면 즉시 종료
- drain deadline(기본 30초) 초과 시 남은 작업을 **unsettled로 포기**한다 → broker가 재전달
- draining 시작 후 새 retry attempt는 만들지 않는다
## Destructive 작업
전부 `DestructiveOperationGuard`를 통과해야 한다.
| 조건 | 요구 |
|---|---|
| admin credential | application runtime은 보유하지 않음 |
| `AdminApproval` | 유효기간 내 |
| dry-run | 항상 허용 |
### Replay
```text
기본: 격리된 consumer group (replay-<requestId>)
기존 group 대상: 승인 티켓 필수
```
기존 production group으로 replay하는 것은 "다시 읽기"가 아니라 **live consumer를 되감는 것**이다.
그 사이의 모든 것이 재처리된다.
### Redrive
```text
dry-run으로 후보 수 확인
→ 승인 획득
→ batch 100건 이하로 실행
→ republish CONFIRMED 인 것만 DLQ에서 settlement
```
`redriveId`로 재구동 루프를 추적한다. 같은 메시지가 반복해서 redrive되면
근본 원인이 해결되지 않은 것이다.
### Offset reset
`KafkaOffsetResetExecutor`는 승인 predicate를 **생성자 인자**로 받는다.
승인 소스 없이 조립된 runtime은 물리적으로 reset을 수행할 수 없다.
## Topology
production topology는 IaC가 만들고 애플리케이션은 **검증만** 한다.
`TopologyValidationRuntime`은 모든 불일치를 한 번에 보고하고 startup을 실패시킨다.
partition 수가 다르면 destination이 광고하는 ordering 보장이 달라지고,
`min.insync.replicas`가 없으면 `acks=all`의 의미가 달라진다.
+95
View File
@@ -0,0 +1,95 @@
# Outbox · Inbox
## 두 패턴이 각각 무엇을 해결하는가
| 패턴 | 해결하는 문제 | 해결하지 않는 문제 |
|---|---|---|
| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 |
| Inbox | 중복 delivery의 side effect | 유실 |
**둘 다 필요하다.** Outbox만으로는 exactly-once가 되지 않는다.
## Outbox
business transaction과 **같은 transaction**에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.
```sql
BEGIN;
UPDATE orders SET status = 'PLACED' WHERE id = ?;
INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
COMMIT;
```
### relay
```text
leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전
→ publish (messageId 그대로)
→ CONFIRMED → markPublished
→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능)
→ REJECTED → markFailed
```
### 핵심 규칙: ambiguous는 같은 messageId로 재시도
새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어
downstream의 어떤 중복 제거도 복구할 수 없다.
failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다.
`message_id`를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다.
### lease
```text
status IN ('PENDING','AMBIGUOUS') AND (lease_expires_at IS NULL OR lease_expires_at <= now)
```
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
## Inbox
reservation과 side effect가 **같은 transaction**이어야 한다.
```java
transactions.inTransaction(() -> {
if (!inbox.reserve(messageId, consumerId, now)) {
return InboxOutcome.duplicate(); // 이미 처리됨
}
return InboxOutcome.processed(sideEffect.get());
});
```
별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다.
### 복합 키
`PRIMARY KEY (message_id, consumer_id)`.
message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가
첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다.
### retention
broker의 최대 redelivery window보다 **길어야** 한다.
row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다.
## Debezium CDC 대안
polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만
인프라와 그 자체의 실패 모드가 추가된다.
wire contract는 동일하다. `DebeziumOutboxEventRouter`가 polling relay와 같은 reserved header를
방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다.
## Claim Check
1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다.
`ClaimCheckReference`는 digest를 **필수**로 가진다. claim check는 메시지를 서로 다른 retention과
replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을
증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다.
`ClaimCheckIntegrityGuard`는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다.
digest 불일치는 `DESERIALIZATION`이 아니라 **validation** 실패로 분류한다.
bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다.
+103
View File
@@ -0,0 +1,103 @@
# Retry · DLQ · Redrive
## 자동 retry는 opt-in이다
일반 destination의 기본값은 **retry 없음**이다. 순서를 깨거나, 멱등하지 않은 side effect를
증폭시키거나, 이미 throttle된 downstream을 더 때리는 retry는 보이는 실패보다 나쁘다.
## 결정 순서
`DefaultRetryDecisionEngine`은 아래 순서를 위에서 아래로 평가한다.
```text
1. non-retryable category → parking(DeadLetter) 또는 Reject
2. attempt >= maxAttempts → DeadLetter
3. PRESERVE + ordered + orderedStream capability → PauseAndRetry
4. mode=PAUSE_PARTITION → PauseAndRetry
5. mode=RETRY_DESTINATION + ALLOW_REORDER → PublishToRetryDestination
6. mode=INLINE|BLOCKING → RetryInline
7. mode=BROKER_DELAYED + delayedDelivery capability → PublishToRetryDestination
8. 그 외 → DeadLetter
```
**retryability를 attempt 예산보다 먼저** 검사한다. deserialization 실패는 payload가 바뀌지 않으므로
재시도가 3번 더 실패할 뿐이다. 첫 delivery에서 바로 park한다.
**순서 보존 전략을 재발행 전략보다 먼저** 검사한다. 둘 다 설정되어 있어도 ordered destination이
reorder 경로로 흘러내리지 않는다.
## 기본 non-retryable
`DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, `CONFIGURATION`은 자동 retry하지 않는다.
매 redelivery마다 동일하게 실패하므로 부하만 늘어난다.
destination profile의 `retryableCategories`로 명시적으로 뒤집을 수는 있다.
## Backoff
`min(maxDelay, initialDelay * multiplier^(attempt-1))`, 이후 full jitter.
full jitter는 `[0, delay]` 균등 분포다. jitter가 없으면 같은 초에 실패한 모든 consumer가
같은 초에 재시도하고, downstream의 회복이 재시도 폭풍으로 즉시 무효화된다.
## Kafka: pause-and-seek vs retry topic
| 전략 | 순서 | 언제 |
|---|---|---|
| `PAUSE_PARTITION` | 유지 | ordered destination |
| `RETRY_DESTINATION` | 깨짐 | work queue, `ALLOW_REORDER` 명시 |
pause-and-seek는 메시지가 로그의 자기 자리를 떠나지 않는다. partition을 멈추고, 기다리고,
같은 offset으로 seek해 재전달한다. 뒤의 메시지도 함께 기다리며 이것이 의도된 동작이다.
## RabbitMQ: delayed retry queue
core broker에 per-message delay가 없으므로 **TTL + DLX**로 구현한다.
retry queue의 `x-message-ttl`이 만료되면 `x-dead-letter-exchange`를 통해 work queue로 되돌아간다.
주의: TTL 만료는 큐 **head**에서 평가된다. 하나의 retry queue에 서로 다른 delay를 섞으면
독립적으로 만료되지 않는다.
`basic.nack(requeue=true)`는 사용하지 않는다. delay 없이 큐 head로 되돌리므로 hot loop가 된다.
## DLQ: publish 확인 후 settlement
이것이 dead lettering이 데이터 손실이 되지 않게 하는 **유일한** 불변식이다.
```text
DLQ envelope 생성 (원래 messageId 유지)
→ DLQ publish
→ CONFIRMED 이면 source settlement
→ REJECTED / AMBIGUOUS 이면 source를 settlement하지 않음
```
source를 먼저 ACK하면, DLQ publish가 실패했을 때 메시지의 사본이 **어디에도 남지 않는다**.
broker는 이미 해제했고 DLQ는 받지 못했다.
AMBIGUOUS DLQ publish는 중복을 만든다. 이것이 의도된 trade다. DLQ는 사람이 읽는 곳이고
중복은 알아볼 수 있지만, 손실은 복구할 수 없다.
## DLQ envelope 내용
reserved header에만 기록한다. payload에 넣지 않는다.
```text
msg.failure-category, msg.failure-code, msg.origin-destination,
msg.retry-attempt, msg.first-failure-at, msg.last-failure-at
```
stack trace, exception message, secret header, 실제 key는 **넣지 않는다**.
DLQ는 원본 topic보다 오래 보관되고 더 많은 사람이 읽는다.
## Redrive
M4 Admin 전용이다. `DestructiveOperationGuard`를 통과해야 한다.
- admin credential 필요 (application runtime은 보유하지 않는다)
- 유효기간 내 `AdminApproval` 필요
- dry-run은 항상 허용 (계획이 공짜여야 사람이 계획한다)
- batch 상한 100건
- source == target 금지
- `redriveId``messageId`와 별개다. 재구동 루프를 식별하기 위해서다.
redrive도 **publish → settlement** 순서다. republish가 confirm되지 않은 메시지는
DLQ에 남는다.
+92
View File
@@ -0,0 +1,92 @@
# Messaging 보안
## Credential 분리
producer / consumer / admin은 **서로 다른 credential**이다.
`MessageSecurityValidator`가 startup에서 강제한다.
```text
producer credential == consumer credential → 실패
admin credential == producer|consumer → 실패
production 프로필에 admin credential 존재 → 실패
```
마지막 규칙이 "애플리케이션은 topic을 purge할 수 없다"를 **구조적으로** 만든다.
runtime이 admin 자격 증명을 아예 보유하지 않으므로, 침해된 handler가 상승시킬 대상이 없다.
## Production 필수 조건
- TLS 활성
- TLS hostname verification 활성
- broker authentication 활성
- topology auto-create 비활성
Kafka는 추가로 `enable.idempotence=true`, `acks=all`,
`max.in.flight.requests.per.connection <= 5`, consumer auto-commit 금지.
RabbitMQ는 추가로 publisher confirm, publisher return, `mandatory=true`,
durable work queue의 quorum queue, consumer auto-ack 금지.
## Credential은 값이 아니라 참조다
`BrokerCredentialProfile`의 어떤 variant도 secret을 담지 않는다.
식별자만 보관하고 connect 시점에 `CredentialProvider`로 해석한다.
heap dump나 설정 출력에서 사용 가능한 credential이 나오지 않는다.
`CredentialIds``bearer `, `sk-`, `-----begin`, `eyJ` 같은 접두사를 거부한다.
참조가 들어갈 자리에 secret 자체를 붙여넣는 가장 흔한 사고를 막는다.
## Rotation
`CredentialRotationPlan.isDue()`는 만료 **전에** 참이 된다.
broker가 연결을 거부하기 시작한 시점에는 이미 publish가 실패하고 consumer가 멈춰 있다.
rotation은 세대 교체다. `DefaultMessagingRuntimeRegistry.install()`이 새 세대를 원자적으로
게시하고, 이전 세대는 마지막 lease가 닫힐 때까지 열려 있다가 닫힌다.
진행 중인 publish는 시작한 연결에서 confirm을 받는다.
drain deadline이 이 대기를 제한한다. 없으면 lease 하나가 새면 폐기된 credential이
무기한 열려 있고, rotation이 보안상 무의미해진다.
## Header
금지 header는 application·platform 양쪽에서 거부한다.
```text
Authorization, Proxy-Authorization, Cookie, Set-Cookie,
access_token, refresh_token, api_key, password, client_secret
```
credential이 header에 들어가면 broker storage, DLQ dump, 운영 도구에 남는다.
downstream redaction으로는 되돌릴 수 없다.
예약 header(`msg.*`, `traceparent`, `tracestate`, `baggage`)는 platform만 쓴다.
application이 `msg.id`를 설정할 수 있으면 Inbox 중복 제거와 DLQ 상관관계가 의존하는
logical identity가 호출자 제어가 된다.
## ACL
`DestinationAccessValidator`가 broker ACL **이전에** 검사한다.
broker ACL 거부는 애플리케이션 컨텍스트가 없는 연결 수준 오류로 도착하므로
"어느 모듈이 어디에 publish하려 했는가"가 조사 대상이 된다.
## 관측성 누출
`MessagingRedactor`는 denylist다.
- secret: authorization, cookie, token, password, secret, credential
- per-message identity: messageId, correlationId, causationId, partitionKey, key, offset, deliveryTag, sequence
- payload: payload, body, data
- 예외 상세: exceptionMessage, stackTrace
identity를 지우는 이유는 두 가지다. bounded metric을 message당 하나의 series로 만들고,
support log를 재식별 표면으로 만들기 때문이다.
`CardinalityGuard`는 dimension당 값 개수를 상한한다.
cardinality 사고는 점진적이지 않다. 테스트 10건에서는 멀쩡하고 운영에서 백엔드를 죽인다.
## 감사
`MessagingAuditEvent`는 replay, redrive, offset reset, purge, delete를 기록한다.
subject(운영자 identity), approval ticket, 그리고 redactor를 통과한 details만 담는다.
누가 무엇을 했는지 증명하되 payload의 두 번째 사본이 되지 않는다.
+116
View File
@@ -0,0 +1,116 @@
# Messaging 지원 매트릭스
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
여기 없는 조합은 지원되지 않는다.
## 브로커 등급
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|---|---|---|---|---|
| Kafka | Stable | 4.2+ / 4.3.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental |
| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 |
| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 |
| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 |
| Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 |
## Capability 매트릭스
`MessagingCapabilities`가 런타임에 선언하는 값이다. `false`인 기능을 요구하는 destination profile은
**startup에서 실패**하며, 조용히 약화되지 않는다.
| Capability | Kafka | Kafka Share | RabbitMQ | Pulsar | NATS JS |
|---|---|---|---|---|---|
| brokerAcknowledgement | O | O | O | O | O |
| replicationOrPersistenceEvidence | O | O | O | O | O |
| perMessageSettlement | O | O | O | O | O |
| batchSettlement | O | X | X | O | O |
| orderedStream | O | **X** | X | X | O |
| keyedOrdering | O | **X** | X | Key_Shared만 | X |
| replay | O | X | X | O | O |
| delayedDelivery | X | X | retry queue로 대행 | O | X |
| brokerTransaction | O | X | X | 미승격 | X |
| deduplicatedPublish | O | X | X | X | O |
| nativeDeadLetter | X | X | O | O | **X** |
| topologyManagement | O | X | O | O | O |
Kafka Share Group이 ordering 전부 `X`인 것은 설계 결정이다. share group은 개별 record를
경쟁 소비자에게 나눠주고 개별 ack하므로 partition 순서를 유지할 수 없다. ordered destination을
share group에 설정하면 `KafkaShareProfileValidator`가 거부한다.
NATS JetStream의 `nativeDeadLetter=X`도 마찬가지다. JetStream은 delivery limit 초과 시 메시지를
**terminate**할 뿐 어디로도 라우팅하지 않으므로, 플랫폼이 DLQ publish를 직접 수행한다.
## 기능 등급
| 기능 | 등급 |
|---|---|
| Typed Publish·Consume | Stable M1 |
| At-least-once contract | Stable |
| Ambiguous publish 결과 | Stable |
| handler 성공 후 자동 settlement | Stable M1 |
| Batch / Manual settlement / Pause·Resume / Delayed / Replay 요청 | M2 |
| Broker transaction / partition / routing / subscription | M3 |
| Replay 실행 / Redrive / offset reset / purge / delete | M4 Admin |
| Kafka Share Group, Pulsar, NATS | Experimental |
| Spring Cloud Stream bridge | Optional |
## 무엇이 "Stable"을 증명하는가
Stable 등급은 두 가지를 **모두** 통과해야 한다. `CompatibilityMatrixTest`가 이 규칙을 강제한다.
### 1. 공유 Contract Suite (`MessagingAdapterContract`, 7개)
Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결정적 하네스를 쓰므로
확인 유실·settlement 유실 같은 장애를 요청 시점에 재현할 수 있다.
### 2. 실 브로커 인증 (Testcontainers)
| 스위트 | 무엇을 증명하는가 |
|---|---|
| `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit |
| `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) |
| `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 |
| `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 |
| `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 |
Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다.
### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`)
`NetworkFaultScenario`가 5개 시나리오와 **각각의 기대 결과**를 코드로 고정한다. 기대 결과를 어댑터별로
두지 않는 것이 핵심이다 — 어댑터마다 다른 답을 허용하면 공유 계약이 존재할 이유가 없다.
| 시나리오 | 시점 | 기대 결과 | 이유 |
|---|---|---|---|
| `connection-refused` | 전송 전 | `REJECTED` | 바이트가 나가지 않았으므로 broker가 가질 수 없다 |
| `connection-cut-after-write` | 전송 후 | `AMBIGUOUS` | broker가 저장했고 confirm만 유실됐을 수 있다 |
| `confirm-timeout` | 전송 후 | `AMBIGUOUS` | timeout은 부재의 증거가 아니라 증거의 부재다 |
| `settlement-lost` | settlement 중 | `REDELIVERED` | 미settlement 메시지는 재전달이 설계다 |
| `high-latency` | 전송 후 | `AMBIGUOUS` | 판단 시점에는 confirm 유실과 구별할 수 없다 |
`CrossBrokerContractSuite`가 릴리스 게이트로 이를 강제한다. Stable 어댑터는 5개 전부를 **실 브로커에서**
커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라
*무엇을 실제로 돌렸는지*의 기록이다.
### 실 브로커가 실제로 잡아낸 결함
이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한
결함을 두 건 잡았다.
1. **Outbox `IN_FLIGHT` 고아 행** — lease 쿼리가 `PENDING`/`AMBIGUOUS`만 클레임 대상으로 봐서,
publish 도중 죽은 relay가 남긴 행이 lease 만료 후에도 영영 회수되지 않았다.
2. **Rabbit confirm 경합** — transport가 publish *후에* confirm을 등록해서, 연결 스레드에서
confirm이 먼저 도착하면 유실되고 호출자가 무한 대기했다.
둘 다 인메모리 double이 실제보다 관대해서 통과하고 있었다.
## 명시적 비지원
- 공통 `EXACTLY_ONCE` 설정 — `DeliveryGuarantee`에 상수가 존재하지 않는다.
- 전역 순서 — `OrderingScope``GLOBAL`이 존재하지 않는다.
- DB와 broker의 자동 원자 transaction, 기본 XA
- Java native serialization
- 무제한 payload·header, 무한 retry
- 운영 application에서의 topology 파괴 작업
- 일반 애플리케이션에 raw broker client 반환
- DLQ publish 확인 전 source ACK
+33
View File
@@ -0,0 +1,33 @@
# Kafka 4.3.x in KRaft mode.
#
# Single broker on purpose: this compose file exists to reproduce the platform's Stable profile
# locally, not to model a production cluster. The settings below are the ones the profile guard
# enforces, so a local run fails the same way a misconfigured deployment would.
services:
kafka:
image: apache/kafka:4.3.0
container_name: messaging-kafka
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
# acks=all is only a durability guarantee when more than one replica must acknowledge.
# With a single broker the platform still requires acks=all; min.insync.replicas is 1 here
# and is expected to be 2 in any environment that claims replication evidence.
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_MIN_INSYNC_REPLICAS: 1
# Topology is created by infrastructure, never by the application.
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
interval: 5s
timeout: 10s
retries: 20
+21
View File
@@ -0,0 +1,21 @@
# NATS 2.14.x with JetStream, Experimental tier.
#
# JetStream is mandatory: core NATS is fire-and-forget with no persistence and no acknowledgement,
# so an at-least-once destination configured against it would report success for messages that were
# never stored. The adapter's validator refuses that combination.
services:
nats:
image: nats:2.14-alpine
container_name: messaging-nats
ports:
- "4222:4222"
- "8222:8222"
command:
- "--jetstream"
- "--store_dir=/data"
- "--http_port=8222"
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8222/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 20
@@ -0,0 +1,27 @@
# PostgreSQL 16 for the Outbox and Inbox.
#
# logical replication is enabled so the optional Debezium CDC relay can be exercised against the
# same database the polling relay uses; the two must produce an identical wire contract.
services:
postgres:
image: postgres:16-alpine
container_name: messaging-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: messaging
POSTGRES_USER: messaging
POSTGRES_PASSWORD: messaging
command:
- "postgres"
- "-c"
- "wal_level=logical"
- "-c"
- "max_replication_slots=4"
- "-c"
- "max_wal_senders=4"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U messaging -d messaging"]
interval: 5s
timeout: 5s
retries: 20
+17
View File
@@ -0,0 +1,17 @@
# Pulsar 4.0 LTS, Experimental tier.
#
# Present so the Experimental adapter can be exercised, not because it is supported. The adapter
# stays disabled unless backend.messaging.experimental.pulsar=true.
services:
pulsar:
image: apachepulsar/pulsar:4.0.3
container_name: messaging-pulsar
ports:
- "6650:6650"
- "8080:8080"
command: bin/pulsar standalone --no-functions-worker --no-stream-storage
healthcheck:
test: ["CMD", "bin/pulsar-admin", "brokers", "healthcheck"]
interval: 10s
timeout: 10s
retries: 20
@@ -0,0 +1,22 @@
# RabbitMQ 4.3.x.
#
# Quorum queues are the default for durable work queues in this platform, so the classic mirroring
# policy is deliberately absent: classic mirrored queues can lose acknowledged messages during a
# partition, which is precisely the guarantee a durable work queue exists to provide.
services:
rabbitmq:
image: rabbitmq:4.3-management
container_name: messaging-rabbitmq
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: messaging
RABBITMQ_DEFAULT_PASS: messaging
# Publisher confirms and returns are client-side settings; the profile guard enforces them.
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit consumer_timeout 1800000"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
interval: 5s
timeout: 10s
retries: 20
+35
View File
@@ -0,0 +1,35 @@
# TLS material
Production profiles require TLS **and** hostname verification; `MessageSecurityValidator` fails
startup without either.
No key material is committed here, and none should be. Certificates are issued by the deployment's
own PKI and mounted at runtime; a keystore in a repository is a credential in a repository, and
rotating it means a commit.
## Local development
The compose files in the sibling directories run plaintext listeners deliberately. They exist to
reproduce the *messaging* semantics locally, not the transport security, and running them with
`production: false` in the destination profile is what keeps the validator honest — a profile marked
`production: true` against a plaintext broker must fail, and that is a test, not an inconvenience.
## Generating a local CA for TLS testing
```bash
openssl req -x509 -newkey rsa:4096 -sha256 -days 30 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=messaging-local-ca"
openssl req -newkey rsa:4096 -nodes -keyout broker.key -out broker.csr \
-subj "/CN=localhost"
openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out broker.crt -days 30 -sha256 \
-extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1")
```
The `subjectAltName` is not optional. Hostname verification is required in production profiles, and
a certificate without a SAN fails it — which is the correct outcome, not something to work around by
disabling the check.
Generated files are ignored by `.gitignore` in this directory.
@@ -0,0 +1,19 @@
# Toxiproxy, for the failures that matter most.
#
# The platform's hardest guarantee is that a lost confirmation is reported as AMBIGUOUS rather than
# guessed. A healthy broker will not lose one on request, so the chaos suite puts a proxy in front of
# it and severs the connection after the record was accepted but before the acknowledgement arrives.
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.12.0
container_name: messaging-toxiproxy
ports:
- "8474:8474" # control API
- "19092:19092" # proxied Kafka
- "15673:15673" # proxied RabbitMQ
- "15433:15433" # proxied PostgreSQL
healthcheck:
test: ["CMD", "/toxiproxy-cli", "list"]
interval: 5s
timeout: 5s
retries: 20
+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
}

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