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>
51 KiB
messaging-reliability-api 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-reliability-apiSSOT owner:messaging-reliability-apiintegration/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 — 여섯 상태와 두 개의 구분
PENDING → IN_FLIGHT → PUBLISHED / 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".
traceContext만 Optional이 아니고 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() 생성 시와 접근 시), headers가 Map.copyOf — messaging-schema-api의 EncodedMessage(그쪽 §4.3)와 같은 패턴이다.
withStatus가 messageId를 파라미터로 받지 않는다 — "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-api의 TraceContext가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.
expiresAt이 Optional이 아니다 — 모든 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.apply가 throws Exception이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.
7. 트랜잭션·동시성·수명주기
이 leaf 전체가 트랜잭션 계약이다. 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다.
| 계약 | 표현 위치 | 강제 |
|---|---|---|
OutboxRepository.append가 호출자 트랜잭션 안 |
인터페이스 javadoc | 없음 |
| 나머지 메서드는 릴레이 자기 트랜잭션 | 같은 javadoc | 없음 |
InboxRepository.reserve가 핸들러 부작용과 같은 트랜잭션 |
인터페이스 javadoc | 없음 |
TransactionalMessageAction이 자기 트랜잭션을 시작하지 않음 |
javadoc | 없음 |
ReliableMessagePublisher.addToOutbox가 void인 것 |
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의 계약 불변식(예: OutboxCanonicalMetadata의 schemaUri 없이 schemaSubject 금지, OutboxLease의 token >= 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를 주고받지 않는다 — reserve는 boolean, isProcessed는 boolean, 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-jpa의 OutboxStoreAdapter·JpaCleanupQueue·JpaUploadSessionStore, cache-redis의 RedisIdempotencyStoreAdapter, notification의 JpaProviderEventLedger. 단어 검색으로 이 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만으로 중복이 제거되지 않는 이유 —
OutboxRecordjavadoc - fencing token이 필요한 이유와 이전 이중 발행 —
OutboxLeasejavadoc - 전이가 결과를 반환해야 하는 이유 —
OutboxTransitionResultjavadoc AMBIGUOUS가 실패의 한 종류가 아닌 이유,EXHAUSTED가FAILED와 다른 이유 —OutboxStatusjavadoc- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) —
OutboxCanonicalMetadatajavadoc - 두 반쪽의 소유자가 다른 이유 —
OutboxRecordjavadoc - inbox 키가 (message, consumer)인 이유 —
InboxRecord·IdempotentMessageHandlerjavadoc InboxResult가 셋인 이유 — 그 javadoc- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 —
InboxRepository·TransactionalMessageActionjavadoc addToOutbox가void인 이유 —ReliableMessagePublisherjavadoc- claim check digest와 만료가 필수인 이유 —
ClaimCheckReferencejavadoc - purge에
limit이 필요한 이유 —OutboxRepositoryjavadoc - inbox 보존이 재전달 창보다 길어야 하는 이유 —
InboxRepositoryjavadoc
추론
ReliableMessagePublisher구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → 추론. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.OutboxRecord.equals가 다섯 필드만 보는 이유 → 미상.sha256이 소문자만 받는 이유 → 미상(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).- 구세대를 남긴 이유 → 부분 명시("remains for inspection paths"). 제거 시점은 미상.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- 13개 타입 817줄 전문의 계약과 불변식
- 이 leaf에 테스트가 하나도 없다는 것(
src/test부재) ReliableMessagePublisher와InboxRecord의 참조 0OutboxRepository가 같은 다섯 전이의 두 세대를 갖고@Deprecated가 하나도 없다는 것- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
OutboxStatus.FAILED의 의미가 저장소 ArchUnit 규칙의 근거라는 것
확인하지 못한 것
- fencing token SQL이 실제 PostgreSQL에서 정확한지. 그것을 검증할 레인이 다른 세대를 쓴다.
messaging-outbox-jdbc-postgresqlleaf가 이 판정을 소유한다. 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이 없다 —
OutboxLeasejavadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, 실제로 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 경로가 실제 데이터베이스에 대해 실행되지 않는다
- 사실.
OutboxRelay는claimBatch/lease 기반 전이만 쓴다.OutboxPostgresIT는leaseBatch/MessageId기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는InMemoryOutboxRepository와RecordingRepository— 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-postgresqlleaf가 소유한다. 여기서는 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. - 왜 문제인가. 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예:
OutboxCanonicalMetadata가schemaUri있고schemaSubject없는 조합을 거절하는 것,OutboxLease가token < 1을 거절하는 것,InboxResult.isSafeToSettle의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(messaging-core-api79개,messaging-policy42개 등). - 확인 방법.
ls src/messaging/messaging-reliability-api/src→main만. - 후보. record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
- 다음 단계. REFERENCE 후보(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).
P3 — inbox 보존 규칙이 문서로만 있다
- 사실.
InboxRepository.purgeProcessedBeforejavadoc이 "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.
DestinationProfileValidator16규칙 전수(재전달 창 관련 없음). - 왜 문제인가. 위반의 결과가 부작용의 이중 실행이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
- 확인 방법.
git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java' - 후보. 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을
messaging-policy나 starter에 추가한다. - 다음 단계. CASE 후보 + REFERENCE 후보(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).
P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다
- 사실.
OutboxRepository.append가 호출자 트랜잭션 안,InboxRepository.reserve가 부작용과 같은 트랜잭션,TransactionalMessageAction이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다. - 근거. 세 javadoc.
- 왜 문제인가.
ReliableMessagePublisher는void반환으로 계약의 일부를 타입에 담았다("Handing back aPublishResulthere 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/hashCode가messageId·status·attempts·payload넷만 본다.destination·metadata·createdAt·leaseExpiresAt·lastFailureCode는 무시한다. - 근거.
OutboxRecord.java:114-126. - 왜 문제인가. record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(
messaging-schema-api의EncodedMessage도 같다). 그러나EncodedMessage는 모든 필드를 비교하고 이쪽은 아니다. 같은messageId·status·attempts·payload를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다. - 확인 방법. 두 record의
equals대조. - 후보. 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
- 다음 단계. REFERENCE 후보(record의
equals를 좁히면 이유를 적는다).
P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
- 사실.
InboxRepository와OutboxRepository가 각각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를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
withStatus가messageId를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것addToOutbox의void반환이 계약인 것- 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에 테스트 레인 없음 |