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>
17 KiB
messaging-pulsar-experimental 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 —
src/mainproduction 8파일 663줄, test 2파일 414줄 축자 통독 완료.STRUCTURAL_ONLY잔여 없음. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-pulsar-experimentalSSOT owner:messaging-pulsar-experimentalintegration/family document:analysis/19-messaging-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지
- 선언 의존: messaging 계열 project 7 + vendor
pulsar-client:4.0.3 runtime_memberships:[]— build-only · 등급 EXPERIMENTAL
| 파일 | LOC |
|---|---|
PulsarMessagingTransport |
275 |
PulsarProfile |
80 |
PulsarProfileValidator |
66 |
PulsarPreSendRejection |
65 |
PulsarSubscriptionMode |
62 |
PulsarTransactionCapability · PulsarMessagePosition |
49 · 49 |
PulsarSubscriptionType |
17 |
| main 합계 | 663 |
PulsarAdapterContractTest · PulsarSubscriptionGuardTest |
289 · 125 |
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
main/java/** |
8 | FULL_READ |
663줄 전 본문 |
test/java/** |
2 | FULL_READ |
414줄 전 본문 · 테스트 27개 |
build.gradle |
1 | FULL_READ |
전문 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
UNCLASSIFIED 0.
1. 이 어댑터가 무엇이고 무엇이 아닌가
"This is an Experimental contract seam, not a Stable adapter. It exercises the transport SPI against a send operation the application supplies; it does not ship a Pulsar client bridge, producer lifecycle, or reconnection."
전송은 PulsarSendOperation 함수형 인터페이스로 주입된다 — 브로커 없이 검증 가능하게 만든 격리다.
2. 실패 분류 — 타입 있는 신호만 본다
if (cause instanceof PulsarPreSendRejection rejection) → REJECTED (CONFIGURATION)
boolean timedOut = cause instanceof TimeoutException;
→ 나머지 전부 AMBIGUOUS (TRANSIENT_INFRASTRUCTURE)
javadoc 이 이전 구현과 그 결함을 적는다.
"Classification used to read the exception's class simple name:
"Timeout"meant ambiguous, anything else meant rejected. A class name is not part of Pulsar's contract — it changes between client versions — and defaulting the unknown case toREJECTEDtells the caller nothing was transmitted, which is how the same entry is published to the bookies twice."
기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.
확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.
3. 호출자의 마감을 존중한다
send.send(profile.topic(), request).toCompletableFuture()
.orTimeout(request.options().timeout().toMillis(), MILLISECONDS)
주석이 이유를 적는다 — 멈춘 전송이 호출자가 요청한 마감이 아니라 SDK 기본값만큼 호출자를 붙들고 있었다.
4. 구독 형태가 보장을 결정한다
PulsarSubscriptionMode 가 구독 종류와 확인 방식을 함께 묶고 두 조합을 생성자에서 거부한다.
"A
Key_Sharedsubscription with cumulative acknowledgement is not keyed ordering with a faster ack — cumulative ack over interleaved keys acknowledges messages from keys the consumer has not finished, so the combination silently loses the property the subscription type was chosen for."
그리고 검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다. 목적지 전체 순서는 아예 거부한다.
5. 트랜잭션은 주석이 아니라 클래스로 거절한다
"Pulsar has transactions. The platform does not offer them, and the distinction matters enough to be a class rather than a comment: an operator reading the capability matrix needs to know the answer is 'not proven here', not 'the broker cannot do it'."
그리고 거절을 던지지 않고 값으로 돌려준다 — 호출부에서 throw 가 보이게 하기 위해서다.
10. 테스트 레인
두 테스트 414줄 · 27개.
PulsarAdapterContractTest 14개 — 복제 증거로서의 확인, 위치 반환, 시간 초과의 모호, 타입 있는 사전 거절만이 NOT_TRANSMITTED, 미인식 실패의 모호, 감싸인 실패의 모호, 호출자 마감, 적재물 상한, 닫힘, register 인자 검사, 능력 세 개.
PulsarSubscriptionGuardTest 13개 — 누적 확인 조합 거부 둘, 순서 범위 둘, 영 지연 거부, 확인 시간 초과 하한, 기본 프로파일이 확인 시간 초과를 끄는 것, 트랜잭션 미승격 둘, 위치 렌더링 셋, 그리고 §17.3 이 다루는 마지막 하나.
전송은 (topic, request) -> CompletionStage<PulsarMessagePosition> 람다로 주입된다. 성공·실패·영영 안 끝남을 테스트가 직접 만든다.
레인에 없는 것 둘. orderedStream() 을 확인하는 단언이 하나도 없다 — §17.1 의 어긋남이 살아남은 자리다. 그리고 register(spec) 를 실제 spec 으로 부르는 테스트가 없어서, 기본 소비자 팩토리가 던지는 PULSAR_CONSUMER_NOT_CONFIGURED 는 한 번도 실행되지 않는다(§17.3).
12. negative-space probes
12.1 도달성. build-only · experimental. PulsarMessagingTransport 는 자기 테스트에서만 만들어진다.
리프 밖에서 dev.caskeleton.messaging.pulsar 가 등장하는 곳은 전부 이름 문자열이다 — config/architecture/modules.json, messaging-testkit/CompatibilityMatrix, 그리고 그것을 읽는 두 테스트. 그중 CrossBrokerContractSuite:110-113 이 이 어댑터의 상태를 명시적으로 못 박는다.
assertThat(matrix.isComplete("messaging-pulsar-experimental")) … ;
assertThat(matrix.gapsFor("messaging-pulsar-experimental")).isNotEmpty();
즉 플랫폼의 호환성 표가 이 어댑터를 "빈칸이 있는 상태"로 기록하고 있고, 그것을 테스트가 지킨다. 등급 표기와 실제 상태가 어긋나면 저 테스트가 깨진다.
12.2 PulsarProfileValidator 는 선언 말고 아무 데도 없다.
$ grep -rn PulsarProfileValidator --include=*.java src/
src/…/pulsar/PulsarProfileValidator.java:20: public final class PulsarProfileValidator {
한 줄. 자기 선언뿐이다 — 리프 밖 참조가 없는 정도가 아니라 리프 안 참조도, 테스트도 없다. 그래서 §4 가 서술하는 "검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다"는 판단은 코드로 적혀 있을 뿐 한 번도 실행된 적이 없다.
자매 어댑터(NATS)의 검증기도 같은 상태다(그쪽 §17.3). 다만 그쪽은 전송 javadoc 이 {@link} 로 가리키기라도 하는데, 이쪽은 그것조차 없다.
12.3 cumulativeAcknowledgement = true 를 만들 수 있는 조합이 없다.
if (cumulativeAcknowledgement && subscriptionType == KEY_SHARED) throw …;
if (cumulativeAcknowledgement && subscriptionType == SHARED) throw …;
PulsarSubscriptionType 의 값은 그 둘뿐이다. 그러므로 이 record 의 두 번째 성분은 false 만 가질 수 있다.
의도의 흔적은 남아 있다 — PulsarSubscriptionType javadoc 이 Exclusive 와 Failover 를 "의도적으로 뺐다"고 적는데, Pulsar 에서 누적 확인이 정당한 것이 정확히 그 두 종류다. 즉 종류를 둘로 줄인 결정이 이 성분을 죽였다.
§4 는 이 짝지음을 "두 값이 함께 보장을 결정한다"고 서술한다. 지금 코드에서는 한 값이 다른 값을 언제나 결정한다. 두 거부 메시지가 서로 다른 이유를 대므로 문서로서는 살아 있고, 그래서 §17 이 아니라 여기에 적는다.
12.4 드리프트. 실험 등급 표기가 코드와 문서에서 일치한다. PulsarTransactionCapability.PROMOTED = false 와 두 능력 상수의 brokerTransaction=false 도 일치한다.
16. 확인하지 못한 것
- 실제 Pulsar 브로커를 띄우지 않았다. 이 리프가 클라이언트 브리지를 싣지 않으므로 그럴 대상도 없다.
- §17.1 의 두 능력 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다.
- 테스트를 실행하지 않았다. 27개 전부 본문으로만 확인했다.
- §17.3 의 두 테스트가 실제로 무엇을 통과시키는지 디버거로 확인하지 않았다.
register의 첫 줄 널 검사와assertThatThrownBy가 단언하는 예외 타입으로 판정했다.
17. 손볼 것
17.1 P2 — 같은 어댑터의 능력을 두 곳이 다르게 답하고, 런타임이 쓰는 쪽이 record 의 문서화된 의미와 어긋난다
전송이 답하는 값:
SHARED_CAPABILITIES = (true, true, true, true, false, false, true, true, false, false, true, true);
KEY_SHARED_CAPABILITIES = (true, true, true, true, false, true, true, true, false, false, true, true);
검증기가 답하는 값:
public MessagingCapabilities capabilities(PulsarSubscriptionType subscriptionType) {
boolean keyed = subscriptionType == PulsarSubscriptionType.KEY_SHARED;
return new MessagingCapabilities(true, true, true, true, keyed, keyed, true, true, false, false, true, true);
}
다섯 번째 성분이 갈린다.
| Key_Shared 에서 | orderedStream |
keyedOrdering |
|---|---|---|
PulsarMessagingTransport.capabilities(...) |
false | true |
PulsarProfileValidator.capabilities(...) |
true | true |
MessagingCapabilities 의 성분 문서가 판정 기준이다.
@param orderedStream the destination preserves order inside an ordering unit
@param keyedOrdering order is preserved per key
Key_Shared 의 순서 단위는 키다. 그 단위 안에서 순서가 보존되므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 keyedOrdering=true 이면서 orderedStream=false 라 자기 안에서 모순이다.
그리고 어긋난 쪽이 런타임이 읽는 쪽이다. capabilities(DestinationName) 이 SPI 메서드이고, orderedStream 은 이 저장소에서 production 코드가 실제로 읽는 세 능력 중 하나다 — DefaultRetryDecisionEngine 이 그 값이 있으면 순서 보존 재시도를 고른다.
결과적으로 Key_Shared 목적지가 키 단위 순서를 약속하면서 순서 보존 재시도를 받지 못한다.
등급. 리프가 미배선이라 오늘의 사고는 아니다. 두 답 중 하나를 고르는 것이 먼저이고, 그 다음이 한 곳에서만 답하게 만드는 것이다. 검증기의 capabilities 는 리프 밖 소비자가 없으므로 전송이 그것을 부르게 하는 쪽이 자연스럽다.
17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
private static TransportPublishResult rejectedLocally(String code, String message) {
return new TransportPublishResult(new PublishResult(
PublishCompletion.REJECTED, PublishEvidence.notTransmitted(), RoutingOutcome.NOT_APPLICABLE,
Optional.empty(), 1, Duration.ZERO,
Optional.of(FailureDescriptor.of(FailureCategory.PERMANENT_BUSINESS, code, message))));
}
두 호출자가 이 메서드를 쓴다.
PAYLOAD_TOO_LARGE — 적재물이 상한을 넘음
PULSAR_TRANSPORT_CLOSED — "the transport is shutting down"
첫째는 영구 업무 실패가 맞다. 둘째는 아니다. 종료 중이라는 것은 이 세대의 사정이고, 다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.
같은 파일의 classify 가 분류를 신중히 나눈다 — 사전 거절은 CONFIGURATION, 모호는 TRANSIENT_INFRASTRUCTURE. 닫힘만 그 규율 밖에 있다.
전송되지 않았다는 증거(notTransmitted)는 옳다. 어긋난 것은 범주뿐이다.
수정은 닫힘에 TRANSIENT_INFRASTRUCTURE 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.
17.3 P3 — 이름이 검사하지 않는 것을 검사한다고 말하는 테스트 둘
하나.
@Test
void theValidatorAcceptsAKeyedProfileOnKeyShared() {
assertThatCode(() -> new PulsarProfile(…, PulsarSubscriptionMode.keyShared(), …))
.doesNotThrowAnyException();
}
본문에 PulsarProfileValidator 가 없다. 만들지도, 부르지도 않는다. 확인하는 것은 PulsarProfile 생성자가 키 공유 모드를 거부하지 않는다는 사실뿐이다.
이 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이고(§12.2), 그래서 이름만 읽으면 검증기에 커버리지가 있다고 읽힌다.
둘.
@Test
void aTransportWithoutAConsumerFactoryRefusesToRegisterRatherThanReturningNothing() {
assertThatThrownBy(() -> confirming().register(null)).isInstanceOf(NullPointerException.class);
}
이름이 말하는 것은 "소비자 팩토리 없이 만든 전송이 등록을 거절한다"이다. 그 거절은 4-인자 생성자가 심어 두는 기본 팩토리에 있다.
spec -> { throw new MessagingCapabilityUnavailableException(
"PULSAR_CONSUMER_NOT_CONFIGURED", "this Pulsar transport was created without a consumer factory"); }
그런데 테스트는 register(null) 을 부른다. register 첫 줄의 Objects.requireNonNull(spec, …) 에서 NullPointerException 이 나고, 팩토리까지 가지 않는다. 단언하는 예외 타입도 NullPointerException 이지 MessagingCapabilityUnavailableException 이 아니다.
결과적으로 PULSAR_CONSUMER_NOT_CONFIGURED 는 이 저장소에서 한 번도 실행되지 않는 코드다.
왜 P3 인가. 어느 쪽도 잘못된 동작을 통과시키지 않는다 — 두 테스트가 확인하는 것은 사실이다. 문제는 커버리지 지도가 틀렸다는 것이고, 그래서 §12.2 의 "검증기에 호출자가 없다"가 지금까지 눈에 띄지 않았다.
수정. 첫째는 new PulsarProfileValidator().validate(profile, KEY_SHARED, true) 를 부르고, 키 순서 목적지를 SHARED 로 넘겼을 때 거부되는 짝 테스트를 붙인다. 둘째는 유효한 TransportConsumerSpec 을 넘겨 MessagingCapabilityUnavailableException 과 그 코드를 단언한다. 두 수정 모두 새 production 코드를 요구하지 않는다.
확인된 설계(문제 아님)
- 알 수 없는 실패의 기본값을 모호로 둔 것과, 이전 구현의 결함을 javadoc 에 남긴 것.
- 클래스 이름이 아니라 타입 있는 신호로 분류하는 것 — 클래스 이름은 클라이언트 판본 사이에서 바뀐다.
- 확인을 복제 증거로 기록한 것 — 영수증과 구분한다.
- 호출자의 마감을
orTimeout으로 존중하는 것. - 구독 종류와 확인 방식을 한 record 로 묶고 두 조합을 생성자에서 거부한 것.
- 트랜잭션 미승격을 클래스로 표현하고, 거절을 던지지 않고 값으로 돌려주는 것.
- 전송 연산을 함수형 인터페이스로 분리해 브로커 없이 검증 가능하게 만든 것.
- 확인 시간 초과를 기본에서 끄고 그 이유를 적은 것 — "an ack timeout redelivers messages from handlers that are merely slow." 테스트가 기본값이 비어 있음을 지킨다.
- 음수 확인 재배달 지연이 곧 백오프라는 것을 밝히고 0 을 거부한 것 — 0 은 실패하는 핸들러를 브로커 대상 스핀 루프로 바꾼다.
- 확인 시간 초과 하한을 Pulsar 자신의 하한(10초)으로 둔 것 — 브로커가 어차피 거부할 값을 시작 시점에 거부한다.
- 메시지 위치를 불투명 문자열이 아니라 네 조각으로 분해해 들고 있는 것 — 배치 메시지는 id 를 공유하므로
batchIndex가 개별 메시지를 주소 지정 가능하게 만드는 유일한 조각이다. Exclusive·Failover구독을 노출하지 않은 것과 그 근거 — 목적지 프로파일이 이미 소유한 토폴로지 결정을 두 곳에서 설정하게 만들지 않는다. (그 결정의 부작용은 §12.3.)
Source anchors
src/messaging/messaging-pulsar-experimental/build.gradle
main/java/…/pulsar/PulsarMessagingTransport.java:1-275
main/java/…/pulsar/PulsarProfileValidator.java:1-66
main/java/…/pulsar/PulsarSubscriptionMode.java:1-62
main/java/…/pulsar/PulsarTransactionCapability.java:1-49
main/java/…/pulsar/PulsarProfile.java:1-80
main/java/…/pulsar/PulsarPreSendRejection.java:1-65
main/java/…/pulsar/PulsarMessagePosition.java:1-49
main/java/…/pulsar/PulsarSubscriptionType.java:1-17
test/java/…/pulsar/PulsarAdapterContractTest.java:1-289
test/java/…/pulsar/PulsarSubscriptionGuardTest.java:1-125
src/messaging/messaging-testkit/…/CrossBrokerContractSuite.java:110-113 (호환성 표의 미완 기록)
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java:11-36 (성분 의미)
src/messaging/messaging-policy/…/DefaultRetryDecisionEngine.java (orderedStream 소비)