The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50 KiB
messaging-runtime-core 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-runtime-coreSSOT owner:messaging-runtime-coreintegration/family document:analysis/19-messaging-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id:
messaging-runtime-core - canonical state
analysisFile:analysis/messaging/messaging-runtime-core.md - source path:
src/messaging/messaging-runtime-core - registry
allowed_dependencies:["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"]— messaging family에서 두 번째로 많은 의존 - registry
runtime_memberships:["app-bootstrap"]
숫자
| 항목 | 수 |
|---|---|
| production Java 파일 | 6 |
| production LOC | 787 |
| 패키지 | 1 (dev.caskeleton.messaging.runtime) |
| test 파일 | 4 (테스트 3 + fixture 1) |
| test 메서드(실행 확인) | 21 |
| 외부(비프로젝트) 의존성 | 0 |
여섯 클래스:
| 클래스 | LOC | 역할 | 출하 조립 |
|---|---|---|---|
DefaultMessagePublisher |
366 | 유일한 발행 경로 | o (:446) |
DefaultDeliveryProcessor |
155 | 핸들러 결과 → 정산 | x |
RegisteredMessageCodecs |
89 | content type → codec | o (:363) |
DestinationProfileRegistry |
62 | 논리 이름 → 프로파일 | o (:377) |
TransportMessagingRuntime |
67 | transport를 세대로 포장 | o (:476) |
DeclaredDestinationAccess |
48 | 기본 접근 정책 | o |
Coverage ledger
| scope/file group | count | disposition | reason |
|---|---|---|---|
src/main/java/** (6) |
6 | FULL_READ |
전 파일 본문 확인 |
src/test/java/** (4) |
4 | FULL_READ |
전 파일 본문 및 단언 확인 |
build.gradle |
1 | FULL_READ |
주석 포함 17줄 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
build/** |
— | EXCLUDED |
빌드 산출물 |
UNCLASSIFIED 0.
1. 모듈의 정체와 경계
이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다. 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. build.gradle이 그 사정을 파일 맨 위에 적는다.
// The central publish and delivery orchestration.
//
// MessagePublisher was an interface with no implementation anywhere in the new platform: the
// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade
// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases
// and observation existed as beans that no publish path ever called. A starter that filled the gap
// with an application-supplied fake would pass a context test while running none of them.
이 진단의 마지막 문장이 핵심이다 — 컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.
six 파일이 메운 구멍:
| 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |
|---|---|---|
MessagePublisher (core-api) |
어디에도 없음 | DefaultMessagePublisher |
MessageCodecRegistry (schema-api) |
어디에도 없음 | RegisteredMessageCodecs |
MessagingRuntime (transport-spi) |
어디에도 없음 | TransportMessagingRuntime |
| (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | DestinationProfileRegistry |
DestinationAccessPolicy 기본값 (security) |
denyAll()뿐 |
DeclaredDestinationAccess |
HandleResult → 정산 (core-api) |
어댑터가 각자 결정 | DefaultDeliveryProcessor |
여섯 중 다섯은 배선됐고 마지막 하나(DefaultDeliveryProcessor)는 배선되지 않았다(§12.1).
2. 의존성과 런타임 배선
들어오는 것: 여섯 project 의존, 전부 api. DefaultMessagePublisher 한 클래스가 그중 다섯을 생성자로 받으므로 api가 맞다.
나가는 것: messaging-spring-boot-starter만.
배선 지점 다섯(전부 MessagingCoreAutoConfiguration):
| 라인 | 무엇 |
|---|---|
| 363 | RegisteredMessageCodecs.of(JacksonMessageCodec.of(...)) |
| 377 | DestinationProfileRegistry.of(destinations.all()) |
| 446 | new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport) |
| 476 | new TransportMessagingRuntime(selected.brokerName(), 1L, selected) — InitializingBean 안 |
| — | DeclaredDestinationAccess.of(...)로 접근 정책 bean |
446의 인자가 여섯 개라는 것이 §12.1의 관측 지점이다.
3. 패키지/컴포넌트 지도
발행 (조립됨)
DefaultMessagePublisher
├── DestinationProfileRegistry 논리 이름 → DestinationProfile
├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)
├── MessageCodecRegistry ← RegisteredMessageCodecs
├── MessagingAdmissionController (policy)
├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime
├── MessagingTransport (transport-spi) → Kafka/Rabbit/…
└── MessagingObservation ← NO_OBSERVATION (§12.1)
소비 (조립 안 됨)
DefaultDeliveryProcessor
├── Function<MessageEnvelope<EncodedMessage>, HandleResult>
├── DeadLetterPublisher (내부 함수형 인터페이스)
└── OneShotSettlement → TransportSettlement
4. 계약·불변식·상태 모델
4.1 DefaultMessagePublisher — 순서가 계약이다
// :40-49
* <p>The order below is fixed, not composed from a map of interceptors. Each stage's position is a
* decision:
*
* <ul>
* <li>destination and access first, so an unauthorized publish never encodes a payload;
* <li>encoding before admission, because the admission bound is on bytes and the byte count is
* not known until the payload is encoded;
* <li>the runtime lease last before the send, so a rotation cannot swap the transport underneath
* a message that has already been counted against the in-flight limit.
* </ul>
실제 순서 여덟 단계:
| # | 단계 | 실패 시 |
|---|---|---|
| 1 | destinations.require(name) |
DESTINATION_NOT_REGISTERED → REJECTED |
| 2 | requireSupportedOptions(profile, options) |
PUBLISH_DEDUPLICATION_UNSUPPORTED → REJECTED |
| 3 | access.mayPublish(name) |
PUBLISH_FORBIDDEN → REJECTED (인코딩 전) |
| 4 | encode(message) |
PUBLISH_PREPARATION_FAILED → REJECTED |
| 5 | 남은 예산 확인 | PUBLISH_DEADLINE_EXCEEDED → REJECTED |
| 6 | admission.admit(name, bytes) |
예외 전파(MessageTooLargeException/MessageBackpressureException) |
| 7 | runtimes.acquire(broker) |
PUBLISH_RUNTIME_UNAVAILABLE → REJECTED |
| 8 | transport.publish(...) + 마감 |
타임아웃 → AMBIGUOUS / 그 외 예외 → AMBIGUOUS |
1–7은 전부 REJECTED, 8만 AMBIGUOUS다. 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.
} catch (RuntimeException beforeTheWire) {
// Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send
// the caller into reconciliation for a message no broker ever saw.
return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt);
}
messaging-core-api의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 rejected(...)가 만드는 PublishResult는 PublishEvidence.notTransmitted()를 쓰므로 PublishResult 생성자의 14가지 금지 조합 검증을 자연히 통과한다.
3번이 4번보다 먼저인 이유가 인라인 주석에 있다.
// Before encoding: an unauthorized publish must not serialise the payload, because the
// encoded bytes are what a claim-check or a log would then be holding.
4.2 예산은 호출 시점부터 센다
// :131-138
* <p>Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as
* the publish operation's deadline, so a slow destination lookup or a large encode spends the
* same budget the broker wait does; timing only the transport call would let the total exceed the
* deadline by however long preparation took.
remainingBudget이 timeout - elapsedSince(startedAt)이고, 0 이하면 전송 전에 REJECTED로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."
4.3 마감을 복사본에 건다
// :143-154
* <p>The bound is applied to a copy so that expiry never completes the transport's own stage: the
* adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime
* lease are released when the copy completes, which is deliberate — holding them until a stalled
* broker answers is how a rotation waits forever on a generation nobody is using.
private static CompletableFuture<TransportPublishResult> withDeadline(
CompletionStage<TransportPublishResult> inFlight, Duration remaining) {
return inFlight.toCompletableFuture().copy()
.orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS);
}
.copy()가 핵심이다. orTimeout을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다.
그 대가도 명시돼 있다 — permit과 lease는 복사본이 완료될 때 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").
4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다
// :51-53
* <p>Everything acquired is released exactly once, on every path — success, failure, exception and
* cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one
* per failure until it stops accepting anything.
두 경로가 있다.
.handle((result, failure) -> {
// One release per acquisition, whatever happened.
held.close();
admission.complete(destination.name().value());
...
});
} catch (RuntimeException beforeTheSend) {
if (lease != null) { lease.close(); }
admission.complete(destination.name().value());
return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...);
}
handle은 whenComplete와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. lease.close()는 MessagingRuntimeLease 계약상 멱등이고(transport-spi §4.1), admission.complete도 미보유 목적지에 대해 무해하다(messaging-policy §4.3).
한 가지 비대칭. 6번(admit)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — try 블록 밖이다. 다른 모든 실패는 PublishResult로 정규화되는데 admission 실패만 예외다. MessageTooLargeException·MessageBackpressureException은 MessagingException이므로 호출자가 FailureDescriptor를 얻을 수 있지만, 반환 타입이 CompletionStage<PublishResult>인 메서드가 동기적으로 throw한다. §17.
4.5 requireSupportedOptions — 조용한 no-op을 막는다
// :65-72
* <p>The transports accept {@code request.options()} and read nothing from it, so an option this
* destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for
* broker-side deduplication got a publish with no deduplication and no error, and then skipped
* the idempotency it would otherwise have written — which is exactly the case {@code
* PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.
messaging-core-api의 PublishDeduplication javadoc("Requesting this on a broker without the deduplicatedPublish capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 startup이 아니라 publish 시점이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.
그리고 "The transports accept request.options() and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.
4.6 encode — 폴백이 기본 codec이다
private <T> MessageEnvelope<EncodedMessage> encode(MessageEnvelope<T> message) {
MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec);
...
}
봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. content type을 무시하는 폴백이다 — 봉투가 application/avro를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로(ContentType.JSON) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(analysis/messaging/messaging-schema-json.md §2). §17.
RegisteredMessageCodecs.defaultCodec()이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8).
4.7 DestinationProfileRegistry — 폴백 없는 조회
// :13-18
* <p>Nothing resolved a logical destination to a profile before this: the brokers took an
* already-resolved {@code DestinationProfile} and the publisher that would have produced one did
* not exist. A registry rather than a lookup with a fallback, because a destination nobody declared
* has no physical name, no ordering guarantee and no payload bound — publishing to it would mean
* inventing all three at the call site.
require가 미등록 목적지에 MessagingConfigurationException("DESTINATION_NOT_REGISTERED")을 던지고 메시지가 세 가지 부재를 나열한다. empty() factory도 있다 — "every publish is refused until a destination is declared".
4.8 RegisteredMessageCodecs — 기본 codec은 명시 선택
// :18-27
* <p>The default codec is a deliberate choice rather than "the first one registered". Selecting one
* by iteration order means the encoding a message is written with depends on how the map was
* populated, which is a wire-format decision made by accident. The registry takes it explicitly and
* refuses to be constructed without it.
*
* <p>The raw-bytes codec is never eligible as the default — that is the contract's own rule, and
* the reason is that raw bytes silently disable schema validation for every destination that forgot
* to declare an encoding.
두 가지를 생성자에서 거절한다.
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }
...
MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);
if (existing != null && existing != codec) {
// Two codecs for one content type is not a preference to resolve at runtime: whichever wins
// decides how bytes on the wire are read by a consumer that was compiled against the other.
throw new IllegalArgumentException("two codecs claim content type " + ...);
}
클래스가 아니라 content type으로 raw-bytes를 거절하는 것이 messaging-schema-api의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.
4.9 TransportMessagingRuntime — 얇은 포장
MessagingRuntime 구현으로 brokerName·generation·transport 셋을 들고 close()가 CAS로 멱등이다.
// close():61-62
// Idempotent: the registry closes a drained generation, and a context shutdown may close it
// again. Closing a transport twice is not an error worth propagating into shutdown.
DefaultMessagingRuntimeRegistry(transport-spi)도 자체 closed CAS를 갖는다 — 두 층이 각각 멱등이다. 중복 방어이지만 transport-spi의 Generation.forceClose()가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.
generation이 항상 1L이다. starter의 유일한 설치 지점(:476)이 리터럴 1L을 넘긴다. MessagingRuntime.generation() javadoc은 "increasing with each replacement"라고 하고, TransportMessagingRuntime javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.
4.10 DeclaredDestinationAccess — 기본값의 세 번째 선택지
// :13-32
* <p>{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}
* factory. Neither is a usable default on its own:
*
* <ul>
* <li><b>Deny everything</b> and the platform assembles, starts, and refuses every publish …
* <li><b>Allow everything</b> and the check is decoration. …
* </ul>
*
* <p>So the default is neither: <b>a deployment may publish to the destinations it declared.</b>
* … a message to a destination nobody declared is not an access-control edge case, it is a typo or
* a module reaching past its own contract.
*
* <p>Consume and administer stay empty. A publisher's default has no business granting either, and
* a deployment that needs them replaces this bean — which is the point of it being a bean.
publish만 허용하고 consume·administer는 빈 집합이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.
4.11 DefaultDeliveryProcessor — 두 규칙 (미조립)
// :27-36
* <li><strong>One terminal call.</strong> A delivery is acknowledged, requeued or discarded once.
* A second call is a programming error … acknowledging after a requeue tells the broker the
* message is done while a copy is already in flight.
* <li><strong>Dead-letter before acknowledgement.</strong> The source is acknowledged only after
* the dead-letter publish is confirmed. …
OneShotSettlement이 AtomicBoolean CAS로 한 번을 강제하고, 두 번째 호출은 CompletableFuture.failedFuture(IllegalStateException)을 반환한다 — 예외를 던지지 않고 stage로 보고한다.
핸들러 예외 처리에 이전 결함이 기록돼 있다.
} catch (RuntimeException handlerFailed) {
// A handler that threw is a retry, not a discard. Treating an exception as "this message is
// undeliverable" is how a transient bug in one consumer silently drops a day of traffic —
// and it is exactly what the Rabbit consumer did by folding handler exceptions into its
// deserialization-failure path.
return settlement.requeue(retryDelay);
}
result == null도 requeue다. 그런데 그것을 서술하는 missingResult() 정적 메서드가 있고 아무도 부르지 않는다 — HANDLER_RETURNED_NOTHING 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17.
DLQ 분기의 두 주석이 trade를 명시한다.
? settlement.acknowledge() // Confirmed: the message exists somewhere else, so removing it here is safe.
: settlement.requeue(retryDelay); // Not confirmed — rejected or ambiguous. Requeueing risks a
// duplicate; acknowledging loses the message outright, and a
// duplicate is the recoverable half of that choice.
messaging-policy의 DeadLetterOrchestrator가 같은 불변식을 다른 형태로 구현한다(§12.3).
5. 주요 실행 경로
발행(조립됨): §4.1의 8단계.
소비(미조립): TransportDelivery → handler.apply(envelope) → HandleResult 4분기 → OneShotSettlement로 정확히 한 번 정산.
세대 설치(조립됨): InitializingBean → transport.getIfAvailable() → null이면 조용히 반환(이유가 주석에 있음) → new TransportMessagingRuntime(brokerName, 1L, transport) → runtimes.install(...).
6. 실패 경로와 복구/번역
DefaultMessagePublisher가 만드는 결과:
| 코드 | completion | category | 언제 |
|---|---|---|---|
PUBLISH_FORBIDDEN |
REJECTED |
CONFIGURATION |
접근 정책 거부 |
PUBLISH_PREPARATION_FAILED |
REJECTED |
CONFIGURATION |
해석·인코딩 중 예외 |
PUBLISH_DEADLINE_EXCEEDED |
REJECTED |
CONFIGURATION |
전송 전 예산 소진 |
PUBLISH_RUNTIME_UNAVAILABLE |
REJECTED |
CONFIGURATION |
lease 획득 실패 |
PUBLISH_DEADLINE_EXCEEDED |
AMBIGUOUS |
AMBIGUOUS |
전송 후 마감 |
PUBLISH_OUTCOME_UNKNOWN |
AMBIGUOUS |
AMBIGUOUS |
전송 후 그 외 실패 |
같은 코드 PUBLISH_DEADLINE_EXCEEDED가 두 completion에 쓰인다. 전송 전이면 REJECTED, 후면 AMBIGUOUS다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.
sanitized(Throwable)가 메시지가 아니라 타입 이름만 남긴다.
// :175-180
* <p>A driver message can carry a routing key, a payload fragment or a connection string, and a
* {@code FailureDescriptor} is designed to be logged and exported.
return cause.getClass().getSimpleName();
messaging-core-api의 FailureDescriptor javadoc("no payload, no stack trace, no credential")과 같은 관심사다.
isDeadline과 sanitized 둘 다 CompletionException을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.
DefaultDeliveryProcessor는 예외를 던지지 않는다. 이중 정산만 failedFuture로 보고한다.
7. 트랜잭션·동시성·수명주기
트랜잭션 없음.
| 지점 | 도구 | 보호 |
|---|---|---|
OneShotSettlement.settled |
AtomicBoolean CAS |
정확히 한 번 정산 |
TransportMessagingRuntime.closed |
AtomicBoolean CAS |
정확히 한 번 transport close |
RegisteredMessageCodecs.byContentType |
Map.copyOf |
불변 |
DestinationProfileRegistry.profiles |
Map.copyOf |
불변 |
withDeadline의 .copy() |
CompletableFuture |
어댑터 stage와 이쪽 경로 분리 |
DefaultMessagePublisher 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. lease만 메서드 지역 변수이고 handle 람다가 held라는 effectively-final 복사본으로 캡처한다.
수명주기 참여는 TransportMessagingRuntime.close()뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.
8. 설정·기능 플래그·환경 차이
설정 없음. 이 leaf의 모든 값은 생성자 인자다.
주입 가능한 두 지점이 테스트 가능성을 만든다.
| 인자 | 기본 | 목적 |
|---|---|---|
LongSupplier nanoTime |
System::nanoTime |
경과 시간을 sleep 없이 테스트 |
MessagingObservation observation |
NO_OBSERVATION |
관측 주입 |
두 번째의 기본값이 §12.1의 발견 지점이다.
TransportMessagingRuntime의 generation은 생성자 인자이고 유일한 호출자가 1L을 넘긴다.
9. 퍼시스턴스/외부 시스템 세부
없다. 브로커 접촉은 MessagingTransport 인터페이스 뒤에 있다.
10. 테스트 레인과 실제 증명 범위
레인: ./gradlew :messaging:messaging-runtime-core:test. BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---|---|---|
DefaultMessagePublisherTest |
10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. 출하 조립이 관측을 넘기는지 |
DefaultDeliveryProcessorTest |
7 | HandleResult 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 |
production에서 호출되는지(§12.1) |
RegisteredMessageCodecsTest |
4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |
DefaultMessagePublisherTest:271이 익명 MessagingObservation을 만들어 관측 호출을 확인한다. 즉 테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다. 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.
RecordingTransport(:426)가 MessagingTransport를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다.
11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
verifyCleanArchitectureDependencies |
여섯 project 의존 |
verifyRuntimeModuleMembership |
["app-bootstrap"] |
vendor api 규칙 |
벤더 의존성 0 |
| ArchUnit | 전용 규칙 없음 |
MessagingStarterOffContractTest(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.
12. 실제 사용 여부와 negative-space probes
원시 증거: evidence/raw/283-runtime-core-observation-noop.txt.
12.1 Public surface reachability
| 타입 | leaf 밖 파일 | 출하 조립 |
|---|---|---|
DefaultMessagePublisher |
2 | o — MessagingCoreAutoConfiguration:446 |
TransportMessagingRuntime |
1 | o — :476 |
RegisteredMessageCodecs |
1 | o — :363 |
DestinationProfileRegistry |
1 | o — :377 |
DeclaredDestinationAccess |
1 | o |
DefaultDeliveryProcessor |
0 | x — src/main 생성 0, src/test 1 |
(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다
DefaultDeliveryProcessor는 leaf 밖 참조가 0이고 src/main에서 생성되지 않는다. 이것이 analysis/messaging/messaging-policy.md §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.
이 클래스의 javadoc은 자기가 고친 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so 'the platform decides when and in what order the settlement happens' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.
(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다
네 조각이 있다.
| 조각 | 상태 |
|---|---|
MessagingObservation 인터페이스 (observability) |
존재 |
MessagingMetrics implements MessagingObservation |
존재 |
DefaultMessagePublisher.observe(...) 호출부 |
존재, 모든 발행 결과를 기록 |
| 8인자 생성자 (관측 주입) | 존재 |
| 출하 조립 | 6인자 생성자 → NO_OBSERVATION |
MessagingMetrics bean |
없음 |
// MessagingCoreAutoConfiguration.java:446-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
destinations, access, codecs, admission, runtimes, transport);
그리고 MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다(MessagingMetricCardinalityTest, MessagingSecretLeakTest).
starter는 MessagingMetrics의 두 협력자를 bean으로 만든다 — MessagingRedactor(:253)와 CardinalityGuard(:264). MessagingMetrics의 생성자는 (registry, CardinalityGuard, MessagingRedactor)를 받는다(테스트가 그렇게 호출한다). 즉 재료 둘은 배선됐고 그것을 조립하는 bean이 없다.
이 클래스의 javadoc이 그 상황을 예언한다.
// DefaultMessagePublisher.java:74-78
* <p>{@code MessagingObservation} existed as a bean and no publish path called it, so the
* platform's own metrics described nothing. It is a constructor argument rather than an optional
* decorator because an unobserved publish path is how "the dashboards were empty during the
* incident" happens.
이전 상태: bean은 있고 호출하는 경로가 없었다. 현재 상태: 호출하는 경로는 있고 bean이 없다.
두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 반대편으로 옮겼다. 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.
(c) 배선된 것은 확실히 배선됐다
발행 경로 다섯이 전부 src/main에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.
한계. 정적 검색이다. ObjectProvider 지연 조회는 MessageContracts와 MessagingTransport 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 MessagingObservation bean을 제공하면 @ConditionalOnMissingBean(MessagePublisher.class) 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.
12.2 Conditional sibling comparison
이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.
MessagingCoreAutoConfiguration이 이 leaf의 타입을 만드는 지점 다섯의 조건:
| 대상 | 조건 |
|---|---|
RegisteredMessageCodecs |
@ConditionalOnMissingBean(MessageCodecRegistry.class) |
DestinationProfileRegistry |
@ConditionalOnMissingBean |
DefaultMessagePublisher |
@ConditionalOnMissingBean(MessagePublisher.class) |
TransportMessagingRuntime |
조건 없음 — InitializingBean 안, transport.getIfAvailable() null 검사 |
DeclaredDestinationAccess |
@ConditionalOnMissingBean |
네 번째만 조건 대신 런타임 null 검사를 쓴다. 그 이유가 주석에 있다.
// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport
// when a broker is selected, and it refuses startup by name when one is not. This
// configuration is also loadable on its own — an adopter composing the policy primitives
// without a transport — and demanding one here would refuse that.
즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 @ConditionalOnBean 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(MessagingProviderSelection)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.
12.3 Duplicate mechanism sweep
(a) DLQ 순서 불변식이 두 곳에 구현돼 있다
messaging-policy DeadLetterOrchestrator |
이 leaf DefaultDeliveryProcessor |
|
|---|---|---|
| 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |
| 미확인 시 | 정산하지 않음(sourceSettled=false) |
requeue |
| 헤더 | 예약 헤더 6개 부착 | 없음 |
| 발행 주체 | MessagePublisher |
DeadLetterPublisher 함수형 인터페이스 |
미확인 시 동작이 다르다. policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 requeue(delay)는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.
둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. analysis/messaging/messaging-policy.md §12.3(b)가 같은 사건을 반대편에서 기록한다.
(b) 재시도 지연이 두 출처
DefaultDeliveryProcessor의 retryDelay는 생성자 인자 하나다. 시도 횟수를 세지 않고 백오프도 없다. messaging-policy의 BackoffCalculator(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.
(c) 멱등 종료가 두 층
TransportMessagingRuntime.close()와 DefaultMessagingRuntimeRegistry.Generation.forceClose()(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 의도된 중복이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님.
(d) content type 폴백
encode가 codecs.find(contentType).orElseGet(codecs::defaultCodec)으로 폴백한다. RegisteredMessageCodecs.find는 미등록이면 Optional.empty()를 주고, defaultCodec()은 JSON이다. 즉 선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점이고, 그 갈라짐이 조용하다. §17.
12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
build.gradle 주석: MessagePublisher에 구현이 없었다 |
현재 이 leaf가 구현하고 :446에서 조립 |
해소됨 |
TransportMessagingRuntime javadoc: registry가 비어 있어 모든 발행이 실패했다 |
현재 :476이 설치 |
해소됨 |
DefaultMessagePublisher javadoc: 관측 bean이 있고 호출 경로가 없었다 |
현재 호출 경로가 있고 bean이 없다 | 반전됨(§12.1b) |
DefaultDeliveryProcessor javadoc: 어댑터가 각자 결정했다 |
한 곳에 모았으나 조립되지 않음 | 부분 해소 |
MessagingRuntime.generation() javadoc: "increasing with each replacement" |
유일한 설치가 리터럴 1L |
미실현 |
support-matrix.md:23: 모든 messaging leaf가 unwired |
이 leaf는 ["app-bootstrap"] |
불일치(family drift) |
세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.
13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf는 통째로 하나의 수정이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(DeclaredDestinationAccess, TransportMessagingRuntime, MessagingCoreAutoConfiguration:461).
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
build.gradle 주석 |
MessagePublisher 구현 없음 |
자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |
TransportMessagingRuntime javadoc |
MessagingRuntime 구현 없음 |
registry가 빈 채로 만들어져 모든 발행이 PUBLISH_RUNTIME_UNAVAILABLE — 목적지 해석·접근 확인·인코딩을 전부 마친 뒤에 |
DestinationProfileRegistry javadoc |
논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |
DefaultDeliveryProcessor javadoc |
HandleResult→정산 연결 없음 |
각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |
DefaultDeliveryProcessor 핸들러 예외 주석 |
Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |
requireSupportedOptions javadoc |
transport가 options를 읽지 않음 |
중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |
withDeadline javadoc |
transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |
build.gradle 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."
14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-283 | command | evidence/raw/283-runtime-core-observation-noop.txt |
여섯 타입 참조 수, 발행 경로 조립 지점, DefaultDeliveryProcessor src/main=0, 관측 4조각과 끊긴 한 지점, MessagingMetrics가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 |
정적 검색. 파생 프로젝트의 대체 조립 미포함 |
| EVD-284 | command | ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks |
BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(RecordingTransport) |
15. 명시적 설계 이유와 추론을 구분한 정리
명시적
- 이 leaf가 존재하는 이유와 이전 결함 —
build.gradle주석 - 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 —
DefaultMessagePublisherjavadoc - 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석
- 전송 전 실패가
REJECTED인 이유 — 인라인 주석 - 예산을 호출 시점부터 세는 이유 —
remainingBudgetjavadoc - 마감을 복사본에 거는 이유와 그 대가 —
withDeadlinejavadoc - 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석
- 지원하지 않는 옵션을 거절하는 이유 —
requireSupportedOptionsjavadoc - 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 —
RegisteredMessageCodecsjavadoc - 폴백 없는 목적지 조회 이유 —
DestinationProfileRegistryjavadoc - 기본 접근 정책이 deny도 allow도 아닌 이유 —
DeclaredDestinationAccessjavadoc - 핸들러 예외가 retry인 이유 — 인라인 주석
- DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석
- transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 —
InitializingBean안 주석 - 관측을 생성자 인자로 둔 이유 —
observation필드 javadoc
추론
- 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → 추론이 아니라 미상. 어디에도 근거가 없고, 8인자 생성자와
MessagingMetrics가 둘 다 존재한다는 점이 미완을 시사한다. generation이 항상 1인 것은 회전 코드가 없기 때문이다 → 추론. 회전 코드 부재는 관측이다.DefaultDeliveryProcessor미조립이 미완인지 확장점인지 → 미상.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- 6개 클래스 787줄 전문의 계약과 순서 결정
- 21개 테스트가 통과하고 무엇을 단언하는지
- 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지
DefaultDeliveryProcessor가src/main에서 생성되지 않는다는 것- 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것
MessagingMetrics가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것generation이 유일한 설치 지점에서 리터럴1L이라는 것
확인하지 못한 것
- 6인자 생성자 선택이 의도인지. 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
MessagingProviderSelection이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.- 어댑터들이
request.options()를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다. - 실제 브로커에서
withDeadline의.copy()전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다. - 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.
17. 손볼 것
P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
- 사실.
DefaultMessagePublisher가 모든 발행 결과를observation.recordPublish(...)로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다.MessagingMetrics가MessagingObservation을 구현한다. 그런데 출하 조립(MessagingCoreAutoConfiguration:446)은 6인자 생성자를 써서NO_OBSERVATION을 넣고,MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다. starter는MessagingMetrics의 협력자 둘(MessagingRedactor:253,CardinalityGuard:264)을 bean으로 만든다. - 근거.
evidence/raw/283§D. - 왜 문제인가. 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 이전 결함을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. 고침이 간극을 닫은 게 아니라 반대편으로 옮겼다. "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
- 확인 방법.
evidence/raw/283§D 재실행. 또는:446의 인자 수와:138-146생성자 시그니처 대조. - 후보. (a)
MessagingMetricsbean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을support-matrix.md에 표시한다. - 다음 단계. CASE 후보. 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 회로가 반대편에서 끊긴 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 REFERENCE 후보다.
P2 — 소비 오케스트레이터가 조립되지 않는다
- 사실.
DefaultDeliveryProcessor는 leaf 밖 참조 0,src/main생성 0,src/test생성 1이다. - 근거.
evidence/raw/283§A·§C. - 왜 문제인가. 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고
DeclaredDestinationAccess가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다. - 확인 방법.
git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src - 다음 단계.
analysis/messaging/messaging-policy.md§17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 동일 사건이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.
P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
- 사실.
encode가codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가application/avro를 선언해도 JSON으로 인코딩되고,EncodedMessage의 content type은 codec이 정하므로application/json이 된다. - 근거.
DefaultMessagePublisher.java:97-102,RegisteredMessageCodecs.find,MessagingCoreAutoConfiguration:363(varargs 비어 있음). - 왜 문제인가. 실패하지 않고 다른 포맷으로 성공한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다.
DestinationProfile.schema().codec()이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다. - 확인 방법. 등록되지 않은 content type의 봉투를 발행해
EncodedMessage.contentType()을 확인. - 후보. 미등록 content type을
MessagingConfigurationException으로 거절하거나,profile.schema().codec()과 대조한다. - 다음 단계. CASE 후보. 조용한 성공이라는 형태가
messaging-core-api의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다.
P3 — 같은 실패 코드가 두 completion에 쓰인다
- 사실.
PUBLISH_DEADLINE_EXCEEDED가 전송 전이면REJECTED(:16-21), 전송 후면AMBIGUOUS(:42-47)로 붙는다. - 근거. 두 위치.
- 왜 문제인가. 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은
messageId로만 재발행.FailureDescriptor.code가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다. - 확인 방법.
git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core - 후보. 전송 전을
PUBLISH_DEADLINE_BEFORE_SEND처럼 분리한다. - 다음 단계. REFERENCE 후보(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).
P3 — admission 실패만 예외로 전파된다
- 사실. 8단계 중 admission(
:24)만try블록 밖이고,MessageTooLargeException·MessageBackpressureException이 그대로 던져진다. 나머지 실패는 전부CompletionStage<PublishResult>로 정규화된다. - 근거.
DefaultMessagePublisher.java:198(admit 호출 위치)과 그 앞뒤 try 블록 범위. - 왜 문제인가. 반환 타입이
CompletionStage인 메서드가 동기적으로 throw한다..publish(...).exceptionally(...)로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다MessagingException이라FailureDescriptor는 있지만 전달 방식이 다른 실패들과 다르다. - 확인 방법. 상한 초과 payload로
publish를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인. - 후보. admission을
try안으로 넣어rejected(...)로 정규화하거나, javadoc에 동기 throw를 명시한다. - 다음 단계. REFERENCE 후보(
CompletionStage를 반환하는 메서드는 동기적으로 던지지 않는다).
P3 — generation이 항상 1이다
- 사실. 유일한 설치 지점(
MessagingCoreAutoConfiguration:476)이 리터럴1L을 넘긴다.MessagingRuntime.generation()javadoc은 "increasing with each replacement",TransportMessagingRuntimejavadoc은 "the credential generation a rotation increments"라고 한다. - 근거.
:476, 두 javadoc. - 왜 문제인가. 오늘 회전 코드가 없으므로 무해하다. 다만
DefaultMessagingRuntimeRegistry의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다. - 확인 방법.
git grep -n 'TransportMessagingRuntime(' -- src/main - 후보. 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다.
- 다음 단계. REFERENCE 후보(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다).
P3 — missingResult()가 아무 데도 쓰이지 않는다
- 사실.
DefaultDeliveryProcessor.missingResult()(package-private static)가HANDLER_RETURNED_NOTHINGdescriptor를 만든다.result == null분기는 그것을 쓰지 않고 바로settlement.requeue(retryDelay)를 부른다. - 근거.
DefaultDeliveryProcessor.java:77-79,:146-154. - 왜 문제인가. 핸들러가 null을 반환한 경우와
HandleResult.Retry를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다. - 확인 방법.
git grep -n 'missingResult' -- src - 후보. null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다.
- 다음 단계. REFERENCE 후보(만들어 두고 흘리지 않는 진단값은 진단이 아니다).
확인된 설계(문제 아님)
- 발행 8단계의 고정 순서와 각 위치의 명시된 근거
- 전송 전/후 경계가
REJECTED/AMBIGUOUS를 가르는 것 - 예산을 호출 시점부터 세는 것
- 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade
- 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것
- 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것
- 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것
- 폴백 없는 목적지 조회
- 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것
- 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것
- 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것
Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MRC-001 | registry | src/config/architecture/modules.json |
21234e38 |
deps 6개, memberships ["app-bootstrap"] |
선언 |
| MRC-002 | build | messaging-runtime-core/build.gradle |
same | 이 leaf가 존재하는 이유(MSG-INT-003 진단) | — |
| MRC-003 | code | .../runtime/DefaultMessagePublisher.java 전문 |
same | §4.1–4.6, §6 | 브로커 대체 테스트만 |
| MRC-004 | code | .../runtime/DefaultDeliveryProcessor.java 전문 |
same | §4.11 두 규칙, 핸들러 예외 이력 | 조립되지 않음(§12.1a) |
| MRC-005 | code | .../runtime/RegisteredMessageCodecs.java |
same | §4.8 기본 codec 규칙과 충돌 거절 | — |
| MRC-006 | code | .../runtime/DestinationProfileRegistry.java |
same | §4.7 폴백 없는 조회 | — |
| MRC-007 | code | .../runtime/TransportMessagingRuntime.java |
same | §4.9 멱등 종료, generation 인자 | 항상 1(§17) |
| MRC-008 | code | .../runtime/DeclaredDestinationAccess.java |
same | §4.10 기본 접근 정책의 세 번째 선택지 | — |
| MRC-009 | test | DefaultMessagePublisherTest (10) |
same | 8단계와 실패 정규화, 관측 호출 | 8인자 생성자 사용 |
| MRC-010 | test | DefaultDeliveryProcessorTest (7) |
same | 4분기 정산, 이중 정산 거절 | 배선 미증명 |
| MRC-011 | test | RegisteredMessageCodecsTest (4) |
same | 기본 codec 규칙 | — |
| MRC-012 | assembly | messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478 |
same | 다섯 조립 지점과 6인자 생성자 선택 | 해당 leaf SSOT가 소유 |
| MRC-013 | cross-leaf code | messaging-observability/.../MessagingMetrics.java:29 |
same | MessagingObservation의 유일한 구현 |
테스트에서만 생성 |
| MRC-014 | cross-leaf code | messaging-policy/.../DeadLetterOrchestrator.java |
same | 경쟁하는 DLQ 구현 | 해당 leaf SSOT가 소유 |
| EVD-283 | command | evidence/raw/283-runtime-core-observation-noop.txt |
same | §12.1 전부 | 정적 검색 |
| EVD-284 | command | ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks |
same | 21 / 0 / 0 | RecordingTransport 대체 |