5057 lines
196 KiB
Markdown
5057 lines
196 KiB
Markdown
# Messaging Platform Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Java/Spring Backend Skeleton에 evidence 기반 publish 결과, duplicate-safe consumer settlement, Kafka·RabbitMQ Stable Adapter, Outbox·Inbox Reliability, Pulsar·NATS Experimental Adapter, Admin Plane, 보안·관측성·장애 검증을 갖춘 Messaging 플랫폼을 구현한다.
|
||
|
||
**Architecture:** `messaging-core-api`가 브로커 중립 공개 계약을 소유하고 `messaging-transport-spi`를 Kafka·RabbitMQ·Pulsar·NATS adapter가 구현한다. 일반 서비스는 M1 Typed Publisher·Handler만 사용하며, M2 Advanced, M3 Native Capability, M4 Admin Plane을 별도 모듈·권한으로 격리한다. publish 결과는 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence를 보존하고, consumer는 handler 성공 뒤에만 settlement하며 retry·DLQ·redrive에서 logical `messageId`를 유지한다.
|
||
|
||
**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Kafka, Spring AMQP, Apache Pulsar Client, NATS Java Client, Spring JDBC/JPA, PostgreSQL 16, Flyway, Jackson, optional Avro and Protobuf, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy.
|
||
|
||
## Global Constraints
|
||
|
||
- `messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message<?>`, Reactor 의존성을 넣지 않는다.
|
||
- Core 비동기 계약은 `CompletionStage`다. Blocking과 Reactor facade는 별도 모듈에서 제공한다.
|
||
- 일반 업무 모듈의 기본 진입점은 M1 Typed Publisher·Handler다.
|
||
- M2는 Batch, Manual Settlement, Pause/Resume, Delayed Delivery, Replay Request만 제공한다.
|
||
- M3는 broker-native capability를 typed interface로만 제공하며 raw broker client를 반환하지 않는다.
|
||
- M4 Admin Plane은 별도 credential과 security chain을 사용한다.
|
||
- 공통 API에 `EXACTLY_ONCE`, `GLOBAL_ORDERING`, DB+broker 원자 transaction 옵션을 만들지 않는다.
|
||
- Stable Adapter는 Kafka 4.2+·4.3.x와 RabbitMQ 4.3.x다.
|
||
- Kafka Share Group, Pulsar 4.0 LTS·4.2, NATS JetStream 2.14.x는 Experimental이다.
|
||
- Stable Kafka producer는 `enable.idempotence=true`, `acks=all`, `max.in.flight.requests.per.connection<=5`를 강제한다.
|
||
- Rabbit publisher는 correlated confirm, return, `mandatory=true`를 강제한다.
|
||
- durable Rabbit work queue 기본은 quorum queue다.
|
||
- publish timeout 또는 connection loss 뒤 broker 처리 여부를 확정할 수 없으면 `AMBIGUOUS`다.
|
||
- publish retry, redelivery, retry destination, DLQ, redrive는 같은 logical `messageId`를 유지한다.
|
||
- M1 consumer는 handler 성공 이전에 source settlement를 수행하지 않는다.
|
||
- DLQ·retry destination publish가 confirm되기 전에 source를 ACK하지 않는다.
|
||
- strict ordering destination에 reorder 가능한 retry 전략을 설정하면 startup을 실패시킨다.
|
||
- JSON은 Stable 기본 codec, Avro·Protobuf는 선택 Stable, Raw Bytes는 M2, Java Serialization은 비지원이다.
|
||
- Domain·Integration Event에 CloudEvents 1.0.2 compatible profile을 선택 제공한다.
|
||
- logical payload 기본 최대는 1,048,576 bytes, global hard maximum은 8,388,608 bytes다.
|
||
- header 총 크기는 32,768 bytes, 개수 64개, key 128 bytes, value 4,096 bytes다.
|
||
- 1 MiB 초과 payload는 Claim Check를 기본으로 사용한다.
|
||
- production topology는 IaC 생성 + application validate-only다.
|
||
- producer, consumer, admin credential을 분리하고 production TLS·broker authentication을 필수화한다.
|
||
- metric label에 message ID, actual key, payload, unbounded tenant ID, credential을 기록하지 않는다.
|
||
- Outbox ambiguous publish는 같은 message ID로 재시도한다.
|
||
- Inbox row와 business side effect는 같은 PostgreSQL transaction에서 commit한다.
|
||
- 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다.
|
||
- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다.
|
||
- 테스트 코드에 사용되는 `*Fixture`, `*Harness`, `Fake*`, `Test*` support type은 별도 경로가 명시되지 않으면 해당 Task의 listed test file 하단에 package-private top-level type으로 함께 작성한다.
|
||
|
||
---
|
||
|
||
## 1. 확정 파일 구조
|
||
|
||
```text
|
||
backend-skeleton/
|
||
├── settings.gradle.kts
|
||
├── build.gradle.kts
|
||
├── build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts
|
||
├── modules/messaging/
|
||
│ ├── messaging-core-api/
|
||
│ ├── messaging-schema-api/
|
||
│ ├── messaging-schema-json/
|
||
│ ├── messaging-schema-avro/
|
||
│ ├── messaging-schema-protobuf/
|
||
│ ├── messaging-cloudevents/
|
||
│ ├── messaging-policy/
|
||
│ ├── messaging-transport-spi/
|
||
│ ├── messaging-observability/
|
||
│ ├── messaging-security/
|
||
│ ├── messaging-kafka/
|
||
│ ├── messaging-kafka-share-experimental/
|
||
│ ├── messaging-rabbit/
|
||
│ ├── messaging-reliability-api/
|
||
│ ├── messaging-outbox-jpa/
|
||
│ ├── messaging-inbox-jpa/
|
||
│ ├── messaging-claim-check/
|
||
│ ├── messaging-admin-api/
|
||
│ ├── messaging-admin-runtime/
|
||
│ ├── messaging-pulsar-experimental/
|
||
│ ├── messaging-nats-experimental/
|
||
│ ├── messaging-spring-cloud-stream-bridge/
|
||
│ ├── messaging-spring-boot-starter/
|
||
│ └── messaging-testkit/
|
||
├── infra/messaging/
|
||
│ ├── kafka/
|
||
│ ├── rabbitmq/
|
||
│ ├── pulsar/
|
||
│ ├── nats/
|
||
│ ├── postgres/
|
||
│ ├── toxiproxy/
|
||
│ └── tls/
|
||
├── docs/messaging/
|
||
│ ├── support-matrix.md
|
||
│ ├── configuration-reference.md
|
||
│ ├── delivery-guarantees.md
|
||
│ ├── retry-dlq-redrive.md
|
||
│ ├── outbox-inbox.md
|
||
│ ├── security.md
|
||
│ ├── operations.md
|
||
│ ├── migration-guide.md
|
||
│ └── experimental-policy.md
|
||
└── docs/superpowers/specs/2026-08-10-messaging-platform-design.md
|
||
```
|
||
|
||
## 2. 핵심 패키지
|
||
|
||
```text
|
||
io.backend.skeleton.messaging.api
|
||
io.backend.skeleton.messaging.api.delivery
|
||
io.backend.skeleton.messaging.api.destination
|
||
io.backend.skeleton.messaging.api.error
|
||
io.backend.skeleton.messaging.api.header
|
||
io.backend.skeleton.messaging.api.publish
|
||
io.backend.skeleton.messaging.api.settlement
|
||
io.backend.skeleton.messaging.schema
|
||
io.backend.skeleton.messaging.schema.json
|
||
io.backend.skeleton.messaging.schema.avro
|
||
io.backend.skeleton.messaging.schema.protobuf
|
||
io.backend.skeleton.messaging.cloudevents
|
||
io.backend.skeleton.messaging.policy
|
||
io.backend.skeleton.messaging.transport
|
||
io.backend.skeleton.messaging.observation
|
||
io.backend.skeleton.messaging.security
|
||
io.backend.skeleton.messaging.kafka
|
||
io.backend.skeleton.messaging.rabbit
|
||
io.backend.skeleton.messaging.reliability
|
||
io.backend.skeleton.messaging.outbox
|
||
io.backend.skeleton.messaging.inbox
|
||
io.backend.skeleton.messaging.claimcheck
|
||
io.backend.skeleton.messaging.admin
|
||
io.backend.skeleton.messaging.pulsar
|
||
io.backend.skeleton.messaging.nats
|
||
io.backend.skeleton.messaging.streambridge
|
||
io.backend.skeleton.messaging.autoconfigure
|
||
io.backend.skeleton.messaging.testkit
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성
|
||
|
||
**Files:**
|
||
- Modify: `settings.gradle.kts`
|
||
- Create: `build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts`
|
||
- Create: `modules/messaging/messaging-core-api/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-schema-api/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-schema-json/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-schema-avro/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-schema-protobuf/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-cloudevents/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-policy/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-transport-spi/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-observability/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-security/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-kafka/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-kafka-share-experimental/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-rabbit/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-reliability-api/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-claim-check/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-admin-api/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-admin-runtime/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-nats-experimental/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-testkit/build.gradle.kts`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/ModuleSmokeTest.java`
|
||
|
||
**Interfaces:**
|
||
- Produces every Gradle project path used by later tasks.
|
||
- `messaging-core-api` has no Spring or broker dependency.
|
||
- Java toolchain is 21 and all tests use JUnit Platform.
|
||
|
||
- [ ] **Step 1: Write the failing core module smoke test**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ModuleSmokeTest {
|
||
@Test
|
||
void coreApiModuleLoads() {
|
||
assertThat(ModuleSmokeTest.class.getPackageName())
|
||
.isEqualTo("io.backend.skeleton.messaging.api");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register module paths and verify configuration fails before build files exist**
|
||
|
||
Add to `settings.gradle.kts`:
|
||
|
||
```kotlin
|
||
include(
|
||
":modules:messaging:messaging-core-api",
|
||
":modules:messaging:messaging-schema-api",
|
||
":modules:messaging:messaging-schema-json",
|
||
":modules:messaging:messaging-schema-avro",
|
||
":modules:messaging:messaging-schema-protobuf",
|
||
":modules:messaging:messaging-cloudevents",
|
||
":modules:messaging:messaging-policy",
|
||
":modules:messaging:messaging-transport-spi",
|
||
":modules:messaging:messaging-observability",
|
||
":modules:messaging:messaging-security",
|
||
":modules:messaging:messaging-kafka",
|
||
":modules:messaging:messaging-kafka-share-experimental",
|
||
":modules:messaging:messaging-rabbit",
|
||
":modules:messaging:messaging-reliability-api",
|
||
":modules:messaging:messaging-outbox-jpa",
|
||
":modules:messaging:messaging-inbox-jpa",
|
||
":modules:messaging:messaging-claim-check",
|
||
":modules:messaging:messaging-admin-api",
|
||
":modules:messaging:messaging-admin-runtime",
|
||
":modules:messaging:messaging-pulsar-experimental",
|
||
":modules:messaging:messaging-nats-experimental",
|
||
":modules:messaging:messaging-spring-cloud-stream-bridge",
|
||
":modules:messaging:messaging-spring-boot-starter",
|
||
":modules:messaging:messaging-testkit"
|
||
)
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: FAIL because one or more registered module directories or build files are missing.
|
||
|
||
- [ ] **Step 3: Add the convention plugin and module build files**
|
||
|
||
`messaging-library-conventions.gradle.kts`:
|
||
|
||
```kotlin
|
||
plugins {
|
||
`java-library`
|
||
}
|
||
|
||
java {
|
||
toolchain {
|
||
languageVersion.set(JavaLanguageVersion.of(21))
|
||
}
|
||
withSourcesJar()
|
||
}
|
||
|
||
tasks.withType<Test>().configureEach {
|
||
useJUnitPlatform()
|
||
}
|
||
|
||
dependencies {
|
||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||
testImplementation("org.assertj:assertj-core")
|
||
}
|
||
```
|
||
|
||
Every module build file starts with:
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("messaging-library-conventions")
|
||
}
|
||
```
|
||
|
||
Add only the dependencies required by that module. `messaging-core-api` remains dependency-free except test libraries.
|
||
|
||
- [ ] **Step 4: Run the complete module smoke build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS with one test and zero failures.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add settings.gradle.kts build-logic modules/messaging
|
||
git commit -m "build: add messaging platform modules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 핵심 식별자와 보장 Enum 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/UuidV7.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageId.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageType.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/SchemaVersion.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ProducerId.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CorrelationId.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CausationId.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryGuarantee.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ProcessingGuarantee.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/OrderingScope.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ExternalSideEffectGuarantee.java`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/CoreValueTypesTest.java`
|
||
|
||
**Interfaces:**
|
||
- Produces immutable value types used by every later task.
|
||
- `MessageId.newId()` uses the local `UuidV7.next()` implementation created in this Task.
|
||
- No enum contains `EXACTLY_ONCE` or `GLOBAL`.
|
||
|
||
- [ ] **Step 1: Write failing value-type tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api;
|
||
|
||
import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee;
|
||
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.Arrays;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class CoreValueTypesTest {
|
||
@Test
|
||
void messageTypeRejectsBlankValue() {
|
||
assertThatThrownBy(() -> new MessageType(" "))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void schemaVersionMustBePositive() {
|
||
assertThatThrownBy(() -> new SchemaVersion(0))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void guaranteeEnumsDoNotAdvertiseUnsupportedSemantics() {
|
||
assertThat(Arrays.stream(DeliveryGuarantee.values()).map(Enum::name))
|
||
.doesNotContain("EXACTLY_ONCE");
|
||
assertThat(Arrays.stream(OrderingScope.values()).map(Enum::name))
|
||
.doesNotContain("GLOBAL");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the focused test and verify missing types**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test --tests '*CoreValueTypesTest'
|
||
```
|
||
|
||
Expected: FAIL because the value types and enums do not exist.
|
||
|
||
- [ ] **Step 3: Implement the value types and exact enum constants**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api;
|
||
|
||
public record MessageType(String value) {
|
||
public MessageType {
|
||
if (value == null || value.isBlank() || value.length() > 240) {
|
||
throw new IllegalArgumentException("messageType must contain 1 to 240 characters");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api;
|
||
|
||
public record SchemaVersion(int value) {
|
||
public SchemaVersion {
|
||
if (value < 1) {
|
||
throw new IllegalArgumentException("schemaVersion must be positive");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.delivery;
|
||
|
||
public enum DeliveryGuarantee {
|
||
AT_MOST_ONCE,
|
||
AT_LEAST_ONCE
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.delivery;
|
||
|
||
public enum OrderingScope {
|
||
NONE,
|
||
DESTINATION,
|
||
PARTITION,
|
||
KEY
|
||
}
|
||
```
|
||
|
||
Implement the remaining records with null, blank, and length validation. Implement `MessageId` as a non-null UUID value and provide `newId()`.
|
||
|
||
- [ ] **Step 4: Run tests and architecture compilation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS with no unsupported guarantee constants.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api
|
||
git commit -m "feat: add messaging core value types"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 제한형 Header와 MessageEnvelope 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderName.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderValue.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/MessageHeaders.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/ReservedHeaders.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ContentType.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TenantContext.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TraceContext.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageEnvelope.java`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/MessageEnvelopeTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Task 2 value types.
|
||
- Produces `MessageEnvelope<T>` and immutable `MessageHeaders` for publisher, schema, and adapters.
|
||
- Enforces 64 headers, 32 KiB total, 128-byte keys, 4 KiB values, reserved and secret header rejection.
|
||
|
||
- [ ] **Step 1: Write failing envelope policy tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api;
|
||
|
||
import io.backend.skeleton.messaging.api.header.HeaderName;
|
||
import io.backend.skeleton.messaging.api.header.HeaderValue;
|
||
import io.backend.skeleton.messaging.api.header.MessageHeaders;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.Map;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class MessageEnvelopeTest {
|
||
@Test
|
||
void applicationCannotSetReservedHeader() {
|
||
assertThatThrownBy(() -> MessageHeaders.application(Map.of(
|
||
new HeaderName("msg.id"), new HeaderValue("forged"))))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void secretHeadersAreRejected() {
|
||
assertThatThrownBy(() -> MessageHeaders.application(Map.of(
|
||
new HeaderName("Authorization"), new HeaderValue("Bearer secret"))))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void payloadCannotBeNull() {
|
||
assertThatThrownBy(() -> TestEnvelopeFactory.envelope(null))
|
||
.isInstanceOf(NullPointerException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test and confirm missing envelope policy**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test --tests '*MessageEnvelopeTest'
|
||
```
|
||
|
||
Expected: FAIL because header and envelope types do not exist.
|
||
|
||
- [ ] **Step 3: Implement immutable header limits and envelope validation**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.header;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
public final class MessageHeaders {
|
||
private static final int MAX_COUNT = 64;
|
||
private static final int MAX_TOTAL_BYTES = 32_768;
|
||
private static final Set<String> SECRET_NAMES = Set.of(
|
||
"authorization", "proxy-authorization", "cookie", "set-cookie",
|
||
"access_token", "refresh_token", "api_key", "password", "client_secret");
|
||
|
||
private final Map<HeaderName, HeaderValue> values;
|
||
|
||
private MessageHeaders(Map<HeaderName, HeaderValue> values) {
|
||
this.values = Map.copyOf(values);
|
||
}
|
||
|
||
public static MessageHeaders application(Map<HeaderName, HeaderValue> input) {
|
||
if (input.size() > MAX_COUNT) {
|
||
throw new IllegalArgumentException("message header count exceeds 64");
|
||
}
|
||
int bytes = 0;
|
||
Map<HeaderName, HeaderValue> copy = new LinkedHashMap<>();
|
||
for (Map.Entry<HeaderName, HeaderValue> entry : input.entrySet()) {
|
||
String normalized = entry.getKey().value().toLowerCase();
|
||
if (ReservedHeaders.isReserved(normalized) || SECRET_NAMES.contains(normalized)) {
|
||
throw new IllegalArgumentException("message header is not allowed: " + normalized);
|
||
}
|
||
bytes += entry.getKey().value().getBytes(StandardCharsets.UTF_8).length;
|
||
bytes += entry.getValue().value().getBytes(StandardCharsets.UTF_8).length;
|
||
copy.put(entry.getKey(), entry.getValue());
|
||
}
|
||
if (bytes > MAX_TOTAL_BYTES) {
|
||
throw new IllegalArgumentException("message header bytes exceed 32768");
|
||
}
|
||
return new MessageHeaders(copy);
|
||
}
|
||
|
||
public Map<HeaderName, HeaderValue> asMap() {
|
||
return values;
|
||
}
|
||
}
|
||
```
|
||
|
||
Implement `MessageEnvelope<T>` as the exact record from the design and validate all required values with `Objects.requireNonNull`.
|
||
|
||
- [ ] **Step 4: Run focused and full core tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS and all reserved/secret header tests succeed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api
|
||
git commit -m "feat: add message envelope and header policy"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 논리 Destination과 Capability 모델 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationName.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationKind.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessageDestination.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/ConfirmationRequirement.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessagingCapabilities.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilities.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/CapabilityRegistry.java`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilityTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Task 2 identifiers and guarantees.
|
||
- Produces logical destination and capability contracts used by policy and adapters.
|
||
|
||
- [ ] **Step 1: Write failing capability tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.destination;
|
||
|
||
import io.backend.skeleton.messaging.api.MessageType;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class DestinationCapabilityTest {
|
||
@Test
|
||
void logicalDestinationDoesNotContainBrokerSpecificAddress() {
|
||
MessageDestination<String> destination = new MessageDestination<>(
|
||
new DestinationName("order-events"),
|
||
new MessageType("order.created"),
|
||
String.class);
|
||
|
||
assertThat(destination.name().value()).isEqualTo("order-events");
|
||
}
|
||
|
||
@Test
|
||
void destinationNameRejectsBrokerSeparatorsAndWhitespace() {
|
||
assertThatThrownBy(() -> new DestinationName("topic://orders"))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused test and observe missing types**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test --tests '*DestinationCapabilityTest'
|
||
```
|
||
|
||
Expected: FAIL because destination types do not exist.
|
||
|
||
- [ ] **Step 3: Implement exact capability contracts**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.destination;
|
||
|
||
public record MessagingCapabilities(
|
||
boolean brokerAcknowledgement,
|
||
boolean replicationOrPersistenceEvidence,
|
||
boolean perMessageSettlement,
|
||
boolean batchSettlement,
|
||
boolean orderedStream,
|
||
boolean keyedOrdering,
|
||
boolean replay,
|
||
boolean delayedDelivery,
|
||
boolean brokerTransaction,
|
||
boolean deduplicatedPublish,
|
||
boolean nativeDeadLetter,
|
||
boolean topologyManagement) {
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.destination;
|
||
|
||
public interface CapabilityRegistry {
|
||
DestinationCapabilities capabilities(DestinationName destination);
|
||
}
|
||
```
|
||
|
||
Implement `DestinationName` with `[a-z0-9][a-z0-9.-]{0,159}` validation. Implement `DestinationKind` with `ASYNC_COMMAND`, `DOMAIN_EVENT`, `INTEGRATION_EVENT`, `WORK_QUEUE`, `PUBLISH_SUBSCRIBE`, `EVENT_STREAM`, `REQUEST_REPLY`. Implement `MessageDestination<T>` as an immutable record requiring name, message type, and payload type.
|
||
|
||
- [ ] **Step 4: Run core tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api
|
||
git commit -m "feat: add logical destination capabilities"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Schema API와 JSON Codec 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodec.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/EncodedMessage.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaReference.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibility.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodecRegistry.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/RawBytesMessageCodec.java`
|
||
- Create: `modules/messaging/messaging-schema-json/src/main/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodec.java`
|
||
- Test: `modules/messaging/messaging-schema-json/src/test/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodecTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MessageType`, `SchemaVersion`, `ContentType`.
|
||
- Produces codec SPI and JSON Stable codec.
|
||
- Rejects unknown message types, oversized encoded payloads, trailing data, and excessive nesting.
|
||
|
||
- [ ] **Step 1: Write failing JSON round-trip and limit tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.schema.json;
|
||
|
||
import io.backend.skeleton.messaging.api.MessageType;
|
||
import io.backend.skeleton.messaging.api.SchemaVersion;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class JacksonMessageCodecTest {
|
||
record OrderCreated(String orderId, long amount) {}
|
||
|
||
@Test
|
||
void roundTripsRegisteredType() {
|
||
JacksonMessageCodec codec = JacksonMessageCodec.testingDefault(
|
||
new MessageType("order.created"), OrderCreated.class);
|
||
|
||
byte[] encoded = codec.encode(
|
||
new MessageType("order.created"),
|
||
new SchemaVersion(1),
|
||
new OrderCreated("o-1", 1000)).bytes();
|
||
|
||
assertThat(codec.decode(
|
||
new MessageType("order.created"),
|
||
new SchemaVersion(1),
|
||
encoded,
|
||
OrderCreated.class)).isEqualTo(new OrderCreated("o-1", 1000));
|
||
}
|
||
|
||
@Test
|
||
void rejectsPayloadOverOneMibibyte() {
|
||
JacksonMessageCodec codec = JacksonMessageCodec.testingDefault(
|
||
new MessageType("text.large"), String.class);
|
||
String value = "a".repeat(1_048_577);
|
||
|
||
assertThatThrownBy(() -> codec.encode(
|
||
new MessageType("text.large"), new SchemaVersion(1), value))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run JSON module test and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-schema-json:test --tests '*JacksonMessageCodecTest'
|
||
```
|
||
|
||
Expected: FAIL because codec classes are missing.
|
||
|
||
- [ ] **Step 3: Implement the schema SPI and Jackson codec**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.schema;
|
||
|
||
import io.backend.skeleton.messaging.api.ContentType;
|
||
import io.backend.skeleton.messaging.api.MessageType;
|
||
import io.backend.skeleton.messaging.api.SchemaVersion;
|
||
|
||
public interface MessageCodec {
|
||
ContentType contentType();
|
||
|
||
EncodedMessage encode(MessageType type, SchemaVersion version, Object payload);
|
||
|
||
<T> T decode(
|
||
MessageType type,
|
||
SchemaVersion version,
|
||
byte[] encoded,
|
||
Class<T> payloadType);
|
||
}
|
||
```
|
||
|
||
Configure Jackson with a closed message-type registry, maximum nesting depth 100, trailing token rejection, unknown subtype rejection, and encoded byte limit 1,048,576 by default. Implement `RawBytesMessageCodec` as an explicit M2 codec that copies the input bytes, enforces the same byte limit, and is never selected as a default codec.
|
||
|
||
- [ ] **Step 4: Run schema tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-schema-api:test :modules:messaging:messaging-schema-json:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-json
|
||
git commit -m "feat: add messaging schema and json codec"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: CloudEvents Event Profile 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventMapper.java`
|
||
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/DefaultCloudEventMapper.java`
|
||
- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventExtensions.java`
|
||
- Test: `modules/messaging/messaging-cloudevents/src/test/java/io/backend/skeleton/messaging/cloudevents/CloudEventMappingTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MessageEnvelope` and schema encoded payload.
|
||
- Produces CloudEvents 1.0.2 compatible mapping for events only.
|
||
- Does not map null data to a Kafka tombstone.
|
||
|
||
- [ ] **Step 1: Write failing CloudEvents mapping test**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.cloudevents;
|
||
|
||
import io.cloudevents.CloudEvent;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.net.URI;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class CloudEventMappingTest {
|
||
@Test
|
||
void mapsLogicalIdentityAndExtensions() {
|
||
DefaultCloudEventMapper mapper = new DefaultCloudEventMapper();
|
||
CloudEvent event = mapper.toCloudEvent(
|
||
CloudEventFixture.orderCreatedEnvelope(),
|
||
URI.create("urn:service:order-api"));
|
||
|
||
assertThat(event.getId()).isEqualTo(
|
||
CloudEventFixture.orderCreatedEnvelope().messageId().value().toString());
|
||
assertThat(event.getType()).isEqualTo("order.created");
|
||
assertThat(event.getExtension("schemaversion")).isEqualTo("1");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused test and verify missing mapper**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-cloudevents:test --tests '*CloudEventMappingTest'
|
||
```
|
||
|
||
Expected: FAIL because mapping classes do not exist.
|
||
|
||
- [ ] **Step 3: Implement exact mapping rules**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.cloudevents;
|
||
|
||
import io.backend.skeleton.messaging.api.MessageEnvelope;
|
||
import io.cloudevents.CloudEvent;
|
||
|
||
import java.net.URI;
|
||
|
||
public interface CloudEventMapper {
|
||
CloudEvent toCloudEvent(MessageEnvelope<?> envelope, URI source);
|
||
MessageEnvelope<byte[]> fromCloudEvent(CloudEvent event);
|
||
}
|
||
```
|
||
|
||
Map `messageId→id`, `producer/source→source`, `messageType→type`, `occurredAt→time`, `contentType→datacontenttype`, and the four documented extensions. Reject command envelopes without `occurredAt` when event mode is requested.
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-cloudevents:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-cloudevents
|
||
git commit -m "feat: add cloudevents message profile"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Publisher API, 결과 Evidence, 안정 예외 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/MessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishOptions.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishResult.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishCompletion.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishEvidence.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/TransmissionEvidence.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/ConfirmationLevel.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/RoutingOutcome.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BrokerPosition.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureCategory.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureDescriptor.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingConfigurationException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingCapabilityUnavailableException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageValidationException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSerializationException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSchemaIncompatibleException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTooLargeException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHeaderRejectedException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishRejectedException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishAmbiguousException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishTimeoutException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRoutingException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthenticationException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthorizationException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageConsumerException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHandlerTimeoutException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementUnknownException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRetryExhaustedException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageDeadLetterException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRedriveException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTopologyException.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBrokerUnavailableException.java`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/publish/PublishResultTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes destination and envelope contracts.
|
||
- Produces the stable publish contract used by all adapters and Outbox.
|
||
- `AMBIGUOUS` is a first-class completion and cannot be marked as retryable success.
|
||
|
||
- [ ] **Step 1: Write failing publish result invariant tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.publish;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.time.Duration;
|
||
import java.util.Optional;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class PublishResultTest {
|
||
@Test
|
||
void confirmedResultRequiresBrokerAcceptance() {
|
||
assertThatThrownBy(() -> new PublishResult(
|
||
PublishCompletion.CONFIRMED,
|
||
new PublishEvidence(false, TransmissionEvidence.TRANSMITTED,
|
||
false, ConfirmationLevel.NONE),
|
||
RoutingOutcome.UNKNOWN,
|
||
Optional.empty(),
|
||
1,
|
||
Duration.ofMillis(10),
|
||
Optional.empty()))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void ambiguousResultCannotClaimReplicationConfirmation() {
|
||
assertThatThrownBy(() -> new PublishResult(
|
||
PublishCompletion.AMBIGUOUS,
|
||
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED,
|
||
true, ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK),
|
||
RoutingOutcome.UNKNOWN,
|
||
Optional.empty(),
|
||
1,
|
||
Duration.ofSeconds(5),
|
||
Optional.empty()))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused test and confirm missing result model**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test --tests '*PublishResultTest'
|
||
```
|
||
|
||
Expected: FAIL because publish contracts are missing.
|
||
|
||
- [ ] **Step 3: Implement publish API and invariants**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.publish;
|
||
|
||
import io.backend.skeleton.messaging.api.MessageEnvelope;
|
||
import io.backend.skeleton.messaging.api.destination.MessageDestination;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public interface MessagePublisher {
|
||
<T> CompletionStage<PublishResult> publish(
|
||
MessageDestination<T> destination,
|
||
MessageEnvelope<T> message,
|
||
PublishOptions options);
|
||
}
|
||
```
|
||
|
||
Implement `PublishResult` constructor validation:
|
||
|
||
```java
|
||
if (completion == PublishCompletion.CONFIRMED && !evidence.brokerAccepted()) {
|
||
throw new IllegalArgumentException("confirmed publish requires broker acceptance");
|
||
}
|
||
if (completion == PublishCompletion.AMBIGUOUS
|
||
&& evidence.confirmationLevel() != ConfirmationLevel.NONE) {
|
||
throw new IllegalArgumentException("ambiguous publish cannot claim confirmation");
|
||
}
|
||
```
|
||
|
||
Add the full stable exception hierarchy from the design with sanitized metadata and no payload fields.
|
||
|
||
- [ ] **Step 4: Run core tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api
|
||
git commit -m "feat: add publish evidence contract"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Consumer Delivery, HandleResult, Settlement 계약 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageHandler.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageDelivery.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryMetadata.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryContext.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/HandleResult.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementController.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementResult.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementCompletion.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/ManualMessageHandler.java`
|
||
- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/delivery/ConsumerContractTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes envelope, destination, broker position.
|
||
- Produces M1 handler result and M2 settlement contracts.
|
||
- M1 exposes no broker ACK handle.
|
||
|
||
- [ ] **Step 1: Write failing consumer contract tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.delivery;
|
||
|
||
import io.backend.skeleton.messaging.api.error.FailureCategory;
|
||
import io.backend.skeleton.messaging.api.error.FailureDescriptor;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ConsumerContractTest {
|
||
@Test
|
||
void retryCarriesStableFailureCategory() {
|
||
FailureDescriptor failure = new FailureDescriptor(
|
||
FailureCategory.PROCESSING_TRANSIENT,
|
||
"DOWNSTREAM_TIMEOUT",
|
||
true,
|
||
"downstream timed out",
|
||
java.util.Optional.of("TimeoutException"));
|
||
|
||
HandleResult result = new HandleResult.Retry(failure);
|
||
|
||
assertThat(((HandleResult.Retry) result).failure().retryable()).isTrue();
|
||
}
|
||
|
||
@Test
|
||
void deliveryMetadataCountsInitialDeliveryAsAttemptOne() {
|
||
DeliveryMetadata metadata = DeliveryMetadataFixture.initial();
|
||
assertThat(metadata.deliveryAttempt()).isEqualTo(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test and verify missing delivery types**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test --tests '*ConsumerContractTest'
|
||
```
|
||
|
||
Expected: FAIL because delivery and settlement contracts are missing.
|
||
|
||
- [ ] **Step 3: Implement exact interfaces and sealed results**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.delivery;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public interface MessageHandler<T> {
|
||
CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.delivery;
|
||
|
||
import io.backend.skeleton.messaging.api.error.FailureDescriptor;
|
||
|
||
public sealed interface HandleResult
|
||
permits HandleResult.Success, HandleResult.Retry,
|
||
HandleResult.DeadLetter, HandleResult.Reject {
|
||
record Success() implements HandleResult {}
|
||
record Retry(FailureDescriptor failure) implements HandleResult {}
|
||
record DeadLetter(FailureDescriptor failure) implements HandleResult {}
|
||
record Reject(FailureDescriptor failure) implements HandleResult {}
|
||
}
|
||
```
|
||
|
||
Implement `SettlementController` with `ack`, `retry`, `deadLetter`, `reject` returning `CompletionStage<SettlementResult>` and no native broker parameters.
|
||
|
||
- [ ] **Step 4: Run core tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api
|
||
git commit -m "feat: add consumer delivery and settlement contracts"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Destination Profile, Retry Policy, Startup Validator 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfile.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PhysicalDestination.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SchemaPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ProducerPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ConsumerPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryMode.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/OrderingImpact.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadPolicy.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfileValidator.java`
|
||
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DestinationProfileValidatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes destination guarantees and capabilities from Tasks 2 and 4.
|
||
- Produces immutable destination profiles used by adapters and starter configuration.
|
||
- Enforces ordering, confirmation, payload, DLQ, retry cycle, production topology, and capability rules.
|
||
|
||
- [ ] **Step 1: Write failing startup validation tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class DestinationProfileValidatorTest {
|
||
private final DestinationProfileValidator validator = new DestinationProfileValidator();
|
||
|
||
@Test
|
||
void strictOrderingRejectsRetryDestination() {
|
||
DestinationProfile profile = DestinationProfileFixtures.kafkaOrdered(
|
||
new RetryPolicy(
|
||
RetryMode.RETRY_DESTINATION,
|
||
5,
|
||
java.time.Duration.ofSeconds(1),
|
||
java.time.Duration.ofMinutes(1),
|
||
2.0,
|
||
true,
|
||
OrderingImpact.PRESERVE,
|
||
java.util.Set.of(),
|
||
java.util.Set.of()));
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("ordering");
|
||
}
|
||
|
||
@Test
|
||
void payloadAboveHardLimitIsRejected() {
|
||
DestinationProfile profile = DestinationProfileFixtures.withPayloadLimit(8_388_609);
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("8388608");
|
||
}
|
||
|
||
@Test
|
||
void dlqCannotPointToItself() {
|
||
DestinationProfile profile = DestinationProfileFixtures.selfReferencingDlq();
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("dead letter");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run policy tests and verify missing profile model**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test --tests '*DestinationProfileValidatorTest'
|
||
```
|
||
|
||
Expected: FAIL because the profile and validator types are missing.
|
||
|
||
- [ ] **Step 3: Implement profile records and exact validation rules**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee;
|
||
import io.backend.skeleton.messaging.api.delivery.ExternalSideEffectGuarantee;
|
||
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
|
||
import io.backend.skeleton.messaging.api.destination.DestinationKind;
|
||
import io.backend.skeleton.messaging.api.destination.DestinationName;
|
||
|
||
public record DestinationProfile(
|
||
DestinationName name,
|
||
String broker,
|
||
DestinationKind kind,
|
||
PhysicalDestination physical,
|
||
SchemaPolicy schema,
|
||
DeliveryGuarantee deliveryGuarantee,
|
||
OrderingScope orderingScope,
|
||
ExternalSideEffectGuarantee externalSideEffectGuarantee,
|
||
ProducerPolicy producer,
|
||
ConsumerPolicy consumer,
|
||
RetryPolicy retry,
|
||
DeadLetterPolicy deadLetter,
|
||
PayloadPolicy payload,
|
||
boolean production) {
|
||
}
|
||
```
|
||
|
||
Implement these exact guards:
|
||
|
||
```java
|
||
if (profile.retry().orderingImpact() == OrderingImpact.PRESERVE
|
||
&& profile.retry().mode() == RetryMode.RETRY_DESTINATION) {
|
||
throw new IllegalArgumentException("retry destination cannot preserve ordering");
|
||
}
|
||
if (profile.payload().maxBytes() > 8_388_608) {
|
||
throw new IllegalArgumentException("payload maximum exceeds 8388608 bytes");
|
||
}
|
||
if (profile.deadLetter().enabled()
|
||
&& profile.deadLetter().destination().equals(profile.name())) {
|
||
throw new IllegalArgumentException("dead letter destination cannot reference itself");
|
||
}
|
||
```
|
||
|
||
Add graph validation for retry and DLQ cycles and require a key resolver when `OrderingScope.KEY` is configured.
|
||
|
||
- [ ] **Step 4: Run policy tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-policy
|
||
git commit -m "feat: add messaging destination policies"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Transport SPI와 Immutable Runtime Registry 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingTransport.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishRequest.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishResult.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerSpec.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerRegistration.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportSettlement.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntime.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeLease.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistry.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/DefaultMessagingRuntimeRegistry.java`
|
||
- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistryTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core publish, delivery, schema, and destination policy contracts.
|
||
- Produces the adapter SPI and atomic runtime generation replacement used for credential and topology rotation.
|
||
- Does not return native client objects.
|
||
|
||
- [ ] **Step 1: Write failing runtime generation tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.transport;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingRuntimeRegistryTest {
|
||
@Test
|
||
void replacingRuntimeReturnsNewGenerationAndKeepsOldUntilReleased() {
|
||
DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry();
|
||
MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1);
|
||
MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2);
|
||
|
||
registry.install(first);
|
||
MessagingRuntimeLease lease = registry.acquire("kafka-primary");
|
||
registry.install(second);
|
||
|
||
assertThat(lease.runtime().generation()).isEqualTo(1);
|
||
assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2);
|
||
lease.close();
|
||
assertThat(first.isClosed()).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused test and verify missing runtime SPI**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-transport-spi:test --tests '*MessagingRuntimeRegistryTest'
|
||
```
|
||
|
||
Expected: FAIL because runtime registry types are missing.
|
||
|
||
- [ ] **Step 3: Implement transport interfaces and reference-counted runtime generations**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.transport;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public interface MessagingTransport extends AutoCloseable {
|
||
CompletionStage<TransportPublishResult> publish(TransportPublishRequest request);
|
||
TransportConsumerRegistration register(TransportConsumerSpec spec);
|
||
String brokerName();
|
||
long generation();
|
||
@Override void close();
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.transport;
|
||
|
||
public interface MessagingRuntimeRegistry {
|
||
void install(MessagingRuntime runtime);
|
||
MessagingRuntimeLease acquire(String brokerName);
|
||
}
|
||
```
|
||
|
||
Use an atomic map swap and reference count. Mark old runtimes draining after replacement and close them only after the last lease closes or the configured drain deadline expires.
|
||
|
||
- [ ] **Step 4: Run transport SPI tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-transport-spi:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-transport-spi
|
||
git commit -m "feat: add messaging transport runtime spi"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: 공통 Security·Redaction·Observation Primitive 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerCredentialProfile.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerSecurityProfile.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialProvider.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/DestinationAccessPolicy.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/MessageSecurityValidator.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingObservation.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTags.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingRedactor.java`
|
||
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingRedactorTest.java`
|
||
- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/MessageSecurityValidatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes envelope and destination identity.
|
||
- Produces low-cardinality observation context, redaction, credential abstraction, and destination ACL validation.
|
||
- Actual broker TLS/auth integration is implemented in Task 31.
|
||
|
||
- [ ] **Step 1: Write failing redaction and forbidden-header tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.observation;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.Map;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingRedactorTest {
|
||
@Test
|
||
void removesMessageIdentityAndSecretsFromDiagnosticMap() {
|
||
MessagingRedactor redactor = new MessagingRedactor();
|
||
|
||
Map<String, String> sanitized = redactor.sanitize(Map.of(
|
||
"messageId", "0190f4aa-0000-7000-8000-000000000001",
|
||
"Authorization", "Bearer secret",
|
||
"destinationProfile", "order-events"));
|
||
|
||
assertThat(sanitized)
|
||
.containsEntry("destinationProfile", "order-events")
|
||
.doesNotContainKeys("messageId", "Authorization");
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.security;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class MessageSecurityValidatorTest {
|
||
@Test
|
||
void productionBrokerWithoutTlsIsRejected() {
|
||
BrokerSecurityProfile profile = BrokerSecurityProfileFixtures.productionWithoutTls();
|
||
|
||
assertThatThrownBy(() -> new MessageSecurityValidator().validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("TLS");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run both modules and verify missing primitives**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test
|
||
```
|
||
|
||
Expected: FAIL because security and observation types are missing.
|
||
|
||
- [ ] **Step 3: Implement closed credential profiles and low-cardinality tags**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.security;
|
||
|
||
public sealed interface BrokerCredentialProfile
|
||
permits BrokerCredentialProfile.SaslScram, BrokerCredentialProfile.OAuth2,
|
||
BrokerCredentialProfile.MutualTls, BrokerCredentialProfile.UsernamePassword,
|
||
BrokerCredentialProfile.Nkey {
|
||
String credentialId();
|
||
record SaslScram(String credentialId) implements BrokerCredentialProfile {}
|
||
record OAuth2(String credentialId) implements BrokerCredentialProfile {}
|
||
record MutualTls(String credentialId) implements BrokerCredentialProfile {}
|
||
record UsernamePassword(String credentialId) implements BrokerCredentialProfile {}
|
||
record Nkey(String credentialId) implements BrokerCredentialProfile {}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.observation;
|
||
|
||
public record MessagingTags(
|
||
String broker,
|
||
String destinationProfile,
|
||
String operation,
|
||
String outcome,
|
||
String failureCategory,
|
||
String retryStage) {
|
||
}
|
||
```
|
||
|
||
Implement redaction using a fixed denylist for message ID, keys, credentials, payload, cookies, authorization, and exception messages. Validate production TLS and the separation of producer, consumer, and admin credential IDs.
|
||
|
||
- [ ] **Step 4: Run security and observability tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-security modules/messaging/messaging-observability
|
||
git commit -m "feat: add messaging security and observation primitives"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Broker-neutral Contract Testkit 기반 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterHarness.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterContract.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractMessage.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/FaultController.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractAssertions.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryMessagingHarness.java`
|
||
- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryHarnessContractTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core API and transport SPI.
|
||
- Produces the common adapter suite invoked by Kafka, Rabbit, Pulsar, and NATS tests.
|
||
- Contract covers confirmation, ambiguity, redelivery, settlement, DLQ failure, identity preservation, payload/header limits, and shutdown.
|
||
|
||
- [ ] **Step 1: Write failing in-memory harness contract**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Nested;
|
||
|
||
class InMemoryHarnessContractTest {
|
||
@Nested
|
||
class Contract extends MessagingAdapterContract {
|
||
@Override
|
||
protected MessagingAdapterHarness harness() {
|
||
return InMemoryMessagingHarness.create();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The abstract contract must contain concrete `@Test` methods named:
|
||
|
||
```text
|
||
publishesAndConfirms
|
||
returnsAmbiguousWhenConfirmIsLost
|
||
redeliversWhenSettlementIsLost
|
||
preservesMessageIdAcrossRetryAndDlq
|
||
keepsSourceUnsettledWhenDlqPublishFails
|
||
rejectsOversizedPayloadBeforeTransport
|
||
stopsAcceptingNewWorkDuringShutdown
|
||
```
|
||
|
||
- [ ] **Step 2: Run testkit tests and verify missing abstract suite**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-testkit:test
|
||
```
|
||
|
||
Expected: FAIL because the testkit contract is missing.
|
||
|
||
- [ ] **Step 3: Implement the abstract contract and deterministic in-memory harness**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
public abstract class MessagingAdapterContract {
|
||
protected abstract MessagingAdapterHarness harness();
|
||
|
||
@Test
|
||
void publishesAndConfirms() {
|
||
MessagingAdapterHarness harness = harness();
|
||
ContractAssertions.assertConfirmed(
|
||
harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join());
|
||
}
|
||
|
||
@Test
|
||
void returnsAmbiguousWhenConfirmIsLost() {
|
||
MessagingAdapterHarness harness = harness();
|
||
harness.faults().dropPublishConfirmation();
|
||
ContractAssertions.assertAmbiguous(
|
||
harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join());
|
||
}
|
||
}
|
||
```
|
||
|
||
Implement the remaining named tests with deterministic latches. The in-memory harness exists only to validate the contract itself and is not a production adapter.
|
||
|
||
- [ ] **Step 4: Run testkit tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-testkit:test
|
||
```
|
||
|
||
Expected: PASS with every abstract contract method executed by the in-memory harness.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-testkit
|
||
git commit -m "test: add messaging adapter contract suite"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Retry Decision Engine 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryContext.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecision.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecisionEngine.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DefaultRetryDecisionEngine.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/BackoffCalculator.java`
|
||
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/RetryDecisionEngineTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Task 9 policies, Task 8 failure descriptors, and Task 4 capabilities.
|
||
- Produces `RetryInline`, `PauseAndRetry`, `PublishToRetryDestination`, `DeadLetter`, or `Reject` decisions.
|
||
|
||
- [ ] **Step 1: Write failing retry decision tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import io.backend.skeleton.messaging.api.error.FailureCategory;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RetryDecisionEngineTest {
|
||
private final DefaultRetryDecisionEngine engine = new DefaultRetryDecisionEngine(
|
||
new BackoffCalculator());
|
||
|
||
@Test
|
||
void deserializationFailureGoesDirectlyToParking() {
|
||
RetryDecision decision = engine.decide(
|
||
RetryContextFixtures.failure(FailureCategory.DESERIALIZATION, 1));
|
||
|
||
assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class);
|
||
}
|
||
|
||
@Test
|
||
void transientOrderedKafkaFailureUsesPauseStrategy() {
|
||
RetryDecision decision = engine.decide(
|
||
RetryContextFixtures.orderedKafkaTransient(1));
|
||
|
||
assertThat(decision).isInstanceOf(RetryDecision.PauseAndRetry.class);
|
||
}
|
||
|
||
@Test
|
||
void exhaustedAttemptGoesToDeadLetter() {
|
||
RetryDecision decision = engine.decide(
|
||
RetryContextFixtures.transientAtMaximumAttempt());
|
||
|
||
assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused tests and verify missing engine**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test --tests '*RetryDecisionEngineTest'
|
||
```
|
||
|
||
Expected: FAIL because retry decision types are missing.
|
||
|
||
- [ ] **Step 3: Implement deterministic decision and exponential jittered backoff**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
public interface RetryDecisionEngine {
|
||
RetryDecision decide(RetryContext context);
|
||
}
|
||
```
|
||
|
||
Implement the decision order exactly:
|
||
|
||
```text
|
||
non-retryable category
|
||
→ parking or reject
|
||
|
||
attempt >= maxAttempts
|
||
→ dead letter
|
||
|
||
ordering preserve + Kafka partition capability
|
||
→ pause and retry
|
||
|
||
retry destination mode + reorder allowed
|
||
→ publish to retry destination
|
||
|
||
inline or blocking mode
|
||
→ retry inline
|
||
|
||
otherwise
|
||
→ dead letter
|
||
```
|
||
|
||
Backoff is `min(maxDelay, initialDelay * multiplier^(attempt-1))` and applies full jitter when enabled.
|
||
|
||
- [ ] **Step 4: Run policy tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-policy
|
||
git commit -m "feat: add messaging retry decision engine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: 공통 DLQ·Parking Orchestrator 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterMetadata.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterEnvelopeFactory.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestrator.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterResult.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SourceSettlement.java`
|
||
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestratorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MessagePublisher`, `MessageDelivery`, failure descriptor, and source settlement callback.
|
||
- Produces the publish-confirm-before-source-settlement invariant used by every adapter.
|
||
|
||
- [ ] **Step 1: Write failing DLQ sequencing tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.concurrent.atomic.AtomicBoolean;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class DeadLetterOrchestratorTest {
|
||
@Test
|
||
void settlesSourceOnlyAfterDlqConfirmation() {
|
||
AtomicBoolean sourceSettled = new AtomicBoolean(false);
|
||
FakePublisher publisher = FakePublisher.confirming();
|
||
DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(publisher);
|
||
|
||
orchestrator.deadLetter(
|
||
DeadLetterFixtures.delivery(),
|
||
DeadLetterFixtures.failure(),
|
||
() -> sourceSettled.set(true)).toCompletableFuture().join();
|
||
|
||
assertThat(publisher.confirmObservedBefore(sourceSettled)).isTrue();
|
||
}
|
||
|
||
@Test
|
||
void keepsSourceUnsettledWhenDlqPublishFails() {
|
||
AtomicBoolean sourceSettled = new AtomicBoolean(false);
|
||
DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(
|
||
FakePublisher.ambiguous());
|
||
|
||
DeadLetterResult result = orchestrator.deadLetter(
|
||
DeadLetterFixtures.delivery(),
|
||
DeadLetterFixtures.failure(),
|
||
() -> sourceSettled.set(true)).toCompletableFuture().join();
|
||
|
||
assertThat(result.sourceSettled()).isFalse();
|
||
assertThat(sourceSettled).isFalse();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused test and verify missing orchestrator**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test --tests '*DeadLetterOrchestratorTest'
|
||
```
|
||
|
||
Expected: FAIL because DLQ orchestration types are missing.
|
||
|
||
- [ ] **Step 3: Implement confirmed publish before settlement**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public interface SourceSettlement {
|
||
CompletionStage<Void> settle();
|
||
}
|
||
```
|
||
|
||
`DeadLetterOrchestrator.deadLetter` must:
|
||
|
||
```text
|
||
create dead-letter envelope with original messageId
|
||
publish to configured DLQ
|
||
if PublishCompletion.CONFIRMED then call source settlement
|
||
if REJECTED or AMBIGUOUS then do not call source settlement
|
||
return a result containing DLQ publish result and settlement state
|
||
```
|
||
|
||
Do not copy the full stack trace or secret headers into the dead-letter envelope.
|
||
|
||
- [ ] **Step 4: Run policy tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-policy
|
||
git commit -m "feat: add confirmed dead letter orchestration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Kafka Test Topology와 Stable Profile Guard 구현
|
||
|
||
**Files:**
|
||
- Modify: `modules/messaging/messaging-kafka/build.gradle.kts`
|
||
- Create: `infra/messaging/kafka/docker-compose.yml`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBrokerProfile.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidator.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidatorTest.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerFixture.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerSmokeTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes destination policy and security profile.
|
||
- Produces validated Kafka client configuration and Testcontainers topology for later adapter tests.
|
||
- Stable profile requires idempotence, `acks=all`, max in-flight at most five, manual consumer commit, and TLS/auth in production.
|
||
|
||
- [ ] **Step 1: Write failing Kafka profile guard tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class KafkaProfileValidatorTest {
|
||
private final KafkaProfileValidator validator = new KafkaProfileValidator();
|
||
|
||
@Test
|
||
void stableProducerRequiresIdempotenceAndAcksAll() {
|
||
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer(
|
||
false, "1", 5);
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("idempotence");
|
||
}
|
||
|
||
@Test
|
||
void stableProducerRejectsTooManyInFlightRequests() {
|
||
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer(
|
||
true, "all", 6);
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("max.in.flight");
|
||
}
|
||
|
||
@Test
|
||
void consumerAutoCommitIsForbidden() {
|
||
KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withAutoCommit(true);
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("auto commit");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Kafka profile tests and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest'
|
||
```
|
||
|
||
Expected: FAIL because profile classes are missing.
|
||
|
||
- [ ] **Step 3: Implement validation and the Kafka 4.3.x container fixture**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
public final class KafkaProfileValidator {
|
||
public void validate(KafkaBrokerProfile profile) {
|
||
if (profile.stable()
|
||
&& (!profile.enableIdempotence() || !"all".equals(profile.acks()))) {
|
||
throw new IllegalArgumentException(
|
||
"stable Kafka producer requires idempotence and acks=all");
|
||
}
|
||
if (profile.maxInFlightRequestsPerConnection() > 5) {
|
||
throw new IllegalArgumentException(
|
||
"max.in.flight.requests.per.connection must be at most 5");
|
||
}
|
||
if (profile.enableAutoCommit()) {
|
||
throw new IllegalArgumentException("consumer auto commit is forbidden");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Create a KRaft-based Kafka 4.3.x Testcontainer fixture. Expose a factory for producer, consumer, topic creation, broker stop, and broker restart. Keep image tags centralized in the fixture.
|
||
|
||
- [ ] **Step 4: Run Kafka profile and container smoke tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' --tests '*KafkaContainerSmokeTest'
|
||
```
|
||
|
||
Expected: PASS and the broker reports a 4.3.x version.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka infra/messaging/kafka
|
||
git commit -m "feat: add kafka stable profile guard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Kafka Producer Adapter와 Publish Evidence 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaMessagingTransport.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishMapper.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapper.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPosition.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishFailureClassifier.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProducerContractTest.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapperTest.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaPublishAmbiguityIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes transport SPI, schema codec, policy profile, and Kafka profile.
|
||
- Produces Stable Kafka publish implementation and `KafkaPosition(topic, partition, offset)`.
|
||
- Maps serializer and authorization failures to `REJECTED`; post-send confirmation loss to `AMBIGUOUS`.
|
||
|
||
- [ ] **Step 1: Write failing producer contract and header mapping tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
|
||
import org.junit.jupiter.api.Nested;
|
||
|
||
class KafkaProducerContractTest {
|
||
@Nested
|
||
class Contract extends MessagingAdapterContract {
|
||
@Override
|
||
protected MessagingAdapterHarness harness() {
|
||
return KafkaHarnessFixture.publisherHarness();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class KafkaHeaderMapperTest {
|
||
@Test
|
||
void writesLogicalIdentityAndSchemaHeaders() {
|
||
var headers = new KafkaHeaderMapper().toKafkaHeaders(
|
||
KafkaFixtures.orderCreatedEnvelope());
|
||
|
||
assertThat(new String(headers.lastHeader("msg.type").value()))
|
||
.isEqualTo("order.created");
|
||
assertThat(new String(headers.lastHeader("msg.schema-version").value()))
|
||
.isEqualTo("1");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Kafka producer tests and verify missing adapter**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaHeaderMapperTest'
|
||
```
|
||
|
||
Expected: FAIL because the transport and mapper are missing.
|
||
|
||
- [ ] **Step 3: Implement publish mapping and failure classification**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import io.backend.skeleton.messaging.api.publish.PublishCompletion;
|
||
import io.backend.skeleton.messaging.api.publish.PublishResult;
|
||
import io.backend.skeleton.messaging.transport.MessagingTransport;
|
||
import io.backend.skeleton.messaging.transport.TransportPublishRequest;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public final class KafkaMessagingTransport implements MessagingTransport {
|
||
private final KafkaTemplate<byte[], byte[]> template;
|
||
private final KafkaPublishMapper mapper;
|
||
private final KafkaPublishFailureClassifier failures;
|
||
|
||
@Override
|
||
public CompletionStage<TransportPublishResult> publish(
|
||
TransportPublishRequest request) {
|
||
ProducerRecord<byte[], byte[]> record = mapper.toRecord(request);
|
||
return template.send(record).handle((result, error) -> {
|
||
if (error != null) {
|
||
return failures.classify(request, error);
|
||
}
|
||
return KafkaPublishMapper.confirmed(result.getRecordMetadata());
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
The failure classifier must return `AMBIGUOUS` for delivery timeout or connection failure after the record entered the producer, and `REJECTED` for serialization, invalid topic, authentication, authorization, and producer fencing.
|
||
|
||
- [ ] **Step 4: Run producer contract and ambiguity integration tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaPublishAmbiguityIT'
|
||
```
|
||
|
||
Expected: PASS. The ambiguity test drops the confirmation path after the broker accepted the record and receives `PublishCompletion.AMBIGUOUS`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka
|
||
git commit -m "feat: add kafka publish evidence adapter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Kafka Consumer Group, Partition Coordinator, Contiguous Offset Commit 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeliveryMapper.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionWorkCoordinator.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionOffsetTracker.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTracker.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementCommand.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementQueue.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTrackerTest.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerSettlementIT.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRebalanceIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MessageHandler`, retry decision, transport consumer SPI, and Kafka profile.
|
||
- Produces a Stable traditional consumer-group implementation.
|
||
- Handler workers never call Kafka consumer methods directly; the poll thread drains settlement commands.
|
||
|
||
- [ ] **Step 1: Write failing contiguous offset and settlement-order tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.apache.kafka.common.TopicPartition;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ContiguousPartitionOffsetTrackerTest {
|
||
@Test
|
||
void commitsOnlyThroughHighestContiguousCompletedOffset() {
|
||
TopicPartition partition = new TopicPartition("orders", 0);
|
||
ContiguousPartitionOffsetTracker tracker = new ContiguousPartitionOffsetTracker();
|
||
|
||
tracker.delivered(partition, 10);
|
||
tracker.delivered(partition, 11);
|
||
tracker.delivered(partition, 12);
|
||
tracker.completed(partition, 10);
|
||
tracker.completed(partition, 12);
|
||
|
||
assertThat(tracker.highestContiguousCompleted(partition)).hasValue(10);
|
||
|
||
tracker.completed(partition, 11);
|
||
assertThat(tracker.highestContiguousCompleted(partition)).hasValue(12);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class KafkaConsumerSettlementIT {
|
||
@Test
|
||
void doesNotCommitOffsetBeforeHandlerSuccess() {
|
||
KafkaConsumerHarness harness = KafkaConsumerHarness.startPausedHandler();
|
||
harness.publish("order.created", "o-1");
|
||
harness.awaitHandlerStarted();
|
||
|
||
assertThat(harness.committedOffset()).isEmpty();
|
||
|
||
harness.completeHandlerSuccessfully();
|
||
assertThat(harness.awaitCommittedOffset()).hasValue(1L);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused tests and verify missing coordinator**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT'
|
||
```
|
||
|
||
Expected: FAIL because consumer coordinator classes are missing.
|
||
|
||
- [ ] **Step 3: Implement partition pause, bounded workers, and poll-thread settlement**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.apache.kafka.common.TopicPartition;
|
||
|
||
import java.util.OptionalLong;
|
||
|
||
public interface PartitionOffsetTracker {
|
||
void delivered(TopicPartition partition, long offset);
|
||
void completed(TopicPartition partition, long offset);
|
||
OptionalLong highestContiguousCompleted(TopicPartition partition);
|
||
}
|
||
```
|
||
|
||
Implement `PartitionWorkCoordinator` with one in-flight handler per partition for strict ordering. The poll thread must:
|
||
|
||
```text
|
||
poll records
|
||
record delivered offsets
|
||
pause partitions with in-flight work
|
||
dispatch handler to bounded worker
|
||
drain settlement queue on every poll
|
||
commit highest contiguous completed offset + 1
|
||
resume partition after terminal settlement
|
||
```
|
||
|
||
On rebalance revoke, stop dispatching new work, drain completed settlements within the revoke deadline, and leave unfinished offsets uncommitted for redelivery.
|
||
|
||
- [ ] **Step 4: Run consumer and rebalance tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' --tests '*KafkaConsumerRebalanceIT'
|
||
```
|
||
|
||
Expected: PASS. No offset beyond a gap is committed and unfinished deliveries are redelivered after rebalance.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka
|
||
git commit -m "feat: add kafka consumer settlement coordinator"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Kafka Ordered Retry, Retry Topic, DLT 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryExecutor.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPartitionRetryScheduler.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicPublisher.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeadLetterPublisher.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryMetadataMapper.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaOrderedRetryIT.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicIdentityIT.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaDltFailureIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Task 13 retry decisions and Task 14 DLQ orchestrator.
|
||
- Produces `PAUSE_PARTITION` retry for order-preserving destinations and retry-topic flow for reorder-allowed destinations.
|
||
- Preserves logical message ID and settles the source only after retry/DLT confirmation.
|
||
|
||
- [ ] **Step 1: Write failing ordering and identity tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class KafkaRetryTopicIdentityIT {
|
||
@Test
|
||
void retryTopicPreservesOriginalMessageIdAndIncrementsAttempt() {
|
||
KafkaRetryHarness harness = KafkaRetryHarness.start();
|
||
var original = harness.publishFailingMessage();
|
||
var retried = harness.awaitRetryTopicRecord();
|
||
|
||
assertThat(retried.messageId()).isEqualTo(original.messageId());
|
||
assertThat(retried.deliveryAttempt()).isEqualTo(2);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class KafkaDltFailureIT {
|
||
@Test
|
||
void sourceOffsetRemainsUncommittedWhenDltPublishCannotBeConfirmed() {
|
||
KafkaRetryHarness harness = KafkaRetryHarness.withUnavailableDlt();
|
||
harness.publishPermanentlyFailingMessage();
|
||
|
||
assertThat(harness.awaitSourceOffsetCommit()).isEmpty();
|
||
assertThat(harness.sourcePartitionPaused()).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests and verify missing retry implementation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT'
|
||
```
|
||
|
||
Expected: FAIL because Kafka retry executors are missing.
|
||
|
||
- [ ] **Step 3: Implement the two retry paths and confirmed DLT flow**
|
||
|
||
`KafkaRetryExecutor` dispatches by decision:
|
||
|
||
```java
|
||
return switch (decision) {
|
||
case RetryDecision.PauseAndRetry retry ->
|
||
partitionRetryScheduler.schedule(delivery, retry.delay());
|
||
case RetryDecision.PublishToRetryDestination retry ->
|
||
retryTopicPublisher.publish(delivery, retry.destination(), retry.delay());
|
||
case RetryDecision.DeadLetter deadLetter ->
|
||
deadLetterPublisher.publish(delivery, deadLetter.failure());
|
||
case RetryDecision.Reject reject ->
|
||
settlement.reject(reject.failure());
|
||
case RetryDecision.RetryInline inline ->
|
||
partitionRetryScheduler.schedule(delivery, inline.delay());
|
||
};
|
||
```
|
||
|
||
For retry-topic and DLT paths, wait for `PublishCompletion.CONFIRMED` before placing a source commit command. On `REJECTED` or `AMBIGUOUS`, leave the source uncommitted and pause the partition.
|
||
|
||
- [ ] **Step 4: Run retry integration suite**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT'
|
||
```
|
||
|
||
Expected: PASS. Ordered retry never processes a later record first; retry-topic mode explicitly allows reorder and preserves identity.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka
|
||
git commit -m "feat: add kafka retry and dead letter flows"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Kafka Native Transaction Capability 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalProcessor.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalDelivery.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalPublisher.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/SpringKafkaTransactionalProcessor.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionProfileValidator.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionIT.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionFencingIT.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReadCommittedIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Kafka adapter and M3 capability injection.
|
||
- Produces Kafka-only read-process-write transaction support.
|
||
- Explicitly excludes external DB and HTTP side effects from the guarantee.
|
||
|
||
- [ ] **Step 1: Write failing commit, abort, and read-committed tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class KafkaTransactionIT {
|
||
@Test
|
||
void commitsOutputAndInputOffsetTogether() {
|
||
KafkaTransactionHarness harness = KafkaTransactionHarness.start();
|
||
harness.publishInput("i-1");
|
||
harness.processSuccessfully();
|
||
|
||
assertThat(harness.readCommittedOutput()).containsExactly("i-1-processed");
|
||
assertThat(harness.inputOffsetCommitted()).isTrue();
|
||
}
|
||
|
||
@Test
|
||
void abortHidesOutputAndLeavesInputOffsetUncommitted() {
|
||
KafkaTransactionHarness harness = KafkaTransactionHarness.start();
|
||
harness.publishInput("i-2");
|
||
harness.processWithFailure();
|
||
|
||
assertThat(harness.readCommittedOutput()).isEmpty();
|
||
assertThat(harness.inputOffsetCommitted()).isFalse();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run transaction tests and verify missing native capability**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT'
|
||
```
|
||
|
||
Expected: FAIL because transactional interfaces are missing.
|
||
|
||
- [ ] **Step 3: Implement M3 transaction boundary and profile guard**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import java.util.concurrent.CompletionStage;
|
||
|
||
public interface KafkaTransactionalProcessor<K, V, R> {
|
||
CompletionStage<R> process(
|
||
KafkaTransactionalDelivery<K, V> delivery,
|
||
KafkaTransactionalPublisher publisher);
|
||
}
|
||
```
|
||
|
||
Use Spring Kafka transaction management so consumed offsets and produced Kafka records commit or abort together. Require a unique transactional ID prefix per instance. Reject registration when the destination profile declares `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL`; that path belongs to the Inbox recipe, not Kafka transaction.
|
||
|
||
- [ ] **Step 4: Run transaction, abort, fencing, and `read_committed` tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' --tests '*KafkaReadCommittedIT'
|
||
```
|
||
|
||
Expected: PASS. A fenced producer fails permanently and aborted output is invisible to `read_committed` consumers.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka
|
||
git commit -m "feat: add kafka transactional capability"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Kafka Replay, Seek, Topology Validation Admin Capability 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayCapability.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlanner.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTopologyInspector.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaOffsetResetExecutor.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlannerTest.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTopologyValidationIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Admin API contracts implemented later through an internal adapter interface.
|
||
- Produces read-only replay planning, isolated replay consumer groups, topology inspection, and guarded offset reset.
|
||
- Existing production group reset is never performed without an approved M4 request.
|
||
|
||
- [ ] **Step 1: Write failing replay plan safety tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.time.Instant;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class KafkaReplayPlannerTest {
|
||
@Test
|
||
void createsIsolatedConsumerGroupByDefault() {
|
||
KafkaReplayPlan plan = new KafkaReplayPlanner().plan(
|
||
KafkaReplayRequest.byTimestamp(
|
||
"order.events.v1", Instant.parse("2026-08-01T00:00:00Z")));
|
||
|
||
assertThat(plan.consumerGroup()).startsWith("replay-");
|
||
assertThat(plan.mutatesProductionOffsets()).isFalse();
|
||
}
|
||
|
||
@Test
|
||
void productionOffsetResetRequiresApproval() {
|
||
assertThatThrownBy(() -> new KafkaReplayPlanner().plan(
|
||
KafkaReplayRequest.productionResetWithoutApproval("order-projection")))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run replay tests and verify missing planner**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest'
|
||
```
|
||
|
||
Expected: FAIL because replay classes are missing.
|
||
|
||
- [ ] **Step 3: Implement isolated replay and topology inspection**
|
||
|
||
`KafkaReplayPlanner` supports:
|
||
|
||
```text
|
||
seek by absolute offset
|
||
seek by timestamp
|
||
replay to isolated consumer group
|
||
replay to a new destination
|
||
approved production offset reset
|
||
```
|
||
|
||
`KafkaTopologyInspector` returns partitions, replication factor, minimum ISR, retention, cleanup policy, and topic configuration drift. It performs no mutation.
|
||
|
||
- [ ] **Step 4: Run replay and topology tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' --tests '*KafkaTopologyValidationIT'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka
|
||
git commit -m "feat: add kafka replay and topology capability"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Kafka Share Group Experimental Adapter 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareWorkQueueCapability.java`
|
||
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareGroupRegistrar.java`
|
||
- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidator.java`
|
||
- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidatorTest.java`
|
||
- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareDeliveryIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes M1 handler and Kafka experimental client capability.
|
||
- Produces work-queue style record settlement only.
|
||
- Rejects ordered-stream profiles and remains disabled unless `backend.messaging.experimental.kafka-share=true`.
|
||
|
||
- [ ] **Step 1: Write failing ordering guard test**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka.share;
|
||
|
||
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class KafkaShareProfileValidatorTest {
|
||
@Test
|
||
void rejectsOrderedStreamUse() {
|
||
KafkaShareProfile profile = KafkaShareProfileFixtures.profile(OrderingScope.KEY);
|
||
|
||
assertThatThrownBy(() -> new KafkaShareProfileValidator().validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("ordered stream");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run experimental module tests and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka-share-experimental:test
|
||
```
|
||
|
||
Expected: FAIL because the experimental adapter is missing.
|
||
|
||
- [ ] **Step 3: Implement record-level work queue registration and explicit acknowledgement mapping**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka.share;
|
||
|
||
public interface KafkaShareWorkQueueCapability {
|
||
<T> AutoCloseable register(
|
||
MessageDestination<T> destination,
|
||
MessageHandler<T> handler,
|
||
KafkaShareOptions options);
|
||
}
|
||
```
|
||
|
||
Allow only `DestinationKind.WORK_QUEUE` and `OrderingScope.NONE`. Map share delivery attempt to `DeliveryMetadata.deliveryAttempt` and preserve `messageId` through redelivery.
|
||
|
||
- [ ] **Step 4: Run validator and Kafka 4.2+ Share Group integration tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-kafka-share-experimental:test
|
||
```
|
||
|
||
Expected: PASS. The module does not participate in the default starter classpath.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-kafka-share-experimental
|
||
git commit -m "feat: add experimental kafka share groups"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: RabbitMQ Test Topology와 Stable Profile Guard 구현
|
||
|
||
**Files:**
|
||
- Modify: `modules/messaging/messaging-rabbit/build.gradle.kts`
|
||
- Create: `infra/messaging/rabbitmq/docker-compose.yml`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBrokerProfile.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitTopologyProfile.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidator.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerFixture.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerSmokeTest.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes destination and security policies.
|
||
- Produces Rabbit 4.3.x connection/topology configuration and Testcontainers fixture.
|
||
- Stable durable work queue requires correlated confirm, returns, mandatory publish, manual ACK, and quorum queue.
|
||
|
||
- [ ] **Step 1: Write failing Rabbit profile guard tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class RabbitProfileValidatorTest {
|
||
private final RabbitProfileValidator validator = new RabbitProfileValidator();
|
||
|
||
@Test
|
||
void stablePublisherRequiresCorrelatedConfirmAndReturns() {
|
||
RabbitBrokerProfile profile = RabbitProfileFixtures.withPublisher(false, false, false);
|
||
|
||
assertThatThrownBy(() -> validator.validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("confirm");
|
||
}
|
||
|
||
@Test
|
||
void durableWorkQueueRequiresQuorumQueue() {
|
||
RabbitTopologyProfile topology = RabbitProfileFixtures.classicDurableWorkQueue();
|
||
|
||
assertThatThrownBy(() -> validator.validate(topology))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("quorum");
|
||
}
|
||
|
||
@Test
|
||
void autoAckIsForbiddenForAtLeastOnce() {
|
||
RabbitTopologyProfile topology = RabbitProfileFixtures.autoAckTopology();
|
||
|
||
assertThatThrownBy(() -> validator.validate(topology))
|
||
.isInstanceOf(IllegalArgumentException.class)
|
||
.hasMessageContaining("manual acknowledgement");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Rabbit profile tests and verify missing model**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest'
|
||
```
|
||
|
||
Expected: FAIL because Rabbit profile classes are missing.
|
||
|
||
- [ ] **Step 3: Implement exact Stable profile rules and Rabbit 4.3.x fixture**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
public final class RabbitProfileValidator {
|
||
public void validate(RabbitBrokerProfile profile) {
|
||
if (profile.stable()
|
||
&& (!profile.correlatedConfirms()
|
||
|| !profile.publisherReturns()
|
||
|| !profile.mandatory())) {
|
||
throw new IllegalArgumentException(
|
||
"stable Rabbit publisher requires correlated confirm, returns, and mandatory publish");
|
||
}
|
||
}
|
||
|
||
public void validate(RabbitTopologyProfile topology) {
|
||
if (topology.durableWorkQueue() && topology.queueType() != RabbitQueueType.QUORUM) {
|
||
throw new IllegalArgumentException("durable work queue requires quorum queue");
|
||
}
|
||
if (topology.atLeastOnce() && topology.autoAck()) {
|
||
throw new IllegalArgumentException("at-least-once requires manual acknowledgement");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Create a RabbitMQ 4.3.x container fixture with management API support and helpers for quorum queue creation, node pause/restart, connection blocking, and queue depth inspection.
|
||
|
||
- [ ] **Step 4: Run profile and container smoke tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' --tests '*RabbitContainerSmokeTest'
|
||
```
|
||
|
||
Expected: PASS and the server reports a 4.3.x version.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-rabbit infra/messaging/rabbitmq
|
||
git commit -m "feat: add rabbit stable profile guard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Rabbit Publisher Confirm·Return Evidence Adapter 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishTracker.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishOutcome.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitHeaderMapper.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishFailureClassifier.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishReference.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProducerContractTest.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitUnroutableIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConfirmLossIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes transport SPI, schema codec, and Rabbit profile.
|
||
- Produces publish result by joining publisher confirm and mandatory return.
|
||
- Confirm ACK with unroutable return is `REJECTED`, never confirmed success.
|
||
|
||
- [ ] **Step 1: Write failing producer contract and unroutable tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
|
||
import org.junit.jupiter.api.Nested;
|
||
|
||
class RabbitProducerContractTest {
|
||
@Nested
|
||
class Contract extends MessagingAdapterContract {
|
||
@Override
|
||
protected MessagingAdapterHarness harness() {
|
||
return RabbitHarnessFixture.publisherHarness();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import io.backend.skeleton.messaging.api.publish.PublishCompletion;
|
||
import io.backend.skeleton.messaging.api.publish.RoutingOutcome;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RabbitUnroutableIT {
|
||
@Test
|
||
void confirmedButUnroutableMessageIsRejected() {
|
||
var result = RabbitHarnessFixture.publishToUnboundRoutingKey();
|
||
|
||
assertThat(result.completion()).isEqualTo(PublishCompletion.REJECTED);
|
||
assertThat(result.routingOutcome()).isEqualTo(RoutingOutcome.UNROUTABLE);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run producer tests and verify missing tracker**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT'
|
||
```
|
||
|
||
Expected: FAIL because Rabbit transport and confirm tracker are missing.
|
||
|
||
- [ ] **Step 3: Implement correlation of confirm and return**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
public record RabbitPublishOutcome(
|
||
RabbitConfirmOutcome confirm,
|
||
RoutingOutcome routing,
|
||
java.util.Optional<String> replyCode,
|
||
java.util.Optional<String> replyText) {
|
||
}
|
||
```
|
||
|
||
`RabbitPublishTracker` must create one pending state per publish sequence, accept a return callback before confirm, and complete only when the result is determinable:
|
||
|
||
```text
|
||
return received + confirm ACK → REJECTED/UNROUTABLE
|
||
confirm ACK + no return by callback ordering boundary → CONFIRMED/ROUTED
|
||
confirm NACK → REJECTED
|
||
channel close or timeout before terminal evidence → AMBIGUOUS
|
||
```
|
||
|
||
Use sanitized reply metadata and never include body content in exceptions.
|
||
|
||
- [ ] **Step 4: Run confirm, return, and connection-loss tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' --tests '*RabbitConfirmLossIT'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-rabbit
|
||
git commit -m "feat: add rabbit publish confirm evidence"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Rabbit Consumer Manual ACK, Prefetch, Async Settlement 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryMapper.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryCoordinator.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementQueue.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementCommand.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerSettlementIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitPrefetchIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitAckLossIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes M1 handler, retry engine, and Rabbit container factory.
|
||
- Produces manual-ack consumer flow with bounded concurrency and prefetch.
|
||
- Channel and delivery tag are never exposed to application handlers.
|
||
|
||
- [ ] **Step 1: Write failing settlement and prefetch tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RabbitConsumerSettlementIT {
|
||
@Test
|
||
void messageRemainsUnackedUntilHandlerSuccess() {
|
||
RabbitConsumerHarness harness = RabbitConsumerHarness.startPausedHandler();
|
||
harness.publish("work-1");
|
||
harness.awaitHandlerStarted();
|
||
|
||
assertThat(harness.unackedCount()).isEqualTo(1);
|
||
assertThat(harness.readyCount()).isZero();
|
||
|
||
harness.completeHandlerSuccessfully();
|
||
assertThat(harness.awaitUnackedCount()).isZero();
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RabbitPrefetchIT {
|
||
@Test
|
||
void unackedDeliveriesNeverExceedConfiguredPrefetch() {
|
||
RabbitConsumerHarness harness = RabbitConsumerHarness.withPrefetch(4);
|
||
harness.publishRange(10);
|
||
harness.blockAllHandlers();
|
||
|
||
assertThat(harness.awaitUnackedCount()).isLessThanOrEqualTo(4);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run consumer tests and verify missing coordinator**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT'
|
||
```
|
||
|
||
Expected: FAIL because the consumer implementation is missing.
|
||
|
||
- [ ] **Step 3: Implement manual ACK after handler completion and channel-safe settlement queue**
|
||
|
||
`RabbitDeliveryCoordinator` must:
|
||
|
||
```text
|
||
receive delivery on listener container thread
|
||
map envelope and delivery metadata
|
||
dispatch handler to bounded executor
|
||
place terminal settlement command in a channel-bound queue
|
||
execute ACK/NACK/reject on the owning consumer channel context
|
||
```
|
||
|
||
If the channel closes after handler success but before ACK confirmation, return `SettlementCompletion.UNKNOWN` and allow broker redelivery. The handler is never reinvoked locally as a substitute for broker redelivery.
|
||
|
||
- [ ] **Step 4: Run settlement, prefetch, and ACK-loss tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' --tests '*RabbitAckLossIT'
|
||
```
|
||
|
||
Expected: PASS. ACK loss causes redelivery with `redelivered=true`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-rabbit
|
||
git commit -m "feat: add rabbit consumer settlement flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 25: Rabbit Retry Queue, Confirmed DLQ, Request–Reply M2 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryExecutor.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryTopology.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeadLetterPublisher.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitNativeDeadLetterCapability.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyClient.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRetryIdentityIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitDlxTargetOutageIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyTimeoutIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Task 13 retry decisions and Task 14 DLQ orchestration.
|
||
- Produces retry queue with bounded attempt metadata, platform-managed confirmed DLQ, optional native quorum at-least-once DLX, and limited request–reply.
|
||
|
||
- [ ] **Step 1: Write failing retry identity and DLQ outage tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RabbitRetryIdentityIT {
|
||
@Test
|
||
void retryQueuePreservesLogicalMessageIdentity() {
|
||
RabbitRetryHarness harness = RabbitRetryHarness.start();
|
||
var first = harness.publishFailingWork();
|
||
var retry = harness.awaitRetryDelivery();
|
||
|
||
assertThat(retry.messageId()).isEqualTo(first.messageId());
|
||
assertThat(retry.deliveryAttempt()).isEqualTo(2);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.rabbit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RabbitDlxTargetOutageIT {
|
||
@Test
|
||
void sourceMessageIsNotLostWhenDeadLetterTargetIsUnavailable() {
|
||
RabbitRetryHarness harness = RabbitRetryHarness.withUnavailableDlqTarget();
|
||
harness.publishPermanentFailure();
|
||
|
||
assertThat(harness.sourceUnackedOrReadyCount()).isEqualTo(1);
|
||
assertThat(harness.deadLetterCount()).isZero();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run retry and request–reply tests and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'
|
||
```
|
||
|
||
Expected: FAIL because retry and request–reply types are missing.
|
||
|
||
- [ ] **Step 3: Implement bounded retry routing and request–reply lifecycle**
|
||
|
||
Retry queue rules:
|
||
|
||
```text
|
||
increment platform delivery attempt
|
||
preserve original messageId
|
||
encode retry delay in topology policy, not arbitrary message expiration
|
||
cap cycle using platform attempt plus x-death inspection
|
||
confirm retry publish before source ACK
|
||
```
|
||
|
||
Native quorum at-least-once dead-letter capability must validate `dead-letter-strategy=at-least-once`, target availability policy, and overflow compatibility before activation.
|
||
|
||
`RabbitRequestReplyClient` requires correlation ID and finite timeout, removes late replies, and never reuses request–reply as the default RPC mechanism.
|
||
|
||
- [ ] **Step 4: Run Rabbit retry, DLQ, and request–reply tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-rabbit
|
||
git commit -m "feat: add rabbit retry dlq and request reply"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 26: Reliability API와 Outbox·Inbox Flyway Schema 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/ReliableMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/IdempotentMessageHandler.java`
|
||
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/TransactionalMessageAction.java`
|
||
- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/InboxResult.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/db/migration/messaging-outbox/V1__create_messaging_outbox.sql`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/resources/db/migration/messaging-inbox/V1__create_messaging_inbox.sql`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxSchemaIT.java`
|
||
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxSchemaIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core destination and envelope types.
|
||
- Produces broker-neutral reliability APIs and the exact PostgreSQL schema from the design.
|
||
- Uses PostgreSQL 16 Testcontainers and Flyway migration locations scoped to each module.
|
||
|
||
- [ ] **Step 1: Write failing schema tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.outbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class OutboxSchemaIT extends PostgreSqlReliabilityFixture {
|
||
@Test
|
||
void createsOutboxWithUniqueLogicalMessageIdAndPollIndex() {
|
||
migrate("classpath:db/migration/messaging-outbox");
|
||
|
||
assertThat(columnNames("messaging_outbox"))
|
||
.contains("message_id", "destination", "payload", "status",
|
||
"lease_owner", "lease_until", "version");
|
||
assertThat(uniqueConstraintColumns("messaging_outbox"))
|
||
.containsExactlyInAnyOrder("message_id");
|
||
assertThat(indexNames("messaging_outbox"))
|
||
.contains("ix_messaging_outbox_poll", "ix_messaging_outbox_lease");
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.inbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class InboxSchemaIT extends PostgreSqlReliabilityFixture {
|
||
@Test
|
||
void usesConsumerNameAndMessageIdAsPrimaryKey() {
|
||
migrate("classpath:db/migration/messaging-inbox");
|
||
|
||
assertThat(primaryKeyColumns("messaging_inbox"))
|
||
.containsExactly("consumer_name", "message_id");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run reliability schema tests and verify missing migrations**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test
|
||
```
|
||
|
||
Expected: FAIL because migration files and reliability APIs are missing.
|
||
|
||
- [ ] **Step 3: Implement exact APIs and SQL migrations**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.reliability;
|
||
|
||
public interface ReliableMessagePublisher {
|
||
<T> void addToOutbox(
|
||
MessageDestination<T> destination,
|
||
MessageEnvelope<T> message);
|
||
}
|
||
```
|
||
|
||
Create `messaging_outbox` and `messaging_inbox` exactly as defined in the design. Use `bytea` payload, `jsonb` headers, UUID message IDs, `timestamptz`, poll/lease/expiry indexes, and no broker-specific columns.
|
||
|
||
- [ ] **Step 4: Run schema and API tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-reliability-api:test :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-reliability-api modules/messaging/messaging-outbox-jpa modules/messaging/messaging-inbox-jpa
|
||
git commit -m "feat: add messaging reliability schema"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 27: Transactional Outbox Repository와 Ambiguous-safe Relay 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRecord.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxStatus.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRepository.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepository.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRelay.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRetryScheduler.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxCleanupJob.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxProperties.java`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepositoryIT.java`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxRelayCrashIT.java`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxAmbiguousPublishIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `MessagePublisher`, `MessageCodecRegistry`, and reliability API.
|
||
- Produces `SELECT FOR UPDATE SKIP LOCKED` leasing, same-message-ID retry, and PENDING/CLAIMED/PUBLISHED/RETRYABLE_FAILURE/PARKED transitions.
|
||
|
||
- [ ] **Step 1: Write failing lease and ambiguity tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.outbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class JdbcOutboxRepositoryIT extends OutboxPostgresFixture {
|
||
@Test
|
||
void concurrentRelaysClaimDisjointRows() throws Exception {
|
||
insertPendingMessages(200);
|
||
|
||
var first = java.util.concurrent.CompletableFuture.supplyAsync(
|
||
() -> repository("relay-a").claimBatch(100));
|
||
var second = java.util.concurrent.CompletableFuture.supplyAsync(
|
||
() -> repository("relay-b").claimBatch(100));
|
||
|
||
var firstIds = first.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet());
|
||
var secondIds = second.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet());
|
||
|
||
assertThat(firstIds).doesNotContainAnyElementsOf(secondIds);
|
||
assertThat(firstIds).hasSize(100);
|
||
assertThat(secondIds).hasSize(100);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.outbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class OutboxAmbiguousPublishIT extends OutboxPostgresFixture {
|
||
@Test
|
||
void retriesAmbiguousPublishWithSameLogicalMessageId() {
|
||
var row = insertPendingMessage();
|
||
FakeMessagePublisher publisher = FakeMessagePublisher.ambiguousThenConfirmed();
|
||
OutboxRelay relay = relay(publisher);
|
||
|
||
relay.runOnce();
|
||
relay.runOnce();
|
||
|
||
assertThat(publisher.publishedMessageIds())
|
||
.containsExactly(row.messageId(), row.messageId());
|
||
assertThat(find(row.id()).status()).isEqualTo(OutboxStatus.PUBLISHED);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run focused tests and verify missing repository**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*JdbcOutboxRepositoryIT' --tests '*OutboxAmbiguousPublishIT'
|
||
```
|
||
|
||
Expected: FAIL because repository and relay classes are missing.
|
||
|
||
- [ ] **Step 3: Implement lease query, state transitions, and relay**
|
||
|
||
The claim query is exact:
|
||
|
||
```sql
|
||
select id
|
||
from messaging_outbox
|
||
where status in ('PENDING', 'RETRYABLE_FAILURE')
|
||
and available_at <= now()
|
||
and coalesce(next_attempt_at, available_at) <= now()
|
||
and (lease_until is null or lease_until < now())
|
||
order by created_at
|
||
for update skip locked
|
||
limit :batch_size
|
||
```
|
||
|
||
Within the claim transaction, set `status='CLAIMED'`, increment attempts, set lease owner and lease until. Relay mapping:
|
||
|
||
```text
|
||
CONFIRMED → PUBLISHED, published_at, clear lease
|
||
REJECTED retryable → RETRYABLE_FAILURE, next_attempt_at
|
||
REJECTED permanent → PARKED
|
||
AMBIGUOUS → RETRYABLE_FAILURE with same messageId
|
||
```
|
||
|
||
Default batch is 100, lease 30 seconds, poll interval 500 ms. A shutdown releases leases owned by the current instance. `OutboxCleanupJob` deletes or archives only `PUBLISHED` rows older than the configured audit retention and never removes active or parked rows.
|
||
|
||
- [ ] **Step 4: Run repository, crash, lease-expiry, and ambiguity tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-outbox-jpa:test
|
||
```
|
||
|
||
Expected: PASS. A process stop after DB commit and before publish resumes later; a stop after broker acceptance and before confirm can duplicate publish but keeps the same message ID.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-outbox-jpa
|
||
git commit -m "feat: add transactional outbox relay"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 28: Inbox Transactional Idempotent Consumer 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRepository.java`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/JdbcInboxRepository.java`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandler.java`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRetentionPolicy.java`
|
||
- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxCleanupJob.java`
|
||
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandlerIT.java`
|
||
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxConcurrentDuplicateIT.java`
|
||
- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxRollbackIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes `IdempotentMessageHandler`, `MessageDelivery`, and Spring transaction management.
|
||
- Produces exactly one DB mutation for duplicate deliveries when the mutation and Inbox insert share the same transaction.
|
||
|
||
- [ ] **Step 1: Write failing duplicate and rollback tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.inbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class TransactionalInboxHandlerIT extends InboxPostgresFixture {
|
||
@Test
|
||
void duplicateDeliveryRunsBusinessMutationOnce() {
|
||
var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000001");
|
||
TransactionalInboxHandler<String> handler = handlerThatIncrementsBusinessCounter();
|
||
|
||
handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join();
|
||
handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join();
|
||
|
||
assertThat(businessCounter()).isEqualTo(1);
|
||
assertThat(inboxRowCount()).isEqualTo(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.inbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class InboxRollbackIT extends InboxPostgresFixture {
|
||
@Test
|
||
void businessFailureRollsBackInboxInsert() {
|
||
var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000002");
|
||
|
||
assertThatThrownBy(() -> handler().handleOnce(
|
||
"order-projection", delivery, failingAction()).toCompletableFuture().join())
|
||
.hasCauseInstanceOf(IllegalStateException.class);
|
||
|
||
assertThat(inboxRowCount()).isZero();
|
||
assertThat(businessCounter()).isZero();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Inbox tests and verify missing implementation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-inbox-jpa:test --tests '*TransactionalInboxHandlerIT' --tests '*InboxRollbackIT'
|
||
```
|
||
|
||
Expected: FAIL because Inbox repository and handler are missing.
|
||
|
||
- [ ] **Step 3: Implement insert-first transactional deduplication**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.inbox;
|
||
|
||
public interface InboxRepository {
|
||
boolean insertIfAbsent(
|
||
String consumerName,
|
||
MessageDelivery<?> delivery,
|
||
java.time.Instant expiresAt);
|
||
}
|
||
```
|
||
|
||
`TransactionalInboxHandler.handleOnce` runs inside one DB transaction:
|
||
|
||
```text
|
||
insert Inbox row with ON CONFLICT DO NOTHING
|
||
if inserted=false return InboxResult.DUPLICATE
|
||
execute business action
|
||
commit Inbox row and business mutation together
|
||
return InboxResult.PROCESSED
|
||
```
|
||
|
||
Calculate `expiresAt` from broker retention + DLQ retention + maximum replay/redrive horizon + configured safety margin. Reject a configured Inbox retention shorter than that horizon.
|
||
|
||
- [ ] **Step 4: Run duplicate, concurrent, rollback, and cleanup tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-inbox-jpa:test
|
||
```
|
||
|
||
Expected: PASS. Two concurrent deliveries produce one business mutation, and a failed business transaction leaves no Inbox row.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-inbox-jpa
|
||
git commit -m "feat: add transactional inbox consumer"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 29: Claim Check API와 Integrity·Retention Guard 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckReference.java`
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckStore.java`
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPublisher.java`
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolver.java`
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPolicy.java`
|
||
- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckIntegrityException.java`
|
||
- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolverTest.java`
|
||
- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckRetentionValidatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes the future objectstorage/fileserver port through `ClaimCheckStore` without importing provider types.
|
||
- Produces large-payload upload, reference envelope, checksum validation, and retention validation.
|
||
- Never places a public signed URL in the message.
|
||
|
||
- [ ] **Step 1: Write failing integrity and URL-rejection tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.claimcheck;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.time.Instant;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class ClaimCheckResolverTest {
|
||
@Test
|
||
void rejectsPublicHttpUrlAsObjectIdentifier() {
|
||
assertThatThrownBy(() -> new ClaimCheckReference(
|
||
"object-storage",
|
||
"https://storage.example.com/signed?token=secret",
|
||
2048,
|
||
"SHA-256",
|
||
"abc",
|
||
"application/octet-stream",
|
||
Instant.now().plusSeconds(3600)))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
|
||
@Test
|
||
void rejectsChecksumMismatch() {
|
||
ClaimCheckResolver resolver = ClaimCheckFixtures.resolverReturning("tampered");
|
||
|
||
assertThatThrownBy(() -> resolver.resolve(ClaimCheckFixtures.reference()).toCompletableFuture().join())
|
||
.hasCauseInstanceOf(ClaimCheckIntegrityException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run claim-check tests and verify missing types**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-claim-check:test
|
||
```
|
||
|
||
Expected: FAIL because claim-check types are missing.
|
||
|
||
- [ ] **Step 3: Implement internal object identifiers, checksum, size, and retention validation**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.claimcheck;
|
||
|
||
public record ClaimCheckReference(
|
||
String store,
|
||
String objectId,
|
||
long size,
|
||
String checksumAlgorithm,
|
||
String checksum,
|
||
String contentType,
|
||
java.time.Instant expiresAt) {
|
||
public ClaimCheckReference {
|
||
if (objectId.contains("://")) {
|
||
throw new IllegalArgumentException("claim check objectId must not be a public URL");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`ClaimCheckPublisher` writes the payload, computes SHA-256, and publishes only the reference. `ClaimCheckResolver` streams the object, enforces declared size, and verifies checksum before returning it to the handler. Validate object expiry against message retention, retry, DLQ, and redrive horizon.
|
||
|
||
- [ ] **Step 4: Run claim-check tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-claim-check:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-claim-check
|
||
git commit -m "feat: add messaging claim check"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 30: Avro·Protobuf Codec와 Schema Registry Compatibility Gate 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaRegistry.java`
|
||
- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibilityValidator.java`
|
||
- Create: `modules/messaging/messaging-schema-avro/src/main/java/io/backend/skeleton/messaging/schema/avro/AvroMessageCodec.java`
|
||
- Create: `modules/messaging/messaging-schema-protobuf/src/main/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufMessageCodec.java`
|
||
- Create: `modules/messaging/messaging-schema-avro/src/test/resources/schemas/order.created/v1.avsc`
|
||
- Create: `modules/messaging/messaging-schema-protobuf/src/test/proto/order_created_v1.proto`
|
||
- Test: `modules/messaging/messaging-schema-avro/src/test/java/io/backend/skeleton/messaging/schema/avro/AvroCompatibilityTest.java`
|
||
- Test: `modules/messaging/messaging-schema-protobuf/src/test/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufCompatibilityTest.java`
|
||
|
||
**Interfaces:**
|
||
- Extends Task 5 codec SPI.
|
||
- Produces optional Avro and Protobuf Stable codecs and registry compatibility validation.
|
||
- Raw bytes remain M2 and bypass attempts are audited.
|
||
|
||
- [ ] **Step 1: Write failing backward compatibility tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.schema.avro;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class AvroCompatibilityTest {
|
||
@Test
|
||
void optionalFieldWithDefaultIsBackwardCompatible() {
|
||
var validator = AvroSchemaFixture.validator();
|
||
|
||
assertThat(validator.isBackwardTransitiveCompatible(
|
||
AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithOptionalCurrency()))
|
||
.isTrue();
|
||
}
|
||
|
||
@Test
|
||
void requiredFieldWithoutDefaultIsRejected() {
|
||
var validator = AvroSchemaFixture.validator();
|
||
|
||
assertThat(validator.isBackwardTransitiveCompatible(
|
||
AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithRequiredCurrency()))
|
||
.isFalse();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run codec modules and verify missing implementations**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test
|
||
```
|
||
|
||
Expected: FAIL because codecs and registry interfaces are missing.
|
||
|
||
- [ ] **Step 3: Implement codecs and compatibility policies**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.schema;
|
||
|
||
public interface SchemaRegistry {
|
||
SchemaReference register(
|
||
MessageType type,
|
||
SchemaVersion version,
|
||
byte[] schema,
|
||
SchemaCompatibility compatibility);
|
||
|
||
byte[] resolve(SchemaReference reference);
|
||
}
|
||
```
|
||
|
||
Avro default is `BACKWARD_TRANSITIVE`. Protobuf must preserve unknown fields and reject enum evolution without an unknown-value strategy documented in the generated type. Both codecs enforce the same 1 MiB default encoded size and 8 MiB hard maximum.
|
||
|
||
- [ ] **Step 4: Run schema compatibility and golden-message tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-avro modules/messaging/messaging-schema-protobuf
|
||
git commit -m "feat: add avro and protobuf messaging codecs"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 31: Broker TLS·Authentication·ACL·Credential Rotation 통합
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntime.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistry.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerTlsPolicy.java`
|
||
- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerAclManifest.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSecurityConfigurer.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSecurityConfigurer.java`
|
||
- Create: `infra/messaging/tls/generate-test-pki.sh`
|
||
- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistryTest.java`
|
||
- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTlsAclIT.java`
|
||
- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitTlsAclIT.java`
|
||
|
||
**Interfaces:**
|
||
- Extends Task 11 security primitives and Task 10 runtime generations.
|
||
- Produces TLS 1.2/1.3, hostname validation, SASL/SCRAM or OAuth/mTLS profiles, destination ACL manifests, and zero-downtime generation replacement.
|
||
|
||
- [ ] **Step 1: Write failing rotation and ACL tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.security;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class CredentialRuntimeRegistryTest {
|
||
@Test
|
||
void newCredentialGenerationServesNewPublishesWhileOldGenerationDrains() {
|
||
CredentialRuntimeRegistry registry = CredentialRuntimeFixtures.registry();
|
||
var oldLease = registry.acquire("kafka-producer");
|
||
|
||
registry.rotate(CredentialRuntimeFixtures.generation(2));
|
||
|
||
assertThat(oldLease.runtime().generation()).isEqualTo(1);
|
||
assertThat(registry.acquire("kafka-producer").runtime().generation()).isEqualTo(2);
|
||
oldLease.close();
|
||
assertThat(registry.closedGenerations()).contains(1L);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.kafka;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class KafkaTlsAclIT {
|
||
@Test
|
||
void producerCredentialCannotResetConsumerOffsets() {
|
||
KafkaSecureHarness harness = KafkaSecureHarness.start();
|
||
|
||
assertThatThrownBy(() -> harness.resetOffsetsWithProducerCredential())
|
||
.isInstanceOf(org.apache.kafka.common.errors.AuthorizationException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run security integration tests and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test --tests '*KafkaTlsAclIT' :modules:messaging:messaging-rabbit:test --tests '*RabbitTlsAclIT'
|
||
```
|
||
|
||
Expected: FAIL because broker security integration and PKI fixtures are missing.
|
||
|
||
- [ ] **Step 3: Implement fail-closed TLS, identity separation, and runtime rotation**
|
||
|
||
`BrokerTlsPolicy` permits TLS 1.2 and 1.3, requires hostname verification, and has no trust-all flag. `CredentialRuntimeRegistry` follows the reference-counted generation design from Task 10.
|
||
|
||
Create separate identities:
|
||
|
||
```text
|
||
messaging-producer
|
||
messaging-consumer
|
||
messaging-admin
|
||
```
|
||
|
||
Kafka producer identity gets write-only topic permissions, consumer gets read/group permissions, and admin gets topology/offset permissions. Rabbit identities get vhost configure/write/read permissions according to role. Startup validation compares expected ACL manifest with a dry-run or describe result where the broker supports it.
|
||
|
||
- [ ] **Step 4: Run TLS, hostname mismatch, ACL, and rotation tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test :modules:messaging:messaging-rabbit:test --tests '*Tls*' --tests '*Acl*' --tests '*Credential*'
|
||
```
|
||
|
||
Expected: PASS. Unknown CA, hostname mismatch, expired certificate, and unauthorized destination fail closed without retry storms.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-security modules/messaging/messaging-kafka modules/messaging/messaging-rabbit infra/messaging/tls
|
||
git commit -m "feat: add messaging transport security"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 32: Metrics, Tracing, Audit, Cardinality Guard 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingMetrics.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTracer.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditEvent.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditSink.java`
|
||
- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/DefaultMessagingObservationConvention.java`
|
||
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingMetricCardinalityTest.java`
|
||
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingTraceLinkTest.java`
|
||
- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingSecretLeakTest.java`
|
||
|
||
**Interfaces:**
|
||
- Extends Task 11 primitives.
|
||
- Produces logical publish/consume observations, physical attempt metrics, asynchronous span links, and admin audit events.
|
||
- Internal semantic model is versioned independently of OpenTelemetry exporter names.
|
||
|
||
- [ ] **Step 1: Write failing cardinality and trace-link tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.observation;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingMetricCardinalityTest {
|
||
@Test
|
||
void tenThousandMessageIdsDoNotCreateNewMetricSeries() {
|
||
TestMeterRegistry registry = new TestMeterRegistry();
|
||
MessagingMetrics metrics = new MessagingMetrics(registry);
|
||
|
||
for (int index = 0; index < 10_000; index++) {
|
||
metrics.publishConfirmed(
|
||
"kafka", "order-events", "order.created",
|
||
"0190f4aa-0000-7000-8000-" + String.format("%012d", index));
|
||
}
|
||
|
||
assertThat(registry.seriesCount("messaging.publish"))
|
||
.isLessThanOrEqualTo(2);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.observation;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingTraceLinkTest {
|
||
@Test
|
||
void consumerSpanLinksToProducerContextInsteadOfAssumingDirectParent() {
|
||
TestTracing tracing = TestTracing.create();
|
||
MessagingTracer tracer = new MessagingTracer(tracing.tracer());
|
||
|
||
tracer.consume(MessagingObservationFixtures.delivery()).close();
|
||
|
||
assertThat(tracing.finishedConsumerSpan().links()).hasSize(1);
|
||
assertThat(tracing.finishedConsumerSpan().parentSpanId()).isNull();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run observability tests and verify missing implementation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-observability:test
|
||
```
|
||
|
||
Expected: FAIL because metrics and tracing classes are missing.
|
||
|
||
- [ ] **Step 3: Implement bounded tags and observation lifecycle**
|
||
|
||
Implement counters/timers for publish, confirmation, ambiguity, receive, processing, settlement, redelivery, retry, DLQ, redrive, backlog, schema failure, Outbox, and Inbox. Allowed tags are broker, destination profile, operation, bounded message type, outcome, failure category, retry stage, and schema codec.
|
||
|
||
`MessagingTracer` creates a producer span, stores trace context in reserved headers, and creates a consumer processing span with a link to that context. Admin audit events include operator, approval ID, dry-run flag, operation, target profile, count, and result, but no payload.
|
||
|
||
- [ ] **Step 4: Run cardinality, trace, and secret leak tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-observability:test
|
||
```
|
||
|
||
Expected: PASS. Static log scanning finds no payload, token, or full message ID.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-observability
|
||
git commit -m "feat: add messaging metrics tracing and audit"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 33: Topology Manifest, Broker Inspectors, Validate-only Runtime 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManifest.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/DestinationTopology.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManagementMode.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidationReport.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyIssue.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidator.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/BrokerTopologyInspector.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/CompositeTopologyValidator.java`
|
||
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/TopologyValidatorTest.java`
|
||
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/ProductionAutoCreateGuardTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Kafka and Rabbit read-only topology inspectors.
|
||
- Produces manifest-based drift reports and environment-specific management modes.
|
||
- Production auto-create is rejected before application startup.
|
||
|
||
- [ ] **Step 1: Write failing topology drift and production guard tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.admin;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class TopologyValidatorTest {
|
||
@Test
|
||
void reportsKafkaPartitionAndRabbitQueueTypeDrift() {
|
||
TopologyValidator validator = TopologyFixtures.validatorWithDrift();
|
||
|
||
TopologyValidationReport report = validator.validate();
|
||
|
||
assertThat(report.errors())
|
||
.extracting(TopologyIssue::code)
|
||
.contains("KAFKA_PARTITION_MISMATCH", "RABBIT_QUEUE_TYPE_MISMATCH");
|
||
}
|
||
|
||
@Test
|
||
void productionRejectsAutoCreateMode() {
|
||
assertThatThrownBy(() -> TopologyManifestFixtures.production(
|
||
TopologyManagementMode.AUTO_CREATE_DEV))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run admin topology tests and verify missing contracts**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-admin-runtime:test
|
||
```
|
||
|
||
Expected: FAIL because topology contracts and validator are missing.
|
||
|
||
- [ ] **Step 3: Implement manifest, inspectors, and drift severity**
|
||
|
||
`TopologyValidationReport` contains `INFO`, `WARNING`, and `ERROR` issues with stable codes. Kafka inspection covers partition count, replication factor, minimum ISR, retention, and cleanup policy. Rabbit inspection covers exchange type, queue type, durability, bindings, dead-letter strategy, overflow, and consumer timeout.
|
||
|
||
`AUTO_CREATE_DEV` may create only non-destructive local/test resources. `VALIDATE_ONLY` performs no mutation. `ADMIN_MANAGED` requires the Admin application.
|
||
|
||
- [ ] **Step 4: Run topology unit and broker integration tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-admin-runtime:test :modules:messaging:messaging-kafka:test --tests '*Topology*' :modules:messaging:messaging-rabbit:test --tests '*Topology*'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime modules/messaging/messaging-kafka modules/messaging/messaging-rabbit
|
||
git commit -m "feat: add messaging topology validation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 34: Replay·Redrive Admin Approval, Dry-run, Audit 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayRequest.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayPlan.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveRequest.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedrivePlan.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/AdminApproval.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayResult.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveResult.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedReplayPlan.java`
|
||
- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedRedrivePlan.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/MessagingAdminService.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DefaultMessagingAdminService.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DestructiveMessagingAdmin.java`
|
||
- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/AdminOperationIdempotencyStore.java`
|
||
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/RedriveAdminTest.java`
|
||
- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/DestructiveAdminGuardTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes topology inspectors, broker replay capabilities, publisher, and audit sink.
|
||
- Produces dry-run planning, approval-bound execution, idempotent admin operations, and separate destructive interface.
|
||
|
||
- [ ] **Step 1: Write failing approval and identity tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.admin;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class RedriveAdminTest {
|
||
@Test
|
||
void redriveKeepsOriginalMessageIdAndAddsRedriveId() {
|
||
DefaultMessagingAdminService service = AdminFixtures.service();
|
||
RedrivePlan plan = service.planRedrive(AdminFixtures.redriveDryRun());
|
||
var result = service.executeRedrive(plan.approve(AdminFixtures.approval()));
|
||
|
||
assertThat(result.published().getFirst().messageId())
|
||
.isEqualTo(AdminFixtures.originalMessageId());
|
||
assertThat(result.published().getFirst().redriveId()).isNotNull();
|
||
}
|
||
|
||
@Test
|
||
void executionWithoutApprovalIsRejected() {
|
||
assertThatThrownBy(() -> AdminFixtures.service()
|
||
.executeRedrive(AdminFixtures.unapprovedPlan()))
|
||
.isInstanceOf(SecurityException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run admin tests and verify missing implementation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-admin-runtime:test --tests '*RedriveAdminTest' --tests '*DestructiveAdminGuardTest'
|
||
```
|
||
|
||
Expected: FAIL because admin execution contracts are missing.
|
||
|
||
- [ ] **Step 3: Implement plan-approve-execute and operation idempotency**
|
||
|
||
Every admin operation follows:
|
||
|
||
```text
|
||
plan with dry-run
|
||
capture topology version and estimated count
|
||
approve with approval ID, operator, reason, expiry
|
||
revalidate topology version
|
||
claim approval ID in idempotency store
|
||
execute bounded batches
|
||
write audit result
|
||
```
|
||
|
||
Redrive preserves original `messageId`, creates a new `redriveId`, increments `redriveCount`, and waits for target confirmation before marking source DLQ state. `DestructiveMessagingAdmin` is a separate bean and interface for offset reset, purge, and delete.
|
||
|
||
- [ ] **Step 4: Run approval, duplicate execution, audit, and broker replay tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-admin-runtime:test
|
||
```
|
||
|
||
Expected: PASS. The same approval cannot execute twice and app-role fixtures cannot obtain the destructive bean.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime
|
||
git commit -m "feat: add guarded replay and redrive admin"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 35: Spring Boot Starter, Properties, Auto-configuration, Actuator 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingProperties.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/KafkaMessagingAutoConfiguration.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/RabbitMessagingAutoConfiguration.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingReliabilityAutoConfiguration.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingAdminAutoConfiguration.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpoint.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-valid.yml`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-invalid-ordering.yml`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingAutoConfigurationTest.java`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpointTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes every Stable module and optional Experimental modules by classpath/property.
|
||
- Produces configuration binding, startup validation, runtime registry, actuator endpoints, and conditional adapter beans.
|
||
- Experimental and Admin features are disabled by default.
|
||
|
||
- [ ] **Step 1: Write failing valid/invalid ApplicationContextRunner tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.autoconfigure;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingAutoConfigurationTest {
|
||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
|
||
MessagingCoreAutoConfiguration.class));
|
||
|
||
@Test
|
||
void validConfigurationCreatesTypedPublisher() {
|
||
runner.withPropertyValues(
|
||
"backend.messaging.brokers.kafka-primary.type=KAFKA",
|
||
"backend.messaging.destinations.order-events.broker=kafka-primary",
|
||
"backend.messaging.destinations.order-events.kind=EVENT_STREAM")
|
||
.run(context -> assertThat(context)
|
||
.hasSingleBean(io.backend.skeleton.messaging.api.publish.MessagePublisher.class));
|
||
}
|
||
|
||
@Test
|
||
void invalidOrderingRetryConfigurationFailsStartup() {
|
||
runner.withPropertyValues(
|
||
"backend.messaging.destinations.order-events.ordering=KEY",
|
||
"backend.messaging.destinations.order-events.retry.mode=RETRY_DESTINATION",
|
||
"backend.messaging.destinations.order-events.retry.ordering-impact=PRESERVE")
|
||
.run(context -> assertThat(context).hasFailed());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run starter tests and verify missing auto-configuration**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-spring-boot-starter:test
|
||
```
|
||
|
||
Expected: FAIL because properties and auto-configurations are missing.
|
||
|
||
- [ ] **Step 3: Implement configuration binding, startup validation, and sanitized actuator output**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.autoconfigure;
|
||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||
|
||
@ConfigurationProperties("backend.messaging")
|
||
public record MessagingProperties(
|
||
java.util.Map<String, BrokerProperties> brokers,
|
||
java.util.Map<String, DestinationProperties> destinations,
|
||
MessagingLimitsProperties limits,
|
||
MessagingSecurityProperties security,
|
||
MessagingObservabilityProperties observability,
|
||
MessagingAdminProperties admin,
|
||
MessagingExperimentalProperties experimental) {
|
||
}
|
||
```
|
||
|
||
Auto-configuration order is core → schema → policy → security/observation → transport adapters → reliability → actuator. Admin and Experimental beans require explicit properties. `/actuator/messaging`, `/topology`, `/outbox`, `/capabilities` return only profile names, status, bounded counts, and capability enums.
|
||
|
||
- [ ] **Step 4: Run starter, endpoint, and startup guard tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-spring-boot-starter:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-spring-boot-starter
|
||
git commit -m "feat: add messaging spring boot starter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 36: Blocking·Reactive·Batch Facade와 Cancellation 계약 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BlockingMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishOptions.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishResult.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishItemResult.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishRequest.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/DelayedMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageHandler.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageDelivery.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchDeliveryMetadata.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/PauseResumeController.java`
|
||
- Modify: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBatchConsumerRegistrar.java`
|
||
- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBatchConsumerRegistrar.java`
|
||
- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBlockingMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/ReactiveMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultReactiveMessagePublisher.java`
|
||
- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBatchMessagePublisher.java`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/PublisherFacadeTest.java`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchPublisherTest.java`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchConsumerFacadeTest.java`
|
||
- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/DelayedPublishCapabilityTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core `MessagePublisher` and Spring Reactor dependency in the starter only.
|
||
- Produces blocking and `Mono<PublishResult>` facades, index-preserving batch publish, M2 batch consume, pause/resume, and capability-gated delayed publish.
|
||
- Batch is explicitly non-transactional and receives no transparent whole-batch retry.
|
||
|
||
- [ ] **Step 1: Write failing facade and batch-result tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.autoconfigure;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.time.Duration;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class PublisherFacadeTest {
|
||
@Test
|
||
void blockingFacadeHonorsFiniteTimeout() {
|
||
DefaultBlockingMessagePublisher publisher = PublisherFacadeFixtures.neverCompletingBlocking();
|
||
|
||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> publisher.publish(
|
||
PublisherFacadeFixtures.destination(),
|
||
PublisherFacadeFixtures.envelope(),
|
||
PublisherFacadeFixtures.options(Duration.ofMillis(50))))
|
||
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessagePublishTimeoutException.class);
|
||
}
|
||
|
||
@Test
|
||
void reactiveCancellationCancelsUnderlyingStage() {
|
||
var fixture = PublisherFacadeFixtures.cancellableReactive();
|
||
var subscription = fixture.publisher().publish(
|
||
fixture.destination(), fixture.envelope(), fixture.options())
|
||
.subscribe();
|
||
|
||
subscription.dispose();
|
||
|
||
assertThat(fixture.underlyingCancelled()).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.autoconfigure;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class BatchPublisherTest {
|
||
@Test
|
||
void preservesInputIndexAndIndividualAmbiguity() {
|
||
DefaultBatchMessagePublisher publisher = PublisherFacadeFixtures.batchWithSecondAmbiguous();
|
||
|
||
var result = publisher.publish(PublisherFacadeFixtures.threeRequests(),
|
||
PublisherFacadeFixtures.batchOptions()).toCompletableFuture().join();
|
||
|
||
assertThat(result.items()).extracting(BatchPublishItemResult::index)
|
||
.containsExactly(0, 1, 2);
|
||
assertThat(result.items().get(1).result().completion().name())
|
||
.isEqualTo("AMBIGUOUS");
|
||
}
|
||
}
|
||
```
|
||
|
||
`BatchConsumerFacadeTest` must assert that Kafka commits only completed contiguous records and Rabbit settles each item or the whole batch according to the configured settlement capability. `DelayedPublishCapabilityTest` must assert that Kafka Stable rejects delayed delivery while Rabbit capability profiles accept it.
|
||
|
||
- [ ] **Step 2: Run facade tests and verify missing implementations**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest'
|
||
```
|
||
|
||
Expected: FAIL because facades and batch types are missing.
|
||
|
||
- [ ] **Step 3: Implement finite blocking wait, Reactor cancellation, and per-item batch completion**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.api.publish;
|
||
|
||
public interface BlockingMessagePublisher {
|
||
<T> PublishResult publish(
|
||
MessageDestination<T> destination,
|
||
MessageEnvelope<T> message,
|
||
PublishOptions options);
|
||
}
|
||
```
|
||
|
||
`DefaultBlockingMessagePublisher` waits no longer than `PublishOptions.timeout` and translates timeout to `MessagePublishTimeoutException` without claiming the broker rejected the message. `DefaultReactiveMessagePublisher` uses `Mono.fromCompletionStage` with cancellation propagation where the transport supports it. `DefaultBatchMessagePublisher` assigns indexes, executes within the configured max in-flight limit, and returns every item result without retrying the whole batch. Implement `BatchMessageHandler<T>` as `CompletionStage<HandleResult> handle(BatchMessageDelivery<T> batch)`. Add Kafka and Rabbit batch registrars behind M2 capability checks. Implement `PauseResumeController` without exposing native partition/channel objects. Implement `DelayedMessagePublisher` so an adapter lacking delayed-delivery capability throws `MessagingCapabilityUnavailableException`; Rabbit maps supported delayed profiles, Kafka Stable rejects them.
|
||
|
||
- [ ] **Step 4: Run facade, cancellation, and batch tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-core-api:test :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-core-api modules/messaging/messaging-spring-boot-starter
|
||
git commit -m "feat: add messaging publisher facades"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 37: Debezium Outbox Event Router 선택 Integration 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxProfile.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapper.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumMappedRecord.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/debezium/outbox-event-router.properties`
|
||
- Create: `infra/messaging/kafka/debezium-compose.yml`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapperTest.java`
|
||
- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes the Outbox table and Kafka destination profile.
|
||
- Produces a CDC relay option that uses outbox `message_id` as event identity and aggregate ID as optional Kafka key.
|
||
- Does not implement or operate a generic CDC engine and cannot run concurrently with the polling relay for the same rows.
|
||
|
||
- [ ] **Step 1: Write failing ID and routing-key mapping tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.outbox;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class DebeziumOutboxRecordMapperTest {
|
||
@Test
|
||
void mapsOutboxMessageIdAndAggregateIdWithoutCreatingNewIdentity() {
|
||
OutboxRecord row = OutboxFixtures.orderCreatedRow();
|
||
DebeziumMappedRecord mapped = new DebeziumOutboxRecordMapper().map(row);
|
||
|
||
assertThat(mapped.eventId()).isEqualTo(row.messageId().toString());
|
||
assertThat(mapped.key()).isEqualTo(row.aggregateId().orElseThrow());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Debezium tests and verify missing mapper**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT'
|
||
```
|
||
|
||
Expected: FAIL because the Debezium profile and fixture are missing.
|
||
|
||
- [ ] **Step 3: Implement the fixed Outbox Event Router mapping and exclusivity guard**
|
||
|
||
Map:
|
||
|
||
```text
|
||
message_id → event ID
|
||
aggregate_id → event key
|
||
destination → route field
|
||
message_type → event type
|
||
payload → event payload
|
||
headers → additional fields limited by allowlist
|
||
```
|
||
|
||
At startup, fail if both `polling-relay.enabled=true` and `debezium-relay.enabled=true` for the same Outbox namespace. Keep the CDC container configuration in infra and label the integration optional.
|
||
|
||
- [ ] **Step 4: Run mapper and end-to-end CDC tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT'
|
||
```
|
||
|
||
Expected: PASS. Kafka receives the original message ID and aggregate key.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-outbox-jpa infra/messaging/kafka/debezium-compose.yml
|
||
git commit -m "feat: add optional debezium outbox integration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 38: Pulsar Experimental Adapter 구현
|
||
|
||
**Files:**
|
||
- Modify: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts`
|
||
- Create: `infra/messaging/pulsar/docker-compose.yml`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagingTransport.java`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfile.java`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfileValidator.java`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionMode.java`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionCapability.java`
|
||
- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagePosition.java`
|
||
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarAdapterContractTest.java`
|
||
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionGuardTest.java`
|
||
- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core transport SPI and policy contracts.
|
||
- Produces Experimental typed publish/consume, Exclusive/Failover/Shared/Key_Shared, schema integration, redelivery, replay, and native transaction capability.
|
||
- Module is absent unless the experimental property and dependency are both present.
|
||
|
||
- [ ] **Step 1: Write failing Core Contract and subscription guard tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.pulsar;
|
||
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterContract;
|
||
import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness;
|
||
import org.junit.jupiter.api.Nested;
|
||
|
||
class PulsarAdapterContractTest {
|
||
@Nested
|
||
class Contract extends MessagingAdapterContract {
|
||
@Override
|
||
protected MessagingAdapterHarness harness() {
|
||
return PulsarHarnessFixture.create();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.pulsar;
|
||
|
||
import io.backend.skeleton.messaging.api.delivery.OrderingScope;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class PulsarSubscriptionGuardTest {
|
||
@Test
|
||
void sharedSubscriptionRejectsKeyOrderingRequirement() {
|
||
PulsarProfile profile = PulsarProfileFixtures.profile(
|
||
PulsarSubscriptionMode.SHARED, OrderingScope.KEY);
|
||
|
||
assertThatThrownBy(() -> new PulsarProfileValidator().validate(profile))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run Pulsar tests and verify missing adapter**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-pulsar-experimental:test
|
||
```
|
||
|
||
Expected: FAIL because the adapter is missing.
|
||
|
||
- [ ] **Step 3: Implement publish, subscription mapping, schema, and transaction capability**
|
||
|
||
Map Pulsar message ID to a `PulsarMessagePosition` without exposing client types. Use Shared only with `OrderingScope.NONE`, Key_Shared for key ordering, and Failover/Exclusive for single active processing profiles. Transaction capability may atomically include Pulsar publish and acknowledge only; reject external DB guarantee claims.
|
||
|
||
- [ ] **Step 4: Run Core Contract against Pulsar 4.0 LTS and 4.2 profiles**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.0
|
||
./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.2
|
||
```
|
||
|
||
Expected: PASS for the experimental support matrix, including transaction commit/abort and subscription redistribution.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-pulsar-experimental infra/messaging/pulsar
|
||
git commit -m "feat: add experimental pulsar adapter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 39: NATS JetStream Experimental Adapter 구현
|
||
|
||
**Files:**
|
||
- Modify: `modules/messaging/messaging-nats-experimental/build.gradle.kts`
|
||
- Create: `infra/messaging/nats/docker-compose.yml`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamTransport.java`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfile.java`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfileValidator.java`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsAckMode.java`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingWorkflow.java`
|
||
- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsStreamPosition.java`
|
||
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAdapterContractTest.java`
|
||
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsDeduplicationIT.java`
|
||
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAckSyncIT.java`
|
||
- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingIT.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core transport SPI and policy contracts.
|
||
- Produces JetStream publish PubAck, `Nats-Msg-Id` dedupe, pull consumer, explicit ACK, AckSync, Nak delay, MaxDeliver advisory, work queue, and replay.
|
||
- Does not claim multi-resource transaction or automatic native DLQ movement.
|
||
|
||
- [ ] **Step 1: Write failing dedupe and AckSync tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.nats;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class NatsDeduplicationIT {
|
||
@Test
|
||
void sameMessageIdWithinWindowProducesOneStoredMessage() {
|
||
NatsHarness harness = NatsHarness.start();
|
||
var message = NatsFixtures.orderCreated();
|
||
|
||
harness.publish(message);
|
||
harness.publish(message);
|
||
|
||
assertThat(harness.streamMessageCount()).isEqualTo(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.nats;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class NatsAckSyncIT {
|
||
@Test
|
||
void ackSyncReturnsSettlementEvidence() {
|
||
NatsHarness harness = NatsHarness.start();
|
||
var result = harness.consumeAndAckSync();
|
||
|
||
assertThat(result.completion().name()).isEqualTo("SETTLED");
|
||
assertThat(result.evidence().brokerAcknowledged()).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run NATS tests and verify missing adapter**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-nats-experimental:test
|
||
```
|
||
|
||
Expected: FAIL because JetStream adapter types are missing.
|
||
|
||
- [ ] **Step 3: Implement JetStream evidence and advisory-based parking**
|
||
|
||
Use `messageId` as `Nats-Msg-Id`. Map PubAck to confirmed publish. Use pull consumers with explicit ACK and bounded `MaxAckPending`. Map `AckSync` server response to settlement evidence. On MaxDeliver advisory, publish to the configured parking destination, wait for PubAck, then terminate or mark the original according to the stream policy.
|
||
|
||
- [ ] **Step 4: Run NATS 2.14.x Core Contract, dedupe-window, AckSync, and MaxDeliver tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-nats-experimental:test
|
||
```
|
||
|
||
Expected: PASS. The module remains Experimental and disabled by default.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-nats-experimental infra/messaging/nats
|
||
git commit -m "feat: add experimental nats jetstream adapter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 40: Spring Cloud Stream Optional Bridge 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/MessagingBindingBridge.java`
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamPublisherBridge.java`
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamConsumerBridge.java`
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingCapabilityReport.java`
|
||
- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidator.java`
|
||
- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidatorTest.java`
|
||
- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BridgePublishEvidenceTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes Core API and Spring Cloud Stream binding interfaces.
|
||
- Produces a migration/compatibility bridge only where binder callbacks can satisfy the requested evidence.
|
||
- Never promotes binder-specific retry or DLQ semantics into the Core policy automatically.
|
||
|
||
- [ ] **Step 1: Write failing evidence-capability guard test**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.streambridge;
|
||
|
||
import io.backend.skeleton.messaging.api.destination.ConfirmationRequirement;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class BindingProfileValidatorTest {
|
||
@Test
|
||
void rejectsReplicationConfirmationWhenBinderCannotProveIt() {
|
||
BindingCapabilityReport capabilities = BindingCapabilityReport.brokerAckOnly();
|
||
|
||
assertThatThrownBy(() -> new BindingProfileValidator().validate(
|
||
ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK,
|
||
capabilities))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run bridge tests and verify missing implementation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test
|
||
```
|
||
|
||
Expected: FAIL because bridge classes are missing.
|
||
|
||
- [ ] **Step 3: Implement capability-reporting bridge and migration-only degradation rules**
|
||
|
||
The bridge must expose a `BindingCapabilityReport` before registration. If the binder cannot distinguish confirm, routing, or settlement evidence required by a destination, reject the binding or label it `MIGRATION_ONLY`. Do not reuse binder retry settings as Core `RetryPolicy`; require explicit mapping and validation.
|
||
|
||
- [ ] **Step 4: Run bridge capability and mapping tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-spring-cloud-stream-bridge
|
||
git commit -m "feat: add optional spring cloud stream bridge"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 41: Global Backpressure, Payload Guard, Graceful Shutdown 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/MessagingAdmissionController.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/InFlightLimiter.java`
|
||
- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadLimitGuard.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingLifecycle.java`
|
||
- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinator.java`
|
||
- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBackpressureException.java`
|
||
- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/MessagingAdmissionControllerTest.java`
|
||
- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes payload policy, adapter runtime, handler coordinator, Outbox relay, and admin runtime.
|
||
- Produces bounded publish admission, handler concurrency, retry concurrency, and deterministic drain ordering.
|
||
|
||
- [ ] **Step 1: Write failing overload and shutdown-order tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.policy;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class MessagingAdmissionControllerTest {
|
||
@Test
|
||
void rejectsBeforeEncodingWhenPayloadExceedsDestinationLimit() {
|
||
PayloadLimitGuard guard = new PayloadLimitGuard(1024, 8192);
|
||
|
||
assertThatThrownBy(() -> guard.checkDeclaredSize(1025))
|
||
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessageTooLargeException.class);
|
||
}
|
||
|
||
@Test
|
||
void boundedProducerAdmissionRejectsAfterFiniteWait() {
|
||
MessagingAdmissionController controller = new MessagingAdmissionController(1);
|
||
var permit = controller.acquire(java.time.Duration.ofMillis(10));
|
||
|
||
assertThatThrownBy(() -> controller.acquire(java.time.Duration.ofMillis(10)))
|
||
.isInstanceOf(io.backend.skeleton.messaging.api.error.MessageBackpressureException.class);
|
||
|
||
permit.close();
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.transport;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingShutdownCoordinatorTest {
|
||
@Test
|
||
void blocksAdmissionBeforePausingConsumersAndDrainingSettlements() {
|
||
ShutdownOrderRecorder recorder = new ShutdownOrderRecorder();
|
||
new MessagingShutdownCoordinator(recorder.components()).shutdown();
|
||
|
||
assertThat(recorder.events()).containsExactly(
|
||
"block-publish-admission",
|
||
"block-new-handlers",
|
||
"pause-consumers",
|
||
"drain-handlers",
|
||
"flush-settlements",
|
||
"await-confirms",
|
||
"release-outbox-leases",
|
||
"close-transports");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run policy and lifecycle tests and verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test --tests '*Admission*' --tests '*Shutdown*'
|
||
```
|
||
|
||
Expected: FAIL because admission and shutdown types are missing.
|
||
|
||
- [ ] **Step 3: Implement bounded permits and the exact shutdown sequence**
|
||
|
||
Use semaphore-style permits with finite acquire timeout for producer, handler, retry publish, and admin batch categories. Payload guard checks declared and encoded size. `MessagingShutdownCoordinator` uses Spring lifecycle phases and a default 30-second drain. After the deadline, unfinished handlers remain unsettled, unconfirmed publishes become ambiguous, and Outbox rows rely on lease expiry.
|
||
|
||
- [ ] **Step 4: Run overload, cancellation, and shutdown integration tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test :modules:messaging:messaging-kafka:test --tests '*Shutdown*' :modules:messaging:messaging-rabbit:test --tests '*Shutdown*'
|
||
```
|
||
|
||
Expected: PASS. No new work begins after shutdown admission closes and unfinished consumer messages are redelivered rather than falsely settled.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging/messaging-policy modules/messaging/messaging-transport-spi modules/messaging/messaging-core-api
|
||
git commit -m "feat: add messaging backpressure and shutdown"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 42: Cross-broker 장애·보안·Reliability Contract Suite 구현
|
||
|
||
**Files:**
|
||
- Create: `infra/messaging/toxiproxy/docker-compose.yml`
|
||
- Create: `build-logic/src/main/kotlin/messaging-chaos-conventions.gradle.kts`
|
||
- Modify: `build.gradle.kts`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/NetworkFaultScenario.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/BrokerFailureMatrix.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/CrossBrokerContractSuite.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaChaosSuiteIT.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitChaosSuiteIT.java`
|
||
- Create: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/ReliabilityCrashMatrixIT.java`
|
||
- Create: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/SecretLeakStaticScanTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes all Stable adapters, Reliability modules, security, observation, and Toxiproxy fixtures.
|
||
- Produces the release-gating failure matrix required by the design.
|
||
|
||
- [ ] **Step 1: Write failing matrix completeness test**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.util.Set;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class CrossBrokerContractSuite {
|
||
@Test
|
||
void stableReleaseIncludesEveryRequiredFailureScenario() {
|
||
Set<String> scenarios = BrokerFailureMatrix.requiredScenarioNames();
|
||
|
||
assertThat(scenarios).contains(
|
||
"publish-confirm-loss",
|
||
"consumer-settlement-loss",
|
||
"leader-or-node-failover",
|
||
"network-partition",
|
||
"retry-exhausted",
|
||
"dlq-target-outage",
|
||
"schema-poison",
|
||
"credential-rotation",
|
||
"tls-hostname-mismatch",
|
||
"acl-denied",
|
||
"graceful-shutdown",
|
||
"outbox-ambiguous-publish",
|
||
"inbox-duplicate-delivery");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run cross-broker tests and verify missing matrix**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-testkit:test :modules:messaging:messaging-kafka:test --tests '*ChaosSuiteIT' :modules:messaging:messaging-rabbit:test --tests '*ChaosSuiteIT'
|
||
```
|
||
|
||
Expected: FAIL because fault scenarios and chaos suites are missing.
|
||
|
||
- [ ] **Step 3: Implement deterministic network and process fault scenarios**
|
||
|
||
The matrix injects:
|
||
|
||
```text
|
||
connection refusal
|
||
latency
|
||
packet loss
|
||
half-open connection
|
||
confirmation path drop
|
||
consumer settlement path drop
|
||
Kafka leader stop
|
||
Rabbit node stop or quorum minority
|
||
PostgreSQL restart
|
||
process kill at Outbox and Inbox crash points
|
||
credential rotation
|
||
certificate rotation
|
||
```
|
||
|
||
Each scenario asserts final state, allowed duplicate count, message identity, settlement state, metric tags, and secret-free logs. Keep broker-specific expected differences in adapter assertions, not the Core guarantee.
|
||
|
||
Register the aggregate task in `messaging-chaos-conventions.gradle.kts`:
|
||
|
||
```kotlin
|
||
tasks.register("messagingStableChaos") {
|
||
group = "verification"
|
||
dependsOn(
|
||
":modules:messaging:messaging-kafka:test",
|
||
":modules:messaging:messaging-rabbit:test",
|
||
":modules:messaging:messaging-outbox-jpa:test",
|
||
":modules:messaging:messaging-inbox-jpa:test"
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the complete Stable failure matrix**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew messagingStableChaos
|
||
```
|
||
|
||
Expected: PASS with Kafka, RabbitMQ, PostgreSQL, TLS, ACL, Outbox, and Inbox scenarios. Experimental adapters are not part of this Stable gate.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add infra/messaging/toxiproxy modules/messaging/messaging-testkit modules/messaging/messaging-kafka modules/messaging/messaging-rabbit modules/messaging/messaging-outbox-jpa modules/messaging/messaging-observability
|
||
git commit -m "test: add messaging stable failure matrix"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 43: 성능 인증, Compatibility Matrix, Resource Leak Gate 구현
|
||
|
||
**Files:**
|
||
- Create: `modules/messaging/messaging-testkit/src/jmh/java/io/backend/skeleton/messaging/testkit/EnvelopeCodecBenchmark.java`
|
||
- Create: `modules/messaging/messaging-kafka/src/jmh/java/io/backend/skeleton/messaging/kafka/KafkaPublishBenchmark.java`
|
||
- Create: `modules/messaging/messaging-rabbit/src/jmh/java/io/backend/skeleton/messaging/rabbit/RabbitPublishBenchmark.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingLoadIT.java`
|
||
- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/ResourceLeakIT.java`
|
||
- Create: `.github/workflows/messaging-compatibility.yml`
|
||
- Create: `build-logic/src/main/kotlin/messaging-verification-conventions.gradle.kts`
|
||
- Modify: `build.gradle.kts`
|
||
- Create: `.github/workflows/messaging-performance.yml`
|
||
- Create: `gradle/messaging-versions.properties`
|
||
|
||
**Interfaces:**
|
||
- Consumes all Stable modules and Experimental smoke tasks.
|
||
- Produces reproducible latency, throughput, heap, thread, connection, backlog, retry-amplification, and compatibility reports.
|
||
|
||
- [ ] **Step 1: Write failing performance-budget and leak tests**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ResourceLeakIT {
|
||
@Test
|
||
void repeatedStartupShutdownReturnsThreadsAndConnectionsToBaseline() {
|
||
ResourceSnapshot before = ResourceSnapshot.capture();
|
||
|
||
for (int index = 0; index < 20; index++) {
|
||
try (MessagingSystemFixture fixture = MessagingSystemFixture.startAndStop()) {
|
||
fixture.publishAndConsume(100);
|
||
}
|
||
}
|
||
|
||
ResourceSnapshot after = ResourceSnapshot.capture();
|
||
assertThat(after.nonDaemonThreadDelta(before)).isLessThanOrEqualTo(2);
|
||
assertThat(after.openConnectionDelta(before)).isZero();
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingLoadIT {
|
||
@Test
|
||
void retryAmplificationStaysWithinConfiguredBudget() {
|
||
LoadReport report = MessagingLoadFixture.runWithTenPercentTransientFailures();
|
||
|
||
assertThat(report.physicalPublishes())
|
||
.isLessThanOrEqualTo(report.logicalPublishes() * 1.20);
|
||
assertThat(report.maxHeapBytes()).isLessThanOrEqualTo(512L * 1024 * 1024);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run performance smoke tests and verify missing harness**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingLoadIT' --tests '*ResourceLeakIT'
|
||
```
|
||
|
||
Expected: FAIL because performance and resource fixtures are missing.
|
||
|
||
- [ ] **Step 3: Implement benchmark scenarios and CI version matrix**
|
||
|
||
Measure:
|
||
|
||
```text
|
||
p50 p95 p99 max publish confirmation latency
|
||
consumer processing and settlement latency
|
||
throughput
|
||
producer buffered bytes
|
||
consumer in-flight and backlog
|
||
heap allocation and GC
|
||
thread count
|
||
broker connection/channel count
|
||
retry amplification
|
||
DLQ throughput
|
||
Outbox age and relay throughput
|
||
Inbox duplicate contention
|
||
```
|
||
|
||
Compatibility workflow runs Kafka 4.2 and 4.3.x, RabbitMQ 4.3.x, PostgreSQL 16, Spring 6.2 and 7.0 lines, plus non-blocking Experimental smoke for Pulsar 4.0/4.2 and NATS 2.14.x. Store baselines as versioned JSON and fail on agreed regression thresholds.
|
||
|
||
Register aggregate tasks in `messaging-verification-conventions.gradle.kts`:
|
||
|
||
```kotlin
|
||
tasks.register("messagingPerformance") {
|
||
group = "verification"
|
||
dependsOn(
|
||
":modules:messaging:messaging-testkit:test",
|
||
":modules:messaging:messaging-kafka:jmh",
|
||
":modules:messaging:messaging-rabbit:jmh"
|
||
)
|
||
}
|
||
|
||
tasks.register("messagingCompatibility") {
|
||
group = "verification"
|
||
dependsOn(
|
||
":modules:messaging:messaging-kafka:test",
|
||
":modules:messaging:messaging-rabbit:test",
|
||
":modules:messaging:messaging-pulsar-experimental:test",
|
||
":modules:messaging:messaging-nats-experimental:test"
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run performance certification and compatibility jobs locally where supported**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew messagingPerformance messagingCompatibility
|
||
```
|
||
|
||
Expected: PASS with generated reports under `build/reports/messaging` and no resource leak.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add modules/messaging .github/workflows gradle/messaging-versions.properties
|
||
git commit -m "test: add messaging performance and compatibility gates"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 44: 지원 문서, Runbook, ADR, Release Gate 완성
|
||
|
||
**Files:**
|
||
- Create: `docs/messaging/support-matrix.md`
|
||
- Create: `docs/messaging/configuration-reference.md`
|
||
- Create: `docs/messaging/delivery-guarantees.md`
|
||
- Create: `docs/messaging/retry-dlq-redrive.md`
|
||
- Create: `docs/messaging/outbox-inbox.md`
|
||
- Create: `docs/messaging/security.md`
|
||
- Create: `docs/messaging/operations.md`
|
||
- Create: `docs/messaging/migration-guide.md`
|
||
- Create: `docs/messaging/experimental-policy.md`
|
||
- Create: `docs/adr/ADR-0041-messaging-core-native-adapters.md`
|
||
- Create: `docs/adr/ADR-0042-no-generic-exactly-once.md`
|
||
- Create: `docs/adr/ADR-0043-outbox-inbox-reliability.md`
|
||
- Create: `docs/messaging/release-checklist.md`
|
||
- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingDocumentationContractTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes every design decision and test report.
|
||
- Produces implementation-independent usage, configuration, security, reliability, migration, operations, and release documentation.
|
||
- Documentation contract verifies that every Stable and Experimental capability is classified and every dangerous operation has a runbook.
|
||
|
||
- [ ] **Step 1: Write failing documentation contract**
|
||
|
||
```java
|
||
package io.backend.skeleton.messaging.testkit;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class MessagingDocumentationContractTest {
|
||
@Test
|
||
void supportMatrixClassifiesEveryAdapterAndCapability() throws Exception {
|
||
String matrix = Files.readString(Path.of("docs/messaging/support-matrix.md"));
|
||
|
||
assertThat(matrix).contains(
|
||
"Kafka | Stable",
|
||
"RabbitMQ | Stable",
|
||
"Kafka Share Group | Experimental",
|
||
"Pulsar | Experimental",
|
||
"NATS JetStream | Experimental",
|
||
"EXACTLY_ONCE | Unsupported");
|
||
}
|
||
|
||
@Test
|
||
void operationsRunbookCoversAmbiguousPublishAndDlqOutage() throws Exception {
|
||
String runbook = Files.readString(Path.of("docs/messaging/operations.md"));
|
||
|
||
assertThat(runbook).contains(
|
||
"Ambiguous publish",
|
||
"DLQ target outage",
|
||
"Consumer settlement unknown",
|
||
"Outbox backlog",
|
||
"Replay and redrive approval");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run documentation contract and verify missing files**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingDocumentationContractTest'
|
||
```
|
||
|
||
Expected: FAIL because the documentation files do not exist.
|
||
|
||
- [ ] **Step 3: Write exact operational and developer documentation**
|
||
|
||
The documents must include:
|
||
|
||
```text
|
||
M1–M4 API examples
|
||
Kafka and Rabbit physical mapping examples
|
||
PublishResult evidence interpretation
|
||
Consumer duplicate and settlement rules
|
||
Retry strategy selection table
|
||
DLQ publish-before-ACK invariant
|
||
Redrive approval workflow
|
||
Outbox and Inbox crash diagrams
|
||
Schema compatibility rules
|
||
Claim Check lifecycle
|
||
TLS/ACL credential separation
|
||
Metric/tag allowlist and denylist
|
||
Kafka and Rabbit failure runbooks
|
||
Experimental feature enablement and rollback
|
||
Spring Cloud Stream migration limitations
|
||
```
|
||
|
||
`release-checklist.md` links every completion criterion to a Gradle task or CI job. ADRs record the own-Core/native-adapter decision, the rejection of generic exactly-once, and the Outbox+Inbox reliability model.
|
||
|
||
- [ ] **Step 4: Run full verification**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./gradlew clean check messagingStableChaos messagingPerformance messagingCompatibility
|
||
```
|
||
|
||
Expected: PASS with zero test failures, zero architecture violations, and generated Stable release reports.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs modules/messaging/messaging-testkit
|
||
git commit -m "docs: complete messaging platform release guidance"
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Plan Self-Review Checklist
|
||
|
||
### Spec coverage
|
||
|
||
- [ ] M1 Typed Publisher·Handler: Tasks 2–8, 35, 36
|
||
- [ ] M2 Batch·Manual Settlement·Replay: Tasks 8, 20, 34, 36
|
||
- [ ] M3 Kafka·Rabbit native capability: Tasks 19, 20, 25
|
||
- [ ] M4 Admin Plane: Tasks 33–35
|
||
- [ ] Publish evidence and ambiguity: Tasks 7, 10, 12, 16, 23
|
||
- [ ] Consumer duplicate and settlement: Tasks 8, 12, 17, 24, 28
|
||
- [ ] Retry·DLQ·redrive identity: Tasks 13, 14, 18, 25, 34
|
||
- [ ] Kafka Stable: Tasks 15–20
|
||
- [ ] RabbitMQ Stable: Tasks 22–25
|
||
- [ ] Outbox·Inbox·Claim Check: Tasks 26–29, 37
|
||
- [ ] JSON·Avro·Protobuf·CloudEvents: Tasks 5, 6, 30
|
||
- [ ] Security and ACL: Tasks 11, 31
|
||
- [ ] Observability and audit: Tasks 11, 32
|
||
- [ ] Topology and operations: Tasks 20, 33, 34
|
||
- [ ] Pulsar·NATS Experimental: Tasks 38, 39
|
||
- [ ] Spring Cloud Stream bridge: Task 40
|
||
- [ ] Backpressure and shutdown: Task 41
|
||
- [ ] Failure matrix and performance: Tasks 42, 43
|
||
- [ ] Documentation and release gate: Task 44
|
||
|
||
### Placeholder scan
|
||
|
||
The final document must contain no unresolved markers, deferred implementation phrases, or cross-task shorthand that omits required code.
|
||
|
||
### Type consistency
|
||
|
||
- `MessagePublisher.publish` always returns `CompletionStage<PublishResult>`.
|
||
- `MessageHandler.handle` always returns `CompletionStage<HandleResult>`.
|
||
- `PublishCompletion` constants are `CONFIRMED`, `REJECTED`, `AMBIGUOUS`.
|
||
- `SettlementCompletion` constants are `SETTLED`, `REJECTED`, `UNKNOWN`.
|
||
- `RetryMode` constants match the design exactly.
|
||
- `messageId` remains `MessageId` from Task 2 through Outbox, Inbox, DLQ, retry, replay, and redrive.
|
||
- Stable modules never depend on Experimental modules.
|
||
|
||
---
|
||
|
||
## 4. Execution Handoff
|
||
|
||
Plan execution begins only after the design and plan are reviewed in the target repository.
|
||
|
||
**Recommended:** `superpowers:subagent-driven-development`
|
||
|
||
- fresh implementation agent per Task
|
||
- requirements review after each Task
|
||
- code quality review after each Task
|
||
- full Stable gate after Tasks 25, 35, 42, and 44
|
||
|
||
**Alternative:** `superpowers:executing-plans`
|
||
|
||
- execute Tasks in the listed order
|
||
- checkpoint after Foundation, Kafka, RabbitMQ, Reliability, Operations, Experimental, Release phases
|
||
- never combine unreviewed Tasks into one commit
|