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>
59 KiB
messaging-admin-runtime 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-admin-runtimeSSOT owner:messaging-admin-runtimeintegration/family document:analysis/19-messaging-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
- registered leaf id:
messaging-admin-runtime - canonical state
analysisFile:analysis/messaging/messaging-admin-runtime.md - source path:
src/messaging/messaging-admin-runtime - registry
allowed_dependencies:["messaging-core-api", "messaging-policy", "messaging-admin-api", "messaging-transport-spi", "messaging-security", "messaging-observability"] - registry
runtime_memberships:["app-bootstrap"]— 배포된다
숫자
| 항목 | 수 |
|---|---|
| production Java 파일 | 12 |
| test Java 파일 | 6 |
| 전체 LOC | 2,304 (main 1,313 / test 991) |
| 패키지 | 1 (dev.caskeleton.messaging.admin.runtime) |
| test 메서드(실행 확인) | 51 (EVD-309) |
| 선언된 의존 | project 6 (전부 api) |
| 실제 import되는 의존 | project 3 — policy·transport-spi·security 는 0건 (§12.4) |
| leaf 밖에서 import 하는 파일 | 3 (starter 2 + kafka IT 1) |
12개 production 타입:
| 타입 | 종류 | 역할 | src/main 생성 |
|---|---|---|---|
MessagingAdminService |
interface | 비파괴 admin 표면 (5메서드) | — |
DefaultMessagingAdminService |
class 257줄 | 유일한 오케스트레이터 | 0 |
DestructiveMessagingAdmin |
interface | purge/delete/offset-reset | 구현 0 |
ReplayService |
class | 리플레이 실행 | 0 |
RedriveService |
class 210줄 | 리드라이브 실행 | 0 (test 1) |
ReplayReport / RedriveReport |
record | 실행 1회 결과 | — |
BrokerTopologyInspector |
interface | 브로커 토폴로지 읽기 SPI | — |
CompositeTopologyValidator |
class | 매니페스트 전건 비교 (Stack A) | 1 (스타터) |
TopologyValidator |
class | 1건 비교 + severity 판정 | 1 (내부 필드) |
TopologyValidationRuntime |
class | 두 번째 토폴로지 스택 (Stack B) | 0 |
InMemoryAdminOperationJournal |
class 237줄 | 단일 프로세스 저널 | 1 (스타터) |
Coverage ledger
| scope/file group | count | disposition | reason |
|---|---|---|---|
src/main/java/** (12) |
12 | FULL_READ |
전 파일 본문 확인 |
src/test/java/** (6) |
6 | FULL_READ |
51개 테스트 본문·대역 구현 확인 |
build.gradle |
1 | FULL_READ |
10줄 |
| 하류/상류 (starter, outbox-jdbc, admin-api) | — | STRUCTURAL_ONLY |
도달성·계약 대조에 필요한 범위. SSOT 는 각 리프 소유 |
build/** |
— | EXCLUDED |
빌드 산출물 |
UNCLASSIFIED 0.
1. 모듈의 정체와 경계
messaging-admin-api 가 정의한 타입들을 실제로 실행하는 계층이다. 계획을 세우고, 저널에 자리를 잡고, 옮기고, 결과를 보고한다.
구조는 세 층이다.
- 오케스트레이션 —
MessagingAdminService/DefaultMessagingAdminService. 계획·승인·저널·실행을 잇는다. - 실행 —
ReplayService,RedriveService. 각각 하나의 작업을 수행하며, 브로커 접촉은 SPI(ReplayExecutor,RedriveSource,RedrivePublisher)로 밀어낸다. - 토폴로지·저널 —
CompositeTopologyValidator+TopologyValidator,TopologyValidationRuntime,InMemoryAdminOperationJournal.
경계 밖: 브로커 클라이언트가 없다. Kafka·Rabbit 어느 것도 import 하지 않고, 모든 브로커 접촉이 함수형 인터페이스 뒤에 있다. Spring 도 없다 — 배선은 전부 starter 몫이다.
읽고 나서 남는 인상은 두 가지로 갈린다. 개별 부품은 대단히 정교하다 — 저널의 펜싱 프로토콜, 리드라이브 루프의 per-item 경계, 토폴로지 severity 판정은 각각 실패 사례를 겪고 나온 코드로 보이며 그 근거가 주석에 있다. 반면 부품을 잇는 층은 실행된 적이 없다 — §12.1 에서 보듯 DefaultMessagingAdminService 는 프로덕션에서도 테스트에서도 인스턴스화되지 않는다.
2. 의존성과 런타임 배선
// messaging-admin-runtime/build.gradle 전문
apply plugin: 'java-library'
dependencies {
api project(':messaging:messaging-core-api')
api project(':messaging:messaging-policy')
api project(':messaging:messaging-admin-api')
api project(':messaging:messaging-transport-spi')
api project(':messaging:messaging-security')
api project(':messaging:messaging-observability')
}
실측 import (EVD-308):
| 선언 | 패키지 | import | 판정 |
|---|---|---|---|
messaging-admin-api |
…messaging.admin |
45 | O |
messaging-core-api |
…messaging.api |
6 | O |
messaging-observability |
…messaging.observation |
1 | O |
messaging-policy |
…messaging.policy |
0 | X |
messaging-transport-spi |
…messaging.transport |
0 | X |
messaging-security |
…messaging.security |
0 | X |
6개 중 3개가 미사용이다. 지금까지 본 리프 중 가장 많다.
배선은 starter 한 곳뿐이고, 이 리프에서 빈이 되는 것은 둘이다(EVD-307).
// MessagingAdminAutoConfiguration.java (@ConditionalOnProperty app.messaging.admin.enabled=true)
:57 return new InMemoryAdminOperationJournal();
:84 return new CompositeTopologyValidator(inspector); // @ConditionalOnBean(BrokerTopologyInspector)
MessagingAdminService, ReplayService, RedriveService, DestructiveMessagingAdmin — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다.
// MessagingAdminAutoConfiguration.java:22-24
* <p>{@link …DestructiveMessagingAdmin} is deliberately absent from this class. No bean for it is
* ever auto-configured: an operator tool that needs purge or delete registers one itself, with an
* admin credential this runtime does not hold.
나머지 셋의 부재에 대한 설명은 어디에도 없다.
3. 패키지/컴포넌트 지도
단일 패키지. 의존 방향이 한 곳에서 어긋난다.
MessagingAdminService (interface)
^
| implements
DefaultMessagingAdminService ──────┐
| |
| uses | uses
v v
ReplayService RedriveService
| |
| ReplayExecutor | RedriveSource / RedrivePublisher
v v
[브로커 — 이 리프 밖] [DLQ — 이 리프 밖]
ReplayService.audit : RedriveService.AuditSink <-- 형제의 중첩 타입에 의존 (§12.3(b))
토폴로지 쪽은 두 개의 완전히 분리된 스택이 나란히 있다(§12.3(a)).
Stack A: BrokerTopologyInspector -> CompositeTopologyValidator -> TopologyValidator
-> List<TopologyIssue> (severity) -> TopologyValidationReport.requireAcceptable()
-> MessagingConfigurationException("TOPOLOGY_MISMATCH")
Stack B: TopologyValidationRuntime.TopologyReader -> TopologyValidationRuntime.validate(...)
-> TopologyManifest.differencesFrom() -> List<String>
-> MessageTopologyException("TOPOLOGY_MISMATCH") (직접 throw)
4. 계약·불변식·상태 모델
4.1 DefaultMessagingAdminService — 검사 순서가 요점이다
// DefaultMessagingAdminService.java:22-42
/**
* Wires plan, approval, and execution together for the non-destructive admin operations.
*
* <p>Execution runs four checks, in this order, and the order is the point.
*
* <ol>
* <li>The approval is still inside its window.
* <li>The topology has not changed since the plan was approved.
* <li>The approval has not already been executed.
* <li>Only then does anything move.
* </ol>
*
* <p>The journal entry is written <em>before</em> the work rather than after it. Writing it
* afterwards leaves a window where a second execution starts while the first is still running,
* which is precisely the double-redrive the journal exists to prevent.
*
* <p>Writing it first used to have a cost the previous store never paid: an operation that died
* halfway had consumed its approval and left no record of how far it got. The journal keeps a
* checkpoint and hands back a lease that says where to resume, so a retry continues the same
* operation instead of either redoing it or requiring a new approval.
*/
코드가 그 순서를 지킨다.
// executeRedrive, :174-203 (executeReplay 도 동형)
plan.requireExecutable(now, inspector.topologyVersion()); // 검사 1·2
AdminOperationLease lease = journal.begin(…); // 검사 3
Instant startedAt = clock.get();
try {
report = redriveService.redrive(…, lease.resumeFrom(), completed -> journal.checkpoint(…));
} catch (RuntimeException failure) {
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
throw failure;
}
journal.complete(lease, report.moved() + report.failed(), clock.get());
실패 경로의 근거도 있다.
// :143-148
} catch (RuntimeException failure) {
// The operation stays resumable rather than silently consuming the approval: the journal
// entry moves to FAILED at its checkpoint, and a retry takes it over from there.
저널에 들어가는 실패 코드는 정제된다.
// :214-227
/**
* <p>The journal is read by operators during incidents and its contents outlive the process. A
* raw exception message can carry a destination, a payload fragment, or a credential from a
* driver's own error text, so only the platform's own code or the exception's simple name goes in.
*/
private static String failureCodeOf(RuntimeException failure) {
if (failure instanceof …MessagingException messaging) { return messaging.failure().code(); }
return failure.getClass().getSimpleName();
}
리스 길이 선택에도 근거가 붙어 있다.
// :45-51
/**
* <p>Long enough that a slow batch does not lose its lease mid-flight, short enough that a dead
* replica does not park an approval for an hour.
*/
private static final Duration LEASE_DURATION = Duration.ofMinutes(5);
리플레이와 리드라이브의 비대칭이 하나 있다. 리드라이브는 lease.resumeFrom() 과 체크포인트 콜백을 실행 측에 넘기지만, 리플레이는 넘기지 않는다.
// :140-142 executeReplay
report = replayService.replay(request, Optional.of(plan.approval()), plan.approval().approvedBy(), now);
ReplayService.replay(...) 시그니처에 resumeFrom 이 없다(ReplayService.java:53-54). 즉 리플레이는 리스를 받지만 재개하지 않는다 — 죽으면 처음부터 다시 읽는다. 클래스 javadoc 의 "a retry continues the same operation instead of either redoing it" 은 리드라이브에만 해당한다.
4.2 RedriveService — per-item 경계와 finally 감사
세 가지 실패를 고쳤다고 javadoc 이 적는다.
// RedriveService.java:67-84
/**
* <p>Three things were wrong with running this as a plain loop. A synchronous failure from the
* publisher — a broker that refuses the connection rather than the message — propagated out of
* the loop, so the remaining candidates were never attempted and the audit record was never
* written: the operation left no trace of the items it had already moved. A retry then started
* from the first candidate and republished them. And nothing bounded how many times one message
* could be redriven.
*/
세 수정이 코드에 있다.
// :142-156 (1) 한 건의 예외는 한 건의 실패지 패스 전체의 실패가 아니다
private boolean attempt(MessageId messageId, RedriveRequest request) {
try { result = publisher.republish(…); }
catch (RuntimeException failure) {
// One message that cannot be republished is a failed item, not a failed pass. Letting it
// propagate abandoned every candidate behind it.
return false;
}
if (result.completion() != PublishCompletion.CONFIRMED) { return false; }
source.settle(request.source(), messageId); // 확인된 것만 정산
return true;
}
// :121-137 (2) 감사 기록은 finally 에서
} finally {
// In the finally block on purpose: an operation that dies partway must still leave a record
// of what it moved, because that record is what the resumed attempt and the incident review
// both read.
audit.record(new MessagingAuditEvent("REDRIVE", subject, …));
}
발행 → 확인 → 정산 순서가 이 리프의 핵심 불변식이다.
// :19-21
/**
* <p>A redrive is a publish followed by a settlement, in that order, exactly like dead lettering in
* reverse. A message whose republish did not confirm stays in the dead letter destination: losing
* it on the way back would be the one outcome worse than leaving it parked.
*/
세 번째 수정 — 재개 — 는 인덱스 계산이 틀렸다. §12.1(a)에서 상술한다.
4.3 ReplayService — 안전한 형태를 공짜로 만든다
// ReplayService.java:13-22
/**
* <p>An isolated replay reads alongside the live consumer and needs no approval, because it changes
* nothing: a throwaway group has its own offsets. Replaying into an existing production group is a
* different operation entirely — it rewinds a live consumer and reprocesses everything since — so
* it goes through the destructive guard.
*
* <p>Making the safe form free and the destructive form approved is what keeps operators from
* reaching for the destructive one out of convenience.
*/
판단은 옳다. 구현이 그 판단을 dryRun 파라미터로 표현한다.
// :58-64
boolean needsApproval = !request.isolatedConsumerGroup();
guard.authorize(
DestructiveOperation.REPLAY,
request.destination(),
approval,
request.dryRun() || !needsApproval, // <- guard 의 dryRun 인자
now);
DestructiveOperationGuard.authorize 는 dryRun 이 참이면 즉시 반환한다(DestructiveOperationGuard.java:54-56). 즉 격리 리플레이는 "승인 불필요" 가 아니라 "dry run 인 척" 으로 통과한다. 감사 이벤트는 그 구분을 남긴다 — approval.map(VerifiedApproval::ticket).orElse("isolated")(:77) — 그러나 guard 쪽에는 남지 않는다. §17 P3.
4.4 InMemoryAdminOperationJournal — 프로토콜이 단순화되지 않았다
// InMemoryAdminOperationJournal.java:17-27
/**
* A single-process journal, for tests and for local development.
*
* <p>It reports {@link #isDurable()} as false, and the starter refuses to run a production profile
* on a journal that says so. That declaration is the point of this class existing at all: the
* previous in-memory store was registered as the production default and nothing distinguished it
* from a shared one, so the gap was invisible until two replicas executed the same approval.
*
* <p>The semantics are otherwise the real ones — uniqueness on {@code (ticket, digest)}, lease
* takeover with a monotonic token, resume from checkpoint — so a test that passes here is testing
* the protocol rather than a simplification of it.
*/
마지막 문장이 지켜지는지가 이 클래스의 값어치다. 확인 결과 지켜진다.
begin 의 claim(...) 이 네 갈래다(:64-115).
| 기존 상태 | 처리 | 코드 |
|---|---|---|
| 없음 | 새 record, token=1, itemsCompleted=0 | :72-85 |
COMPLETED |
APPROVAL_ALREADY_EXECUTED — "an approval authorises one execution, not a standing permission" |
:86-92 |
STARTED + 리스 유효 |
ADMIN_OPERATION_IN_FLIGHT — "two runtimes executing one approval is a duplicate storm, not a faster redrive" |
:93-100 |
FAILED 또는 리스 만료 |
체크포인트 유지, token+1 로 인수 | :101-114 |
펜스는 update(...) 에 있다.
// :206-214
if (current.leaseToken() != lease.leaseToken()) {
// The fence. A stalled runtime that wakes up and writes here would otherwise overwrite
// the progress of whichever replica took the operation over.
throw new MessageAuthorizationException("ADMIN_OPERATION_LEASE_LOST", …);
}
그리고 키 생성이 ApprovalGrant.canonicalForm() 의 규칙을 그대로 가져온다.
// :219-225
private static String key(String approvalTicket, PlanDigest planDigest) {
// Length-prefixed for the same reason the grant's canonical form is: a ticket containing the
// separator must not be able to collide with a different ticket and digest pair.
return String.join("", Integer.toString(approvalTicket.length()), ":", approvalTicket,
planDigest.value());
}
길이 접두 규칙이 messaging-admin-api 밖으로 전파된 사례다. (그 규칙이 닿지 않은 유일한 곳이 계획 다이제스트라는 점은 analysis/messaging/messaging-admin-api.md §12.3(b)에 있다.)
checkpoint/complete/fail 셋 다 Math.max(current.itemsCompleted(), itemsCompleted) 로 clamp 한다(:128, :147, :167). 이것이 DefaultMessagingAdminService 가 journal.fail(lease, lease.resumeFrom(), …) 로 낡은 값을 넘겨도 진행이 되돌아가지 않는 이유다. §12.4(c).
4.5 TopologyValidator — severity 가 판단이다
// TopologyValidator.java:11-22
/**
* <p>Which discrepancies block is a judgement encoded here rather than left to configuration.
* Replication factor and absence are blocking because a destination that is missing or unreplicated
* cannot deliver the durability its profile promises. A partition count that is <em>higher</em>
* than declared is advisory rather than blocking: extra partitions do not break durability, and
* someone scaling a topic up deliberately should not be met with a refusal to start.
*
* <p>A partition count that is <em>lower</em> is blocking, because it silently reduces the
* concurrency the destination was sized for and, on a keyed topic, changes which key lands where.
*/
| 조건 | severity | 코드 |
|---|---|---|
| 목적지 부재 | BLOCKING (그리고 즉시 반환) | :39-43 |
physicalName 불일치 |
BLOCKING | :45-49 |
| 파티션 < 선언 | BLOCKING | :51-57 |
| 파티션 > 선언 | ADVISORY | :58-66 |
| 복제 계수 < 선언 | BLOCKING | :68-75 |
| 필수 설정 불일치/부재 | BLOCKING ("unset") |
:77-87 |
부재 시 즉시 반환하는 것도 옳다 — 없는 목적지의 파티션 수를 보고할 이유가 없다.
4.6 DestructiveMessagingAdmin — 분리가 곧 통제
// DestructiveMessagingAdmin.java:10-20
/**
* The operations that destroy data an application cannot recreate.
*
* <p>A separate interface from {@link MessagingAdminService}, and no bean for it is ever registered
* in an application runtime. The separation is the control: an application that never receives this
* type cannot purge a topic even if every other guard is bypassed, because the method does not
* exist on anything it holds.
*
* <p>Each operation takes an {@link Approved} argument rather than an approval parameter, so the
* authorisation cannot be forgotten at a call site — there is no way to call these without one.
*/
첫 문단의 논리는 견고하다. 두 번째 문단이 문제다 — Approved 가 담는 것은 VerifiedApproval 이 아니라 평범한 AdminApproval 이다. §17 P2.
5. 주요 실행 경로
경로 A — 리드라이브 (설계상 의도된 흐름)
DefaultMessagingAdminService.executeRedrive(ApprovedRedrivePlan)
1) plan.requireExecutable(now, inspector.topologyVersion())
승인 윈도우 / 승인 토폴로지 / 계획 토폴로지 / 루프 승인
2) journal.begin(ticket, digest, redriveId, leaseOwner, 5분, now)
-> COMPLETED 면 거절, 유효 리스 있으면 거절, 아니면 token+1 로 인수
-> AdminOperationLease(resumeFrom = 이전 체크포인트)
3) redriveService.redrive(request, approval, subject, now, resumeFrom, checkpoint)
guard.authorize(REDRIVE, source, approval, dryRun, now)
candidates = source.peek(source, batchSize)
for m in candidates.subList(resumeFrom, end):
republish -> CONFIRMED 면 settle, 아니면 failed++
completed++ ; checkpoint(completed) -> journal.checkpoint(...)
finally: audit.record(...)
4) journal.complete(lease, moved + failed, now)
5) RedriveResult(candidates, moved, stillParked=failed, elapsed, dryRun)
경로 B — 토폴로지 검증
Stack A 는 validateTopology() 로 진입해 보고서를 돌려준다. 그 보고서로 requireAcceptable() 을 부르는 코드는 없다. Stack B 는 validate(...) 안에서 직접 던진다. 둘 다 프로덕션 진입점이 없다(EVD-307).
경로 C — 파괴적 작업
없다. DestructiveMessagingAdmin 구현체가 0건이므로 PURGE·OFFSET_RESET·DELETE_DESTINATION 은 이 저장소에 실행 경로가 없다.
6. 실패 경로와 복구/번역
| 상황 | 처리 | 위치 |
|---|---|---|
| 발행이 예외를 던짐 | 그 한 건만 실패 처리, 루프 계속 | RedriveService:146-150 |
| 발행이 CONFIRMED 아님 | 실패 처리, 정산하지 않음 → DLQ 잔류 | RedriveService:151-153 |
| 실행 중 예외 | journal.fail(...) 후 재던짐 → 재개 가능 상태 |
DefaultMessagingAdminService:143-148 |
| 예외 메시지 | 코드 또는 클래스 단순명만 저널에 | :222-227 |
| 승인 이미 소진 | APPROVAL_ALREADY_EXECUTED |
InMemory…:86-92 |
| 다른 런타임이 실행 중 | ADMIN_OPERATION_IN_FLIGHT |
:93-100 |
| 리스 상실 후 쓰기 | ADMIN_OPERATION_LEASE_LOST |
:206-214 |
| 저널 항목 없음 | ADMIN_OPERATION_NOT_JOURNALLED |
:201-205 |
| 토폴로지 불일치 (A) | MessagingConfigurationException("TOPOLOGY_MISMATCH") |
TopologyValidationReport:78 |
| 토폴로지 불일치 (B) | MessageTopologyException("TOPOLOGY_MISMATCH") |
TopologyValidationRuntime:54 |
마지막 두 줄이 §12.3(a)의 요약이다 — 같은 코드 문자열, 다른 예외 타입, 다른 판정 규칙.
attempt(...) 가 모든 RuntimeException 을 삼키는 것은 근거가 있지만 대가도 있다: 실패 사유가 어디에도 남지 않는다. 감사 이벤트는 failed 개수만 담고(:135), 어떤 메시지가 왜 실패했는지는 기록되지 않는다.
7. 트랜잭션·동시성·수명주기
트랜잭션 경계 없음 — InMemoryAdminOperationJournal 은 ConcurrentHashMap.compute(...) 로 키 단위 원자성을 얻는다(:44, :198). begin 의 검사-후-갱신 전체가 compute 람다 안에 있어 두 복제본이 동시에 begin 해도 하나만 성공한다. AdminOperationJournalTest.twoReplicasRacingProduceExactlyOneLease 가 그것을 검증한다.
펜싱 토큰은 세 지점에서 동작한다: 인수 시 existing.leaseToken() + 1(:110), 쓰기 시 토큰 대조(:206), 그리고 clamp 로 인한 단조성(:128, :147, :167). aRuntimeThatLostItsLeaseCannotWriteOverTheSuccessor 가 세 가지를 한 번에 확인한다 — 낡은 리스의 complete(30) 이 거절되고 기록은 45·STARTED 로 남는다.
RedriveService·ReplayService·DefaultMessagingAdminService 는 모두 불변 필드만 갖는다. clock 을 Supplier<Instant> 로 주입받아 시간도 외부화되어 있다.
수명주기 훅 없음. 이 리프의 어떤 클래스도 InitializingBean·SmartLifecycle 을 구현하지 않는다 — 이것이 §17 첫 항목의 직접 원인이다.
8. 설정·기능 플래그·환경 차이
이 리프 자체에는 설정이 없다. 상수 하나가 코드에 고정되어 있다.
| 값 | 위치 | 근거 |
|---|---|---|
LEASE_DURATION = 5분 |
DefaultMessagingAdminService:51 |
javadoc :47-49 |
MAX_BATCH = 100 |
(admin-api RedriveRequest:24) |
— |
리스 5분은 프로퍼티가 아니다. 근거는 명시적이지만("느린 배치가 리스를 잃지 않을 만큼 길고, 죽은 복제본이 승인을 한 시간 묶어두지 않을 만큼 짧게"), 배치 크기·브로커 지연에 따라 달라질 값을 조정할 수단이 없다.
9. 퍼시스턴스/외부 시스템 세부
직접 접점 없음. 전부 SPI 뒤에 있다.
| SPI | 구현 (프로덕션) | 구현 (테스트) |
|---|---|---|
BrokerTopologyInspector |
0 — 애플리케이션이 제공해야 함 | TopologyValidatorTest:133 익명 1 |
ReplayService.ReplayExecutor |
0 | 0 |
RedriveService.RedriveSource |
0 | RecordingSource 1 |
RedriveService.RedrivePublisher |
0 | 람다 4 |
RedriveService.AuditSink |
0 | RecordingAudit 1 |
TopologyValidationRuntime.TopologyReader |
0 | 람다 4 |
DefaultMessagingAdminService.ReplayEstimator |
0 | 0 |
DefaultMessagingAdminService.RedriveEstimator |
0 | 0 — 그리고 패키지 밖에서는 구현 불가 (§12.4(a)) |
여덟 개 SPI 전부 프로덕션 구현이 0이다. AdminOperationJournal 만이 예외로, JdbcAdminOperationJournal(outbox-jdbc-postgresql)과 InMemoryAdminOperationJournal 둘을 갖는다.
10. 테스트 레인과 실제 증명 범위
EVD-309: ./gradlew :messaging:messaging-admin-runtime:test --rerun-tasks → 51 tests, 0 failures, 0 skipped.
| 클래스 | 수 | 실제 겨냥 대상 |
|---|---|---|
TopologyValidatorTest |
13 | TopologyValidator(6) · TopologyValidationReport(2) · CompositeTopologyValidator(1) · TopologyManagementMode(3, admin-api 소유) |
ApprovedPlanExecutionTest |
11 | 전부 admin-api 타입 (Approved*Plan, *Result, *Plan.describeImpact) |
ApprovalForgeryTest |
10 | 전부 admin-api 타입 (VerifiedApproval, HmacApprovalVerifier, ApprovalGrant) |
AdminOperationJournalTest |
8 | InMemoryAdminOperationJournal |
RedriveResumptionTest |
5 | RedriveService |
TopologyValidationRuntimeTest |
4 | TopologyValidationRuntime |
51건 중 21건이 이 리프의 클래스를 거치지 않는다. ApprovalForgeryTest 와 ApprovedPlanExecutionTest 는 messaging-admin-api 의 타입을 직접 조립해 검증한다. 이는 admin-api 문서 §10에서 본 것의 반대쪽 면이다 — 그 리프의 불변식이 여기서 검증되고, 여기의 오케스트레이터는 검증되지 않는다.
증명되지 않는 것:
DefaultMessagingAdminService257줄 — 인스턴스화하는 테스트 0건(EVD-307). 검사 순서, 저널 begin/checkpoint/fail 시퀀스, 실패 시 재던짐,failureCodeOf정제 — 전부 미실행.ReplayService99줄 — 인스턴스화 0건. 격리 리플레이의 guard 우회, 감사 이벤트 구성, dry run 조기 반환 전부 미실행.RedriveService의 실패+재개 교집합(§12.1(a)).- Stack A 와 Stack B 의 파티션 스케일업 불일치 — 양쪽이 각자의 테스트에서 반대 결과를 내는데, 그 대비를 확인하는 테스트가 없다(§12.3(a)).
컨테이너 레인 없음. JdbcAdminOperationJournal 의 Postgres IT 는 다른 리프 소유이며 이 세션에서 실행하지 않았다.
11. 빌드/ArchUnit/CI 강제 지점
build.gradle 10줄. 이 리프 고유의 게이트는 없다. 루트 공통 게이트만 적용된다.
주목: RedriveEstimate 의 접근성 문제를 잡는 게이트가 없다. public 인터페이스가 package-private 타입을 반환하는 것은 Java 가 허용하고 Checkstyle·SpotBugs·ErrorProne 기본 설정 어느 것도 기본으로 잡지 않는다. ErrorProne 에 관련 검사가 있으나 활성화되어 있지 않다.
12. 실제 사용 여부와 negative-space probes
12.1 Public surface reachability
(a) [P1] 재개된 리드라이브가 옮기지 못한 메시지를 건너뛴다 (EVD-306)
resumeFrom 은 매 시도마다 새로 peek 한 목록의 인덱스로 쓰인다.
// RedriveService.java:100, 107-120
List<MessageId> candidates = source.peek(request.source(), request.batchSize());
…
int completed = resumeFrom;
for (MessageId messageId :
candidates.subList(Math.min(resumeFrom, candidates.size()), candidates.size())) {
if (attempt(messageId, request)) { moved.add(messageId); } else { failed++; }
completed++; // 성공·실패 양쪽에서 증가
checkpoint.accept(completed);
}
주석은 // Everything before resumeFrom was moved and settled by the previous attempt. 이라고 쓴다(:109-110). 그러나 completed 는 moved + failed 다. 실패분은 settle 되지 않아 DLQ 에 남고, 다음 peek 결과에 그대로 포함된다. 성공분만 사라진다.
구체적 시나리오:
DLQ = [m1, m2, m3, m4, m5]
1차: peek -> [m1..m5]
m1 CONFIRMED -> settle (DLQ 에서 제거) completed=1, checkpoint(1)
m2 미확인 -> failed++ (DLQ 잔류) completed=2, checkpoint(2)
프로세스 사망. 저널 itemsCompleted = 2
2차: lease.resumeFrom = 2
peek -> [m2, m3, m4, m5] (m1 만 사라짐)
subList(min(2,4), 4) = [m4, m5]
-> m2(실패했던 것), m3(시도조차 안 된 것)을 영구히 건너뛴다
m4, m5 성공. RedriveReport(candidates=4, moved=2, failed=0)
journal.complete(lease, 2, now) -> COMPLETED, 승인 소진
운영자에게는 성공으로 보이고, m2·m3 는 DLQ 에 남으며, 어떤 기록도 그 둘을 지목하지 않는다. 승인이 소진되었으므로 재실행은 APPROVAL_ALREADY_EXECUTED 로 거절된다.
플랫폼은 이것을 감지할 술어를 이미 갖고 있다.
// messaging-admin-api/RedriveResult.java:40-50
/**
* <p>An unaccounted message is a bug, not a partial success: it was neither republished nor left
* parked, which means the redrive lost track of it.
*/
public boolean isFullyAccounted() { return moved + stillParked == candidates; }
위 시나리오는 2 + 0 == 4 → false. 정확히 이 결함을 잡는다. 그러나 isFullyAccounted() 의 프로덕션 호출부는 0건이다(EVD-302). 아무도 묻지 않는다.
(b) 오케스트레이션 계층이 어디에서도 생성되지 않는다 (EVD-307)
DefaultMessagingAdminService src/main=0 src/test=0
ReplayService src/main=0 src/test=0
RedriveService src/main=0 src/test=1
TopologyValidationRuntime src/main=0 src/test=4
CompositeTopologyValidator src/main=1 src/test=1
InMemoryAdminOperationJournal src/main=1 src/test=3
src/main 생성은 전 저장소에서 2건뿐이며 둘 다 starter 다(:57, :84).
DefaultMessagingAdminService 는 이 리프에서 가장 큰 클래스이고 "검사 순서가 요점" 이라고 스스로 말하는 클래스인데, 그 순서가 한 번도 실행된 적이 없다.
(c) DestructiveMessagingAdmin 은 구현체가 0건이다
git grep -n "DestructiveMessagingAdmin" -- src
DestructiveMessagingAdmin.java:21 (선언)
MessagingAdminService.java:21 ({@link} 참조)
MessagingAdminAutoConfiguration.java:22 ({@link} 참조)
git grep -n "DestructiveMessagingAdmin.Approved|new Approved(|DestructiveResult" -- src
(선언 파일 제외 후 출력 없음)
DestructiveOperation 5개 상수 중 PURGE·OFFSET_RESET·DELETE_DESTINATION 세 개는 이 저장소에 실행 경로가 없다. starter 가 그 부재를 의도로 설명하지만("an operator tool … registers one itself"), 그 도구는 이 저장소에 없다.
(d) 여덟 개 SPI 전부 프로덕션 구현 0건. §9 표.
12.2 Conditional sibling comparison
대조군 1 — 리플레이 vs 리드라이브의 재개. 리드라이브는 resumeFrom + 체크포인트 콜백을 받고, 리플레이는 받지 않는다(§4.1). 둘 다 같은 저널을 쓰고 같은 리스를 받는다. 리플레이가 재개되지 않는 이유를 설명하는 문장은 없다. 리플레이가 본질적으로 멱등(같은 구간을 다시 읽음)이라 재개가 불필요하다는 해석은 가능하나, 그렇다면 리스를 받는 이유가 설명되지 않는다.
대조군 2 — 두 개의 저널 구현. InMemoryAdminOperationJournal(Math.max)과 JdbcAdminOperationJournal(GREATEST)이 독립적으로 같은 clamp 를 구현했다. 인터페이스는 그것을 요구하지 않는다. §12.4(c).
대조군 3 — MessagingAuditSink vs RedriveService.AuditSink. 시그니처가 동일한 두 인터페이스. 전자는 "모든 기록이 MessagingRedactor 를 통과했다" 는 계약을 갖고, 후자는 갖지 않는다. §12.3(b).
12.3 Duplicate mechanism sweep
(a) 토폴로지 검증 스택 2벌 — 판정이 어긋난다 (EVD-307)
| 항목 | Stack A (CompositeTopologyValidator+TopologyValidator) |
Stack B (TopologyValidationRuntime) |
|---|---|---|
| 입력 SPI | BrokerTopologyInspector |
TopologyReader |
| 비교 로직 | TopologyValidator.compare |
TopologyManifest.differencesFrom |
| 결과 타입 | List<TopologyIssue> (severity) |
List<String> |
| 파티션 > 선언 | ADVISORY — 기동 허용 | 차이 → 기동 거부 |
physicalName 검사 |
O (BLOCKING) | X |
| 부재 처리 | BLOCKING issue | "… does not exist" 문자열 |
| 실패 방식 | 보고서 반환 → requireAcceptable() |
validate(...) 안에서 직접 throw |
| 예외 타입 | MessagingConfigurationException |
MessageTopologyException |
| 코드 문자열 | TOPOLOGY_MISMATCH |
TOPOLOGY_MISMATCH |
| 프로덕션 호출부 | 0 | 0 |
파티션 스케일업 판정이 정반대이며, 양쪽 다 자기 테스트에서 확인된다.
// TopologyValidatorTest.java:63-72 (Stack A)
void extraPartitionsAreAdvisoryBecauseScalingUpIsLegitimate() {
List<TopologyIssue> issues = validator.compare(manifest(), observed(24, 3, …)); // 선언 12
assertThat(issues).singleElement()
.satisfies(issue -> assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.ADVISORY));
}
// TopologyValidatorTest.java:118-127
void anAdvisoryOnlyReportStillStarts() {
… assertThatCode(report::requireAcceptable).doesNotThrowAnyException();
}
Stack A 의 판단에는 근거가 명시되어 있다(TopologyValidator.java:59 — "Scaling a topic up is a legitimate operation; refusing to start would punish it"). Stack B 의 differencesFrom 은 actualPartitions != partitions 로 방향을 구분하지 않는다(TopologyManifest.java:57). TopologyValidationRuntimeTest 4건은 스케일업을 시도하지 않아 불일치가 드러나지 않는다.
(b) 감사 싱크 인터페이스 2벌 (EVD-308)
// messaging-observability/MessagingAuditSink.java:18-25
public interface MessagingAuditSink { void record(MessagingAuditEvent event); }
// RedriveService.java:199-209
public interface AuditSink { void record(…observation.MessagingAuditEvent event); }
시그니처도 이벤트 타입도 같다. admin-runtime 은 이미 messaging-observability 를 의존하며 그 모듈에서 MessagingAuditEvent 를 import 한다(RedriveService:126). 즉 표준 싱크를 쓸 수 있는데 중첩 인터페이스를 새로 선언했다.
파생 결과 셋:
ReplayService가 형제 서비스의 중첩 타입에 의존한다 —private final RedriveService.AuditSink audit;(ReplayService:28).MessagingAuditSink는InMemory구현을 제공하는데(:33-55),RedriveResumptionTest는RecordingAudit를 다시 만든다(:203-210).- 계약이 하나 유실된다.
MessagingAuditSinkjavadoc: "Every record has already passedMessagingRedactor, so an audit trail proves who did what without becoming a second copy of the payload."RedriveService.AuditSink에는 그런 서술이 없고,RedriveService:125-136은 목적지 이름과 details 를 레닥션 없이 넣는다.
(c) TopologyValidator 인스턴스가 CompositeTopologyValidator 의 private final 필드로 고정되어 있다.
// CompositeTopologyValidator.java:22
private final TopologyValidator validator = new TopologyValidator();
주입이 아니라 생성이다. TopologyValidator 가 상태 없는 순수 비교기이므로 실질 문제는 없으나, severity 판정을 교체하려면 이 클래스를 고쳐야 한다 — "which discrepancies block is a judgement encoded here rather than left to configuration"(TopologyValidator:14)와 일관된 선택이다.
12.4 Documentation / measured-count drift
(a) public 인터페이스가 패키지 밖에서 구현 불가능하다 (EVD-308)
// DefaultMessagingAdminService.java:242-256
record RedriveEstimate(int candidates, int alreadyRedriven) {} // 수식어 없음 = package-private
@FunctionalInterface
public interface RedriveEstimator { // public
RedriveEstimate estimate(RedriveRequest request); // package-private 반환 타입
}
생성자는 이것을 외부에서 받는다 — public DefaultMessagingAdminService(…, RedriveEstimator, …)(:78-88). 그러나 RedriveEstimator 를 구현하려면 RedriveEstimate 를 이름으로 써야 하고, 그 타입은 패키지 밖에서 접근할 수 없다. 컴파일은 통과한다.
대조: 같은 파일의 ReplayEstimator 는 long 을 반환하므로 외부 구현이 가능하다.
현재 드러나지 않는 이유는 §12.1(b) 다 — 이 생성자를 부르는 코드가 없다.
(b) 선언된 의존 6개 중 3개가 import 0건. messaging-policy, messaging-transport-spi, messaging-security. §2 표.
(c) 저널의 단조성이 인터페이스 계약에 없다 (EVD-308)
AdminOperationJournal javadoc 은 구현 의무 셋을 명시한다 — "shared and durable", "uniqueness on (approvalTicket, planDigest)", "leases with a monotonic fencing token". itemsCompleted 의 단조성은 그 목록에 없다. fail 의 @param 은 오히려 반대로 읽힌다: "how many items are durably done".
그런데 유일한 호출자가 낡은 값을 넘긴다.
// DefaultMessagingAdminService.java:146, :200
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
lease.resumeFrom() 은 이번 시도가 시작될 때의 값이다. 이번 시도의 체크포인트로 올라간 값이 아니다. 진행이 되돌아가지 않는 것은 두 구현이 각각 clamp 하기 때문이다.
// InMemoryAdminOperationJournal.java:128, 147, 167
Math.max(current.itemsCompleted(), itemsCompleted)
// JdbcAdminOperationJournal CHECKPOINT / SETTLE SQL
SET items_completed = GREATEST(items_completed, ?)
파라미터를 문자 그대로 저장하는 세 번째 구현은 이 호출자와 결합했을 때 체크포인트를 잃는다. AdminOperationJournalTest.aCheckpointNeverMovesBackwards 가 in-memory 구현에 대해 이 성질을 검증하지만, 그것은 구현 테스트지 계약이 아니다.
(d) DestructiveMessagingAdmin.Approved 가 VerifiedApproval 이 아니라 AdminApproval 을 담는다. §17 P2.
13. Git/설계 문서에서 확인한 변화와 실패 기록
이 리프도 javadoc 이 이력을 대신한다. 다섯 개의 "이전에는 이랬다" 가 있고 전부 분산 실행의 실패를 가리킨다.
| 위치 | 기록된 과거 결함 |
|---|---|
DefaultMessagingAdminService:34-41 |
"The journal entry is written before the work rather than after it. Writing it afterwards leaves a window where a second execution starts while the first is still running…" |
RedriveService:70-75 |
"A synchronous failure from the publisher … propagated straight out, so every candidate behind it was abandoned and the audit record was never written. … A retry then started from the first candidate and republished them." |
InMemoryAdminOperationJournal:20-23 |
"the previous in-memory store was registered as the production default and nothing distinguished it from a shared one, so the gap was invisible until two replicas executed the same approval." |
AdminOperationJournalTest:19-22 |
"recorded a single fact — 'this approval was claimed' — before any work happened, in a map. An operation that died halfway had spent its approval…" |
RedriveResumptionTest:33-36 |
"The loop had no per-item boundary. … The operation left no trace of what it had already moved, and a retry started again from the first candidate and republished it." |
다섯이 하나의 이야기다: 크래시와 복제본을 고려하지 않은 admin 평면. 고친 결과가 리스·펜싱·체크포인트·per-item 경계다.
그리고 마지막 두 항목이 §12.1(a)와 이어진다 — "재시도가 처음부터 다시 시작하는" 문제를 고치려고 resumeFrom 을 도입했고, 도입한 지점의 인덱스 계산이 실패분을 고려하지 않았다.
커밋 로그는 정보가 없다(4개, messaging 전체 공통).
14. 런타임·터미널 Evidence
| ID | 파일 | 내용 |
|---|---|---|
| EVD-306 | evidence/raw/306-redrive-resume-skips-unmoved.txt |
재개 인덱스 결함, 구체적 시나리오, 테스트 대역이 불변식을 재현하지 못하는 지점 |
| EVD-307 | evidence/raw/307-admin-runtime-two-topology-stacks.txt |
토폴로지 스택 2벌 대조표, 조립 탐침 전수, DestructiveMessagingAdmin 구현 0건 |
| EVD-308 | evidence/raw/308-admin-runtime-api-and-dependency-defects.txt |
RedriveEstimate 접근성, 감사 싱크 중복, 미사용 의존 3건, 저널 단조성 계약 부재, Approved 의 승인 타입 |
| EVD-309 | evidence/raw/309-messaging-admin-runtime-test-lane.txt |
51건 통과 + 커버리지 분포 |
15. 명시적 설계 이유와 추론을 구분한 정리
코드/주석에 명시된 것
- 검사 순서와 그 이유 (
DefaultMessagingAdminService:25-32). - 저널을 작업 전에 쓰는 이유 (
:34-37). - 리스가 재개 지점을 나르는 이유 (
:38-41). - 리스 5분의 상하한 근거 (
:47-49). - 실패 시 재개 가능 상태로 남기는 이유 (
:144-145). - 실패 코드를 정제하는 이유 — 저널은 사건 중 운영자가 읽고 프로세스보다 오래 산다 (
:216-220). - 한 건의 발행 예외가 패스 전체를 죽이면 안 되는 이유 (
RedriveService:147-148). - 감사 기록을
finally에 두는 이유 (:122-124). - 발행→확인→정산 순서의 이유 (
:19-21). - 리드라이브 id·카운터가 메시지와 함께 이동하는 이유 (
:23-25). - 격리 리플레이를 무료로 두는 이유 (
ReplayService:16-22). - in-memory 저널이
isDurable()==false를 선언하는 이유와 그 존재 이유 (InMemoryAdminOperationJournal:20-23). - 프로토콜을 단순화하지 않은 이유 (
:25-27). - 리스 인수 시 토큰을 올리는 이유 = 펜스 (
:101-102, :207-208). - 저널 키를 길이 접두로 만든 이유 (
:221-222). - severity 판정을 코드에 두는 이유, 그리고 각 판정의 근거 (
TopologyValidator:14-22, :59). - 전부 모아 보고하는 이유 (
CompositeTopologyValidator:14-17). BrokerTopologyInspector가 읽기 전용인 이유 (:9-12).- 파괴적 작업을 별도 인터페이스로 분리한 이유 (
DestructiveMessagingAdmin:13-16). - 토폴로지 버전이 파싱되지 않는 불투명 값인 이유 (
BrokerTopologyInspector:27-28).
추론 (근거는 있으나 문서에 없음)
- 리플레이가 재개되지 않는 이유. 리플레이가 멱등이라 불필요하다는 해석이 자연스러우나, 그렇다면 리스를 받는 이유가 설명되지 않는다.
RedriveService.AuditSink를MessagingAuditSink대신 선언한 이유. 의존 순서 문제로 보이지는 않는다 — 이미 그 모듈을 의존한다.TopologyValidationRuntime(Stack B)이 남아 있는 이유. Stack A 가 나중 것으로 보이나(severity·physicalName 검사가 추가되었으므로), 그 판단을 뒷받침할 커밋 이력이 없다.policy·transport-spi·security의존이 남아 있는 이유.RedriveEstimate가 package-private 인 것이 의도인지 누락인지.
16. 확인한 것 / 확인하지 못한 것
확인한 것
- production 12 + test 6 = 18개 Java 파일 전부 본문 확인.
- 테스트 레인 51건 전건 통과, 클래스별 분포 (
EVD-309). - 재개 인덱스 결함과 테스트 대역이 그것을 재현할 수 없는 이유 (
EVD-306). - 조립 탐침 전수 —
DefaultMessagingAdminService·ReplayService생성 0건 (EVD-307). - 토폴로지 두 스택의 판정 대조표, 양쪽 테스트가 반대 결과를 확인한다는 사실 (
EVD-307). DestructiveMessagingAdmin구현 0건,Approved/DestructiveResult사용 0건 (EVD-307).RedriveEstimate접근성, 감사 싱크 중복, 의존 3건 미사용, 저널 clamp 를 두 구현이 각각 갖는다는 사실 (EVD-308).
확인하지 못한 것
- §12.1(a)의 시나리오를 실제로 재현하지 않았다. 결함은 코드와 테스트 대역을 읽어 도출했고, 실패+재개를 조합하는 테스트를 작성해 관찰하지는 않았다. (문서화 작업이 애플리케이션 소스를 수정하지 않는다는 제약 때문. 재현 테스트는 코드 변경 요청이 있을 때 작성하는 것이 맞다.)
JdbcAdminOperationJournal의 실제 동작 — Postgres 컨테이너 필요, 미실행. SQL 문자열은 읽어서GREATEST를 확인했다.- 부팅된 컨텍스트에서
app.messaging.admin.enabled=true일 때의 빈 그래프 — 런타임 관측 미수행. BrokerTopologyInspector의 실제 구현이 어떤topologyVersion문자열을 내는지 — 구현이 저장소에 없다.- Stack B 가 언제·왜 남았는지.
17. 손볼 것
P1 — 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛴다
resumeFrom 은 "시도한 개수"(moved + failed)인데, subList 로 건너뛰는 대상은 매번 새로 peek 한 목록이고 그 목록에서 사라진 것은 "성공한 것"뿐이다. 실패분과 미시도분이 앞쪽에 남아 있으므로, 건너뛰기는 정확히 그것들을 지운다(EVD-306).
결과: 리드라이브가 성공으로 보고되고, 승인이 소진되고, 일부 메시지가 DLQ 에 남으며, 어떤 기록도 그것들을 지목하지 않는다. 사건 복구 중에 실행되는 작업이라는 점이 심각도를 올린다.
고칠 방향은 두 가지다.
- 인덱스 대신 신원으로 재개한다. 저널이 개수가 아니라 이미 옮긴
MessageId집합(또는 마지막 성공 위치의 브로커 오프셋)을 들고 있으면 목록이 줄어드는 것과 무관해진다.AdminOperationRecord에 필드 추가가 필요하다. completed를moved.size()로 바꾸고 실패분은 세지 않는다. 그러면resumeFrom이 "사라진 개수" 와 일치하므로 새 peek 의 인덱스로 유효해진다. 다만 실패분을 반복해서 재시도하게 되므로, 리드라이브 횟수 상한(javadoc:75가 언급하는 "nothing bounded how many times one message could be redriven")이 함께 필요하다.
어느 쪽이든 RedriveService.RedriveSource 대역이 settle 시 staged 에서 제거하도록 고쳐야 회귀 테스트가 성립한다. 현재 대역은 실제 불변식을 재현하지 못한다.
// RedriveResumptionTest.java:192-200 — settle 이 staged 를 줄이지 않는다
@Override public List<MessageId> peek(DestinationName destination, int batchSize) { return staged; }
@Override public void settle(DestinationName destination, MessageId messageId) { settled.add(messageId); }
그리고 RedriveResult.isFullyAccounted() 를 실제로 호출하는 곳을 만들어야 한다. 이 결함을 잡는 술어가 이미 존재하는데 프로덕션 호출부가 0건이다(EVD-302). DefaultMessagingAdminService.executeRedrive 가 결과를 만든 직후 확인하고, 불일치면 저널에 FAILED 로 남기는 것이 자연스럽다.
P2 — 파괴적 작업의 승인만 위조 가능한 형태로 남아 있다
// DestructiveMessagingAdmin.java:23-38
record Approved(
DestructiveOperation operation,
DestinationName destination,
AdminApproval approval, // <- public 생성자를 가진 평범한 record
long estimatedMessagesAffected) { … }
생성자는 null·음수만 본다. approval 이 이 operation 을 인가하는지, 이 destination 을 인가하는지, estimatedMessagesAffected 가 승인 상한 이하인지 — 아무것도 검사하지 않는다. 계획 다이제스트 필드 자체가 없다.
이 형태가 정확히 messaging-admin-api 가 고쳤다고 기록한 것이다.
// messaging-admin-api/VerifiedApproval.java:9-13
* <p>The approved-plan types used to hold a plain {@code AdminApproval} record with a public
* constructor, so "this plan was approved" was a claim the caller made about itself. Any code that
* could reach the execute method could write {@code new AdminApproval("TICKET-1", "someone", now,
* later)} and the platform believed it.
수정은 REPLAY·REDRIVE(복구 가능한 작업)에 적용되었고, PURGE·DELETE_DESTINATION·OFFSET_RESET(복구 불가능한 작업)에는 적용되지 않았다.
현재 구현체가 0건이라 실행되는 결함은 아니다(EVD-307). 그러나 이 인터페이스는 운영자 도구가 구현하라고 존재하는 것이고, 그 도구가 생기는 순간의 모양이 이것이다. Approved 를 ApprovedReplayPlan 과 같은 형태로 — VerifiedApproval + 생성자 검사 — 바꾸는 것이 맞다.
P2 — 토폴로지 검증 스택이 두 벌이고 판정이 어긋난다
Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다. Stack B 는 같은 상황을 차이로 보고 기동을 거부한다. 둘 다 프로덕션 호출부가 0건이라 지금은 충돌하지 않지만, analysis/messaging/messaging-admin-api.md §17 첫 항목대로 토폴로지 검증을 기동에 배선하는 순간 어느 스택을 배선하느냐가 스케일업한 배포의 기동 여부를 가른다.
Stack A 가 남아야 할 것으로 보인다 — severity 구분, physicalName 검사, 근거 주석이 있고 테스트도 13건으로 더 두껍다. Stack B(TopologyValidationRuntime, TopologyReader, ObservedTopology, 그리고 그것만 쓰는 TopologyManifest.differencesFrom)를 제거하는 편이 낫다.
같은 코드 문자열 TOPOLOGY_MISMATCH 를 두 예외 타입이 쓰는 것도 정리 대상이다.
P2 — 오케스트레이터가 어디에서도 실행되지 않는다
DefaultMessagingAdminService 257줄과 ReplayService 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(EVD-307). 검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.
DefaultMessagingAdminService 의 생성자는 10개 인자를 받고 그중 8개가 SPI 또는 Supplier 이므로, 대역으로 조립하는 테스트를 쓰는 비용은 낮다. §12.1(a)의 회귀 테스트도 이 층에서 쓰는 것이 자연스럽다 — 저널·리스·리드라이브 루프가 함께 도는 것이 결함이 나타나는 조건이기 때문이다.
P3 — public 인터페이스를 패키지 밖에서 구현할 수 없다
RedriveEstimator(public)의 반환 타입 RedriveEstimate 가 package-private 이다(EVD-308). DefaultMessagingAdminService 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.
RedriveEstimate 를 public 으로 올리는 것이 최소 수정이다. 더 나은 방향은 DefaultMessagingAdminService 밖의 최상위 record 로 꺼내는 것 — 지금은 오케스트레이터의 내부 타입이 SPI 계약의 일부가 되어 있다.
P3 — 감사 싱크가 중복 선언되어 있고 레닥션 계약이 유실된다
RedriveService.AuditSink 는 MessagingAuditSink 와 시그니처가 같다. admin-runtime 은 이미 messaging-observability 를 의존한다. 표준 싱크를 쓰면 세 가지가 함께 해결된다: ReplayService 가 형제의 중첩 타입에 의존하는 것, InMemory 구현 재작성, 그리고 무엇보다 "모든 기록이 MessagingRedactor 를 통과했다" 는 계약.
현재 RedriveService:125-136 은 목적지 이름과 details 를 그대로 넣는다. 목적지 이름은 DestinationName 이라 형식이 제한되어 있어 지금은 문제가 아니지만, 계약이 없는 자리에 값이 늘어나는 것을 막을 것이 없다.
P3 — 저널의 itemsCompleted 단조성이 인터페이스 계약에 없다
AdminOperationJournal javadoc 은 구현 의무 셋을 명시하면서 이것을 빠뜨렸고, fail 의 @param 은 오히려 문자 그대로 저장하라고 읽힌다. 유일한 호출자는 낡은 값을 넘긴다. 두 구현이 각각 clamp 해서 무사한 상태다(EVD-308).
두 가지 중 하나가 필요하다. 인터페이스 javadoc 에 "itemsCompleted 는 단조 증가해야 하며 구현은 기존 값보다 작은 값을 무시한다" 를 명시하거나, 호출자가 실제 체크포인트 값을 넘기도록 고친다. 후자가 더 정직하다 — 지금 journal.fail(lease, lease.resumeFrom(), …) 은 "이번 시도가 아무것도 못 했다" 고 주장하는 것이고, 그것은 대개 사실이 아니다.
P3 — 리플레이가 리스를 받지만 재개하지 않는다
executeReplay 는 journal.begin(...) 으로 리스를 받고 lease.resumeFrom() 을 쓰지 않는다. ReplayService.replay(...) 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다. 클래스 javadoc 의 "a retry continues the same operation" 은 리드라이브에만 해당한다.
리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고, 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다. 재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.
P3 — 격리 리플레이의 guard 우회가 dryRun 파라미터로 표현된다
// ReplayService.java:58-64
guard.authorize(REPLAY, request.destination(), approval, request.dryRun() || !needsApproval, now);
판단 자체는 근거가 있다. 다만 "승인이 필요 없다" 와 "실제로는 아무것도 하지 않는다" 가 guard 입장에서 구별되지 않는다. DestructiveOperationGuard 에 skipAuthorization 성격의 별도 경로를 두거나, 격리 리플레이는 애초에 guard 를 거치지 않는 편이 의도를 드러낸다.
P3 — 선언된 의존 6개 중 3개가 import 0건
messaging-policy, messaging-transport-spi, messaging-security. 제거 후보.
P3 — 실패한 리드라이브 항목의 사유가 어디에도 남지 않는다
attempt(...) 는 예외와 미확인을 모두 false 로 접는다(RedriveService:142-156). 감사 이벤트는 failed 개수만 담는다(:135). 사건 복구 중에 "왜 이 메시지들이 안 갔는가" 를 물을 수 있어야 하는데 답이 없다. RedriveReport 에 실패 사유별 집계(코드 → 개수) 정도만 추가해도 크게 달라진다.
확인된 설계(문제 아님)
- 저널을 작업 전에 쓰고, 리스·펜싱 토큰·체크포인트로 분산 실행을 통제하는 프로토콜.
begin의 네 갈래,update의 토큰 대조, 인수 시 토큰 증가가 전부 근거와 함께 있고 테스트 8건이 확인한다. compute(...)로 검사-후-갱신을 원자화한 것. 두 복제본 경쟁이 정확히 하나의 리스를 낳는다.- 저널 키의 길이 접두.
ApprovalGrant.canonicalForm()의 규칙을 명시적으로 인용해 가져왔다. - 실패 코드 정제. 저널이 사건 중에 읽히고 프로세스보다 오래 산다는 이유가 명시적이다.
- 리드라이브 루프의 per-item 경계. 한 건의 예외가 뒤의 후보를 버리지 않는다.
- 감사 기록을
finally에 둔 것. 중단된 작업도 흔적을 남긴다. - 발행→확인→정산 순서. 미확인 메시지는 DLQ 에 남는다 — "돌아오는 길에 잃는 것이 주차된 채로 두는 것보다 나쁘다".
- 격리 리플레이를 무료로 둔 것. 안전한 형태를 편하게 만들어 파괴적 형태로 손이 가지 않게 한다.
isDurable()선언 + starter 의 기동 거부. 이 리프에서 배선까지 완료된 유일한 안전 장치.- in-memory 저널이 프로토콜을 단순화하지 않은 것. 여기서 통과한 테스트가 프로토콜을 검증한다는 주장이 실제로 성립한다.
- 토폴로지 severity 판정을 설정이 아니라 코드에 둔 것, 그리고 각 판정에 근거를 붙인 것.
- 부재 시 즉시 반환. 없는 목적지의 파티션 수를 보고하지 않는다.
- 파괴적 작업을 별도 인터페이스로 분리하고 빈을 만들지 않는 것. 타입을 받지 못한 코드는 메서드 자체가 없다.
BrokerTopologyInspector를 읽기 전용으로 둔 것.topologyVersion을 파싱하지 않는 불투명 값으로 규정한 것.- 시간을
Supplier<Instant>로 외부화한 것.
Source anchors
src/messaging/messaging-admin-runtime/build.gradle:1-10
src/config/architecture/modules.json (messaging-admin-runtime 항목)
main/…/MessagingAdminService.java:13-24,25-65
main/…/DefaultMessagingAdminService.java:22-42,45-51,53-62,64-103,105-108,110-119,121-158,160-170,172-212,214-227,229-240,242-256
main/…/DestructiveMessagingAdmin.java:10-20,23-38,40-57,59-81
main/…/ReplayService.java:13-23,26-42,44-85,87-98
main/…/RedriveService.java:16-26,29-51,53-65,67-91,92-140,142-156,158-179,181-197,199-209
main/…/ReplayReport.java:6-20
main/…/RedriveReport.java:3-17
main/…/BrokerTopologyInspector.java:6-13,16-22,24-32
main/…/CompositeTopologyValidator.java:11-18,21-22,24-31,33-51
main/…/TopologyValidator.java:11-22,25-90
main/…/TopologyValidationRuntime.java:10-17,20-29,31-58,60-71,73-87
main/…/InMemoryAdminOperationJournal.java:17-28,33-62,64-115,117-135,137-154,156-174,176-179,181-184,186-193,195-217,219-225,227-236
test/…/AdminOperationJournalTest.java:16-23,34-57,59-90,92-112,114-125,127-136,138-144
test/…/RedriveResumptionTest.java:30-37,51-73,75-91,93-113,115-125,127-137,139-158,183-201,203-210
test/…/TopologyValidatorTest.java:22-30,32-104,106-127,129-152,154-171
test/…/TopologyValidationRuntimeTest.java:14-16,18-64
test/…/ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192,219
test/…/ApprovedPlanExecutionTest.java:39,121-221
src/messaging/messaging-admin-api/.../AdminOperationJournal.java:7-19,43-54,65-73
src/messaging/messaging-admin-api/.../RedriveResult.java:40-50
src/messaging/messaging-admin-api/.../TopologyManifest.java:44-75
src/messaging/messaging-admin-api/.../VerifiedApproval.java:9-13
src/messaging/messaging-admin-api/.../DestructiveOperationGuard.java:54-56
src/messaging/messaging-observability/.../MessagingAuditSink.java:7-17,18-25,33-55
src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-24,37-41,54-58,80-85
src/messaging/messaging-outbox-jdbc-postgresql/.../JdbcAdminOperationJournal.java:73-89,217-245