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>
167 lines
12 KiB
Plaintext
167 lines
12 KiB
Plaintext
# 저장소 전체 (62 줄)
|
|
GrpcOutcomeReplay.java:1 package dev.caskeleton.grpc.idempotency;
|
|
GrpcOutcomeReplay.java:2
|
|
GrpcOutcomeReplay.java:3 import java.util.Optional;
|
|
GrpcOutcomeReplay.java:4 import java.util.concurrent.ConcurrentHashMap;
|
|
GrpcOutcomeReplay.java:5 import java.util.concurrent.ConcurrentMap;
|
|
GrpcOutcomeReplay.java:6
|
|
GrpcOutcomeReplay.java:7 /**
|
|
GrpcOutcomeReplay.java:8 * Returns the stored answer for an operation that already committed.
|
|
GrpcOutcomeReplay.java:9 *
|
|
GrpcOutcomeReplay.java:10 * <p>Separated from the ledger because the two have different size problems. The ledger row must
|
|
GrpcOutcomeReplay.java:11 * stay small and is written inside the business transaction; a response may be large and is only
|
|
GrpcOutcomeReplay.java:12 * needed if somebody actually retries. So the ledger stores a reference and this resolves it,
|
|
GrpcOutcomeReplay.java:13 * against whatever the deployment chose — a small inline store, an object store, or a re-read of
|
|
GrpcOutcomeReplay.java:14 * the committed resource.
|
|
GrpcOutcomeReplay.java:15 */
|
|
GrpcOutcomeReplay.java:16 public final class GrpcOutcomeReplay {
|
|
GrpcOutcomeReplay.java:17
|
|
GrpcOutcomeReplay.java:18 private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
|
GrpcOutcomeReplay.java:19 private final int maxInlineBytes;
|
|
GrpcOutcomeReplay.java:20
|
|
GrpcOutcomeReplay.java:21 /**
|
|
GrpcOutcomeReplay.java:22 * @param maxInlineBytes the largest response stored inline; anything larger must be kept behind a
|
|
GrpcOutcomeReplay.java:23 * reference in an object store instead, which is why {@link #store} refuses it rather than
|
|
GrpcOutcomeReplay.java:24 * silently truncating
|
|
GrpcOutcomeReplay.java:25 */
|
|
GrpcOutcomeReplay.java:26 public GrpcOutcomeReplay(int maxInlineBytes) {
|
|
GrpcOutcomeReplay.java:27 if (maxInlineBytes < 1) {
|
|
GrpcOutcomeReplay.java:28 throw new IllegalArgumentException("an inline outcome store needs a positive size limit");
|
|
GrpcOutcomeReplay.java:29 }
|
|
GrpcOutcomeReplay.java:30 this.maxInlineBytes = maxInlineBytes;
|
|
GrpcOutcomeReplay.java:31 }
|
|
GrpcOutcomeReplay.java:32
|
|
GrpcOutcomeReplay.java:33 /** Stores a response under {@code outcomeReference}. */
|
|
GrpcOutcomeReplay.java:34 public void store(String outcomeReference, byte[] serializedResponse) {
|
|
GrpcOutcomeReplay.java:35 if (outcomeReference == null || outcomeReference.isBlank()) {
|
|
GrpcOutcomeReplay.java:36 throw new IllegalArgumentException("an outcome needs a reference to be stored under");
|
|
GrpcOutcomeReplay.java:37 }
|
|
GrpcOutcomeReplay.java:38 if (serializedResponse == null) {
|
|
GrpcOutcomeReplay.java:39 throw new IllegalArgumentException("an outcome needs a serialized response");
|
|
GrpcOutcomeReplay.java:40 }
|
|
GrpcOutcomeReplay.java:41 if (serializedResponse.length > maxInlineBytes) {
|
|
GrpcOutcomeReplay.java:42 throw new IllegalArgumentException(
|
|
GrpcOutcomeReplay.java:43 "response of "
|
|
GrpcOutcomeReplay.java:44 + serializedResponse.length
|
|
GrpcOutcomeReplay.java:45 + " bytes exceeds the inline outcome limit of "
|
|
GrpcOutcomeReplay.java:46 + maxInlineBytes
|
|
GrpcOutcomeReplay.java:47 + "; store it behind an object reference instead");
|
|
GrpcOutcomeReplay.java:48 }
|
|
GrpcOutcomeReplay.java:49 storedOutcomes.put(outcomeReference, serializedResponse.clone());
|
|
GrpcOutcomeReplay.java:50 }
|
|
GrpcOutcomeReplay.java:51
|
|
GrpcOutcomeReplay.java:52 /** The stored response, if there is one. */
|
|
GrpcOutcomeReplay.java:53 public Optional<byte[]> replay(String outcomeReference) {
|
|
GrpcOutcomeReplay.java:54 byte[] stored = storedOutcomes.get(outcomeReference);
|
|
GrpcOutcomeReplay.java:55 return Optional.ofNullable(stored).map(byte[]::clone);
|
|
GrpcOutcomeReplay.java:56 }
|
|
GrpcOutcomeReplay.java:57
|
|
GrpcOutcomeReplay.java:58 /** How many outcomes are held. */
|
|
GrpcOutcomeReplay.java:59 public int size() {
|
|
GrpcOutcomeReplay.java:60 return storedOutcomes.size();
|
|
GrpcOutcomeReplay.java:61 }
|
|
GrpcOutcomeReplay.java:62 }
|
|
|
|
# 그 맵에 가해지는 연산 전부
|
|
GrpcOutcomeReplay.java:18 private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
|
GrpcOutcomeReplay.java:49 storedOutcomes.put(outcomeReference, serializedResponse.clone());
|
|
GrpcOutcomeReplay.java:54 byte[] stored = storedOutcomes.get(outcomeReference);
|
|
GrpcOutcomeReplay.java:60 return storedOutcomes.size();
|
|
클래스와 필드의 접근 한정 :
|
|
GrpcOutcomeReplay.java:16 public final class GrpcOutcomeReplay {
|
|
GrpcOutcomeReplay.java:18 private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
|
줄이는 연산 이름 열여덟을 통틀어 : 0 건
|
|
같은 검색을 대조 파일에 걸면 : 5 건 (검색이 헛돌지 않음을 보인다)
|
|
|
|
# 크기 상한이 실제로 거부하는가
|
|
16 바이트 저장 후 size : 1
|
|
17 바이트 저장 : response of 17 bytes exceeds the inline outcome limit of 16; store it behind an object reference instead
|
|
거부 후 size : 1
|
|
서로 다른 키 1000 개 저장 후 size : 1001
|
|
그 1000 개를 모두 replay 한 뒤 : 1001
|
|
같은 키로 1000 번 덮어쓴 뒤 : 1001
|
|
|
|
# 이 타입이 나오는 파일 전부 — 경로 제한 없이, 절단 없이
|
|
docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md
|
|
src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOutcomeReplay.java
|
|
src/grpc/grpc-policy/src/test/java/dev/caskeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java
|
|
자기 파일 밖 main 참조 : 0 개
|
|
[대조] 같은 패키지의 GrpcIdempotencyDecision : 1 개 (0 이 아니어야 정상)
|
|
git 이 추적하지 않는 변경 : 0 건
|
|
.java 밖에서 이 이름을 쓰는 파일 : 1 개
|
|
|
|
# 같은 패키지의 인터셉터는 재생을 어떻게 판정하는가
|
|
GrpcIdempotencyInterceptor.java:95 if (!record.sameRequestAs(requestFingerprint)) {
|
|
GrpcIdempotencyInterceptor.java:96 return GrpcIdempotencyDecision.fingerprintMismatch();
|
|
GrpcIdempotencyInterceptor.java:97 }
|
|
GrpcIdempotencyInterceptor.java:98 if (record.state() == GrpcOperationLedgerState.COMMITTED) {
|
|
GrpcIdempotencyInterceptor.java:99 return GrpcIdempotencyDecision.replay(
|
|
GrpcIdempotencyInterceptor.java:100 record
|
|
GrpcIdempotencyInterceptor.java:101 .outcomeReference()
|
|
GrpcIdempotencyInterceptor.java:102 .orElseThrow(
|
|
GrpcIdempotencyInterceptor.java:103 () ->
|
|
GrpcIdempotencyInterceptor.java:104 new IllegalStateException(
|
|
GrpcIdempotencyInterceptor.java:105 "COMMITTED ledger record for '"
|
|
GrpcIdempotencyInterceptor.java:106 + identity.storageKey()
|
|
GrpcIdempotencyInterceptor.java:107 + "' has no outcome reference to replay")));
|
|
GrpcIdempotencyInterceptor.java:108 }
|
|
GrpcIdempotencyInterceptor.java:109 if (record.state() == GrpcOperationLedgerState.FAILED_TERMINAL) {
|
|
GrpcIdempotencyInterceptor.java:110 // A terminal failure is not replayed: the caller asked for the operation, it did not happen,
|
|
그 파일이 GrpcOutcomeReplay 를 부르는가 : 0 건
|
|
|
|
# 자바독이 이 타입을 무엇이라고 적는가
|
|
GrpcOutcomeReplay.java:7 /**
|
|
GrpcOutcomeReplay.java:8 * Returns the stored answer for an operation that already committed.
|
|
GrpcOutcomeReplay.java:9 *
|
|
GrpcOutcomeReplay.java:10 * <p>Separated from the ledger because the two have different size problems. The ledger row must
|
|
GrpcOutcomeReplay.java:11 * stay small and is written inside the business transaction; a response may be large and is only
|
|
GrpcOutcomeReplay.java:12 * needed if somebody actually retries. So the ledger stores a reference and this resolves it,
|
|
GrpcOutcomeReplay.java:13 * against whatever the deployment chose — a small inline store, an object store, or a re-read of
|
|
GrpcOutcomeReplay.java:14 * the committed resource.
|
|
GrpcOutcomeReplay.java:15 */
|
|
GrpcOutcomeReplay.java:16 public final class GrpcOutcomeReplay {
|
|
|
|
# 대조 클래스에 같은 잣대를 댄다 (123 줄)
|
|
이 파일 : src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/idempotency/GrpcOutcomeReplay.java
|
|
대조 파일 : src/grpc-advanced/grpc-advanced-streaming/src/main/java/dev/caskeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java
|
|
GrpcClientMessageDeduplicator.java:11 * <p>Checkpoint-based rather than a set of seen keys. A set grows without bound for the life of a
|
|
GrpcClientMessageDeduplicator.java:12 * session and answers "have I seen this" — which is not quite the question. The question is "has
|
|
GrpcClientMessageDeduplicator.java:13 * this been applied", and a monotonic applied-sequence answers it in constant space and survives
|
|
GrpcClientMessageDeduplicator.java:14 * the process restart that a set does not.
|
|
GrpcClientMessageDeduplicator.java:16 * <p>Replayed outcomes are kept for the small window after the checkpoint, so a duplicate that
|
|
GrpcClientMessageDeduplicator.java:17 * arrives before the checkpoint advances gets the original answer rather than being reapplied.
|
|
GrpcClientMessageDeduplicator.java:18 */
|
|
GrpcClientMessageDeduplicator.java:19 public final class GrpcClientMessageDeduplicator {
|
|
GrpcClientMessageDeduplicator.java:20
|
|
GrpcClientMessageDeduplicator.java:21 private final ConcurrentMap<String, GrpcClientStreamCheckpoint> checkpoints =
|
|
GrpcClientMessageDeduplicator.java:22 new ConcurrentHashMap<>();
|
|
GrpcClientMessageDeduplicator.java:23 private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
|
|
적용된 메시지마다 무엇을 넣는가 :
|
|
GrpcClientMessageDeduplicator.java:70 public void recordApplied(
|
|
GrpcClientMessageDeduplicator.java:71 GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
|
|
GrpcClientMessageDeduplicator.java:72 GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId());
|
|
GrpcClientMessageDeduplicator.java:73 checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
|
|
GrpcClientMessageDeduplicator.java:74 if (outcomeReference != null && !outcomeReference.isBlank()) {
|
|
GrpcClientMessageDeduplicator.java:75 replayableOutcomes.put(message.dedupKey(), outcomeReference);
|
|
GrpcClientMessageDeduplicator.java:76 }
|
|
GrpcClientMessageDeduplicator.java:77 }
|
|
정리 경로와 그 호출자 :
|
|
main · GrpcClientMessageDeduplicator.java:110 public void endSession(GrpcClientStreamSessionId sessionId) {
|
|
test · GrpcClientMessageDeduplicatorTest.java:117 deduplicator.endSession(session);
|
|
그 대조 클래스의 자기 파일 밖 main 참조 : 0 개
|
|
|
|
# 두 클래스가 같은 family 인가
|
|
modules.json 의 family 키 : 0 건
|
|
src/grpc/CLAUDE.md:1 # grpc — local authority for the gRPC platform family
|
|
src/grpc/CLAUDE.md:2
|
|
src/grpc/CLAUDE.md:3 이 문서는 `grpc:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
|
|
src/grpc-advanced/CLAUDE.md:3 이 문서는 `grpc-advanced:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
|
|
src/grpc-advanced/CLAUDE.md:4 `src/config/architecture/modules.json`이 SSOT다. Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면
|
|
src/grpc-advanced/CLAUDE.md:5 root가 이긴다.
|
|
src/grpc-advanced/CLAUDE.md:6
|
|
src/grpc-advanced/CLAUDE.md:7 이 family는 Stable gRPC 플랫폼(`grpc:*`)이 **의도적으로 제외한** 능력들을 담는다. 별도 디렉터리와
|
|
src/grpc-advanced/CLAUDE.md:8 별도 Gradle prefix인 이유는 하나다: "Stable starter가 advanced module을 참조하면 build가 실패한다"는
|
|
src/grpc-advanced/CLAUDE.md:9 불변 조건을 registry의 `allowed_dependencies`만으로 기계 검증할 수 있게 하기 위해서다.
|
|
src/grpc-advanced/CLAUDE.md:15 grpc:* → grpc-advanced:* (금지 — registry가 거부한다)
|
|
src/grpc-advanced/CLAUDE.md:74 - Stable leaf(`grpc:*`)가 이 family를 참조하는 것.
|