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>
717 lines
42 KiB
Markdown
717 lines
42 KiB
Markdown
# messaging-transport-spi 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/messaging/messaging-transport-spi`
|
|
> SSOT owner: `messaging-transport-spi`
|
|
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
|
|
|
---
|
|
|
|
## 0. SSOT identity / 커버리지와 숫자 지도
|
|
|
|
- registered leaf id: `messaging-transport-spi`
|
|
- canonical state `analysisFile`: `analysis/messaging/messaging-transport-spi.md`
|
|
- source path: `src/messaging/messaging-transport-spi`
|
|
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy"]`
|
|
- registry `runtime_memberships`: `["app-bootstrap"]`
|
|
|
|
### 숫자
|
|
|
|
| 항목 | 수 |
|
|
|---|---:|
|
|
| production Java 파일 | 13 |
|
|
| production LOC | 776 |
|
|
| 패키지 | 1 (`dev.caskeleton.messaging.transport`) |
|
|
| test 파일 | 4 |
|
|
| test 메서드(실행 확인) | 24 |
|
|
| 외부(비프로젝트) 의존성 | **0** |
|
|
|
|
13개 타입:
|
|
|
|
| 타입 | 종류 | 역할 |
|
|
|---|---|---|
|
|
| `MessagingTransport` | interface | **브로커 어댑터가 구현하는 SPI** |
|
|
| `TransportPublishRequest` | record | 이미 인코딩된 발행 요청 |
|
|
| `TransportPublishResult` | record | `PublishResult` 래퍼 |
|
|
| `TransportConsumerSpec` | record | 프로파일 + 콜백 |
|
|
| `TransportConsumerRegistration` | interface | 살아 있는 구독 |
|
|
| `TransportDelivery` | record | 아직 인코딩된 수신 |
|
|
| `TransportSettlement` | interface | 어댑터 측 정산 핸들 |
|
|
| `MessagingRuntime` | interface | 한 세대의 연결·자격증명·토폴로지 |
|
|
| `MessagingRuntimeLease` | interface | 세대 참조 대여 |
|
|
| `MessagingRuntimeRegistry` | interface | 브로커별 현재 세대 |
|
|
| `DefaultMessagingRuntimeRegistry` | class | 참조 계수 + 원자 교체 구현 |
|
|
| `GracefulShutdownCoordinator` | class | 드레인 조정자 |
|
|
| `MessagingLifecycle` | interface | **8단계 종료 순서 계약 — 구현체 없음(§12.1)** |
|
|
|
|
### Coverage ledger
|
|
|
|
| scope/file group | count | disposition | reason |
|
|
|---|---:|---|---|
|
|
| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |
|
|
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 |
|
|
| `build.gradle` | 1 | `FULL_READ` | 7줄 |
|
|
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
|
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
|
|
|
`UNCLASSIFIED` 0.
|
|
|
|
---
|
|
|
|
## 1. 모듈의 정체와 경계
|
|
|
|
브로커 어댑터가 구현할 **SPI**와, 그 어댑터들의 **수명주기·세대 관리**를 소유한다. 벤더 의존성이 0이다.
|
|
|
|
가장 중요한 경계 규칙이 `MessagingTransport`의 javadoc에 있다.
|
|
|
|
```java
|
|
// MessagingTransport.java:10-12
|
|
* <p>No method returns a native client object. Handing back a raw producer or channel would let an
|
|
* application bypass destination policy, payload limits, and the settlement ordering in one call,
|
|
* and the resulting code would silently stop working the moment the broker changed.
|
|
```
|
|
|
|
13개 타입 중 어느 것도 브로커 네이티브 타입을 시그니처에 노출하지 않는다. `BrokerPosition`(core-api)이 `Map<String,String> diagnosticAttributes()`로 좌표를 문자열로만 내보내는 것과 같은 규율이다.
|
|
|
|
두 번째 경계는 **인코딩 위치**다.
|
|
|
|
```java
|
|
// TransportDelivery.java:11-13
|
|
* <p>Decoding happens above the transport so that a payload the consumer cannot parse is classified
|
|
* as a schema failure by the platform, and parked, rather than being turned into an
|
|
* adapter-specific exception each broker reports differently.
|
|
```
|
|
|
|
`TransportPublishRequest`도 대칭이다 — "The payload arrives already encoded and the profile arrives already validated, so an adapter never chooses a codec or a limit for itself. That is what keeps two adapters from disagreeing about what 'the same message' means."
|
|
|
|
---
|
|
|
|
## 2. 의존성과 런타임 배선
|
|
|
|
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `messaging-policy`(api). 셋 다 `api`인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — `TransportPublishRequest`가 `DestinationProfile`(policy)·`MessageEnvelope`(core-api)·`EncodedMessage`(schema-api)를 필드로 갖는다.
|
|
|
|
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-admin-runtime`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`.
|
|
|
|
런타임 편입은 starter closure를 통해서다. 이 leaf 자체는 bean을 만들지 않는다.
|
|
|
|
---
|
|
|
|
## 3. 패키지/컴포넌트 지도
|
|
|
|
세 축이 한 패키지에 있다.
|
|
|
|
```
|
|
[SPI] MessagingTransport
|
|
├── publish(TransportPublishRequest) → TransportPublishResult
|
|
├── register(TransportConsumerSpec) → TransportConsumerRegistration
|
|
├── capabilities(DestinationName) → DestinationCapabilities
|
|
└── brokerName / generation / close
|
|
↑ 구현: Kafka · Rabbit · Pulsar · NATS (4)
|
|
|
|
[세대] MessagingRuntime ── MessagingRuntimeLease ── MessagingRuntimeRegistry
|
|
↑
|
|
DefaultMessagingRuntimeRegistry (구현)
|
|
|
|
[종료] GracefulShutdownCoordinator (사용됨: 11개 파일)
|
|
MessagingLifecycle.ShutdownPhase(8) (구현 없음, 소비자 0)
|
|
```
|
|
|
|
세 축이 **다른 정도로 살아 있다.** SPI는 4개 어댑터가 구현하고, 세대 관리는 구현이 하나 있고, 종료 계약은 절반만 실현됐다(§12.1).
|
|
|
|
---
|
|
|
|
## 4. 계약·불변식·상태 모델
|
|
|
|
### 4.1 세대 모델: 회전은 변경이 아니라 교체다
|
|
|
|
```java
|
|
// MessagingRuntime.java:5-8
|
|
* <p>Credential rotation and topology reload replace a whole generation rather than mutating a live
|
|
* one. In-flight publishes keep the generation they started on, which is what makes a rotation
|
|
* invisible to callers instead of a burst of authentication failures.
|
|
```
|
|
|
|
세 타입이 그 모델을 이룬다.
|
|
|
|
| 타입 | 불변식 |
|
|
|---|---|
|
|
| `MessagingRuntime` | 불변. `close()`는 **멱등이어야 한다**(javadoc이 명시) |
|
|
| `MessagingRuntimeLease` | 참조를 pin. `close()`는 **멱등이어야 한다** |
|
|
| `MessagingRuntimeRegistry` | 브로커당 현재 세대 하나 |
|
|
|
|
### 4.2 `DefaultMessagingRuntimeRegistry`: 참조 계수와 원자 교체
|
|
|
|
이 leaf의 유일한 실질 구현이고 동시성 설계가 조밀하다.
|
|
|
|
**설치(교체)**
|
|
|
|
```java
|
|
Generation retired = current.put(runtime.brokerName(), new Generation(runtime));
|
|
if (retired == null) return;
|
|
retired.retire(now);
|
|
if (!retired.closeIfIdle()) {
|
|
synchronized (draining) { draining.add(retired); }
|
|
}
|
|
```
|
|
|
|
`ConcurrentHashMap.put`이 원자적이므로 호출자는 옛 세대 또는 새 세대만 본다 — javadoc: "never a half-rebuilt connection pool".
|
|
|
|
**대여**
|
|
|
|
```java
|
|
Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
|
|
value.leases.incrementAndGet();
|
|
return value;
|
|
});
|
|
```
|
|
|
|
`computeIfPresent`의 리맵 함수가 **버킷 잠금 안에서** 실행되므로, 조회와 증가가 원자적이다. `get` 후 증가였다면 그 사이에 `install`이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.
|
|
|
|
**해제**
|
|
|
|
```java
|
|
void release() {
|
|
if (leases.decrementAndGet() == 0) { closeIfIdle(); }
|
|
}
|
|
boolean closeIfIdle() {
|
|
if (retired.get() && leases.get() == 0) { return forceClose(); }
|
|
return false;
|
|
}
|
|
boolean forceClose() {
|
|
if (closed.compareAndSet(false, true)) { runtime.close(); return true; }
|
|
return false;
|
|
}
|
|
```
|
|
|
|
`closed`가 CAS로 보호되므로 **정확히 한 번만** `runtime.close()`가 불린다. 테스트가 그것을 직접 단언한다(`aRetiredGenerationIsClosedExactlyOnce`, `as("a second close on a real connection pool throws from a shutdown hook")`).
|
|
|
|
`Lease.close()`도 자체 `AtomicBoolean released`로 멱등이다 — 두 층의 멱등성이다.
|
|
|
|
**세대별 은퇴 시각**
|
|
|
|
```java
|
|
// Generation.retiredAt javadoc:180-183
|
|
* <p>Each generation carries its own. The deadline check took one {@code retiredAt} from the
|
|
* caller and applied it to every draining generation, so a rotation during a drain either
|
|
* force-closed a generation that had just retired or gave an old one a fresh deadline —
|
|
* depending on which timestamp the caller happened to pass.
|
|
```
|
|
|
|
이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.
|
|
|
|
**닫힌 세대의 목록 제거**
|
|
|
|
```java
|
|
// closeExpiredDraining:112-113
|
|
// Anything already closed leaves the list too: it is not draining, and leaving it there is
|
|
// what made drainingCount report work that had finished.
|
|
draining.removeIf(Generation::isClosed);
|
|
```
|
|
|
|
`drainingCount()`가 관측 지표이므로, 이미 닫힌 세대가 목록에 남으면 지표가 영원히 0으로 안 떨어진다.
|
|
|
|
**`close()`가 현재 세대까지 닫는다**
|
|
|
|
```java
|
|
// close() javadoc:131-134
|
|
* <p>Nothing closed the current generation. The registry only ever closed what a rotation had
|
|
* retired, so a process that shut down without rotating left its broker connections to the JVM's
|
|
* exit — which drops unflushed producer batches and leaves consumer sessions to time out on the
|
|
* broker instead of leaving the group.
|
|
```
|
|
|
|
이것도 이전 결함이다. 회전 없이 종료하는 프로세스(=대부분의 프로세스)가 연결을 정리하지 않았다.
|
|
|
|
**동시성 미세 결함 하나.** `close()`가 `draining`은 `synchronized`로 비우지만 `current`는 `List.copyOf(current.keySet())` 후 하나씩 `remove`한다. 그 사이에 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.
|
|
|
|
### 4.3 `GracefulShutdownCoordinator`: 세 단계와 그 이유
|
|
|
|
```java
|
|
// GracefulShutdownCoordinator.java:12-22
|
|
* <p>Shutdown has three phases, in order: stop accepting new work, let what is running finish, then
|
|
* close. Skipping the middle phase is what produces the classic shutdown bug — a handler is
|
|
* interrupted between its side effect and its settlement, so the message is redelivered and the
|
|
* effect happens twice.
|
|
*
|
|
* <p>The deadline exists because draining cannot be unbounded: a stuck handler would otherwise hold
|
|
* the process open forever. Work still running at the deadline is abandoned <em>unsettled</em>, so
|
|
* the broker redelivers it rather than the platform pretending it completed.
|
|
*
|
|
* <p>No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
|
|
* guarantees it will be abandoned at the deadline.
|
|
```
|
|
|
|
`tryBeginWork`가 **이중 검사**다.
|
|
|
|
```java
|
|
public boolean tryBeginWork() {
|
|
if (draining.get()) return false;
|
|
inFlight.incrementAndGet();
|
|
if (draining.get()) { inFlight.decrementAndGet(); return false; }
|
|
return true;
|
|
}
|
|
```
|
|
|
|
증가 후 다시 확인해서, 증가와 `beginDrain` 사이의 경합에서 계수를 되돌린다. 이 패턴이 없으면 드레인 시작 직후 시작된 작업이 계수에 남아 `isDrained`가 영원히 false가 된다.
|
|
|
|
`endWork`가 0에서 clamp한다.
|
|
|
|
```java
|
|
// :65-67
|
|
* <p>Clamped at zero. A double release used to drive the count negative, and a negative in-flight
|
|
* count reports the drain as complete while work is still running — which is exactly when the
|
|
* process shuts down underneath it.
|
|
public void endWork() {
|
|
inFlight.updateAndGet(current -> current > 0 ? current - 1 : current);
|
|
}
|
|
```
|
|
|
|
`isDrained(now)`가 세 갈래다 — 드레인 전이면 false, 계수 0이면 true, 아니면 마감 경과 여부. `abandonedWorkAtDeadline`이 "마감으로 끝났는가"를 별도로 답해서, 완주한 드레인과 포기한 드레인을 구분할 수 있다.
|
|
|
|
### 4.4 `MessagingLifecycle`: 8단계 순서 계약
|
|
|
|
```java
|
|
// MessagingLifecycle.java:8-15
|
|
* <p>The order in {@link ShutdownPhase} is the contract, not an implementation detail. Closing
|
|
* connections before settlements have been transmitted loses the settlements, and pausing consumers
|
|
* after draining lets fresh deliveries arrive into a runtime that is already shutting down. Each
|
|
* adapter implements the phases; none of them chooses the order.
|
|
*
|
|
* <p>Implementations are driven by the Spring lifecycle rather than a JVM shutdown hook alone. A
|
|
* shutdown hook runs after the context has already begun disposing beans, so a handler mid-drain
|
|
* can find its datasource closed underneath it.
|
|
```
|
|
|
|
여덟 단계:
|
|
|
|
| # | 단계 | 뜻 |
|
|
|---:|---|---|
|
|
| 1 | `STOP_PUBLISH_ADMISSION` | 새 발행 거부 |
|
|
| 2 | `STOP_NEW_HANDLERS` | 새 핸들러 시작 거부 |
|
|
| 3 | `PAUSE_CONSUMERS` | 브로커에 전달 중단 요청 |
|
|
| 4 | `DRAIN_HANDLERS` | 실행 중 핸들러 완료 대기 |
|
|
| 5 | `FLUSH_SETTLEMENTS` | 그 핸들러들이 만든 정산 전송 |
|
|
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 미확인 발행이 모호로 남지 않게 |
|
|
| 7 | `RELEASE_OUTBOX_LEASES` | 다른 relay가 즉시 claim 가능하게 |
|
|
| 8 | `CLOSE_CONNECTIONS` | 연결·채널 종료 |
|
|
|
|
`shutdown(Duration)`이 마감 시점에 실행 중이던 단계를 반환한다 — 완주하면 `CLOSE_CONNECTIONS`.
|
|
|
|
**이 인터페이스를 구현하는 것이 저장소에 없다.** §12.1.
|
|
|
|
### 4.5 `TransportConsumerRegistration`: 순서 단위별 pause
|
|
|
|
```java
|
|
// :8-10
|
|
* <p>Pause and resume operate on an ordering unit rather than the whole consumer, because that is
|
|
* what makes {@code PAUSE_PARTITION} retry possible: one stuck key must not stall every other
|
|
* partition on the same connection.
|
|
```
|
|
|
|
`scope`가 빈 문자열이면 전체다. `core-api`의 `PauseResumeController`는 `"*"`를 전체로 쓴다 — 두 인터페이스가 같은 개념에 **다른 sentinel**을 쓴다. `PauseResumeController`는 소비자가 0이므로(`analysis/messaging/messaging-core-api.md` §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.
|
|
|
|
### 4.6 `TransportSettlement`: 애플리케이션에 노출되지 않는다
|
|
|
|
```java
|
|
// :10-11
|
|
* <p>Deliberately not exposed to application code. Handlers state an intent; the platform decides
|
|
* when and in what order the settlement happens, and this is the seam it uses to do that.
|
|
```
|
|
|
|
`acknowledge` / `requeue(delay)` / `discard` 셋이고, `core-api`의 `SettlementController`(`ack`/`retry`/`deadLetter`/`reject`)와 **이름도 개수도 다르다.** 전자는 어댑터 측 원시 연산, 후자는 M2 수동 정산 API다. `deadLetter`가 전자에 없는 것이 핵심이다 — DLQ 발행은 플랫폼(`DefaultDeliveryProcessor`)이 하고 어댑터는 `acknowledge`만 받는다.
|
|
|
|
---
|
|
|
|
## 5. 주요 실행 경로
|
|
|
|
**발행:** 상위(`DefaultMessagePublisher`)가 `TransportPublishRequest`를 만들어 `MessagingTransport.publish` → 어댑터가 `TransportPublishResult(PublishResult)` 반환
|
|
|
|
**수신:** 상위가 `TransportConsumerSpec(profile, sink)`로 `register` → 어댑터가 메시지마다 `sink.apply(TransportDelivery)` → 상위가 `TransportSettlement`으로 정산
|
|
|
|
**회전:** 새 `MessagingRuntime` 생성 → `registry.install(runtime, now)` → 옛 세대 `retire` → lease가 0이면 즉시 close, 아니면 `draining`에 적재 → 스케줄러가 `closeExpiredDraining(now)` 호출
|
|
|
|
**종료:** (실제 경로) `MessagingShutdownLifecycle.stop()` → `admission.stopAcceptingNewWork()` → `drain.beginDrain(now)` → 50 ms 폴링으로 `isDrained` 대기 → 마감 도달 시 중단
|
|
|
|
---
|
|
|
|
## 6. 실패 경로와 복구/번역
|
|
|
|
이 leaf가 직접 던지는 예외는 **하나**다.
|
|
|
|
| 코드 | 예외 | 조건 |
|
|
|---|---|---|
|
|
| `RUNTIME_NOT_INSTALLED` | `MessagingConfigurationException` | `acquire(brokerName)`인데 그 브로커의 세대가 없음 |
|
|
|
|
나머지는 `IllegalArgumentException`(생성자 인자 검증)과 `NullPointerException`(`Objects.requireNonNull`)이다. 이 leaf가 다루는 실패의 대부분은 **예외가 아니라 상태**다 — 드레인 마감 초과는 `abandonedWorkAtDeadline(now)`가 true를 반환하는 것이고, 세대 강제 종료는 `closeExpiredDraining`의 반환 계수다.
|
|
|
|
**포기가 조용하지 않다는 것이 설계다.** 마감에 도달한 작업은 정산되지 않은 채 버려지고, 브로커가 재전달한다. `GracefulShutdownCoordinator` javadoc: "rather than the platform pretending it completed."
|
|
|
|
---
|
|
|
|
## 7. 트랜잭션·동시성·수명주기
|
|
|
|
이 leaf는 messaging family에서 **동시성 밀도가 가장 높다.**
|
|
|
|
| 지점 | 도구 | 보호하는 것 |
|
|
|---|---|---|
|
|
| `current` 맵 | `ConcurrentHashMap` | 세대 교체의 원자성 |
|
|
| lease 증가 | `computeIfPresent` 리맵 | 조회-증가 사이의 교체 |
|
|
| `leases` | `AtomicInteger` | 참조 계수 |
|
|
| `retired`, `closed` | `AtomicBoolean` + CAS | 정확히 한 번 close |
|
|
| `Lease.released` | `AtomicBoolean` + CAS | 이중 close 방지 |
|
|
| `retiredAt` | `volatile Instant` | 세대별 마감 가시성 |
|
|
| `draining` 리스트 | `synchronized` 블록 | `ArrayList` 보호 |
|
|
| `inFlight` | `AtomicInteger` + 이중 검사 + clamp | 드레인 계수 |
|
|
| `draining`(coordinator) | `AtomicBoolean` CAS | 드레인 시작 한 번 |
|
|
| `drainStartedAt` | `volatile Instant` | 마감 가시성 |
|
|
|
|
**주목할 비대칭:** `DefaultMessagingRuntimeRegistry`가 `current`는 lock-free(`ConcurrentHashMap`)로, `draining`은 `synchronized ArrayList`로 다룬다. `draining`은 회전 때만 접근하므로 경합이 없다 — 합리적 선택이지만 주석이 없다.
|
|
|
|
수명주기는 §4.4의 8단계가 **선언**이고 §12.1이 실현 상태를 다룬다.
|
|
|
|
---
|
|
|
|
## 8. 설정·기능 플래그·환경 차이
|
|
|
|
설정 없음.
|
|
|
|
| 상수 | 값 | 위치 |
|
|
|---|---|---|
|
|
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | 30초 | `:28` (private) |
|
|
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | 30초 | `:40` (public, 인터페이스 상수) |
|
|
|
|
**같은 값이 두 곳에 있다.** 그리고 `MessagingShutdownLifecycle`(starter)은 셋 중 어느 것도 참조하지 않고 생성자 인자로 받는다. 세 번째 값이 프로퍼티에서 올 수 있다는 뜻이다 — 그 배선은 starter leaf가 소유한다.
|
|
|
|
---
|
|
|
|
## 9. 퍼시스턴스/외부 시스템 세부
|
|
|
|
없다. 이 leaf는 브로커를 만지지 않는다 — 만지는 방법의 **모양**만 정의한다.
|
|
|
|
---
|
|
|
|
## 10. 테스트 레인과 실제 증명 범위
|
|
|
|
레인: `./gradlew :messaging:messaging-transport-spi:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
|
|
|
|
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
|
|---|---:|---|---|
|
|
| `MessagingRuntimeRegistryTest` | 9 | 세대 설치·대여·은퇴·드레인 계수 | 실제 브로커 연결 |
|
|
| `ResourceLeakGateTest` | 4 | 20세대 연속 회전 후 현재 세대만 열림, 누수 lease가 마감에 강제 종료, 막힌 작업도 드레인 종료, 은퇴 세대가 **정확히 한 번** close | 며칠 단위 실행 |
|
|
| `GracefulShutdownTest` | 5 | 드레인이 새 작업만 막고 실행 중은 완료, 재시도 금지, 마감 경계(29초 false / 30초 true), 유휴 코디네이터, 이중 `endWork` clamp | — |
|
|
| `MessagingLifecycleTest` | 6 | **enum 선언 순서와 상수 값** | **아무 종료 동작도 증명하지 않는다** |
|
|
|
|
### 10.1 `ResourceLeakGateTest`의 자기 규정
|
|
|
|
```java
|
|
// :12-18
|
|
* <p>Every resource the platform holds is bounded by something that must eventually release it: a
|
|
* runtime generation by its last lease, an in-flight slot by its handler finishing, a drain by its
|
|
* deadline. Each of those has a failure mode that is invisible in a short test and fatal over days
|
|
* — a retired generation whose credential never gets revoked, a partition that never accepts work
|
|
* again, a shutdown that never completes.
|
|
```
|
|
|
|
세 자원과 각각의 해제 조건을 명시하고, "짧은 테스트에서 안 보이고 며칠이면 치명적"이라는 실패 성격까지 적는다. 20세대 회전 루프가 그 형태를 압축한 것이다.
|
|
|
|
### 10.2 `MessagingLifecycleTest`가 실제로 단언하는 것
|
|
|
|
여섯 테스트 중 다섯이 이 형태다.
|
|
|
|
```java
|
|
List<ShutdownPhase> order = List.of(ShutdownPhase.values());
|
|
assertThat(order.indexOf(ShutdownPhase.DRAIN_HANDLERS))
|
|
.as("flushing before the handlers finish would lose the settlements they produce")
|
|
.isLessThan(order.indexOf(ShutdownPhase.FLUSH_SETTLEMENTS));
|
|
```
|
|
|
|
`ShutdownPhase.values()`는 **소스에 상수가 적힌 순서**를 반환한다. 이 단언이 검증하는 것은 "누군가 enum 상수를 이 순서로 타이핑했다"이다. 여섯 번째는 상수 값 비교(`DEFAULT_DRAIN_DEADLINE == 30초`)다.
|
|
|
|
`as(...)` 문구들은 실제 시스템 동작을 서술한다 — "flushing before the handlers finish would lose the settlements", "a confirm that arrives after close cannot be observed". 그러나 그 동작을 수행하는 코드가 없다(§12.1). 테스트 이름(`handlersDrainBeforeTheirSettlementsAreFlushed`)과 실제 단언(enum 인덱스 비교) 사이의 거리가 이 레인에서 가장 큰 항목이다.
|
|
|
|
이 여섯 테스트는 **enum 상수 순서를 바꾸면 실패한다.** 그리고 순서를 바꿔도 시스템 동작은 바뀌지 않는다 — 아무도 그 순서를 읽지 않기 때문이다. 게이트가 지키는 것과 게이트가 지킨다고 이름 붙인 것이 다르다.
|
|
|
|
---
|
|
|
|
## 11. 빌드/ArchUnit/CI 강제 지점
|
|
|
|
| 게이트 | 이 leaf에 대해 |
|
|
|---|---|
|
|
| `verifyCleanArchitectureDependencies` | 세 project 의존 |
|
|
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
|
| vendor `api` 규칙 | 벤더 의존성 0이므로 대상 없음. 세 project 의존은 전부 `api`이고 시그니처에 실제로 등장 |
|
|
| ArchUnit | 전용 규칙 없음 |
|
|
| `MessagingLifecycle` 구현 강제 | **없음** — 인터페이스는 컴파일 타임 강제를 만들지 않는다 |
|
|
|
|
마지막 행이 §12.1의 구조적 이유다. `MessagingTransport`는 어댑터가 구현하지 않으면 `TransportMessagingRuntime`이 컴파일되지 않는다. `MessagingLifecycle`은 아무도 받지 않으므로 구현하지 않아도 아무것도 깨지지 않는다.
|
|
|
|
---
|
|
|
|
## 12. 실제 사용 여부와 negative-space probes
|
|
|
|
원시 증거: `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt`.
|
|
|
|
### 12.1 Public surface reachability
|
|
|
|
| 타입 | leaf 밖 파일 수 | 판정 |
|
|
|---|---:|---|
|
|
| `TransportPublishRequest` | 18 | 활발 |
|
|
| `TransportConsumerSpec` | 14 | 활발 |
|
|
| `MessagingTransport` | 12 | 4개 어댑터가 구현 |
|
|
| `GracefulShutdownCoordinator` | 11 | 활발 |
|
|
| `TransportPublishResult` | 10 | 활발 |
|
|
| `TransportDelivery` | 9 | 활발 |
|
|
| `TransportConsumerRegistration` | 8 | 활발 |
|
|
| `TransportSettlement` | 4 | 활발 |
|
|
| `MessagingRuntime` | 3 | `TransportMessagingRuntime`이 구현 |
|
|
| `MessagingRuntimeRegistry` | 3 | |
|
|
| `MessagingRuntimeLease` | 2 | |
|
|
| `DefaultMessagingRuntimeRegistry` | 2 | |
|
|
| **`MessagingLifecycle`** | **0** | **구현 없음, 소비자 없음** |
|
|
|
|
**이 leaf는 messaging family에서 가장 잘 쓰이는 leaf 중 하나다.** 13개 중 12개가 실제 소비자를 갖는다. 그래서 나머지 하나가 두드러진다.
|
|
|
|
**`MessagingLifecycle`의 세 겹 부재**
|
|
|
|
1. `git grep -E 'implements .*MessagingLifecycle'` → exit 1. **구현체 없음.**
|
|
2. `git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi'` → exit 1. **8단계 enum의 외부 소비자 없음.**
|
|
3. `MessagingLifecycle`의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.
|
|
|
|
한편 `MessagingTransport`는 넷이 구현한다 — `KafkaMessagingTransport`, `RabbitMessagingTransport`, `PulsarMessagingTransport`, `NatsJetStreamTransport`. **네 어댑터 중 어느 것도 `MessagingLifecycle`을 구현하지 않는다.** javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.
|
|
|
|
**실제 종료 경로는 존재하고 다른 타입으로 되어 있다.**
|
|
|
|
`messaging-spring-boot-starter`의 `MessagingShutdownLifecycle implements SmartLifecycle`이 종료를 수행한다.
|
|
|
|
```java
|
|
public void stop() {
|
|
if (!running.compareAndSet(true, false)) return;
|
|
admission.stopAcceptingNewWork(); // ≈ phase 1
|
|
Instant startedAt = clock.get();
|
|
drain.beginDrain(startedAt); // ≈ phase 2
|
|
Instant deadline = startedAt.plus(drainDeadline);
|
|
while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) { ... } // ≈ phase 4
|
|
}
|
|
```
|
|
|
|
선언된 8단계와 대조:
|
|
|
|
| # | 선언 단계 | 실제 수행 |
|
|
|---:|---|---|
|
|
| 1 | `STOP_PUBLISH_ADMISSION` | **수행** — `admission.stopAcceptingNewWork()` |
|
|
| 2 | `STOP_NEW_HANDLERS` | **수행** — `beginDrain` 이후 `tryBeginWork()`가 false |
|
|
| 3 | `PAUSE_CONSUMERS` | 명시적 호출 없음. 어댑터의 registrar가 자체 처리 |
|
|
| 4 | `DRAIN_HANDLERS` | **수행** — 폴링 루프 |
|
|
| 5 | `FLUSH_SETTLEMENTS` | 명시적 단계 없음 |
|
|
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 명시적 단계 없음 |
|
|
| 7 | `RELEASE_OUTBOX_LEASES` | 명시적 단계 없음 — `getPhase()` javadoc이 outbox relay와의 상대 순서만 언급 |
|
|
| 8 | `CLOSE_CONNECTIONS` | Spring bean 소멸에 위임 — `getPhase()`가 `Integer.MAX_VALUE - 1024`로 transport보다 먼저 멈춤 |
|
|
|
|
즉 **8단계 중 셋이 명시적으로 수행되고, 하나는 Spring 단계 순서에 위임되며, 넷은 명시적 단계가 없다.** 그리고 순서를 결정하는 것은 `ShutdownPhase` enum이 아니라 Spring의 `getPhase()` 정수다.
|
|
|
|
`MessagingShutdownLifecycle`의 javadoc이 자기 순서를 스스로 설명한다 — "The order is admission first, drain second. Reversed, the drain waits for a count that new work keeps topping up." 두 단계에 대해서만 순서를 논한다.
|
|
|
|
**한계.** `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`가 어댑터 내부에서 다른 이름으로 수행될 수 있다. `KafkaConsumerRegistrar`와 `RabbitConsumerRegistrar`가 `GracefulShutdownCoordinator`를 쓰므로 그 leaf들이 답을 갖는다. 이 문서는 **`ShutdownPhase`가 그 순서를 결정하지 않는다**만 주장한다.
|
|
|
|
### 12.2 Conditional sibling comparison
|
|
|
|
Spring 주석 0개, bean 없음.
|
|
|
|
**`MessagingTransport` 구현 sibling 넷의 비대칭이 관측된다.**
|
|
|
|
| 어댑터 | `MessagingTransport` | registry membership |
|
|
|---|:---:|---|
|
|
| `KafkaMessagingTransport` | o | `["app-bootstrap"]` |
|
|
| `RabbitMessagingTransport` | o | `["app-bootstrap"]` |
|
|
| `PulsarMessagingTransport` | o | `[]` |
|
|
| `NatsJetStreamTransport` | o | `[]` |
|
|
|
|
넷 다 같은 SPI를 구현하고 둘만 편입된다 — `docs/messaging/support-matrix.md`의 experimental 구분과 정합한다. 각 어댑터의 조건부 활성화는 해당 leaf SSOT가 소유한다.
|
|
|
|
### 12.3 Duplicate mechanism sweep
|
|
|
|
**(a) 드레인 마감 30초가 세 곳에 있다**
|
|
|
|
| 위치 | 가시성 |
|
|
|---|---|
|
|
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | public 인터페이스 상수 |
|
|
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | private |
|
|
| `MessagingShutdownLifecycle`(starter) | 생성자 인자 |
|
|
|
|
public 상수가 있는데 같은 leaf의 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest`의 여섯 번째 테스트가 public 쪽만 고정한다 — private 쪽이 바뀌어도 통과한다.
|
|
|
|
**(b) 정산 인터페이스가 둘**
|
|
|
|
| 인터페이스 | leaf | 연산 |
|
|
|---|---|---|
|
|
| `TransportSettlement` | 이 leaf | `acknowledge` / `requeue(delay)` / `discard` |
|
|
| `SettlementController` | `messaging-core-api` | `ack` / `retry(delay)` / `deadLetter(failure)` / `reject(failure)` |
|
|
|
|
책임이 다르다 — 전자는 어댑터 원시 연산, 후자는 M2 수동 정산 API이고 `deadLetter`가 추가돼 있다. 중복이 아니라 계층이다. 다만 `SettlementController`는 소비자가 0이므로(`messaging-core-api` §12.1) 오늘 계층의 위쪽이 비어 있다.
|
|
|
|
**(c) pause scope sentinel이 둘**
|
|
|
|
| 인터페이스 | 전체를 뜻하는 값 |
|
|
|---|---|
|
|
| `TransportConsumerRegistration.pause(String scope)` | **빈 문자열** |
|
|
| `PauseResumeController.pause(dest, String scope)` (core-api) | **`"*"`** |
|
|
|
|
두 javadoc이 각각 명시한다. 이으려면 변환이 필요하고, 그 변환 코드는 없다(`PauseResumeController` 소비자 0).
|
|
|
|
**(d) 드레인 조정 로직**
|
|
|
|
`GracefulShutdownCoordinator`가 유일하다. 저장소의 다른 곳에서 in-flight 계수 + 마감 패턴을 다시 만든 곳은 messaging family 안에 없다. 다른 family(grpc의 admission controller 등)와의 비교는 cross-scope가 소유한다.
|
|
|
|
### 12.4 Documentation / measured-count drift
|
|
|
|
| 문서 주장 | 재측정 | 결과 |
|
|
|---|---|---|
|
|
| `MessagingLifecycle` javadoc: "Each adapter implements the phases" | 4개 어댑터 중 0개 구현 | **불일치** |
|
|
| `MessagingLifecycle` javadoc: "The order in ShutdownPhase is the contract" | 그 순서를 읽는 코드 0 | **불일치** |
|
|
| `MessagingTransport` javadoc: 네이티브 클라이언트 미반환 | 13개 타입 시그니처 전수 확인 | **일치** |
|
|
| `TransportDelivery` javadoc: 디코딩이 transport 위에서 | `TransportDelivery.envelope`이 `MessageEnvelope<EncodedMessage>` | **일치** |
|
|
| `TransportSettlement` javadoc: 애플리케이션에 미노출 | 이 leaf가 `..application..`에서 참조 0(ArchUnit이 금지) | **일치** |
|
|
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift, `messaging-core-api` §12.4가 소유) |
|
|
|
|
---
|
|
|
|
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
|
|
|
코드 주석이 네 결함을 보존한다. 전부 **장기 실행에서만 드러나는** 종류다.
|
|
|
|
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
|
|---|---|---|
|
|
| `Generation.retiredAt` javadoc | 호출자가 넘긴 하나의 `retiredAt`을 전체 draining 목록에 적용 | 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우 |
|
|
| `closeExpiredDraining` 주석 | 이미 닫힌 세대가 목록에 잔류 | `drainingCount()`가 끝난 작업을 영원히 보고 |
|
|
| `close()` javadoc | 회전이 은퇴시킨 것만 닫음 | 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃 |
|
|
| `endWork` javadoc | clamp 없음 | 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고 |
|
|
|
|
네 번째와 `LeakTrackingRuntime.closeCount()` javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — **해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.**
|
|
|
|
---
|
|
|
|
## 14. 런타임·터미널 Evidence
|
|
|
|
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
|
|---|---|---|---|---|
|
|
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | 13개 타입 참조 수, `MessagingLifecycle` 구현 0(exit=1)·`ShutdownPhase` 외부 소비자 0(exit=1), 순서 테스트가 실제로 단언하는 것, 배선된 종료 경로와 그 4개 호출 | 정적 `git grep`. 어댑터 내부의 pause/flush 수행 여부는 각 leaf가 답함 |
|
|
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | 실제 브로커 없음 |
|
|
|
|
---
|
|
|
|
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
|
|
|
**명시적**
|
|
|
|
- 네이티브 클라이언트를 반환하지 않는 이유 — `MessagingTransport` javadoc
|
|
- 디코딩이 transport 위에서 일어나는 이유 — `TransportDelivery` javadoc
|
|
- 어댑터가 codec/limit을 고르지 않는 이유 — `TransportPublishRequest` javadoc
|
|
- 회전이 세대 교체인 이유 — `MessagingRuntime` javadoc
|
|
- lease가 세대를 pin하는 이유 — `MessagingRuntimeLease` javadoc
|
|
- 드레인 마감이 필요한 이유, 재시도 금지 이유 — `GracefulShutdownCoordinator` javadoc
|
|
- 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
|
|
- 순서 단위별 pause가 필요한 이유 — `TransportConsumerRegistration` javadoc
|
|
- `TransportSettlement`을 애플리케이션에 노출하지 않는 이유 — 그 javadoc
|
|
- 네 개의 이전 결함 — §13
|
|
|
|
**추론**
|
|
|
|
- `MessagingLifecycle`이 미구현인 것은 `MessagingShutdownLifecycle`이 Spring `SmartLifecycle`로 같은 일을 다르게 하기로 했기 때문이다 → **추론**. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.
|
|
- `current`는 lock-free, `draining`은 `synchronized`인 이유 → **추론**(경합 빈도 차이). 주석 없음.
|
|
- pause sentinel이 둘인 이유 → **미상**.
|
|
|
|
---
|
|
|
|
## 16. 확인한 것 / 확인하지 못한 것
|
|
|
|
**확인한 것**
|
|
|
|
- 13개 타입 776줄 전문의 계약과 불변식
|
|
- 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 `MessagingLifecycleTest`가 enum 선언 순서만 단언한다는 것
|
|
- `MessagingLifecycle` 구현 0, `ShutdownPhase` 외부 소비자 0 (둘 다 exit 1로 확인)
|
|
- 실제 배선된 종료 경로(`MessagingShutdownLifecycle`)가 8단계 중 셋을 명시적으로 수행하고 하나를 Spring 단계에 위임한다는 것
|
|
- 참조 계수·CAS·이중 검사·clamp의 동시성 설계와 그것을 만든 네 개의 이전 결함
|
|
|
|
**확인하지 못한 것**
|
|
|
|
- **어댑터가 `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`를 다른 이름으로 수행하는지.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar`가 `GracefulShutdownCoordinator`를 쓰는 것은 확인했으나 그 내부는 각 leaf가 소유한다.
|
|
- 실제 종료에서 이 순서가 지켜지는지. 컨테이너 레인(`KafkaBrokerIT`, `KafkaConsumerSettlementIT`)이 있으나 이번 분석에서 실행하지 않았다.
|
|
- `MessagingLifecycle`을 남겨 둔 것이 의도인지, 미완인지.
|
|
- `close()`와 `install()`이 동시에 일어나는 경우의 실제 빈도. 코드상 창은 존재한다(§4.2).
|
|
|
|
---
|
|
|
|
## 17. 손볼 것
|
|
|
|
### P2 — 8단계 종료 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
|
|
|
|
- **사실.** `MessagingLifecycle`은 8단계 종료 순서를 선언하고 javadoc이 "The order in ShutdownPhase is the contract, not an implementation detail. … Each adapter implements the phases; none of them chooses the order"라고 적는다. 저장소에 구현체가 없고(`git grep -E 'implements .*MessagingLifecycle'` exit 1), `ShutdownPhase`의 외부 소비자도 없다(exit 1). `MessagingTransport`를 구현하는 네 어댑터 중 어느 것도 이 인터페이스를 구현하지 않는다. `MessagingLifecycleTest`의 다섯 순서 테스트는 전부 `List.of(ShutdownPhase.values()).indexOf(A) < indexOf(B)` 형태로, **소스에 상수가 적힌 순서**를 단언한다.
|
|
- **근거.** `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` §B·§C.
|
|
- **왜 문제인가.** 세 겹이다.
|
|
- 실제 종료는 `MessagingShutdownLifecycle`(starter)이 하고, 8단계 중 **셋만 명시적으로 수행**한다(admission 정지 · 새 핸들러 정지 · 드레인). 나머지는 Spring `getPhase()` 정수와 bean 소멸 순서에 위임되거나 명시 단계가 없다. 순서를 결정하는 것은 `ShutdownPhase`가 아니다.
|
|
- 테스트 이름과 `as(...)` 문구가 시스템 동작을 서술한다("flushing before the handlers finish would lose the settlements they produce"). 통과하는 것은 그 동작이 아니라 타이핑 순서다. **이 여섯 테스트는 enum 상수를 재배열하면 실패하고, 재배열해도 시스템은 바뀌지 않는다** — 게이트가 지키는 것과 이름이 어긋난다.
|
|
- 인터페이스는 컴파일 강제를 만들지 않는다. `MessagingTransport`는 구현 안 하면 빌드가 깨지고, 이것은 아무것도 깨지지 않는다.
|
|
- **확인 방법.** `evidence/raw/280` 재실행. 또는 `git grep -n -w MessagingLifecycle -- src` → 두 줄(자기 선언, 자기 테스트).
|
|
- **후보.** (a) 네 어댑터가 `MessagingLifecycle`을 구현하고 `MessagingShutdownLifecycle`이 `shutdown(deadline)`을 호출하게 한다. (b) 인터페이스를 제거하고 순서 규칙을 `MessagingShutdownLifecycle`과 각 registrar의 계약으로 옮긴다. (c) 인터페이스를 "미실현 설계"로 표시하고 테스트가 enum 순서만 본다는 것을 이름과 javadoc에 반영한다.
|
|
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** Case는 "선언된 순서 계약과 실제 종료 경로의 불일치"이고, Reference는 "enum 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다"이다.
|
|
|
|
### P3 — 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
|
|
|
|
- **사실.** `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`(public), `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE`(private), `MessagingShutdownLifecycle`의 생성자 인자.
|
|
- **근거.** 세 위치.
|
|
- **왜 문제인가.** public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign`이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다. 그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
|
|
- **확인 방법.** `git grep -n 'DEFAULT_DRAIN_DEADLINE' -- 'src/messaging/**/*.java'`
|
|
- **후보.** registry가 `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`를 참조하거나, 값의 주인을 한 곳으로 정한다.
|
|
- **다음 단계.** 첫 항목과 같은 사건의 일부다 → 그 CASE에 **MERGED** 후보.
|
|
|
|
### P3 — 종료 중 `install`이 닫히지 않는 창
|
|
|
|
- **사실.** `close()`가 `draining`은 `synchronized`로 비우고, `current`는 `List.copyOf(current.keySet())` 후 개별 `remove`한다. 그 사이 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다.
|
|
- **근거.** `DefaultMessagingRuntimeRegistry.java:141-156`.
|
|
- **왜 문제인가.** 종료 중 회전은 정상 시나리오가 아니므로 실질 위험이 낮다. 다만 이 클래스의 다른 모든 경로가 "정확히 한 번 close"를 CAS로 보장하는 것과 대비되고, 남는 것은 닫히지 않은 브로커 연결이다 — §13의 세 번째 결함과 같은 결과다.
|
|
- **확인 방법.** 코드 검토. 테스트로 재현하려면 `close()` 중 `install`을 끼워 넣어야 한다.
|
|
- **후보.** `close()`에 종료 플래그를 두고 `install`이 그 이후에는 즉시 `runtime.close()`하도록 한다.
|
|
- **다음 단계.** **REFERENCE 후보**(멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다).
|
|
|
|
### P3 — pause scope sentinel이 두 인터페이스에서 다르다
|
|
|
|
- **사실.** `TransportConsumerRegistration.pause`는 빈 문자열이 전체, `PauseResumeController.pause`(core-api)는 `"*"`가 전체.
|
|
- **근거.** 두 javadoc.
|
|
- **왜 문제인가.** `PauseResumeController`가 소비자 0이므로 오늘 충돌하지 않는다. 그것을 배선하려는 사람이 변환을 넣어야 하고, 빠뜨리면 `"*"`가 이름이 `"*"`인 파티션을 가리키게 된다 — 실패하지 않고 아무것도 일시정지하지 않는다.
|
|
- **확인 방법.** 두 javadoc 대조.
|
|
- **후보.** sentinel을 통일하거나 `Optional<String>`으로 바꾼다.
|
|
- **다음 단계.** **REFERENCE 후보**(같은 개념의 sentinel은 계층을 넘어 하나로 정한다).
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- 네이티브 클라이언트를 반환하지 않는 SPI 경계
|
|
- 인코딩/디코딩을 transport 밖에 두어 실패 분류를 플랫폼이 소유하는 것
|
|
- 세대 교체 + 참조 계수 + CAS로 "정확히 한 번 close"를 보장하는 것과, 그것을 20세대 회전으로 확인하는 테스트
|
|
- `computeIfPresent`로 조회-증가를 원자화한 것
|
|
- `tryBeginWork`의 이중 검사와 `endWork`의 clamp
|
|
- 마감 도달 작업을 **정산하지 않고** 버려 브로커가 재전달하게 하는 것
|
|
- 세대별 `retiredAt`과 닫힌 세대의 목록 제거
|
|
|
|
---
|
|
|
|
## Source anchors
|
|
|
|
| id | kind | path | revision | what it proves | limitations |
|
|
|---|---|---|---|---|---|
|
|
| MTS-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, memberships `["app-bootstrap"]` | 선언 |
|
|
| MTS-002 | build | `messaging-transport-spi/build.gradle` | same | 벤더 의존성 0, 세 project 의존이 전부 `api` | — |
|
|
| MTS-003 | code | `.../transport/MessagingTransport.java` | same | SPI 경계와 네이티브 미노출 | — |
|
|
| MTS-004 | code | `.../transport/DefaultMessagingRuntimeRegistry.java` 전문 | same | §4.2 동시성 설계 전부와 세 개의 이전 결함 | 종료 중 install 창(§17) |
|
|
| MTS-005 | code | `.../transport/GracefulShutdownCoordinator.java` 전문 | same | §4.3 드레인 계약과 clamp 결함 이력 | — |
|
|
| MTS-006 | code | `.../transport/MessagingLifecycle.java` | same | 8단계 선언과 "order is the contract" 진술 | 구현 없음(§12.1) |
|
|
| MTS-007 | code | `.../transport/Transport*.java` (6) | same | 발행·수신·정산 계약 | — |
|
|
| MTS-008 | test | `MessagingRuntimeRegistryTest` (9) | same | 세대 관리 | 실제 브로커 없음 |
|
|
| MTS-009 | test | `ResourceLeakGateTest` (4) | same | 20세대 회전, 누수 lease 강제 종료, 정확히 한 번 close | 며칠 단위 아님 |
|
|
| MTS-010 | test | `GracefulShutdownTest` (5) | same | 드레인 경계 29/30초, 이중 endWork clamp | — |
|
|
| MTS-011 | test | `MessagingLifecycleTest` (6) | same | **enum 선언 순서와 상수 값만** | 종료 동작 미증명(§10.2) |
|
|
| MTS-012 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingShutdownLifecycle.java` 전문 | same | 실제 배선된 종료 경로와 그것이 수행하는 3단계, `getPhase()` 위임 | 해당 leaf SSOT가 소유 |
|
|
| MTS-013 | cross-leaf code | 4개 `*MessagingTransport.java` | same | SPI 구현 넷, `MessagingLifecycle` 구현 0 | 각 leaf SSOT가 소유 |
|
|
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | same | §12.1 전부, exit code 포함 | 정적 검색 |
|
|
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | same | 24 / 0 / 0 | 브로커 없음 |
|