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>
12 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | assets | evidence | source | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a05-f021-complete-replayttl | 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다 | state-machines-and-ownership | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a05-f021-complete-replayttl | 2026-09-02 |
|
|
|
전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다
이미 완료된 같은 연산의 재생 분기는 응답 다이제스트만 비교한다. 완료가 다이제스트에 넣어 열에 저장한 재생 창은 읽히지 않는다. 나머지 네 전이는 그 열을 비교하는 공용 헬퍼를 쓰고 다른 인자를 들고 온 재시도를 충돌로 돌려보낸다.
관계
- transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다 같은 다이제스트가 무엇을 덮어야 하는지 다룬 사례다.
- digest는 길이 프레이밍하고 버전을 붙인다 이 다이제스트의 형식 규칙이다.
- 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다 같은 분석 절이 짚은 나머지 한 군데 재생 경계다.
문제
완료가 정하는 것은 둘이다. 응답으로 무엇을 남길지, 그리고 그것을 언제까지 재생할지다.
첫 완료는 둘 다 다이제스트에 넣는다. 두 번째 완료는 앞의 것만 본다.
결론
실제 PostgreSQL 16 에서 같은 연산과 같은 응답에 1시간과 9시간을 차례로 넣었다. 두 번째는 이미 완료된 같은 결과로 답하고, 행에는 첫 1시간이 남고, 전이 다이제스트도 그대로다.
값이 다르게 계산된다는 것은 이미 단위 시험이 고정하고 있다. 60000 과 90000 을 넣은 완료 다이제스트가 다르다는 시험이 같은 모듈에 있다. 값은 계산되고, 열에 저장되고, 시험으로 지켜진다. 그것을 읽지 않는 쪽이 재생 판정이다.
나머지 네 전이는 다르게 한다. 시작과 갱신과 실패 표시와 해제가 공용 헬퍼에 자기 다이제스트를 넘기고, 그 헬퍼가 행의 전이 다이제스트와 비교한다. 완료는 그 헬퍼를 부르지 않는다.
갱신 경로의 주석이 왜 그래야 하는지 적는다. 임대 유효 기간이 갱신이 결정한 것의 일부이므로 다이제스트에 들어가고, 그것이 없으면 다른 임대를 요청한 재시도가 이미 적용된 갱신으로 확인된다는 것이다.
레디스 구현도 같은 자리에서 멈춘다. 완료 재생에서 응답 페이로드만 비교하고, 전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없다. 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
갈리는 조건은 좁다. 멱등성 실행기 쪽은 주입 시점의 재생 창을 끝까지 들고 간다. 창이 갈리는 조건은 둘이다. 설정 변경 뒤의 재시도이거나, 이 포트를 직접 부르는 별도 호출자다. 원본 분석이 이것을 만료 해석 불일치와 달리 P2 로 둔 자리도 거기다.
고치려면 주의가 필요하다. 헬퍼는 어긋났다는 답만 주고, 응답 때문인지 창 때문인지는 말하지 않는다. 완료가 지금 돌려주는 응답 충돌을 그대로 두려면, 헬퍼의 어긋남 판정 뒤에 다이제스트 비교를 한 번 더 넣어 두 경우를 갈라야 한다.
검증 환경
OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 다섯 전이의 재생 판정 경로 대조, 실제 PostgreSQL 에서 같은 응답에 두 재생 창으로 완료 호출, 형제 구현 대조 소스 수정 : x
재현 조건
- 완료의 재생 분기와 첫 완료의 다이제스트 인자를 나란히 읽는다.
- 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열, 그리고 그 열의 마이그레이션을 확인한다.
- PostgreSQL 을 띄우고 청구와 시작을 거쳐 1시간으로 완료한 뒤, 같은 연산·같은 응답에 9시간으로 다시 완료한다.
- 두 번째 결과와 행의 재생 창, 그리고 전이 다이제스트를 본다.
본문
완료는 두 가지를 정한다. 무엇을 응답으로 남길지, 그리고 그 응답을 언제까지 재생할지다.
같은 응답에 다른 창을 넣으면
:::evidence key="a05-f021-complete-replayttl-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용한 뒤, 같은 연산과 같은 응답에 재생 창만 1시간과 9시간으로 바꿔 완료를 두 번 부르고 각 호출의 결과와 행에 저장된 재생 창, 그리고 전이 다이제스트가 그대로인지를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 1시간으로 완료 뒤 저장 3600초 · 같은 응답에 9시간을 넣은 둘째 완료는 이미 완료된 같은 결과 · 저장은 3600초 그대로, 전이 다이제스트도 그대로 — 8줄 · exit 0" zoom="true" :::
첫 완료 (재생 창 1시간) : COMPLETED
저장된 재생 창(초) : 3600
둘째 완료 (재생 창 9시간) : ALREADY_COMPLETED_SAME_RESULT
저장된 재생 창(초) : 3600
전이 다이제스트 그대로인가 : true
두 번째 호출은 다른 인자를 들고 왔는데 같은 결과로 확인됐다.
두 번째 완료가 비교하는 것
:::evidence key="a05-f021-complete-replayttl" alt="이미 완료된 같은 연산의 재생 분기가 비교하는 값, 첫 완료가 전이 다이제스트에 넣는 인자와 그 위 주석, 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열과 그 열의 마이그레이션, 그 헬퍼를 쓰는 네 전이와 완료 본문에서의 호출 수, 재생 창이 다르면 완료 다이제스트가 다르다는 단위 시험, 갱신 경로의 주석, 출하 통합 시험이 덮는 인자 재생과 완료 호출 수, 그리고 레디스 구현의 완료 재생 분기와 전이 스크립트를 출력한 터미널 기록." caption="재생 분기는 응답 다이제스트만 비교 · 첫 완료는 재생 창을 다이제스트에 넣음 · 헬퍼는 행의 전이 다이제스트를 비교하고 그 열은 마이그레이션에 있음 · 그 헬퍼를 쓰는 전이 넷, 완료 0 · 창이 다르면 다이제스트가 다르다는 단위 시험 · 레디스도 응답만 비교 — 81줄 · exit 0" zoom="true" :::
if (row.state() == IdempotencyState.COMPLETED
&& "COMPLETE".equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastTransitionOperationId())) {
return responseDigest.equals(row.responseDigest())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
}
첫 완료가 다이제스트에 넣는 것
// The transition digest, not the response digest. Reusing the response digest here made
// two completions of different operations with identical payloads indistinguishable,
// and lost the replay window the completion also decided.
transitionDigest(
"COMPLETE",
operationId,
owner,
responseDigest,
Long.toString(replayTtl.toMillis())),
주석은 응답 다이제스트를 쓰던 때에 무엇을 잃었는지 과거형으로 적는다. 재생 창도 그 목록에 있다.
값이 다르다는 것은 이미 시험이 고정한다
같은 모듈의 단위 시험에 완료 다이제스트가 재생 창에 따라 달라진다는 것을 고정하는 시험이 있다.
assertThat(IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "60000"))
.isNotEqualTo(
IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "90000"));
값은 계산되고, last_transition_result_digest 열에 저장되고, 시험으로 지켜진다. 재생 판정만 그것을 읽지 않는다.
공용 헬퍼와 그것을 쓰는 네 전이
if (!transitionKind.equals(row.lastTransitionKind())
|| !operationId.value().equals(row.lastTransitionOperationId())) {
return ReplayVerdict.NOT_A_REPLAY;
}
return expectedDigest.equals(row.lastTransitionResultDigest())
? ReplayVerdict.SAME_ARGUMENTS
: ReplayVerdict.DIFFERENT_ARGUMENTS;
182: switch (replayVerdict(row, "START", operationId, startDigest)) {
221: switch (replayVerdict(row, "RENEW", operationId, renewDigest)) {
319: switch (replayVerdict(row, transitionKind, operationId, failDigest)) {
366: switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) {
완료 본문에서 그것을 부르는 줄은 0 이다.
갱신 경로의 주석
216: // The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a
217: // retry asking for a different lease was confirmed as the renewal already applied, and the
218: // caller went on believing it held the record for longer than the row says it does.
재생 창도 호출자가 나중에 읽는 지속 상태다.
출하 통합 시험이 덮는 인자 재생
같은 연산 식별자에 다른 보존 기간이 오면 충돌이라는 시험, 다른 임대가 오면 충돌이라는 시험, 같은 인자면 확인이라는 시험이 있다. 완료를 두 번 부르는 시험은 그 파일에 없다.
레디스 구현도 같은 자리에서 멈춘다
case "ALREADY" ->
reply.payload().equals(response.payload())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없으므로, 이것은 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
언제 갈리는가
애플리케이션의 멱등성 실행기는 주입받은 재생 창 하나를 계속 쓴다. 창이 달라지려면 설정이 바뀐 뒤 재시도가 넘어오거나, 이 포트를 직접 부르는 다른 호출자가 있어야 한다.
고칠 방향
완료의 재생 분기도 완료 전이 다이제스트를 먼저 계산해 공용 헬퍼에 넘기면 창이 다른 호출을 걸러낼 수 있다.
다만 그 헬퍼의 판정은 응답이 달라서 어긋난 경우와 창이 달라서 어긋난 경우를 구분하지 않는다. 지금 완료가 돌려주는 응답 충돌을 유지하려면, 헬퍼가 어긋났다고 답한 뒤 응답 다이제스트를 한 번 더 비교해 두 답을 나눠야 한다.
확인하지 못한 것
첫 창이 남은 뒤 실제 재생 요청이 어떻게 처리되는지는 관측하지 않았다. 확인한 것은 두 번째 완료의 답과 행에 남은 값까지다.