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>
96 lines
5.0 KiB
Plaintext
96 lines
5.0 KiB
Plaintext
# 청구 경로는 행을 잠근 뒤 데이터베이스 시각을 읽고 만료를 먼저 본다
|
|
127: IdempotencyRecordRow row =
|
|
128: rows.findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
|
|
129: Instant dbNow = rows.databaseNow();
|
|
130: if (inserted == 1) {
|
|
131: return acquired(row);
|
|
132: }
|
|
133:
|
|
134: if (isExpiredCompleted(row, dbNow)) {
|
|
135: return resetClaim(request, row);
|
|
136: }
|
|
137: if (!row.requestHash().equals(request.requestFingerprint().hex())) {
|
|
138: return new IdempotencyClaimOutcome.FingerprintMismatch();
|
|
139: }
|
|
140: if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) {
|
|
141: return new IdempotencyClaimOutcome.CompletedReplay(
|
|
142: new StoredResponse(row.responsePayload()), row.replayUntil());
|
|
143: }
|
|
586: private static boolean isExpiredCompleted(IdempotencyRecordRow row, Instant dbNow) {
|
|
587- return row.state() == IdempotencyState.COMPLETED
|
|
588- && row.replayUntil() != null
|
|
589- && !dbNow.isBefore(row.replayUntil());
|
|
590- }
|
|
# claim 본문에서 databaseNow 를 부르는 줄: 1
|
|
|
|
# 조회 경로 전문
|
|
393: public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
|
|
394: Objects.requireNonNull(request, "request");
|
|
395: Optional<IdempotencyRecordRow> found = rows.find(request.scope().digest());
|
|
396: if (found.isEmpty()) {
|
|
397: return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT);
|
|
398: }
|
|
399: IdempotencyRecordRow row = found.get();
|
|
400: if (!row.requestHash().equals(request.requestFingerprint().hex())) {
|
|
401: return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH);
|
|
402: }
|
|
403: boolean sameAttempt = sameClaimAttempt(row, request.claimAttempt());
|
|
404: if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) {
|
|
405: return new IdempotencyInspection(
|
|
406: IdempotencyInspectionOutcome.COMPLETED_REPLAY,
|
|
407: Optional.empty(),
|
|
408: Optional.empty(),
|
|
409: Optional.of(new StoredResponse(row.responsePayload())),
|
|
410: Optional.ofNullable(row.replayUntil()));
|
|
411: }
|
|
412: if (sameAttempt && row.state() == IdempotencyState.CLAIMED) {
|
|
413: return inspectionWithOwner(IdempotencyInspectionOutcome.CLAIMED_SAME_OPERATION, row);
|
|
414: }
|
|
415: if (sameAttempt && row.state() == IdempotencyState.EXECUTING) {
|
|
416: return inspectionWithOwner(IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION, row);
|
|
417: }
|
|
418: if (row.ownerToken().equals(request.claimAttempt().ownerToken()) && !sameAttempt) {
|
|
419: return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.OPERATION_CONFLICT);
|
|
420: }
|
|
421: return switch (row.state()) {
|
|
422: case FAILED_RETRYABLE ->
|
|
423: IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FAILED_RETRYABLE);
|
|
424: case ABANDONED -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABANDONED);
|
|
425: default -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.IN_PROGRESS_OTHER);
|
|
426: };
|
|
427: }
|
|
# inspect 본문에서 databaseNow 를 부르는 줄: 0
|
|
# inspect 본문이 replayUntil 로 하는 일
|
|
18: Optional.ofNullable(row.replayUntil()));
|
|
# 만료 비교는 파일에 한 곳뿐이고, 그것을 부르는 곳
|
|
134: if (isExpiredCompleted(row, dbNow)) {
|
|
586: private static boolean isExpiredCompleted(IdempotencyRecordRow row, Instant dbNow) {
|
|
# 조회가 쓰는 조회 SQL 에 시간 술어가 있는가
|
|
|
|
# 조회 결과를 받는 세 자리
|
|
146: case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
|
|
147- case FINGERPRINT_MISMATCH ->
|
|
148- throw IdempotencyRequestMismatchException.forScopeDigest(diagnostic(request.scope()));
|
|
149- case IN_PROGRESS_OTHER ->
|
|
--
|
|
192: case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
|
|
193- case UNAVAILABLE -> throw new IdempotencyUnavailableException();
|
|
194- default -> throw recovery("execution start is indeterminate");
|
|
195- };
|
|
--
|
|
261: case COMPLETED_REPLAY -> {
|
|
262- StoredResponse stored = requireResponse(inspection);
|
|
263- if (stored.payload().equals(codec.serialize(result))) {
|
|
264- yield result;
|
|
|
|
# 출하 통합 시험의 만료 시험이 무엇을 만료시키는가
|
|
130: void expiredClaimCanBeTakenOverButTheStaleOwnerCannotStart() {
|
|
164: void expiredExecutingRecordRequiresReconciliationAndIsNeverBlindlyTakenOver() {
|
|
138: Duration.ofMillis(25))))
|
|
171: store.claim(request(original, Duration.ofMillis(25))))
|
|
366: private static IdempotencyClaimRequest request(
|
|
367- IdempotencyClaimAttempt attempt, Duration processingLease) {
|
|
368- return new IdempotencyClaimRequest(
|
|
369- SCOPE, FINGERPRINT, attempt, processingLease, Duration.ofHours(24), "json.v1", 2);
|
|
grep: adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyRecordRows.java: No such file or directory
|