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>
390 lines
26 KiB
Markdown
390 lines
26 KiB
Markdown
# grpc-policy 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 62파일 4,781줄 + `src/test` 18파일 2,800줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/grpc/grpc-policy`
|
|
> SSOT owner: `grpc-policy`
|
|
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
|
|
|
---
|
|
|
|
## 0. SSOT identity / 커버리지
|
|
|
|
- `runtime_memberships`: **`[]`** — build-only
|
|
- vendor: `io.grpc` BOM 을 **모듈 범위**로 가져온다
|
|
|
|
```groovy
|
|
// build.gradle:6-8
|
|
// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so
|
|
// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape
|
|
// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local.
|
|
```
|
|
|
|
| 패키지 | 파일 | 줄 | 주제 |
|
|
|---|---:|---:|---|
|
|
| `streaming` | 19 | 1,440 | 봉투·재개 토큰·직렬 기록기·간극 탐지·흐름 제어·수명·승인·심박 |
|
|
| `resilience` | 11 | 809 | 재시도 설정·예산·조정자·결정·자격·소유권·소유권 검증·서비스 설정·wait-for-ready 3종 |
|
|
| `idempotency` | 8 | 627 | 결정·인터셉터·지문·결과 재생·완료 조정·완료 판정·연산 상태·상태 질의 |
|
|
| `deadline` | 6 | 402 | 계산기·정책 검증기·취소 조정자·취소 가능 연산·취소 사유·의존 예산 |
|
|
| `error` | 4 | 407 | 매퍼·노출 정책·리치 상세·상태 매핑 |
|
|
| `policy` | 4 | 314 | 적재물 경계·메시지 크기·압축 프로파일·크기 위반 |
|
|
| `security` | 4 | 329 | TLS 프로파일·인증 프로파일·자격 세대·회전 관리자 |
|
|
| `context` | 3 | 229 | 문맥 결속기·전파 정책·스냅숏 |
|
|
| `validation` | 3 | 284 | 전송 검증기·위반·protovalidate 인터셉터 |
|
|
|
|
main 총 **62파일 / 4,781줄**.
|
|
|
|
### Coverage ledger
|
|
|
|
| scope | count | disposition | reason |
|
|
|---|---:|---|---|
|
|
| `main/java/**` | 62 | `FULL_READ` | 4,781줄. 패키지 9개의 전 파일 본문 |
|
|
| `test/java/**` | 18 | `FULL_READ` | 2,800줄. 패키지 9개 전부 |
|
|
| `build.gradle` | 1 | `FULL_READ` | 23줄 전문 |
|
|
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물이고 의미 없는 좌표 반복 |
|
|
|
|
`UNCLASSIFIED` 0.
|
|
|
|
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판의 패키지 표는 합이 47이었다 — `streaming` 을 9, `resilience` 를 7 로 적고 `security`·`validation` 에 `3+` 라고 썼다. 전 파일을 열지 않은 채 적힌 수였고, 그 표가 곧 통독이 끝나지 않았다는 증거였다. 아래 §17.6–17.8 은 나머지 15파일을 읽고 나서야 나온 것이다.
|
|
---
|
|
|
|
## 1. 오류 매퍼 — 클라이언트는 메시지 문자열을 읽지 않는다
|
|
|
|
> "The rule it exists to hold is that a client never reads a message string. Everything a caller
|
|
> needs to branch on is a code, an `ErrorInfo.reason`, or a typed detail; the description is for a
|
|
> human reading a log and is replaced wholesale whenever it is not provably safe."
|
|
|
|
> "An unrecognised exception becomes `INTERNAL` with an opaque execution id and nothing else. The id
|
|
> is the entire bridge between what the client saw and what the operator can find, and it is
|
|
> generated rather than derived so that it cannot accidentally encode a key or a row id."
|
|
|
|
실행 식별자 공급자가 주입되는 이유도 적혀 있다 — 무작위 값을 단언하지 않고도 그 식별자가 트레일러에 닿는 것을 테스트가 확인할 수 있게 하기 위해서다.
|
|
|
|
## 2. 적재물 경계 — 자원이 아니라 구조의 문제
|
|
|
|
> "The binary rule is the one with an architectural reason behind it rather than a resource one.
|
|
> This repository already has a file server and an object store; a method that accepts a file as
|
|
> bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file in
|
|
> a request that has to be buffered whole to be parsed."
|
|
|
|
그리고 도달할 수 없는 설정을 생성자가 거부한다 — 인라인 이진 임계값이 메시지 상한보다 크면 결코 발화하지 않는다.
|
|
|
|
## 3. 재개 토큰 — 서명하고, 구분자를 봉인한다
|
|
|
|
`GrpcResumeToken` 의 아홉 성분 각각이 왜 필요한지가 javadoc 에 있다 — 스냅숏 판본 없이는 사라진 뷰의 위치에서 재개하고, 만료 없이는 이력이 사라진 커서에서 재개하고, 필터 지문 없이는 남의 필터를 자기 위치에서 재개해 요청하지 않은 행을 받는다.
|
|
|
|
그리고 문자열 성분이 구분자를 담지 못하게 생성자가 거부한다.
|
|
|
|
```java
|
|
if (value.indexOf('|') >= 0) {
|
|
throw new IllegalArgumentException(what + " must not contain '|', which separates the token's fields");
|
|
}
|
|
```
|
|
|
|
`GrpcResumeTokenCodec` 의 검증이 세 성질을 지킨다 — 상수 시간 비교(`MessageDigest.isEqual`), 알 수 없는 키 식별자 거부, 세 실패의 구분 불가.
|
|
|
|
> "A codec that retries verification with every key it holds turns key rotation into a window in
|
|
> which a token signed by a compromised key still verifies."
|
|
|
|
> "The three are deliberately indistinguishable to a caller: telling them apart is a probing oracle."
|
|
|
|
## 4. 재시도 예산 — 이 가족의 원자성 정본
|
|
|
|
```java
|
|
public boolean tryConsume() {
|
|
while (true) {
|
|
long observed = tokens.get();
|
|
if (observed < tokensPerRetry) { return false; }
|
|
if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { return true; }
|
|
}
|
|
}
|
|
```
|
|
|
|
같은 문제를 이 리프의 `GrpcStreamAdmission` 과 `grpc-server` 의 `GrpcAdmissionController` 는 비원자적으로 푼다(§17.1).
|
|
|
|
## 5. 자격증명 회전 — 준비 후 교체 후 배수
|
|
|
|
> "Replacing the material in place is what produces the failure this exists to avoid: every call that
|
|
> was mid-flight when the swap happened fails with an authentication error that looks, from the
|
|
> client, exactly like a credential that was never valid."
|
|
|
|
같은 세대를 다시 적용하는 것은 무동작이다 — 재시도된 회전이 첫 회전이 받아들인 호출을 취소하면 안 되기 때문이다.
|
|
|
|
## 10. 테스트 레인
|
|
|
|
18파일 2,800줄. 패키지 9개 전부에 테스트가 있고, 배치는 균등하지 않다.
|
|
|
|
| 패키지 | 테스트 | 줄 |
|
|
|---|---:|---:|
|
|
| `streaming` | 5 | 675 |
|
|
| `resilience` | 3 | 630 |
|
|
| `idempotency` | 3 | 397 |
|
|
| `validation` | 1 | 240 |
|
|
| `error` | 1 | 182 |
|
|
| `security` | 1 | 163 |
|
|
| `context` | 1 | 156 |
|
|
| `deadline` | 2 | 227 |
|
|
| `policy` | 1 | 130 |
|
|
|
|
`idempotency` 의 세 번째 파일은 테스트가 아니라 손으로 쓴 원장 이중 `InMemoryOperationLedger` 이고, 그 javadoc 이 자기 존재 이유를 적어 둔다.
|
|
|
|
> "`putIfAbsent` on a concurrent map is the in-memory equivalent of the unique constraint the real
|
|
> adapter relies on, so the claim is atomic here for the same reason it is there. A fake that read
|
|
> and then wrote would let the policy tests pass while the property they exist to check does not
|
|
> hold."
|
|
|
|
즉 이 리프의 멱등 테스트는 원장의 원자성을 **가정**한다. 그 가정이 실제 어댑터에서 성립하는지는 `grpc-operation-ledger-jpa` §17.1 의 주제이고, 그 리프의 판정은 성립하지 않는다는 것이다. 이중이 production 보다 엄격하다.
|
|
|
|
이 리프는 동시성 테스트를 쓸 줄 안다. `GrpcSerializedStreamWriterTest.concurrentProducersDoNotTouchTheTransport` 는 생산자 8개로 400회를 밀어 넣고 순서·중복 없음을 단언한다. 그래서 §17.1·§17.2·§17.6 의 경합이 단일 스레드로만 시험되는 것은 능력의 한계가 아니라 선택이다.
|
|
|
|
## 12. negative-space probes
|
|
|
|
**12.1 도달성.** build-only. `grpc-spring-boot-starter` 가 이 리프의 타입 중 문맥 결속기와 오류 매퍼만 빈으로 만든다. 인터셉터 둘(`ProtovalidateGrpcInterceptor`·`GrpcIdempotencyInterceptor`)과 스트림 계열 19파일 전부는 조립되지 않는다. 이 리프의 모든 판정 등급이 그래서 한 칸 낮다 — 오늘의 사고가 아니라 배선하는 날의 사고다.
|
|
|
|
**12.2 대조군 — 원자성 셋.** 같은 저장소 안에 세 구현이 있다.
|
|
|
|
| 구현 | 형태 |
|
|
|---|---|
|
|
| `GrpcRetryBudget.tryConsume` | 비교 후 교체 루프 — 정확 |
|
|
| `GrpcStreamAdmission.tryAdmit` | 읽고 비교한 뒤 별도 증가 — §17.1 |
|
|
| `GrpcAdmissionController.tryAdmit`(grpc-server) | 같은 형태 |
|
|
|
|
**12.3 대조군 — 배수 플래그.** 저장소 전체에서 `volatile boolean` 은 정확히 둘이다.
|
|
|
|
```
|
|
grpc-client/…/GrpcChannelRuntime.java:20 private volatile boolean draining;
|
|
grpc-admin/…/GrpcServiceHealthRegistry.java:25 private volatile boolean draining;
|
|
```
|
|
|
|
같은 뜻의 세 번째 플래그가 `GrpcStreamLifecycleCoordinator.drainSignalled` 인데 여기에는 `volatile` 이 없다(§17.6). 같은 저장소가 같은 문제를 두 번은 표시하고 한 번은 표시하지 않았다.
|
|
|
|
**12.4 드리프트.** build.gradle 이 서술한 아홉 주제가 전부 패키지로 존재한다. 파일 수의 분포는 균등하지 않다 — `streaming` 하나가 main 의 30%(19/62)다.
|
|
|
|
**12.5 테스트가 볼 수 없는 것.** 세 곳에서 테스트의 형태가 결함을 구조적으로 가린다.
|
|
|
|
| 결함 | 가리는 형태 |
|
|
|---|---|
|
|
| §17.4 DROP_OLDEST 바이트 계산 | `writer(policy, messageSize)` 가 `() -> messageSize` 상수 크기 공급자를 넘긴다. 모든 메시지가 같은 크기면 잘못 뺀 값과 옳은 값이 같다 |
|
|
| §17.1 승인 경계 경합 | `streamAdmissionBoundsTotalAndPerCaller` 가 단일 스레드다 |
|
|
| §17.6 배수 신호 가시성 | `aDrainOutranksTheTimers` 가 `signalDrain()` 과 `terminationDue()` 를 같은 스레드에서 부른다 |
|
|
|
|
**12.6 설정처럼 보이지만 상수인 것.** `GrpcContextPropagationPolicy.clearAfterTask` 는 두 값을 받는 성분인데 생성자가 `false` 를 무조건 거부한다. 합법 값이 하나뿐이고, 그 값을 읽는 production 코드도 없다(§17.8).
|
|
|
|
## 16. 확인하지 못한 것
|
|
|
|
- 어떤 인터셉터도 실제 서버에 걸어 돌리지 않았다. 배선 경로가 없다.
|
|
- 동시 회전·동시 해제·동시 승인을 실행으로 재현하지 않았다. 원자성 분석과 JMM 으로 판정했다.
|
|
- §17.6 의 가시성 실패를 관측하지 않았다. `volatile` 부재와 두 호출자의 스레드 소속으로 판정했다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다.
|
|
- §17.7 의 IPv6 누출을 실제 예외 메시지로 재현하지 않았다. 거부 목록 아홉 패턴을 전부 읽고 IPv4 점표기 외에 주소 형태를 보는 패턴이 없음을 확인해 판정했다.
|
|
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
|
|
|
## 17. 손볼 것
|
|
|
|
### 17.1 P2 — 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다
|
|
|
|
```java
|
|
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
|
|
if (callerCount.get() >= maxStreamsPerCaller) { return false; }
|
|
if (openStreams.get() >= maxConcurrentStreams) { return false; }
|
|
callerCount.incrementAndGet();
|
|
openStreams.incrementAndGet();
|
|
```
|
|
|
|
읽고 비교한 뒤 별도로 증가한다. 경계에 있는 N 개 스레드가 모두 통과한다.
|
|
|
|
이 클래스의 javadoc 이 서술하는 실패 상황이 곧 고동시성이다 — "a client that reconnects on every error opens streams faster than the old ones close." 재접속 폭풍에서 경계가 가장 많이 샌다.
|
|
|
|
`release` 도 같은 형태라 음수로 갈 수 있다.
|
|
|
|
그리고 `perCaller` 에서 항목이 제거되지 않는다. `computeIfAbsent` 가 호출자 지문마다 계수기를 만들고 `release` 는 값만 줄인다. 서로 다른 호출자 수만큼 맵이 자란다 — `grpc-observability` 의 `GrpcMetricCardinalityPolicy` 가 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이고, 여기에는 그 가드가 없다.
|
|
|
|
정본이 같은 리프에 있다 — `GrpcRetryBudget.tryConsume` 의 비교 후 교체 루프.
|
|
|
|
### 17.2 P2 — 자격증명 회전이 비교 후 교체가 아니고, 배수 완료가 진행 중인 회전을 되돌릴 수 있다
|
|
|
|
```java
|
|
State observed = state.get();
|
|
…
|
|
state.set(new State(next, observed.current(), deadline)); // rotate
|
|
…
|
|
public void completeDrain() {
|
|
State observed = state.get();
|
|
state.set(new State(observed.current(), null, null)); // completeDrain
|
|
}
|
|
```
|
|
|
|
`AtomicReference` 를 쓰면서 두 메서드 모두 읽고 나서 조건 없이 쓴다.
|
|
|
|
두 결과가 다르다.
|
|
|
|
**회전 경합.** 두 회전이 같은 `observed` 를 읽으면 둘 다 승계 검사를 통과할 수 있고, 나중 `set` 이 앞의 것을 덮는다. 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.
|
|
|
|
javadoc 이 이 상황을 이미 알고 있다 — 승계 검사의 존재 이유로 "the usual reason for one is two rotators racing" 를 든다. 검사는 있고 원자성이 없다.
|
|
|
|
**배수 완료의 되돌림이 더 무겁다.** `completeDrain()` 이 자기가 읽은 `observed.current()` 로 새 상태를 만든다. 읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 **이전 세대가 다시 현재가 된다.** 즉 방금 교체된 자격증명이 되살아난다.
|
|
|
|
클래스의 존재 이유가 "in-flight 작업을 떨어뜨리지 않고 자격 자재를 교체하는 것" 인데, 이 경로는 교체 자체를 되돌린다.
|
|
|
|
수정은 두 메서드를 비교 후 교체로 바꾸는 것이다. `rotate` 는 `compareAndSet(observed, next)` 가 실패하면 다시 읽어 판정하고, `completeDrain` 은 `updateAndGet(s -> new State(s.current(), null, null))` 로 현재 값을 원자적으로 읽어 쓰면 된다. 후자는 한 줄이다.
|
|
|
|
같은 형태가 `grpc-client` 의 `GrpcChannelRuntimeRegistry.rotate` 에도 있다. 두 리프가 같은 자료구조를 같은 방식으로 잘못 쓴다.
|
|
|
|
### 17.3 P2 — 결과 재생 저장소에 제거 경로가 없다
|
|
|
|
```java
|
|
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
|
```
|
|
|
|
`maxInlineBytes` 는 항목 하나의 크기를 제한하고, 개수를 제한하는 것은 없다. `remove`·`clear`·축출·만료가 전부 없다. `size()` 만 있고 그 값을 읽는 곳도 없다.
|
|
|
|
`store` 는 멱등 키가 필요한 메서드가 커밋될 때마다 불리므로 프로세스 수명 동안 커밋 수만큼 쌓인다.
|
|
|
|
javadoc 이 이 저장소를 "a small inline store" 라 부르는데 작게 유지하는 장치가 없다. 크기를 넘는 응답은 거부하면서 개수는 거부하지 않는다.
|
|
|
|
이웃 리프의 자매 클래스가 같은 문제를 명시적으로 다룬다 — `GrpcClientMessageDeduplicator`(advanced-streaming)의 javadoc 이 "A set grows without bound for the life of a session" 을 집합 방식을 거부한 이유로 들고, `endSession()` 으로 세션 단위 정리를 한다.
|
|
|
|
다만 그 자매 클래스도 절반만 지킨다. 재통독에서 확인했다 — 체크포인트 맵은 세션당 항목 하나로 유지되지만, 형제인 `replayableOutcomes` 는 적용된 메시지마다 항목을 쌓고 `endSession` 전까지 줄지 않는다. 그 리프의 §17.1 이 그것을 자기 판정으로 기록한다. 그러므로 이 자리의 대조는 "저쪽은 풀었고 이쪽은 안 풀었다" 가 아니라 **"두 리프가 같은 형태의 무제한 증가를 갖고 있고, 한쪽만 세션 경계라는 부분적 상한을 갖는다"** 이다.
|
|
|
|
### 17.4 P2 — 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
|
|
|
|
```java
|
|
case DROP_OLDEST -> {
|
|
GrpcStreamEnvelope<T> dropped = queue.pollFirst();
|
|
if (dropped != null) {
|
|
queuedBytes = Math.max(0L, queuedBytes - nextBytes); // ← 들어오는 메시지의 크기
|
|
droppedMessages++;
|
|
}
|
|
enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
|
|
yield GrpcStreamWriteResult.DROPPED;
|
|
}
|
|
```
|
|
|
|
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
|
|
|
|
계산을 따라가면 이렇다. 한 번의 DROP_OLDEST 마다 `queuedBytes` 는 `nextBytes` 만큼 빠졌다가 `enqueue` 에서 같은 값만큼 다시 더해진다 — **순변화 0**. 그런데 큐의 실제 내용은 `nextBytes - droppedBytes` 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.
|
|
|
|
방향은 둘 다 틀렸다. 들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 **낮아져** 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 **높아져** 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
|
|
|
|
**범위는 flush 창 하나다.** `flush()` 가 큐를 비우면서 `queuedBytes = 0L` 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.
|
|
|
|
### 17.5 P2 — 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
|
|
|
|
```java
|
|
private final List<PendingCase> pending = new ArrayList<>();
|
|
…
|
|
pending.add(new PendingCase(...)); // reconcile(...) — 요청 경로
|
|
List.copyOf(pending); // pendingCases()
|
|
pending.remove(resolved); // clearPending(...)
|
|
```
|
|
|
|
`synchronized`·`Concurrent*`·`volatile`·`Lock` 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 `GrpcSerializedStreamWriter` 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
|
|
|
|
`reconcile` 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. 그리고 `pending` 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
|
|
|
|
### 17.6 P2 — 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 `volatile` 이 아니다
|
|
|
|
```java
|
|
private boolean drainSignalled;
|
|
|
|
public void signalDrain() { drainSignalled = true; }
|
|
|
|
public Optional<GrpcStreamTerminationReason> terminationDue(Instant now, Instant credentialExpiry) {
|
|
…
|
|
if (drainSignalled) { return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); }
|
|
```
|
|
|
|
두 메서드의 호출자가 다른 스레드다. `signalDrain()` 은 서버가 내려갈 때 종료 훅이 부르고, `terminationDue(...)` 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
|
|
|
|
평범한 `boolean` 이고 `volatile`·`synchronized`·`AtomicBoolean` 어느 것도 없다. 자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
|
|
|
|
같은 저장소가 같은 뜻의 플래그를 두 번은 `volatile` 로 적었다(§12.3). 세 번째만 빠졌다.
|
|
|
|
수정은 `volatile boolean` 한 단어다. `heartbeat` 의 `lastActivity` 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
|
|
|
|
### 17.7 P3 — 오류 노출 거부 목록의 "호스트와 포트" 규칙이 IPv4 점표기만 본다
|
|
|
|
```java
|
|
Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"),
|
|
```
|
|
|
|
아홉 패턴을 전부 읽으면 주소 형태를 보는 것은 이 하나다. 클래스 javadoc 은 거부 대상을 "a stack frame, a SQL fragment, a JDBC URL, a bearer token, **a host and port**, a file path" 로 서술하는데, 실제로 걸리는 host 는 IPv4 점표기뿐이다.
|
|
|
|
통과하는 것들:
|
|
|
|
- IPv6 리터럴 — `fe80::1`, `[2001:db8::1]:5432`
|
|
- DNS 이름과 포트 — `documents-db.internal:5432`, `kafka-0.kafka-headless:9092`
|
|
|
|
`jdbc:postgresql://db/app` 이 막히는 것은 host 규칙이 아니라 `jdbc:` 규칙 때문이다. 즉 이 구멍은 테스트에도 없다 — `exposurePolicyRefusesLeakyStrings` 의 아홉 사례 중 주소는 `upstream 10.0.3.14:5432 refused` 하나이고 IPv4 다.
|
|
|
|
닿는 경로는 `mapUnknown` 이다. 인식되지 않은 예외의 메시지를 `safeToExpose` 가 통과시키면 그대로 클라이언트로 간다. IPv6 클러스터나 쿠버네티스 서비스 이름을 쓰는 배포에서 상류 좌표가 밖으로 나간다.
|
|
|
|
등급이 P3 인 이유는 두 가지다. 이 리프가 build-only 라 오늘 닿지 않고, 노출되는 것이 자격증명이 아니라 내부 좌표다. 다만 이 정책이 존재하는 이유 자체가 "부분 마스킹이 아니라 통째 교체" 이므로, 목록에 빠진 형태는 통째로 통과한다.
|
|
|
|
### 17.8 P3 — `clearAfterTask` 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
|
|
|
|
```java
|
|
public record GrpcContextPropagationPolicy(
|
|
boolean failClosedWithoutContext, boolean clearAfterTask) {
|
|
…
|
|
public GrpcContextPropagationPolicy {
|
|
if (!clearAfterTask) { throw new IllegalArgumentException("context must be cleared after every task; …"); }
|
|
}
|
|
}
|
|
```
|
|
|
|
`false` 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 `true` 하나다. 그리고 저장소 전체에서 `clearAfterTask()` 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
|
|
|
|
읽지 않아도 되는 이유는 `GrpcContextBinder` 가 옳게 쓰였기 때문이다. `runWith`·`callWith`·`wrap` 이 전부 `finally` 에서 detach 한다. 불변식이 이미 구조로 지켜진다.
|
|
|
|
그래서 이 성분은 설정처럼 보이지만 설정이 아니다. 읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.
|
|
|
|
수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 `backgroundWork()`·`stable()` 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- **클라이언트가 메시지 문자열을 읽지 않는다는 규칙과, 인식되지 않은 예외의 불투명 처리.**
|
|
- **실행 식별자를 파생이 아니라 생성으로 만든 것** — 키나 행 식별자를 우연히 담을 수 없다.
|
|
- **적재물 경계의 근거를 자원이 아니라 책임 중복으로 든 것.**
|
|
- **도달할 수 없는 임계값 설정을 생성자가 거부한 것.**
|
|
- **재개 토큰의 아홉 성분 각각에 이유를 붙인 것.**
|
|
- **토큰 문자열 성분이 구분자를 담지 못하게 한 것** — 같은 저장소의 web 지문이 이 프레이밍을 하지 않는 것과 대비된다.
|
|
- **토큰 검증의 상수 시간 비교·키 식별자 거부·실패 구분 불가.**
|
|
- **재시도 예산의 비교 후 교체 루프.**
|
|
- **자격증명 회전의 준비-교체-배수 순서와 같은 세대 재적용의 무동작 처리.**
|
|
- **BOM 을 모듈 범위로 가져와 잠금 파급을 지역화한 것.**
|
|
|
|
---
|
|
|
|
## Source anchors
|
|
|
|
```
|
|
src/grpc/grpc-policy/build.gradle:1-23
|
|
main/java/…/context/{GrpcContextBinder:1-122, GrpcContextPropagationPolicy:1-37, GrpcContextSnapshot:1-70}
|
|
main/java/…/deadline/{GrpcCancellableOperation:1-23, GrpcCancellationCoordinator:1-122, GrpcCancellationReason:1-48,
|
|
GrpcDeadlineCalculator:1-68, GrpcDeadlinePolicyValidator:1-93, GrpcDependencyBudget:1-48}
|
|
main/java/…/error/{GrpcErrorExposurePolicy:1-82, GrpcErrorMapper:1-179, GrpcRichErrorDetail:1-93, GrpcStatusMapping:1-53}
|
|
main/java/…/idempotency/{GrpcCompletionReconciler:1-93, GrpcCompletionResolution:1-52, GrpcIdempotencyDecision:1-95,
|
|
GrpcIdempotencyInterceptor:1-148, GrpcOperationStatus:1-32, GrpcOperationStatusQuery:1-90,
|
|
GrpcOutcomeReplay:1-62, GrpcRequestFingerprint:1-55}
|
|
main/java/…/policy/{GrpcCompressionProfile:1-50, GrpcMessageSizeProfile:1-57, GrpcPayloadBoundaryPolicy:1-161,
|
|
GrpcSizeViolation:1-46}
|
|
main/java/…/resilience/{GrpcMethodRetryConfig:1-99, GrpcRetryBudget:1-76, GrpcRetryCoordinator:1-126,
|
|
GrpcRetryDecision:1-64, GrpcRetryEligibility:1-103, GrpcRetryOwner:1-39,
|
|
GrpcRetryOwnershipValidator:1-79, GrpcServiceConfigPolicy:1-73,
|
|
GrpcWaitForReadyDecision:1-40, GrpcWaitForReadyProfile:1-54, GrpcWaitForReadyValidator:1-56}
|
|
main/java/…/security/{GrpcAuthenticationProfile:1-59, GrpcCredentialGeneration:1-47,
|
|
GrpcCredentialRotationManager:1-122, GrpcTlsProfile:1-101}
|
|
main/java/…/streaming/{GrpcFlowControlDecision:1-43, GrpcFlowControlPolicy:1-88, GrpcResumeDecision:1-57,
|
|
GrpcResumeToken:1-91, GrpcResumeTokenCodec:1-142, GrpcSerializedStreamWriter:1-176,
|
|
GrpcSlowConsumerPolicy:1-26, GrpcStreamAdmission:1-76, GrpcStreamEnvelope:1-128,
|
|
GrpcStreamGapDetector:1-104, GrpcStreamHeartbeat:1-62, GrpcStreamId:1-41,
|
|
GrpcStreamLifecycleCoordinator:1-82, GrpcStreamLifetimePolicy:1-71, GrpcStreamProfile:1-47,
|
|
GrpcStreamSequence:1-48, GrpcStreamTerminationReason:1-51, GrpcStreamWriteResult:1-25,
|
|
GrpcStreamWriterState:1-22}
|
|
main/java/…/validation/{GrpcTransportValidator:1-134, GrpcValidationViolation:1-35, ProtovalidateGrpcInterceptor:1-115}
|
|
test/java/…/{context:1, deadline:2, error:1, idempotency:3, policy:1, resilience:3, security:1, streaming:5, validation:1} — 18파일 2,800줄
|
|
grpc-client/…/GrpcChannelRuntime.java:20 (§12.3 대조)
|
|
grpc-admin/…/GrpcServiceHealthRegistry.java:25 (§12.3 대조)
|
|
```
|