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>
114 lines
7.3 KiB
Plaintext
114 lines
7.3 KiB
Plaintext
# 주제: messaging-admin-runtime 의 공개 API·의존 선언에서 확인된 것들
|
|
# revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
|
|
# ---- (1) public 인터페이스가 package-private 타입을 반환한다 ----
|
|
# DefaultMessagingAdminService.java:242-256
|
|
# /** How many messages a redrive would move, and how many already failed one. */
|
|
# record RedriveEstimate(int candidates, int alreadyRedriven) {} <-- 수식어 없음 = package-private
|
|
#
|
|
# /** Estimates a redrive's candidates. */
|
|
# @FunctionalInterface
|
|
# public interface RedriveEstimator { <-- public
|
|
# RedriveEstimate estimate(RedriveRequest request); <-- package-private 반환 타입
|
|
# }
|
|
#
|
|
# DefaultMessagingAdminService 는 public final class 이고 그 생성자는
|
|
# public DefaultMessagingAdminService(…, ReplayEstimator, RedriveEstimator, …) (:78-88)
|
|
# 로 두 estimator 를 외부에서 받는다. 그런데 RedriveEstimator 를 구현하려면
|
|
# 반환 타입 RedriveEstimate 를 이름으로 써야 하고, 그 타입은 패키지 밖에서 접근할 수 없다.
|
|
# => public 생성자가 요구하는 public 인터페이스를 패키지 밖에서 구현할 수 없다.
|
|
# (컴파일은 된다. Java 는 이 조합을 막지 않는다.)
|
|
# 대조: 같은 파일의 ReplayEstimator 는 long 을 반환하므로 외부 구현이 가능하다(:229-240).
|
|
# command: git grep -n "RedriveEstimate" -- src
|
|
# DefaultMessagingAdminService.java:163, 243, 255 — 전부 같은 파일. 외부 구현 시도 자체가 없다.
|
|
|
|
# ---- (2) 감사 싱크 인터페이스가 중복이다 ----
|
|
# RedriveService.java:199-209
|
|
# /** Records privileged operations. */
|
|
# @FunctionalInterface
|
|
# public interface AuditSink {
|
|
# void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event);
|
|
# }
|
|
# messaging-observability/.../MessagingAuditSink.java:18-25
|
|
# public interface MessagingAuditSink {
|
|
# void record(MessagingAuditEvent event);
|
|
# }
|
|
# => 시그니처가 동일하고 이벤트 타입도 같다. admin-runtime 은 이미 messaging-observability 를
|
|
# 의존하며 MessagingAuditEvent 를 그 모듈에서 import 한다(RedriveService.java:126).
|
|
# 즉 플랫폼 표준 싱크를 쓸 수 있는데 중첩 인터페이스를 새로 선언했다.
|
|
#
|
|
# 부수 효과 2개:
|
|
# (a) ReplayService 가 형제 서비스의 중첩 타입에 의존한다.
|
|
# ReplayService.java:28 private final RedriveService.AuditSink audit;
|
|
# ReplayService.java:38 public ReplayService(…, RedriveService.AuditSink audit)
|
|
# (b) MessagingAuditSink 는 InMemory 구현을 이미 제공한다(:33-55). RedriveResumptionTest 는
|
|
# RecordingAudit 를 다시 만든다(:203-210).
|
|
#
|
|
# 그리고 계약이 하나 유실된다: MessagingAuditSink javadoc:15-16
|
|
# "Every record has already passed {@link MessagingRedactor}, so an audit trail proves who did
|
|
# what without becoming a second copy of the payload."
|
|
# RedriveService.AuditSink 에는 그런 서술이 없고, RedriveService:125-136 은
|
|
# request.source().value() 와 details 를 레닥션 없이 그대로 넣는다.
|
|
|
|
# ---- (3) 선언된 의존 6개 중 3개가 import 0건 ----
|
|
# command: git grep -h "^import dev.caskeleton.messaging.<pkg>" -- src/messaging/messaging-admin-runtime/src/main | wc -l
|
|
messaging-core-api import dev.caskeleton.messaging.api = 6건 O
|
|
messaging-admin-api import dev.caskeleton.messaging.admin = 45건 O
|
|
messaging-observability import dev.caskeleton.messaging.observation = 1건 O
|
|
messaging-policy import dev.caskeleton.messaging.policy = 0건 X
|
|
messaging-transport-spi import dev.caskeleton.messaging.transport = 0건 X
|
|
messaging-security import dev.caskeleton.messaging.security = 0건 X
|
|
# build.gradle 은 6개 전부 api 로 선언한다.
|
|
|
|
# ---- (4) 저널의 단조성(monotonic itemsCompleted)이 인터페이스 계약에 없다 ----
|
|
# DefaultMessagingAdminService.java:146, 200
|
|
# journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
|
|
# -> 이번 시도에서 checkpoint 로 올린 값이 아니라, 시도 **시작 시점**의 값을 넘긴다.
|
|
# AdminOperationJournal.fail javadoc (messaging-admin-api/AdminOperationJournal.java:65-73)
|
|
# "Marks the operation failed at its current checkpoint, leaving it resumable."
|
|
# "@param itemsCompleted how many items are durably done"
|
|
# -> 파라미터를 문자 그대로 저장하라는 서술이다.
|
|
#
|
|
# 두 구현 모두 최댓값으로 clamp 한다:
|
|
# InMemoryAdminOperationJournal.java:128,147,167 Math.max(current.itemsCompleted(), itemsCompleted)
|
|
# JdbcAdminOperationJournal CHECKPOINT/SETTLE SQL SET items_completed = GREATEST(items_completed, ?)
|
|
# => 진행 상황이 0으로 되돌아가지 않는 것은 두 구현이 각각 clamp 하기 때문이며,
|
|
# 인터페이스가 요구해서가 아니다. 파라미터를 문자 그대로 저장하는 세 번째 구현은
|
|
# 유일한 호출자와 결합했을 때 체크포인트를 잃는다.
|
|
|
|
# ---- (5) DestructiveMessagingAdmin.Approved 가 검증되지 않은 승인을 담는다 ----
|
|
# DestructiveMessagingAdmin.java:23-38
|
|
# record Approved(
|
|
# DestructiveOperation operation,
|
|
# DestinationName destination,
|
|
# AdminApproval approval, <-- public 생성자를 가진 평범한 record
|
|
# long estimatedMessagesAffected) { … }
|
|
# 생성자는 null/음수만 검사한다. 다음을 검사하지 않는다:
|
|
# - approval 이 이 operation 을 인가하는가
|
|
# - approval 이 이 destination 을 인가하는가
|
|
# - estimatedMessagesAffected 가 승인 상한 이하인가
|
|
# - 계획 다이제스트 결합 (필드 자체가 없다)
|
|
#
|
|
# 대조군: messaging-admin-api 의 ApprovedReplayPlan/ApprovedRedrivePlan 은
|
|
# VerifiedApproval 을 담고 생성자에서 4가지를 검사한다.
|
|
# 그리고 VerifiedApproval javadoc:9-13 이 정확히 이 형태를 과거 결함으로 기록한다:
|
|
# "The approved-plan types used to hold a plain 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 new AdminApproval("TICKET-1", "someone", now, later)
|
|
# and the platform believed it."
|
|
# => 그 수정이 REPLAY/REDRIVE(복구 가능한 작업)에는 적용되었고,
|
|
# PURGE/DELETE_DESTINATION/OFFSET_RESET(복구 불가능한 작업)에는 적용되지 않았다.
|
|
# 현재 구현체가 0건이므로 실행되는 결함은 아니다(EVD-307). 그러나 운영자 도구를 쓰는 순간의 모양이다.
|
|
|
|
# ---- (6) guard 우회를 dryRun 파라미터로 표현한다 ----
|
|
# ReplayService.java:58-64
|
|
# boolean needsApproval = !request.isolatedConsumerGroup();
|
|
# guard.authorize(DestructiveOperation.REPLAY, request.destination(), approval,
|
|
# request.dryRun() || !needsApproval, <-- dryRun 자리
|
|
# now);
|
|
# DestructiveOperationGuard.authorize 의 dryRun 파라미터는 :54-56 에서 무조건 return 한다.
|
|
# 격리 컨슈머그룹 리플레이가 승인 불필요라는 판단 자체는 ReplayService.java:16-19 에 근거가 있다.
|
|
# 다만 그 판단이 "dryRun=true 로 넘긴다" 로 구현되어, 감사 로그(:77)에는
|
|
# approval.map(VerifiedApproval::ticket).orElse("isolated")
|
|
# 로 남고 guard 쪽에는 dry run 과 구별되지 않는다.
|