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>
13 KiB
grpc-advanced-streaming 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) —
src/mainproduction 14파일 833줄 +src/test4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다.STRUCTURAL_ONLY는gradle.lockfile하나. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc-advanced/grpc-advanced-streamingSSOT owner:grpc-advanced-streamingintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지
allowed_dependencies:["grpc-core-api", "grpc-policy", "grpc-advanced-bootstrap"]runtime_memberships:[]— build-only
| 파일 | LOC |
|---|---|
GrpcClientMessageDeduplicator |
123 |
GrpcDemandController |
105 |
GrpcBidiSession · GrpcBidiSequenceTracker |
77 · 62 |
GrpcBidiDirectionState · GrpcClientStreamResumeDecision · GrpcClientStreamCheckpoint |
61 · 58 · 56 |
GrpcClientStreamPolicy · GrpcClientStreamSessionId · GrpcManualFlowControlPolicy |
50 · 46 · 45 |
GrpcBidiResumeState · GrpcDemandDecision · GrpcClientStreamState · GrpcClientStreamMessage |
42 · 40 · 36 · 32 |
| test 4파일 | 429 |
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
main/java/** |
14 | FULL_READ |
833줄 전 본문 |
test/java/** |
4 | FULL_READ |
429줄 |
build.gradle |
1 | FULL_READ |
전문 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
UNCLASSIFIED 0.
1. 모듈의 정체
// build.gradle:3-5
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
// manual flow-control approval API.
2. 적용됨과 수신됨을 구분한다
GrpcClientStreamCheckpoint javadoc:
"Applied, not received. The distinction is the whole contract: the transport acknowledging a message means it reached the server's buffer, and a checkpoint means the application committed its effect. A resume that continues from a transport acknowledgement skips everything that was received and not yet applied when the connection died."
그리고 체크포인트는 뒤로 갈 수 없다 — 뒤로 가려는 시도는 두 기록자가 한 세션을 체크포인트하고 있다는 뜻이다.
3. 집합이 아니라 체크포인트
GrpcClientMessageDeduplicator javadoc:
"Checkpoint-based rather than a set of seen keys. A set grows without bound for the life of a session and answers 'have I seen this' — which is not quite the question. The question is 'has this been applied', and a monotonic applied-sequence answers it in constant space and survives the process restart that a set does not."
판정은 셋이다 — 이미 적용됨이면 재생, 다음 순번보다 앞서면 간극, 아니면 적용.
재개 판정은 두 겹이다. 제시한 호출자가 세션 소유자와 다르면 거절하고, 체크포인트가 없으면 새 세션으로 돌린다.
"the server holds no checkpoint for this session; resuming would leave its prefix either lost or applied twice, with nothing to tell which"
그리고 적용 기록의 자바독이 저장소 쪽 요구를 적는다 — 적용 효과와 체크포인트는 한 트랜잭션에 있어야 하며, 따로 커밋하면 효과는 내구적이고 체크포인트는 아닌 창이 생긴다.
4. 방향마다 독립된 순번
"the client's message 5 and the server's message 5 are unrelated events, and a shared counter makes a resume token from one side meaningless to the other — so a reconnect either skips or replays, depending on which side moved faster."
절반 닫기와 취소가 방향별로 따로 있다.
5. 수동 흐름 제어
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
"Approval is a field because this capability is granted per method, not per service. A method that reads a large result set benefits; the one next to it does not, and enabling both because they share a service is how the second one acquires a bug nobody was looking for."
수요 상한과 교착 감시가 필수다 — 상한 없는 request(n) 은 단계만 늘린 무제한 버퍼링이다.
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
10. 테스트 레인
네 테스트 429줄. 중복 제거 판정과 재개, 수요 상한과 교착, 방향별 순번, 클라이언트 스트림 정책 거부를 확인한다.
12. negative-space probes
12.1 도달성. Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없다.
12.2 대조군 — 동시성 규율. 이 리프는 가족 안에서 동시성을 가장 잘 다룬다.
| 클래스 | 보호 |
|---|---|
GrpcDemandController |
모든 공개 메서드 synchronized |
GrpcBidiSequenceTracker |
모든 공개 메서드 synchronized |
GrpcClientMessageDeduplicator |
ConcurrentHashMap 둘 |
특히 GrpcDemandController.messageReceived 의 if (outstandingDemand > 0) outstandingDemand--; 는 synchronized 안이라 경합하지 않는다. 같은 형태가 grpc-server 의 GrpcAdmissionController.release 와 grpc-client 의 GrpcChannelRuntime.finishUnaryCall 에서는 보호 없이 쓰여 각각 결함이 된다.
12.4 드리프트. build.gradle 이 서술한 세 요소가 전부 존재한다.
16. 확인하지 못한 것
- 실제 스트림을 열어 재개를 재현하지 않았다. 배선 경로가 없다.
replayableOutcomes의 증가를 장시간 실행으로 측정하지 않았다(§17.1). 제거 경로 부재로 판정했다.
17. 손볼 것
17.1 P3 — 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.
형제 맵은 지키지 않는다.
private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
…
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
if (outcomeReference != null && !outcomeReference.isBlank()) {
replayableOutcomes.put(message.dedupKey(), outcomeReference); // ← 메시지마다 한 항목
}
}
제거는 endSession 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다. 상한도 만료도 없다.
클래스 javadoc 은 다르게 말한다.
"Replayed outcomes are kept for the small window after the checkpoint, so a duplicate that arrives before the checkpoint advances gets the original answer rather than being reapplied."
작은 창이 코드에 없다. 체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
그리고 실제로 필요한 창은 좁다 — 판정이 alreadyApplied(sequence) 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다.
수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
17.2 P3 — 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다.
"all four bounds are about the client rather than the server: how long it may hold the stream, how long it may go quiet, how fast it may send, and how much it may have unacknowledged."
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
maxMessagesPerSecond production 호출 0
maxInFlightMessages production 호출 0
wholeStreamRetryAllowed production 호출 0
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 GrpcDemandController 는 GrpcManualFlowControlPolicy 를 쓰고, 이 정책을 보지 않는다.
wholeStreamRetryAllowed() 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다. 나머지 둘은 강제 지점이 필요하다.
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
17.3 P3 — 체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다
GrpcClientStreamCheckpoint.advancedTo 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 호출한 스레드가 읽은 값 이다.
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); // ← 읽기
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at)); // ← 조건 없는 쓰기
…
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 advancedTo 를 통과한다. 5 를 든 쪽이 나중에 put 하면 체크포인트는 6 에서 5 로 뒤로 간다 — advancedTo 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.
그러면 순번 6 의 메시지가 다시 APPLY 로 판정되어 두 번 적용된다. 이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.
ConcurrentHashMap 에는 이 형태를 위한 연산이 있다.
checkpoints.compute(key, (k, existing) -> existing.advancedTo(message.sequence(), at));
compute 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 advancedTo 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
대조. 같은 리프의 GrpcDemandController 는 모든 공개 메서드가 synchronized 이고, GrpcBidiSequenceTracker 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 ConcurrentMap 의 원자 연산을 쓰지 않는다.
시험이 보지 못하는 이유. 중복 제거기 시험 아홉 개가 전부 단일 스레드다. 순차적으로는 advancedTo 가 정확히 작동하고, 전용 시험(aCheckpointRecordsWhatWasApplied)이 그것을 확인한다 — 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다.
등급. 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.
확인된 설계(문제 아님)
- 적용됨과 수신됨을 구분하고 그 차이를 계약으로 삼은 것.
- 집합 대신 단조 증가 순번으로 상수 공간을 쓴 것.
- 체크포인트가 뒤로 가려는 시도를 두 기록자의 신호로 읽는 것.
- 재개에서 소유자 불일치를 거절하고, 체크포인트 부재를 새 세션으로 돌리는 것.
- 적용 효과와 체크포인트를 한 트랜잭션에 두라는 요구를 자바독에 남긴 것.
- 방향별 순번을 합치지 않은 것과 그 근거.
- 수동 흐름 제어 승인을 메서드 단위 필드로 둔 것.
- 감시견이 잠들지 않고 두 시각을 비교하는 것.
- 전체 스트림 재시도를 설정이 아니라 상수 거절로 둔 것.
- 동시성 보호를 실제로 적용한 것 — 가족의 다른 리프와 대조된다.
Source anchors
src/grpc-advanced/grpc-advanced-streaming/build.gradle
main/java/…/streaming/GrpcClientMessageDeduplicator.java:1-123
main/java/…/streaming/GrpcDemandController.java:1-105
main/java/…/streaming/GrpcBidiSequenceTracker.java:1-62
main/java/…/streaming/GrpcClientStreamCheckpoint.java:1-56
main/java/…/streaming/GrpcClientStreamPolicy.java:1-50
main/java/…/streaming/GrpcManualFlowControlPolicy.java:1-45
main/java/…/streaming/GrpcClientStreamMessage.java:1-32
main/java/…/streaming/(GrpcBidiSession · GrpcBidiDirectionState · GrpcBidiResumeState · GrpcClientStreamResumeDecision · GrpcClientStreamSessionId · GrpcDemandDecision · GrpcClientStreamState)
test/java/…/streaming/(GrpcClientMessageDeduplicatorTest · GrpcDemandControllerTest · GrpcBidiSequenceTrackerTest · GrpcClientStreamPolicyTest)