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>
88 lines
5.2 KiB
Plaintext
88 lines
5.2 KiB
Plaintext
# 주제: 종료 시 드레인 루프가 프로덕션에서 아무도 증가시키지 않는 카운터를 폴링한다
|
|
# revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
# severity: P2
|
|
|
|
# ---- 종료 순서 ----
|
|
# MessagingShutdownLifecycle.java:83-102
|
|
# public void stop() {
|
|
# if (!running.compareAndSet(true, false)) return;
|
|
# admission.stopAcceptingNewWork(); // (1) 새 발행 거절
|
|
# Instant startedAt = clock.get();
|
|
# drain.beginDrain(startedAt); // (2) 드레인 시작
|
|
# Instant deadline = startedAt.plus(drainDeadline);
|
|
# while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) {
|
|
# Thread.sleep(POLL.toMillis()); // (3) 드레인 완료까지 대기
|
|
# }
|
|
# }
|
|
# 클래스 javadoc:15-19
|
|
# "a context shutdown went straight to closing beans while publishes were still in flight ...
|
|
# which is the shape that turns a deploy into duplicated effects and unexplained ambiguous
|
|
# publishes."
|
|
|
|
# ---- 두 개의 서로 다른 in-flight 카운터 ----
|
|
# (A) MessagingAdmissionController.inFlight() <- InFlightLimiter. 발행이 증가시킨다.
|
|
# MessagingCoreAutoConfiguration:439-447 이 이것을 DefaultMessagePublisher 에 주입한다.
|
|
# MessagingEndpoint:55 가 이것을 actuator 로 보고한다.
|
|
# (B) GracefulShutdownCoordinator.inFlight() <- tryBeginWork()/endWork() 가 증가·감소시킨다.
|
|
# MessagingShutdownLifecycle 의 드레인 루프가 폴링하는 것은 이쪽이다.
|
|
|
|
# ---- (B)를 증가시키는 프로덕션 코드가 없다 ----
|
|
# command: git grep -n "tryBeginWork|endWork()|mayCreateRetryAttempt" -- src | grep "/src/main/"
|
|
src/messaging/messaging-kafka/src/main/java/.../KafkaConsumerRegistrar.java:233,244,289,325
|
|
src/messaging/messaging-rabbit/src/main/java/.../RabbitConsumerRegistrar.java:103,150,217
|
|
# => 프로덕션 소비자는 두 개의 ConsumerRegistrar 뿐이다.
|
|
# 그 둘은 src/main 에서 한 번도 생성되지 않는다(EVD-269 계열, 그리고 EVD-316 의 스타터 빈 전수).
|
|
#
|
|
# command: git grep -n "GracefulShutdownCoordinator" -- src/messaging/messaging-runtime-core
|
|
# exit: 1 (DefaultMessagePublisher 는 이 타입을 모른다)
|
|
# DefaultMessagePublisher 생성자 파라미터(6-arg, 스타터가 쓰는 것):
|
|
# DestinationProfileRegistry, DestinationAccessPolicy, MessageCodecRegistry,
|
|
# MessagingAdmissionController, MessagingRuntimeRegistry, MessagingTransport
|
|
# -> GracefulShutdownCoordinator 없음.
|
|
|
|
# ---- 결과 ----
|
|
# 부팅된 애플리케이션에서 GracefulShutdownCoordinator.inFlight() 는 항상 0이다.
|
|
# GracefulShutdownCoordinator.isDrained(now):126-128
|
|
# if (!draining.get()) return false;
|
|
# if (inFlight.get() == 0) return true; <-- 항상 여기서 true
|
|
# => beginDrain 직후 첫 검사에서 isDrained 가 true 이고, while 루프는 한 번도 sleep 하지 않는다.
|
|
# stop() 은 즉시 반환한다.
|
|
#
|
|
# 즉 (1) 새 발행 거절은 실제로 동작하지만,
|
|
# (3) "이미 진행 중인 발행을 기다린다" 는 부분은 동작하지 않는다.
|
|
# 기다려야 할 대상은 (A)의 카운터인데 루프는 (B)를 본다.
|
|
# javadoc 이 고쳤다고 말하는 "publishes were still in flight" 상태로 빈 소멸이 시작된다.
|
|
|
|
# ---- 테스트가 이것을 드러내지 못하는 이유 ----
|
|
# MessagingShutdownLifecycleTest.java:52-73
|
|
# void aLeakedHandlerDoesNotHoldTheProcessOpen() {
|
|
# // Work that begins and never ends: the drain can only finish by reaching its deadline.
|
|
# GracefulShutdownCoordinator leaking = new GracefulShutdownCoordinator(Duration.ofMillis(100));
|
|
# leaking.tryBeginWork(); <-- 테스트가 직접 카운터를 채운다
|
|
# ...
|
|
# }
|
|
# => 프로덕션에서 아무도 호출하지 않는 메서드를 테스트가 손으로 호출해 상황을 만든다.
|
|
#
|
|
# MessagingShutdownLifecycleTest.java:37-50
|
|
# void stoppingRefusesNewPublishesFirst() {
|
|
# lifecycle.stop();
|
|
# assertThatThrownBy(() -> admission.admit("order-events", 16)) ... // (1) 검증
|
|
# assertThat(drain.isAcceptingWork()).isFalse(); // (2) 검증
|
|
# assertThat(lifecycle.isRunning()).isFalse();
|
|
# }
|
|
# => 공유 drain 의 inFlight 는 0이므로 즉시 드레인 완료된다.
|
|
# 이 테스트는 "기다렸다" 를 단언하지 않는다 — (1)과 (2)만 본다.
|
|
|
|
# ---- 참고: 8단계 계약과의 관계 ----
|
|
# MessagingLifecycle.ShutdownPhase (messaging-transport-spi) 는 8단계를 선언하고 구현체가 0이다.
|
|
# MessagingShutdownLifecycle 이 수행하는 것:
|
|
# STOP_PUBLISH_ADMISSION -> admission.stopAcceptingNewWork() (실제 동작)
|
|
# STOP_NEW_HANDLERS -> drain.beginDrain() 이 isAcceptingWork()=false 로 만듦
|
|
# (그러나 그것을 읽는 프로덕션 코드가 없음)
|
|
# DRAIN_HANDLERS -> 폴링 루프 (그러나 카운터가 항상 0)
|
|
# CLOSE_CONNECTIONS -> Spring 빈 소멸. KafkaMessagingAutoConfiguration:128
|
|
# @Bean(destroyMethod = "close") 가 producer 를 닫는다.
|
|
# PAUSE_CONSUMERS / FLUSH_SETTLEMENTS / AWAIT_PRODUCER_CONFIRMS / RELEASE_OUTBOX_LEASES
|
|
# -> 수행 주체 없음
|
|
# (단계별 상세 판정은 analysis/messaging/messaging-transport-spi.md 소유)
|