Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-reliability-api.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

51 KiB

messaging-reliability-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-reliability-api SSOT owner: messaging-reliability-api integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-reliability-api
  • canonical state analysisFile: analysis/messaging/messaging-reliability-api.md
  • source path: src/messaging/messaging-reliability-api
  • registry allowed_dependencies: ["messaging-core-api"]
  • registry runtime_memberships: ["app-bootstrap"]

숫자

항목
production Java 파일 13
production LOC 817
패키지 1 (dev.caskeleton.messaging.reliability)
test 파일 0 — src/test 디렉터리가 없다
외부(비프로젝트) 의존성 0

13개 타입:

타입 leaf 밖 참조
Outbox OutboxRepository · OutboxRecord · OutboxCanonicalMetadata · OutboxStatus · OutboxLease · OutboxTransitionResult 7 · 13 · 8 · 7 · 6 · 6
Inbox InboxRepository · InboxRecord · InboxResult · IdempotentMessageHandler · TransactionalMessageAction 6 · 0 · 2 · 1 · 1
기타 ClaimCheckReference · ReliableMessagePublisher 6 · 0

Coverage ledger

scope/file group count disposition reason
src/main/java/** (13) 13 FULL_READ 전 파일 본문 확인
src/test/** 0 존재하지 않음(§10)
build.gradle 1 FULL_READ 5줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 effectively-once 처리의 계약을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 isExpired/expiredAt 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.

세 개의 독립적인 메커니즘을 담는다.

Outbox — dual-write 문제의 답.

// ReliableMessagePublisher.java:14-15
 * <p>This is the answer to the dual-write problem. Writing to the database and publishing to the
 * broker in the same method cannot be made atomic; writing both to the database can.

Inbox — 소비 측 중복 제거.

// InboxRepository.java:9-13
 * <p>{@link #reserve} must run inside the same database transaction as the handler's side effect.
 * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write
 * commit together, so a redelivered message either finds the row already present and skips, or
 * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to
 * close.

Claim Check — 브로커 밖 payload 참조.

그리고 셋의 관계를 OutboxRecord가 명시한다.

// OutboxRecord.java:21-24
 * <p>What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will
 * retry it, and the same message may reach the broker twice. Effectively-once processing comes from
 * this row carrying a stable {@code messageId} and the consumer having an Inbox  not from the
 * outbox alone.

Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다. 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.


2. 의존성과 런타임 배선

들어오는 것: messaging-core-api(api) 하나.

나가는 것: messaging-outbox-jdbc-postgresql, messaging-inbox-jdbc-postgresql, messaging-claim-check, messaging-spring-boot-starter.

구현 leaf가 셋 있고 전부 배선된다.

포트 구현 조립
OutboxRepository messaging-outbox-jdbc-postgresql/JdbcOutboxRepository starter MessagingReliabilityAutoConfiguration
InboxRepository messaging-inbox-jdbc-postgresql/JdbcInboxRepository 같음
IdempotentMessageHandler messaging-inbox-jdbc-postgresql/TransactionalInboxHandler transactionalInboxHandler bean
ReliableMessagePublisher 없음

ReliableMessagePublisher는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.

이 leaf 자체는 Spring 주석을 갖지 않는다.


3. 패키지/컴포넌트 지도

Outbox
  ReliableMessagePublisher.addToOutbox(dest, envelope)   ← 구현 0
        ↓ (쓰기)
  OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers
                ├─ OutboxCanonicalMetadata  (provenance 10필드)
                └─ status / attempts / leaseExpiresAt / lastFailureCode
        ↓ (릴레이)
  OutboxRepository ─┬─ append
                    ├─ [구세대] leaseBatch → List<OutboxRecord>
                    │            markPublished/markAmbiguous/markFailed/releaseLease(MessageId)  → void
                    └─ [신세대] claimBatch → List<OutboxLease>
                                 markPublished/markAmbiguous/markExhausted/markFailed/releaseLease(OutboxLease)
                                    → OutboxTransitionResult {APPLIED, STALE_LEASE}
  OutboxStatus {PENDING, IN_FLIGHT, PUBLISHED, AMBIGUOUS, FAILED, EXHAUSTED}

Inbox
  IdempotentMessageHandler.handleOnce(consumerName, delivery, TransactionalMessageAction)
  InboxRepository.reserve(messageId, consumerId, now) → boolean
  InboxRecord (messageId + consumerId + processedAt)   ← 참조 0
  InboxResult {APPLIED, ALREADY_APPLIED, CLAIMED_ELSEWHERE}

Claim Check
  ClaimCheckReference (storageKey, sizeBytes, sha256, expiresAt)

4. 계약·불변식·상태 모델

4.1 OutboxLease — fencing token

이 leaf에서 가장 중요한 안전 장치이고, 이전 결함이 javadoc에 통째로 있다.

// OutboxLease.java:8-16
 * <p>The port used to take a {@code MessageId} for every terminal transition, so a write said which
 * row to change and nothing about which claim it belonged to. A relay that stalled past its lease
 * could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already
 * written, and the row became claimable again  one message, published twice, by a system whose
 * whole purpose is to publish it once.
 *
 * <p>The token is the part that makes staleness detectable. It increases on every claim, so a
 * superseded relay holds a number the row no longer has and its update matches zero rows.

token < 1을 거절하는 이유도 적혀 있다 — "a claim's token starts at 1; 0 is the value of a row nobody has claimed".

expiredAt(now)!now.isBefore(expiresAt)다.

4.2 OutboxTransitionResult — void가 삼킨 것

// :5-9
 * <p>The transitions returned {@code void}, so an update that matched zero rows was
 * indistinguishable from one that matched one. That is precisely the stale-lease case: the relay
 * believes it recorded the outcome, the row still says something else, and nothing anywhere counts
 * the disagreement.

두 값이고 STALE_LEASE의 javadoc이 운영 의미까지 적는다.

 * <p>Another relay claimed it after the lease expired. Not an error to throw  the message is
 * being handled by somebody else  but never a success either: it is the signal that this
 * worker's publish attempt may have produced a duplicate, and it belongs on a metric.

"belongs on a metric" — 그 메트릭이 존재하는지는 outbox leaf가 답한다.

4.3 OutboxStatus — 여섯 상태와 두 개의 구분

PENDINGIN_FLIGHTPUBLISHED / AMBIGUOUS / FAILED / EXHAUSTED.

두 쌍의 구분이 각각 이유를 갖는다.

AMBIGUOUS vs FAILED:

// :6-9
 * <p>{@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose
 * publish timed out may already be on the broker; retrying it is correct, but only under the same
 * logical message id, and an operator looking at the table needs to be able to tell those rows
 * apart from ones that definitely never landed.

EXHAUSTED vs FAILED:

// :31-34
 * <p>Distinct from {@link #FAILED}, which means the broker refused the message: this one means
 * nobody ever got an answer. Collapsing the two loses the difference between "this message is
 * invalid" and "the broker was unreachable for an hour", and those need different operator
 * actions  the first a fix, the second a redrive.

OutboxRepository.markExhausted의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."

FAILED의 의미가 애플리케이션 쪽 동명 enum과 반대다. CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM.because(...)가 그것을 ArchUnit 규칙의 근거로 든다 — "its OutboxStatus.FAILED means the opposite of the legacy OutboxEventStatus.FAILED, so the two models cannot be mixed by name without inverting retryable and terminal." 즉 이 enum의 의미가 저장소 규칙 하나의 존재 이유다.

4.4 InboxResult — 두 개가 아니라 세 개

// :6-9
 * <p>Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}
 * into a single "duplicate" would settle a message whose effect is still only half-written by
 * another instance: if that instance then rolls back, the effect is lost and the broker will never
 * redeliver, because this instance already acknowledged it.

safeToSettle 플래그가 상수에 붙어 있다.

safeToSettle
APPLIED true 이 트랜잭션에서 효과 실행
ALREADY_APPLIED true 커밋된 예약 존재 — 이미 실행됨
CLAIMED_ELSEWHERE false 다른 인스턴스가 미커밋 예약 보유

세 번째의 javadoc이 결론을 적는다 — "Do not settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect."

세 값 모두 필요한 이유가 명확하고, isSafeToSettle()이 그 판단을 하나로 모은다.

4.5 InboxRepository — 키가 (message, consumer)다

// InboxRecord.java:9-12
 * <p>Keyed by message id <em>and</em> consumer id, because two independent consumers of the same
 * event must each process it once  deduplicating on the message alone would let the first consumer
 * suppress the second.

IdempotentMessageHandler의 javadoc이 같은 이유를 API 형태로 반복한다 — consumerName이 파라미터인 이유.

purgeProcessedBefore의 javadoc이 보존 기간 규칙을 적는다.

 * <p>Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery
 * arrives after its inbox row was pruned and is processed a second time.

이 규칙을 강제하는 코드가 없다. 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, messaging-policy의 프로파일 검증기에도 없다. §17.

4.6 TransactionalMessageAction — 트랜잭션 경계의 소유권

// :8-16
 * <p>Sharing one transaction is the entire mechanism. If the effect committed separately from the
 * "I have handled this message" marker, a crash between the two would either replay the effect or
 * suppress a message that was never handled  and which of those you get would depend on the order
 * the two commits happened to be written in.
 *
 * <p>Implementations must not settle the message, publish, or start their own transaction. The
 * runtime owns the transaction boundary precisely so that the action cannot accidentally commit
 * half of it.

세 금지("settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라")가 문서로만 표현된다. 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.

4.7 OutboxCanonicalMetadata — 컬럼이어야 하는 이유

이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.

// :14-28
 * <p>They used to live nowhere. A row held identity, type, version, content type, payload and an
 * arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either
 * invented when the envelope was rebuilt  {@code Optional.empty()} for every one of them  or
 * smuggled through the header map under reserved names the platform was supposed to own.
 *
 * <p>Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant
 * without decoding the payload, so the operational question "which tenant is backed up" has no
 * answer; and a message that crossed the outbox arrived at its consumer with a different tenant,
 * trace and correlation than the one that was published, which makes the publish path  direct,
 * polling or CDC  part of the message's meaning.
 *
 * <p>Columns rather than a blob, because the point is that the database can answer questions about
 * them. A versioned envelope encoding would round-trip just as faithfully and would still leave the
 * relay unable to select rows for one tenant.

세 번째 문단이 고려된 대안을 명시적으로 기각한다 — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.

불변식 하나: schemaUri.isPresent() && schemaSubject.isEmpty()를 거절한다 — "a reader would have a URI and no way to know what it is a schema for".

traceContextOptional이 아니고 TraceContext.none()이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것("the reader's behaviour is the same: carry what is there and invent nothing").

4.8 OutboxRecord — 두 반쪽의 소유자가 다르다

// :26-29
 * <p>{@link OutboxCanonicalMetadata} is a separate component rather than more fields here because
 * the two halves answer to different owners. Identity, payload, status, attempts and lease are the
 * relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to
 * survive the round trip through the database unchanged.

payload가 양방향 방어 복사(payload.clone() 생성 시와 접근 시), headersMap.copyOfmessaging-schema-apiEncodedMessage(그쪽 §4.3)와 같은 패턴이다.

withStatusmessageId를 파라미터로 받지 않는다 — "The message id is never a parameter, so no state transition can change it." 타입이 불변식을 강제하는 예다.

equals/hashCode다섯 필드 중 넷만 본다 — messageId, status, attempts, payload. destination·metadata·createdAt·leaseExpiresAt·lastFailureCode는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 그 이유가 어디에도 적혀 있지 않다. §17.

toString이 payload를 담지 않는다.

4.9 ClaimCheckReference — digest가 선택이 아니다

// :10-16
 * <p>The digest is part of the reference, not an optional extra. A claim check splits a message
 * into two systems with independent retention and replication, so a consumer that fetches the
 * payload has to be able to prove it got the bytes the producer stored  otherwise a truncated or
 * replaced object is indistinguishable from a valid one.
 *
 * <p>The expiry is carried for the same reason: a claim check whose payload has been reaped is a
 * dead message, and detecting that at fetch time is better than a mysterious not-found.

sha256[a-f0-9]{64} 정확 일치다 — 대문자 hex를 거절한다. messaging-core-apiTraceContext가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.

expiresAtOptional이 아니다 — 모든 claim check가 만료를 갖는다.


5. 주요 실행 경로

Outbox 쓰기: 애플리케이션 트랜잭션 안에서 ReliableMessagePublisher.addToOutbox(...)OutboxRepository.append(record)진입점 구현이 없다(§12.1)

Outbox 릴레이: claimBatch(owner, size, lease, now, maxAttempts)List<OutboxLease> → 각 lease에 대해 발행 → 결과에 따라 markPublished/markAmbiguous/markExhausted/markFailed(lease 기반) → APPLIED면 정상, STALE_LEASE면 다른 릴레이가 가져감

Inbox: handleOnce(consumerName, delivery, action) → 한 트랜잭션 안에서 reserve(messageId, consumerId, now) → true면 action.apply(delivery) → 커밋


6. 실패 경로와 복구/번역

이 leaf는 MessagingException을 하나도 던지지 않는다. 실패를 상태와 반환값으로 표현한다.

표현
릴레이 전이 결과 OutboxTransitionResult.{APPLIED, STALE_LEASE}
Outbox 행 상태 OutboxStatus 6개
Inbox 판정 InboxResult 3개 + isSafeToSettle()
claim check 만료 ClaimCheckReference.isExpired(now)
lease 만료 OutboxLease.expiredAt(now)

IllegalArgumentException을 던지는 곳은 record 생성자 여섯이다 — 전부 호출자의 프로그래밍 오류다.

TransactionalMessageAction.applythrows Exception이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.


7. 트랜잭션·동시성·수명주기

이 leaf 전체가 트랜잭션 계약이다. 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다.

계약 표현 위치 강제
OutboxRepository.append가 호출자 트랜잭션 안 인터페이스 javadoc 없음
나머지 메서드는 릴레이 자기 트랜잭션 같은 javadoc 없음
InboxRepository.reserve가 핸들러 부작용과 같은 트랜잭션 인터페이스 javadoc 없음
TransactionalMessageAction이 자기 트랜잭션을 시작하지 않음 javadoc 없음
ReliableMessagePublisher.addToOutboxvoid인 것 javadoc 타입이 강제

마지막 하나만 타입이 강제한다.

// ReliableMessagePublisher.java:9-12
 * <p>The return type is {@code void}, and that is the contract. There is no publish outcome to
 * report yet: the row is written inside the caller's transaction, so if the transaction rolls back
 * the message never existed, and if it commits the relay will publish it later. Handing back a
 * {@code PublishResult} here would be a lie about work that has not happened.

동시성 원시 요소는 하나 — fencing token. 그것이 OutboxLease.token이고 검사는 구현의 SQL WHERE에 있다(§12.1).

모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.

수명주기 참여 없음.


8. 설정·기능 플래그·환경 차이

설정 없음. 상수도 없다 — ClaimCheckReference.SHA256 정규식 하나가 private이다.

OutboxRepository의 두 purge* 메서드가 limit 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.

// :143-147
 * <p>The unbounded version deletes everything before the cutoff in one statement. On a table that
 * has been accumulating published rows since the last sweep that is a single long transaction
 * holding locks and generating WAL in proportion to the backlog, which shows up as the relay and
 * the business writes stalling behind retention. The cleanup jobs describe themselves as bounded
 * by batch size; this is the parameter that makes that true.

InboxRepository도 같은 쌍을 갖는다.


9. 퍼시스턴스/외부 시스템 세부

없다 — 포트만 정의한다. 다만 포트가 저장소 기술을 전제한다.

  • InboxRepository.reserve의 메커니즘이 "the uniqueness constraint on the inbox row"다 — 유니크 제약이 있는 저장소를 전제
  • OutboxRepository.claimBatch의 의미가 "a record claimed by one relay is invisible to the others"다 — 행 잠금 또는 그에 준하는 것을 전제
  • OutboxTransitionResult.STALE_LEASE가 "its update matches zero rows"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제

세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(*-jdbc-postgresql)이 실제 선택을 드러낸다.


10. 테스트 레인과 실제 증명 범위

이 leaf에는 테스트가 없다. src/test 디렉터리 자체가 존재하지 않는다 — src 아래에 main만 있다.

13개 타입 중 record 생성자 검증이 있는 것이 여섯(ClaimCheckReference, InboxRecord, OutboxCanonicalMetadata, OutboxLease, OutboxRecord, OutboxTransitionResult는 enum), 술어가 있는 것이 셋(isExpired, expiredAt, isSafeToSettle)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.

검증은 전부 구현 leaf에서 일어난다.

검증 위치 무엇을
messaging-outbox-jdbc-postgresql 테스트 4개 OutboxRepository 구현, 릴레이
messaging-inbox-jdbc-postgresql 테스트 4개 InboxRepository 구현, 멱등 핸들러
messaging-claim-check 테스트 3개 claim check
starter MessagingOutboxRelayLifecycleTest 릴레이 수명주기

그 결과 이 leaf의 계약 불변식(예: OutboxCanonicalMetadataschemaUri 없이 schemaSubject 금지, OutboxLeasetoken >= 1, InboxResult.isSafeToSettle의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.

그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies ["messaging-core-api"]
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM ..application..이 이 leaf를 포함한 dev.caskeleton.messaging..을 참조하는 것을 금지. 규칙의 근거가 이 leaf의 OutboxStatus.FAILED 의미다
SecretLeakStaticScanTest(observability leaf) 이 leaf 소스도 스캔 대상
ArchUnit 전용 규칙 없음

네 번째가 특이하다 — ArchUnit 규칙 하나가 이 leaf의 enum 상수 의미를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/289-reliability-api-two-generations.txt.

12.1 Public surface reachability

타입 leaf 밖 파일 판정
OutboxRecord 13 활발
OutboxCanonicalMetadata 8 활발
OutboxRepository 7 구현 1 + 릴레이 + 테스트
OutboxStatus 7 활발
OutboxLease 6 활발
OutboxTransitionResult 6 활발
InboxRepository 6 구현 1 + 테스트
ClaimCheckReference 6 활발
InboxResult 2
IdempotentMessageHandler 1 TransactionalInboxHandler
TransactionalMessageAction 1 같음
InboxRecord 0
ReliableMessagePublisher 0

(a) Outbox 쓰기 진입점에 구현이 없다

ReliableMessagePublisher는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.

OutboxRepository.append는 존재하지만 그것은 저장소 포트다 — javadoc이 "must be callable inside the caller's business transaction"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 ReliableMessagePublisher가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.

그리고 애플리케이션은 이 leaf를 참조할 수 없다APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM이 금지한다. 즉 ReliableMessagePublisher를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. messaging-spring-cloud-stream-bridge가 후보 이름이지만 그 leaf는 runtime_memberships: []다.

(b) InboxRecord가 쓰이지 않는다

InboxRepository의 어느 메서드도 InboxRecord를 주고받지 않는다 — reserveboolean, isProcessedboolean, purge*int다. record는 "One row of the consumer inbox"를 서술하지만 그 행을 반환하는 API가 없다.

같은 leaf의 OutboxRecord는 정반대다 — leaseBatch/find가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.

(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다

OutboxRepository는 같은 다섯 전이에 대해 두 세대를 갖는다.

전이 구세대 (MessageId) 신세대 (OutboxLease)
배치 획득 leaseBatch(size, lease, now)List<OutboxRecord> claimBatch(owner, size, lease, now[, maxAttempts])List<OutboxLease>
발행 확정 markPublished(MessageId, Instant)void markPublished(OutboxLease, Instant)OutboxTransitionResult
모호 markAmbiguous(MessageId, String, Instant)void markAmbiguous(OutboxLease, ...)OutboxTransitionResult
실패 markFailed(MessageId, String, Instant)void markFailed(OutboxLease, ...)OutboxTransitionResult
반납 releaseLease(MessageId)void releaseLease(OutboxLease)OutboxTransitionResult
소진 markExhausted(OutboxLease, String, Instant)

production 릴레이는 신세대만 쓴다.

OutboxRelay.java:158  repository.claimBatch(owner, batchSize, leaseDuration, now, scheduler.maxAttempts())
OutboxRelay.java:171  repository.markPublished(lease, now) == OutboxTransitionResult.APPLIED
OutboxRelay.java:189  repository.markExhausted(lease, reason, now)
OutboxRelay.java:192  repository.markAmbiguous(...)
OutboxRelay.java:205  repository.markFailed(...)

실제 PostgreSQL 컨테이너 테스트는 구세대만 쓴다.

OutboxPostgresIT.java:92,111,112,121,124,133,148,161  repository.leaseBatch(...)
OutboxPostgresIT.java:135  repository.markAmbiguous(record.messageId(), "CONFIRM_TIMEOUT", NOW)
OutboxPostgresIT.java:150,200  repository.markPublished(record.messageId(), NOW)
OutboxPostgresIT.java:163  repository.markFailed(record.messageId(), "INVALID_TOPIC", NOW)

fencing token 경로가 실제 데이터베이스에 대해 한 번도 실행되지 않는다. 그 경로의 정확성은 구현의 SQL WHERE ... AND token = ?이 영향 행 수를 정확히 세는지에 달려 있는데, 그것을 검증할 수 있는 유일한 레인이 다른 세대를 쓴다. 나머지 검증은 InMemoryOutboxRepository(OutboxRelayTest:223)와 RecordingRepository(OutboxOperationsTest:23) — 둘 다 SQL이 없는 fake다.

OutboxLease javadoc이 fencing token을 만든 이유로 든 사고("one message, published twice")가 정확히 그 SQL이 막는 것이다.

이 판정의 소유권. API 형태(두 세대 공존, @Deprecated 부재)는 이 leaf가 소유하고, 테스트 커버리지 판정은 messaging-outbox-jdbc-postgresql leaf가 소유한다. 여기서는 관측과 교차 참조를 남긴다.

(d) 구세대가 prose로만 deprecated다

// OutboxRepository.java:41-43
 * <p>The token is what a terminal write is checked against. {@link #leaseBatch} returns records
 * without one, so its callers cannot prove a write belongs to their claim; it remains for
 * inspection paths and is deprecated for the relay's use.

@Deprecated 애노테이션이 이 leaf 전체에 하나도 없다(git grep '@Deprecated' -- src/messaging/messaging-reliability-api exit 1).

결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.

(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다

이 항목은 messaging-inbox-jdbc-postgresql 분석 중에 확인됐다. 이 문서의 초판은 §17의 "확인된 설계"에 "purge에 limit 파라미터를 둔 것"을 넣었는데, 그것은 파라미터의 존재만 본 판정이었다. 호출 여부를 재측정해 정정한다.

InboxRepository.purgeProcessedBefore(Instant, int)OutboxRepository.purgePublishedBefore(Instant, int)가 선언돼 있고 두 JDBC 구현이 LIMIT(inbox는 FOR UPDATE SKIP LOCKED까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 하나도 없다. 두 cleanup job이 무제한 오버로드를 부른다 — InboxCleanupJob:56, OutboxCleanupJob:50.

OutboxRepository:140-151의 javadoc이 그 상황을 예고한다.

The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.

그 파라미터를 아무도 넘기지 않는다. 판정은 analysis/messaging/messaging-inbox-jdbc-postgresql.md §17(P1)이 소유하고, 이 문서는 포트가 두 오버로드를 나란히 노출했다는 것을 기여한다 — (a)의 두 세대 전이와 같은 형태다.

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. 구현 leaf 셋의 sibling 비교가 유의미하다.

포트 구현 leaf membership starter bean
OutboxRepository messaging-outbox-jdbc-postgresql ["app-bootstrap"] MessagingReliabilityAutoConfiguration
InboxRepository messaging-inbox-jdbc-postgresql ["app-bootstrap"] 같음
IdempotentMessageHandler messaging-inbox-jdbc-postgresql 같음 transactionalInboxHandler bean
ReliableMessagePublisher 없음

네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.

12.3 Duplicate mechanism sweep

(a) 같은 전이의 두 세대 — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.

(b) outbox 개념이 저장소에 둘 있다

이 leaf application-core
상태 enum OutboxStatus OutboxEventStatus
FAILED의 뜻 브로커가 확정적으로 거절 — 재시도 안 함 (반대 의미, ArchUnit javadoc이 명시)
행 타입 OutboxRecord NewOutboxEvent
사용처 messaging family application + persistence-jpa

의도된 분리다. ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 .because(...)가 이유를 적는다 — "the two outbox status models mean opposite things under the same names". 중복 경쟁이 아니라 명시적으로 격리된 두 모델이다.

다만 그 결과 ReliableMessagePublisher가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.

(c) 이름 충돌 주의

markPublished·markFailed·releaseLease라는 메서드 이름이 저장소의 완전히 다른 인터페이스 여러 곳에 있다 — persistence-jpaOutboxStoreAdapter·JpaCleanupQueue·JpaUploadSessionStore, cache-redisRedisIdempotencyStoreAdapter, notificationJpaProviderEventLedger. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 src/messaging/**로 범위를 좁혀 얻은 것이다.

12.4 Documentation / measured-count drift

문서 주장 재측정 결과
OutboxRepository:43: leaseBatch가 "deprecated for the relay's use" @Deprecated 0건, 컨테이너 테스트가 사용 미강제
OutboxRecord javadoc: outbox만으로는 중복 제거 안 됨 InboxRepository가 별도 존재 일치
InboxRepository.purge* javadoc: 보존이 브로커 재전달 창보다 길어야 함 그 비교를 하는 코드 없음 미강제
TransactionalMessageAction javadoc: 구현이 settle/publish/트랜잭션 시작 금지 타입이 강제하지 않음 미강제
ReliableMessagePublisher javadoc: dual-write의 답 구현 0 미실현
OutboxTransitionResult.STALE_LEASE javadoc: "it belongs on a metric" 이 leaf에 메트릭 없음. outbox leaf가 답함 미확인
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf의 javadoc은 세 개의 서로 다른 결함을 보존한다.

위치 이전 상태 그것이 만든 실패
OutboxLease javadoc 모든 terminal 전이가 MessageId만 받음 lease를 넘긴 릴레이가 다른 릴레이의 PUBLISHED 위에 AMBIGUOUS를 기록 → 행이 다시 claim 가능해짐 → 한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서
OutboxTransitionResult javadoc 전이가 void 반환 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 그 불일치를 아무도 세지 않음
OutboxCanonicalMetadata javadoc provenance가 어디에도 없음 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 Optional.empty()가 되거나 헤더 맵에 예약 이름으로 밀반입 → outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착, 즉 발행 경로가 메시지의 의미의 일부가 됨
OutboxRepository.purgePublishedBefore javadoc 무제한 삭제 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → 릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤

첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.

세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — "which makes the publish path — direct, polling or CDC — part of the message's meaning." 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-294 command evidence/raw/294-bounded-purge-never-called.txt bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 정적 검색. messaging-inbox-jdbc-postgresql이 판정 소유
EVD-289 command evidence/raw/289-reliability-api-two-generations.txt src/test 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, OutboxRepository의 두 세대 시그니처 전수, @Deprecated 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 정적 검색. 이 leaf에 실행할 테스트 레인이 없음

이 leaf에는 test lane evidence가 없다src/test가 존재하지 않으므로 :messaging:messaging-reliability-api:test는 실행할 소스가 없다.


15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • outbox만으로 중복이 제거되지 않는 이유 — OutboxRecord javadoc
  • fencing token이 필요한 이유와 이전 이중 발행 — OutboxLease javadoc
  • 전이가 결과를 반환해야 하는 이유 — OutboxTransitionResult javadoc
  • AMBIGUOUS가 실패의 한 종류가 아닌 이유, EXHAUSTEDFAILED와 다른 이유 — OutboxStatus javadoc
  • provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — OutboxCanonicalMetadata javadoc
  • 두 반쪽의 소유자가 다른 이유 — OutboxRecord javadoc
  • inbox 키가 (message, consumer)인 이유 — InboxRecord·IdempotentMessageHandler javadoc
  • InboxResult가 셋인 이유 — 그 javadoc
  • 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — InboxRepository·TransactionalMessageAction javadoc
  • addToOutboxvoid인 이유 — ReliableMessagePublisher javadoc
  • claim check digest와 만료가 필수인 이유 — ClaimCheckReference javadoc
  • purge에 limit이 필요한 이유 — OutboxRepository javadoc
  • inbox 보존이 재전달 창보다 길어야 하는 이유 — InboxRepository javadoc

추론

  • ReliableMessagePublisher 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → 추론. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.
  • OutboxRecord.equals가 다섯 필드만 보는 이유 → 미상.
  • sha256이 소문자만 받는 이유 → 미상(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).
  • 구세대를 남긴 이유 → 부분 명시("remains for inspection paths"). 제거 시점은 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 13개 타입 817줄 전문의 계약과 불변식
  • 이 leaf에 테스트가 하나도 없다는 것(src/test 부재)
  • ReliableMessagePublisherInboxRecord의 참조 0
  • OutboxRepository가 같은 다섯 전이의 두 세대를 갖고 @Deprecated가 하나도 없다는 것
  • production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
  • 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
  • OutboxStatus.FAILED의 의미가 저장소 ArchUnit 규칙의 근거라는 것

확인하지 못한 것

  • fencing token SQL이 실제 PostgreSQL에서 정확한지. 그것을 검증할 레인이 다른 세대를 쓴다. messaging-outbox-jdbc-postgresql leaf가 이 판정을 소유한다.
  • STALE_LEASE가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.
  • inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.
  • ReliableMessagePublisher를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.
  • OutboxRecord.equals의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.

17. 손볼 것

P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 @Deprecated가 없다

  • 사실. OutboxRepository가 다섯 전이 각각에 대해 MessageId 기반(반환 void)과 OutboxLease 기반(반환 OutboxTransitionResult) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 @Deprecated 애노테이션이 이 leaf 전체에 0건이다.
  • 근거. evidence/raw/289 §E·§F.
  • 왜 문제인가. 전자에는 fencing이 없다 — OutboxLease javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, 실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
  • 확인 방법. git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api → 없음. evidence/raw/289 §E.
  • 후보. (a) 구세대 다섯에 @Deprecated를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(OutboxInspection)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "prose deprecation은 컴파일러가 읽지 않는다"가 재사용 가능한 기준이다.

P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다

  • 사실. OutboxRelayclaimBatch/lease 기반 전이만 쓴다. OutboxPostgresITleaseBatch/MessageId 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 InMemoryOutboxRepositoryRecordingRepository — SQL이 없는 fake다.
  • 근거. evidence/raw/289 §G.
  • 왜 문제인가. fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. OutboxTransitionResult.STALE_LEASE는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.
  • 확인 방법. evidence/raw/289 §G 재실행. OutboxPostgresIT에서 claimBatch 검색 → 없음.
  • 후보. 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.
  • 다음 단계. 판정은 messaging-outbox-jdbc-postgresql leaf가 소유한다. 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. CASE 후보(그 leaf).

P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다

  • 사실. ReliableMessagePublisher가 구현 0, 참조 0이다. javadoc은 "This is the answer to the dual-write problem"이라고 한다.
  • 근거. evidence/raw/289 §B·§C·§D.
  • 왜 문제인가. OutboxRepository.append가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, ReliableMessagePublisher는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 "릴레이가 읽는 쪽"만 배선돼 있고 "애플리케이션이 쓰는 쪽"이 비어 있다.
  • 확인 방법. git grep -n -E 'implements .*ReliableMessagePublisher' -- src → 없음.
  • 후보. (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 "파생 프로젝트가 구현하는 확장점"임을 명시한다.
  • 다음 단계. OPEN QUESTION 후보. 판정이 "두 outbox 모델 중 어느 쪽이 정본인가"에 걸리고, 그 질문은 application-core와 cross-scope가 함께 답한다.

P3 — 이 leaf에 테스트가 없다

  • 사실. src/test 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.
  • 근거. evidence/raw/289 §A.
  • 왜 문제인가. 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: OutboxCanonicalMetadataschemaUri 있고 schemaSubject 없는 조합을 거절하는 것, OutboxLeasetoken < 1을 거절하는 것, InboxResult.isSafeToSettle의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(messaging-core-api 79개, messaging-policy 42개 등).
  • 확인 방법. ls src/messaging/messaging-reliability-api/srcmain만.
  • 후보. record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
  • 다음 단계. REFERENCE 후보(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).

P3 — inbox 보존 규칙이 문서로만 있다

  • 사실. InboxRepository.purgeProcessedBefore javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 messaging-policy의 프로파일 검증기에도 없다.
  • 근거. 해당 javadoc. DestinationProfileValidator 16규칙 전수(재전달 창 관련 없음).
  • 왜 문제인가. 위반의 결과가 부작용의 이중 실행이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
  • 확인 방법. git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java'
  • 후보. 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 messaging-policy나 starter에 추가한다.
  • 다음 단계. CASE 후보 + REFERENCE 후보(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).

P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다

  • 사실. OutboxRepository.append가 호출자 트랜잭션 안, InboxRepository.reserve가 부작용과 같은 트랜잭션, TransactionalMessageAction이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.
  • 근거. 세 javadoc.
  • 왜 문제인가. ReliableMessagePublishervoid 반환으로 계약의 일부를 타입에 담았다("Handing back a PublishResult here would be a lie"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — InboxRepository.reserve를 별도 트랜잭션에서 부르면 "exactly the gap the Inbox exists to close"가 다시 열린다.
  • 확인 방법. 세 javadoc과 구현의 @Transactional 배치 대조 — 구현 leaf가 소유한다.
  • 후보. 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 append/reserve 호출부의 트랜잭션 컨텍스트를 검사한다.
  • 다음 단계. REFERENCE 후보(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).

P3 — OutboxRecord.equals가 다섯 필드만 비교하고 이유가 없다

  • 사실. equals/hashCodemessageId·status·attempts·payload 넷만 본다. destination·metadata·createdAt·leaseExpiresAt·lastFailureCode는 무시한다.
  • 근거. OutboxRecord.java:114-126.
  • 왜 문제인가. record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(messaging-schema-apiEncodedMessage도 같다). 그러나 EncodedMessage모든 필드를 비교하고 이쪽은 아니다. 같은 messageId·status·attempts·payload를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.
  • 확인 방법. 두 record의 equals 대조.
  • 후보. 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
  • 다음 단계. REFERENCE 후보(record의 equals를 좁히면 이유를 적는다).

P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다

  • 사실. InboxRepositoryOutboxRepository가 각각 purge*Before(Instant)purge*Before(Instant, int)를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
  • 근거. evidence/raw/294-bounded-purge-never-called.txt.
  • 왜 문제인가. §12.1(a)의 두 세대 전이와 같은 형태다 — 한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, @Deprecated도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다. 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
  • 확인 방법. git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'
  • 다음 단계. 판정은 analysis/messaging/messaging-inbox-jdbc-postgresql.md §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 같은 CASE로 묶을 후보다.

확인된 설계(문제 아님)

  • outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것
  • fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계
  • AMBIGUOUS/FAILED/EXHAUSTED 세 상태의 구분과 각각의 운영 행동 차이
  • InboxResult가 셋이고 isSafeToSettle()이 그 판단을 모으는 것
  • inbox 키가 (message, consumer)인 것
  • provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
  • withStatusmessageId를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것
  • addToOutboxvoid 반환이 계약인 것
  • claim check의 digest와 만료가 필수인 것
  • 두 outbox 모델을 ArchUnit으로 격리한 것

Source anchors

id kind path revision what it proves limitations
MRA-001 registry src/config/architecture/modules.json 21234e38 deps 1개, memberships ["app-bootstrap"] 선언
MRA-002 build messaging-reliability-api/build.gradle same 벤더 의존성 0
MRA-003 code .../reliability/OutboxRepository.java 전문 same 두 세대 17메서드, purge limit 이유 @Deprecated 없음
MRA-004 code .../reliability/OutboxLease.java same fencing token과 이중 발행 이력
MRA-005 code .../reliability/OutboxTransitionResult.java same void 반환이 삼킨 것
MRA-006 code .../reliability/OutboxStatus.java same 여섯 상태와 두 구분의 이유
MRA-007 code .../reliability/OutboxCanonicalMetadata.java same provenance 결함 이력, 기각된 대안
MRA-008 code .../reliability/OutboxRecord.java same 두 반쪽 분리, 방어 복사, 좁은 equals equals 이유 없음(§17)
MRA-009 code .../reliability/{InboxRepository,InboxRecord,InboxResult}.java same 트랜잭션 계약, (message,consumer) 키, 세 판정 InboxRecord 참조 0
MRA-010 code .../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java same 멱등 핸들러 계약과 세 금지 금지 미강제
MRA-011 code .../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java same dual-write 답, digest 필수 publisher 구현 0
MRA-012 cross-leaf code messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205 same production이 신세대만 사용 해당 leaf SSOT가 소유
MRA-013 cross-leaf test messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200 same 컨테이너 테스트가 구세대만 사용 해당 leaf SSOT가 소유
MRA-014 architecture test src/app-bootstrap/.../CleanArchitectureTest.java:229-240 same OutboxStatus.FAILED 의미가 규칙의 근거 정적 분석
EVD-289 command evidence/raw/289-reliability-api-two-generations.txt same §12.1 전부, src/test 부재 정적 검색. 이 leaf에 테스트 레인 없음