feat: redis, fileserver, httpclient 런타임 시점 구현 추가
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `adapter-outbound-messaging`
|
||||
- Gradle path: `:adapter:outbound:messaging`
|
||||
- Focused test: `./gradlew :adapter:outbound:messaging:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:messaging:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.messaging`.
|
||||
|
||||
@@ -15,13 +15,19 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
|
||||
- Implement outbound message publication and broker integration behind application/domain ports.
|
||||
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
|
||||
publication adaptation.
|
||||
- Own structured rendering of `OutboxRelayFailureReport` through the single unconditional
|
||||
`Slf4jOutboxRelayFailureReportAdapter` bean.
|
||||
- Reuse `adapter:outbound:support` for shared technical concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- Allowed dependency edges come only from the module's
|
||||
`src/config/architecture/modules.json` entry.
|
||||
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
|
||||
- Do not hide use-case sequencing or business routing policy in broker adapters.
|
||||
- `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed
|
||||
FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps
|
||||
`FailOpenDependencyLogger`.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유
|
||||
correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한다. outbox relay 실패는 이
|
||||
모듈이 별도의 typed report adapter로 구조화한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
허용/금지 의존 정책은 `src/config/architecture/modules.json`과 이 모듈의
|
||||
[CLAUDE.md](CLAUDE.md)가 소유한다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
|
||||
참조용 기록이다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
@@ -41,4 +41,20 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에
|
||||
`MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께
|
||||
로깅하고 삼켜(→ `:adapter:outbound:support` 의 `FailOpenDependencyLogger`) outbox/retry 로
|
||||
위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort` 를
|
||||
쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
|
||||
쓴다. `OutboxMessagePublishAdapter`는 envelope mapping + broker send만 수행하며 runtime 예외를
|
||||
그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다.
|
||||
반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
|
||||
|
||||
## OutboxRelayFailureReport 구조화 ERROR
|
||||
|
||||
`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의
|
||||
`Slf4jOutboxRelayFailureReportAdapter`를 등록한다. broker 설정이 blank면 안전한
|
||||
`dependency_name=disabled`를 쓴다. 이 adapter는 confirmed FAILED/DEAD report 하나를 SLF4J 2 fluent
|
||||
ERROR 하나로 렌더링한다.
|
||||
|
||||
공통 field는 `error.code`, `error.category`, `dependency_name`,
|
||||
`dependency_type=messaging`, `outcome`, `event_id`, `event_type`, `aggregate_id`,
|
||||
`correlation_id`, `attempt_count`, `runbook_link`다. retry report만 `next_attempt_at`을 추가한다.
|
||||
payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로
|
||||
붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted
|
||||
FAILED/DEAD outcome을 바꾸지 않는다.
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -41,11 +41,10 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
@@ -59,13 +58,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
@@ -90,7 +87,7 @@ org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
@@ -111,12 +108,11 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -125,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -149,7 +145,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+11
-4
@@ -6,8 +6,10 @@ import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -37,13 +39,18 @@ public class MessagingConfig {
|
||||
|
||||
@Bean
|
||||
public OutboxMessagePublishPort outboxMessagePublishPort(
|
||||
ObjectProvider<MessageBroker> brokerProvider,
|
||||
MessagingSettings settings,
|
||||
FailOpenDependencyLogger dependencyLogger) {
|
||||
ObjectProvider<MessageBroker> brokerProvider, MessagingSettings settings) {
|
||||
MessageBroker active = resolveBroker(brokerProvider, settings);
|
||||
return (active == null)
|
||||
? new DisabledOutboxMessagePublisher()
|
||||
: new OutboxMessagePublishAdapter(active, dependencyLogger);
|
||||
: new OutboxMessagePublishAdapter(active);
|
||||
}
|
||||
|
||||
/** Always available, including when broker publication is disabled. */
|
||||
@Bean
|
||||
public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) {
|
||||
String dependencyName = settings.broker().isBlank() ? "disabled" : settings.broker();
|
||||
return new Slf4jOutboxRelayFailureReportAdapter(dependencyName);
|
||||
}
|
||||
|
||||
private static MessageBroker resolveBroker(
|
||||
|
||||
+7
-15
@@ -2,30 +2,26 @@ package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link
|
||||
* OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}; on
|
||||
* failure it logs and re-throws so the relay can drive the FAILED/DEAD transition (the documented
|
||||
* fail-closed contract — contrast the fail-open general {@code OutboundMessagePublisher}).
|
||||
* OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}.
|
||||
* Runtime failures propagate unchanged and checked failures are wrapped with their cause so the
|
||||
* relay can drive the FAILED/DEAD transition. This adapter emits no dependency log; the confirmed
|
||||
* transition has one canonical ERROR reporter.
|
||||
*
|
||||
* <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never
|
||||
* touches this class.
|
||||
*/
|
||||
public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "messaging";
|
||||
|
||||
private final MessageBroker broker;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public OutboxMessagePublishAdapter(
|
||||
MessageBroker broker, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.broker = broker;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
public OutboxMessagePublishAdapter(MessageBroker broker) {
|
||||
this.broker = Objects.requireNonNull(broker, "broker must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -34,14 +30,10 @@ public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
|
||||
OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
|
||||
try {
|
||||
broker.send(message);
|
||||
dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish");
|
||||
} catch (RuntimeException ex) {
|
||||
// fail-closed: log then propagate — the relay must observe this to drive FAILED/DEAD.
|
||||
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
// Wrap checked exceptions; preserve cause so the relay can inspect it.
|
||||
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
|
||||
throw new RuntimeException(
|
||||
"outbox publish failed for broker '" + broker.brokerId() + "'", ex);
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
|
||||
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.spi.LoggingEventBuilder;
|
||||
|
||||
/** Renders confirmed outbox relay failure transitions as one safe structured SLF4J ERROR. */
|
||||
public final class Slf4jOutboxRelayFailureReportAdapter implements OutboxRelayFailureReportPort {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "messaging";
|
||||
private static final String LOG_MESSAGE = "confirmed outbox relay failure";
|
||||
|
||||
private final String dependencyName;
|
||||
private final Logger logger;
|
||||
|
||||
public Slf4jOutboxRelayFailureReportAdapter(String dependencyName) {
|
||||
this(dependencyName, LoggerFactory.getLogger(Slf4jOutboxRelayFailureReportAdapter.class));
|
||||
}
|
||||
|
||||
Slf4jOutboxRelayFailureReportAdapter(String dependencyName, Logger logger) {
|
||||
if (dependencyName == null || dependencyName.isBlank()) {
|
||||
throw new IllegalArgumentException("dependencyName must not be blank");
|
||||
}
|
||||
this.dependencyName = dependencyName;
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void report(OutboxRelayFailureReport report) {
|
||||
try {
|
||||
Objects.requireNonNull(report, "report must not be null");
|
||||
boolean retryable = report.code() == OperationalError.OUTBOX_PUBLISH_FAILED;
|
||||
LoggingEventBuilder event =
|
||||
logger
|
||||
.atError()
|
||||
.setCause(report.cause())
|
||||
.addKeyValue("error.code", report.code().code())
|
||||
.addKeyValue("error.category", report.code().category().name())
|
||||
.addKeyValue("dependency_name", dependencyName)
|
||||
.addKeyValue("dependency_type", DEPENDENCY_TYPE)
|
||||
.addKeyValue("outcome", retryable ? "FAILED" : "DEAD")
|
||||
.addKeyValue("event_id", report.eventId())
|
||||
.addKeyValue("event_type", report.eventType())
|
||||
.addKeyValue("aggregate_id", report.aggregateId())
|
||||
.addKeyValue("correlation_id", report.correlationId())
|
||||
.addKeyValue("attempt_count", report.attemptCount())
|
||||
.addKeyValue(
|
||||
"runbook_link",
|
||||
retryable ? "runbook://outbox/publish-failed" : "runbook://outbox/dead-letter");
|
||||
if (retryable) {
|
||||
event = event.addKeyValue("next_attempt_at", report.nextAttemptAt().toString());
|
||||
}
|
||||
event.log(LOG_MESSAGE);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Diagnostics are non-authoritative and must never escape into the relay.
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-73
@@ -9,19 +9,16 @@ import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
import dev.caskeleton.application.outbox.OutboxEventStatus;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Broker-agnostic outbox publish adapter (fail-closed). Covers:
|
||||
@@ -29,33 +26,13 @@ import org.slf4j.MDC;
|
||||
* <ul>
|
||||
* <li>Success — correct OutboundMessage (topic=eventType, key=aggregateId, payload=envelope JSON)
|
||||
* sent to the active broker.
|
||||
* <li>Fail-closed — broker failure is logged then propagated (never swallowed); checked
|
||||
* <li>Fail-closed — broker failure is propagated without duplicate dependency logs; checked
|
||||
* exceptions are wrapped.
|
||||
* <li>Envelope JSON fields and escaping.
|
||||
* </ul>
|
||||
*/
|
||||
class OutboxMessagePublishAdapterTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
/** Fake broker (brokerId "kafka") capturing sends, optionally failing with a given throwable. */
|
||||
private static final class FakeBroker implements MessageBroker {
|
||||
final List<OutboundMessage> sent = new ArrayList<>();
|
||||
@@ -102,8 +79,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
@Test
|
||||
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
@@ -116,8 +92,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
@Test
|
||||
void publishEnvelopeContainsAllFields() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
@@ -134,8 +109,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
@Test
|
||||
void publishEnvelopeFieldValuesMatchEvent() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
@@ -156,68 +130,53 @@ class OutboxMessagePublishAdapterTest {
|
||||
@Test
|
||||
void publishFailurePropagatesAsRuntimeException() {
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class);
|
||||
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("broker down");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureIsLoggedBeforePropagation() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1");
|
||||
void publishFailureEmitsNoDuplicateDependencyLog() {
|
||||
ch.qos.logback.classic.Logger dependencyLogger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(FailOpenDependencyLogger.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
dependencyLogger.addAppender(appender);
|
||||
dependencyLogger.setLevel(Level.DEBUG);
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
try {
|
||||
adapter.publish(sampleEvent());
|
||||
} catch (RuntimeException ignored) {
|
||||
// expected
|
||||
} finally {
|
||||
dependencyLogger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
boolean warnLogged =
|
||||
appender.list.stream()
|
||||
.anyMatch(
|
||||
e ->
|
||||
e.getLevel() == Level.WARN
|
||||
&& e.getFormattedMessage().contains("corr-fail-1"));
|
||||
assertThat(warnLogged).as("Expected a WARN log with the correlationId on failure").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureLogCarriesDependencyAndOperation() {
|
||||
FakeBroker broker = new FakeBroker(new RuntimeException("connection refused"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
try {
|
||||
adapter.publish(sampleEvent());
|
||||
} catch (RuntimeException ignored) {
|
||||
// expected
|
||||
}
|
||||
|
||||
String msg =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.findFirst()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.orElse("");
|
||||
assertThat(msg)
|
||||
.contains("dependency_name=\"kafka\"")
|
||||
.contains("dependency_type=\"messaging\"")
|
||||
.contains("operation=\"publish\"");
|
||||
assertThat(appender.list).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWrapsCheckedExceptionInRuntimeException() {
|
||||
FakeBroker broker = new FakeBroker(new IOException("network error"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adapterStateContainsOnlyTheBroker() {
|
||||
assertThat(
|
||||
Arrays.stream(OutboxMessagePublishAdapter.class.getDeclaredFields())
|
||||
.map(field -> field.getName()))
|
||||
.containsExactly("broker");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -237,7 +196,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
OutboxEventStatus.IN_FLIGHT,
|
||||
1);
|
||||
FakeBroker broker = new FakeBroker();
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote);
|
||||
new OutboxMessagePublishAdapter(broker).publish(eventWithQuote);
|
||||
|
||||
assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote");
|
||||
}
|
||||
@@ -256,7 +215,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
OutboxEventStatus.IN_FLIGHT,
|
||||
1);
|
||||
FakeBroker broker = new FakeBroker();
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event);
|
||||
new OutboxMessagePublishAdapter(broker).publish(event);
|
||||
|
||||
String payload = broker.sent.get(0).payload();
|
||||
assertThat(payload).contains("{\"nested\":{\"a\":1}}");
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.classic.spi.ThrowableProxy;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.event.KeyValuePair;
|
||||
|
||||
class Slf4jOutboxRelayFailureReportAdapterTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private Slf4jOutboxRelayFailureReportAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox.relay.failure.report");
|
||||
logger.setAdditive(false);
|
||||
logger.setLevel(Level.ERROR);
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
adapter = new Slf4jOutboxRelayFailureReportAdapter("kafka", logger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryableFailureEmitsOneSafeStructuredErrorWithCause() {
|
||||
RuntimeException cause = new RuntimeException("unsafe-exception-derived-value");
|
||||
Instant nextAttemptAt = Instant.parse("2026-07-25T01:02:03Z");
|
||||
|
||||
adapter.report(
|
||||
OutboxRelayFailureReport.retryableFailure(
|
||||
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, nextAttemptAt, cause));
|
||||
|
||||
assertThat(appender.list).hasSize(1);
|
||||
ILoggingEvent event = appender.list.getFirst();
|
||||
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
|
||||
assertThat(event.getFormattedMessage()).isEqualTo("confirmed outbox relay failure");
|
||||
assertThat(keyValues(event))
|
||||
.containsExactlyInAnyOrderEntriesOf(
|
||||
Map.ofEntries(
|
||||
Map.entry("error.code", "OUTBOX_PUBLISH_FAILED"),
|
||||
Map.entry("error.category", "TRANSIENT_DEPENDENCY"),
|
||||
Map.entry("dependency_name", "kafka"),
|
||||
Map.entry("dependency_type", "messaging"),
|
||||
Map.entry("outcome", "FAILED"),
|
||||
Map.entry("event_id", "evt-1"),
|
||||
Map.entry("event_type", "WorkLogReserved"),
|
||||
Map.entry("aggregate_id", "agg-1"),
|
||||
Map.entry("correlation_id", "corr-1"),
|
||||
Map.entry("attempt_count", 2),
|
||||
Map.entry("runbook_link", "runbook://outbox/publish-failed"),
|
||||
Map.entry("next_attempt_at", "2026-07-25T01:02:03Z")));
|
||||
assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause);
|
||||
assertThat(event.getFormattedMessage()).doesNotContain("unsafe-exception-derived-value");
|
||||
assertThat(keyValues(event).toString())
|
||||
.doesNotContain("payload-secret", "idempotency-secret", "unsafe-exception-derived-value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadLetterEmitsTerminalMappingWithoutNextAttemptAt() {
|
||||
RuntimeException cause = new RuntimeException("broker down");
|
||||
|
||||
adapter.report(
|
||||
OutboxRelayFailureReport.deadLetter(
|
||||
"evt-2", "WorkLogReserved", "agg-2", "corr-2", 3, cause));
|
||||
|
||||
assertThat(appender.list).hasSize(1);
|
||||
ILoggingEvent event = appender.list.getFirst();
|
||||
assertThat(keyValues(event))
|
||||
.containsEntry("error.code", "OUTBOX_DEAD_LETTER")
|
||||
.containsEntry("error.category", "INTERNAL")
|
||||
.containsEntry("outcome", "DEAD")
|
||||
.containsEntry("runbook_link", "runbook://outbox/dead-letter")
|
||||
.doesNotContainKey("next_attempt_at");
|
||||
assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loggerRuntimeExceptionIsContained() {
|
||||
Logger throwingLogger = mock(Logger.class);
|
||||
when(throwingLogger.atError()).thenThrow(new RuntimeException("logger failed"));
|
||||
Slf4jOutboxRelayFailureReportAdapter throwingAdapter =
|
||||
new Slf4jOutboxRelayFailureReportAdapter("kafka", throwingLogger);
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
throwingAdapter.report(
|
||||
OutboxRelayFailureReport.deadLetter(
|
||||
"evt-3",
|
||||
"WorkLogReserved",
|
||||
"agg-3",
|
||||
"corr-3",
|
||||
3,
|
||||
new RuntimeException("broker down"))))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullReportIsContainedByTheNoThrowAdapterContract() {
|
||||
assertThatCode(() -> adapter.report(null)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static Map<String, Object> keyValues(ILoggingEvent event) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (KeyValuePair pair : event.getKeyValuePairs()) {
|
||||
values.put(pair.key, pair.value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user