# 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` 람다로 주입된다. 성공·실패·영영 안 끝남을 테스트가 직접 만든다. **레인에 없는 것 둘.** `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 소비) ```