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>
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
# grpc-advanced-streaming 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 14파일 833줄 + `src/test` 4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/grpc-advanced/grpc-advanced-streaming`
|
|
> SSOT owner: `grpc-advanced-streaming`
|
|
> integration/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. 모듈의 정체
|
|
|
|
```groovy
|
|
// 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".
|
|
|
|
체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.
|
|
|
|
형제 맵은 지키지 않는다.
|
|
|
|
```java
|
|
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". 그 가드가 보는 것은 **호출한 스레드가 읽은 값** 이다.
|
|
|
|
```java
|
|
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` 에는 이 형태를 위한 연산이 있다.
|
|
|
|
```java
|
|
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)
|
|
```
|