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>
297 lines
17 KiB
Markdown
297 lines
17 KiB
Markdown
# messaging-pulsar-experimental 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 — `src/main` production 8파일 663줄, test 2파일 414줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/messaging/messaging-pulsar-experimental`
|
|
> SSOT owner: `messaging-pulsar-experimental`
|
|
> integration/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. 실패 분류 — 타입 있는 신호만 본다
|
|
|
|
```java
|
|
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 to `REJECTED` tells the caller nothing was
|
|
> transmitted, which is how the same entry is published to the bookies twice."
|
|
|
|
기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.
|
|
|
|
확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.
|
|
|
|
## 3. 호출자의 마감을 존중한다
|
|
|
|
```java
|
|
send.send(profile.topic(), request).toCompletableFuture()
|
|
.orTimeout(request.options().timeout().toMillis(), MILLISECONDS)
|
|
```
|
|
|
|
주석이 이유를 적는다 — 멈춘 전송이 호출자가 요청한 마감이 아니라 SDK 기본값만큼 호출자를 붙들고 있었다.
|
|
|
|
## 4. 구독 형태가 보장을 결정한다
|
|
|
|
`PulsarSubscriptionMode` 가 구독 종류와 확인 방식을 함께 묶고 두 조합을 생성자에서 거부한다.
|
|
|
|
> "A `Key_Shared` subscription 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` 이 이 어댑터의 상태를 명시적으로 못 박는다.
|
|
|
|
```java
|
|
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` 를 만들 수 있는 조합이 없다.**
|
|
|
|
```java
|
|
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 의 문서화된 의미와 어긋난다
|
|
|
|
전송이 답하는 값:
|
|
|
|
```java
|
|
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);
|
|
```
|
|
|
|
검증기가 답하는 값:
|
|
|
|
```java
|
|
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 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
|
|
|
|
```java
|
|
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 — 이름이 검사하지 않는 것을 검사한다고 말하는 테스트 둘
|
|
|
|
**하나.**
|
|
|
|
```java
|
|
@Test
|
|
void theValidatorAcceptsAKeyedProfileOnKeyShared() {
|
|
assertThatCode(() -> new PulsarProfile(…, PulsarSubscriptionMode.keyShared(), …))
|
|
.doesNotThrowAnyException();
|
|
}
|
|
```
|
|
|
|
본문에 `PulsarProfileValidator` 가 없다. 만들지도, 부르지도 않는다. 확인하는 것은 `PulsarProfile` 생성자가 키 공유 모드를 거부하지 않는다는 사실뿐이다.
|
|
|
|
이 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이고(§12.2), 그래서 이름만 읽으면 검증기에 커버리지가 있다고 읽힌다.
|
|
|
|
**둘.**
|
|
|
|
```java
|
|
@Test
|
|
void aTransportWithoutAConsumerFactoryRefusesToRegisterRatherThanReturningNothing() {
|
|
assertThatThrownBy(() -> confirming().register(null)).isInstanceOf(NullPointerException.class);
|
|
}
|
|
```
|
|
|
|
이름이 말하는 것은 "소비자 팩토리 없이 만든 전송이 등록을 거절한다"이다. 그 거절은 4-인자 생성자가 심어 두는 기본 팩토리에 있다.
|
|
|
|
```java
|
|
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 소비)
|
|
```
|