Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-pulsar-experimental.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

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 소비)
```