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>
10 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 | a-lease-without-an-owner | 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다 | owner-safe-state-machines | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a-lease-without-an-owner | 2026-09-02 |
|
|
|
리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다
outbox 리스가 만료 시각만 담고 최종 상태 쓰기가 메시지 식별자만으로 행을 찾았다. 리스를 지나 멈춰 있던 작업자가 이미 발행된 행의 상태를 덮을 수 있었고, 그러면 행은 다시 청구 가능해져 메시지가 두 번 발행된다.
관계
- fenced lease — 만료 시각만으로는 부족한 이유 이 사례가 만든 개념이다.
- CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다 이 결함의 수정 형태를 규칙으로 옮긴 것이다.
- 만료된 claim과 만료된 실행은 다르게 다뤄야 한다 만료된 claim 과 만료된 실행을 갈라 다루는 판단이다.
문제
V1 스키마는 리스 만료 시각만 기록했다. 청구는 언제 끝나는지를 말했고 누가 들고 있는지는 말하지 않았다.
릴레이의 최종 상태 쓰기는 메시지 식별자만으로 행을 찾았다. 그래서 리스를 지나 멈춰 있던 작업자의 쓰기가 다른 작업자의 결과를 덮을 수 있었다.
결론
V2 가 세 컬럼을 더해 고쳤다. 리스 소유자와 리스 토큰과 다음 시도 시각이다. 청구가 토큰을 서버에서 올리고, 최종 쓰기가 소유자와 토큰을 함께 조건으로 건다.
같은 마이그레이션이 최종 상태 어휘도 넓혔다. 시도 예산이 확인 없이 소진된 상태는 브로커가 거절한 것과 다르므로 별도 상태가 됐다.
수정은 전이 메서드를 두 세대로 남겼다. 이 저장소의 프로덕션 코드는 전부 신세대만 부르지만, 펜싱 없는 옛 경로가 인터페이스와 구현에 그대로 있고 컴파일러가 막지 않는다.
검증 환경
데이터베이스 : PostgreSQL 확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 스키마·구현 확인 소스 수정 : x
재현 조건
- 메시징 outbox 의 V2 마이그레이션이 더한 컬럼과 제약, 상태 CHECK 변경을 읽는다.
- 구현의 청구문이 소유자와 토큰을 어디서 쓰는지, 그 청구가 읽는 술어와 부분 인덱스를 확인한다.
- 최종 쓰기의 술어와 0행 처리 방식을 확인한다.
- 릴레이가 부르는 전이가 어느 세대인지 센다.
- 펜싱 없는 옛 메서드가 남아 있는지, 그 자신의 javadoc 과 신세대 javadoc 이 각각 무엇을 적는지 대조한다.
- 두 세대의 AMBIGUOUS 쓰기가 지우는 컬럼을 대조한다.
본문
시간을 늘려도 순서는 막히지 않는다
마이그레이션 헤더가 순서를 세 줄로 적어 둔다. 릴레이 A 가 청구하고 브로커를 부른다. 리스가 만료되어 릴레이 B 가 다시 청구하고, 발행하고, PUBLISHED 를 쓴다. 릴레이 A 가 그제서야 타임아웃되어 그 위에 AMBIGUOUS 를 쓴다.
AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행 대상이 된다.
리스를 발행 타임아웃보다 길게 잡으면 확률은 내려간다. 그래도 GC 정지와 스케줄러 지연과 느린 브로커는 그 방식으로 데이터 제약이 되지 않는다.
V2 가 더한 것: 소유자와 토큰과 다음 시도 시각
:::evidence key="a-lease-without-an-owner" alt="코드베이스에서 V2 마이그레이션이 더한 컬럼과 토큰 제약, 다섯 상태에서 여섯으로 바뀐 CHECK, 청구문이 소유자와 토큰을 쓰는 SET 절과 그 청구가 읽는 술어와 그에 맞춘 부분 인덱스, 최종 쓰기의 펜싱 술어와 0행을 보고한다는 주석, 릴레이가 부르는 다섯 전이, 펜싱 없는 옛 메서드의 선언과 그 자신의 javadoc 과 신세대 javadoc 의 폐기 문장과 @Deprecated 매치 수와 호출 수, 두 세대가 AMBIGUOUS 에서 남기는 컬럼의 차이, 그리고 파일서버 마이그레이션이 같은 결함을 다른 곳에서 지목하는 줄을 뽑은 출력. 옛 메서드의 javadoc 이 아직 안전을 주장하고 신세대 javadoc 이 그것을 폐기라 적는다는 것이 나란히 보인다." caption="V2 컬럼과 제약 · 청구 술어와 부분 인덱스 · 펜싱 술어와 0행 보고 · 옛 경로의 두 javadoc · AMBIGUOUS 에서의 세대 차이" zoom="true" :::
토큰에는 음수가 아니라는 제약이 붙는다. 백필은 하지 않는다 — 기본값이 0 이고 첫 청구가 그것을 올리므로 정확성에 필요하지 않으며, 제약은 코드가 의존하는 불변식을 스키마에 남기려는 것이다.
청구문이 토큰을 o.lease_token + 1 로 올린다. 청구를 내주는 바로 그 문장 안에서, 서버가 올린다. 그래서 같은 행을 두고 경쟁한 두 릴레이가 같은 번호를 받을 수 없다.
같은 청구가 next_attempt_at 과 attempts 도 술어에 넣었다. 그 두 술어가 없을 때는 AMBIGUOUS 행이 바로 다음 순회에 다시 청구 가능해져서, 브로커 장애 한 번이 폴링 간격마다 백로그 전체를 재발행하게 만들었다. V2 의 부분 인덱스가 그 술어를 그대로 담는다.
최종 쓰기는 0행을 삼키지 않는다
최종 쓰기의 술어에 lease_owner 와 lease_token 이 들어간다. 지나간 획득의 쓰기는 걸릴 행이 없다.
그 자리 javadoc 이 0행을 어떻게 다루는지 적는다. 0행은 삼키지 않고 보고한다 — 지나간 쓰기가 있었다는 것은 이 작업자가 중복 발행을 만들었을 수 있다는 뜻이고, 그것이 운영자가 봐야 하는 사실이라는 것이다.
술어를 붙이는 SQL 문자열은 두 개이고, 그 둘을 만드는 헬퍼 둘이 다섯 전이의 최종 쓰기를 전부 처리한다.
여섯 번째 상태는 리뷰가 아니라 제약에서 막혔다
시도 예산이 확인 없이 소진된 것과 브로커가 메시지를 거절한 것은 다른 결말인데, V1 의 CHECK 가 다섯 상태를 열거하고 있었다.
그래서 여섯 번째를 쓰려는 시도는 코드 리뷰가 아니라 데이터베이스에서 실패했다. 열거형 CHECK 는 스키마 변경 없이 어휘를 넓히지 못하게 한다.
옛 경로가 옆에 남아 있고, 두 javadoc 이 반대말을 한다
이 저장소의 프로덕션 코드는 청구도 전이 넷도 전부 신세대만 부른다. 아래는 지금 일어나는 일이 아니라 포트가 두 형태를 나란히 둔 결과다.
옛 청구 메서드가 인터페이스에 그대로 있다. 그 메서드 자신의 javadoc 은 아직 이렇게 적는다 — 단순 조회가 아니라 리스를 잡는 것이 여러 릴레이를 안전하게 만들고, 한 릴레이가 청구한 레코드는 리스가 만료될 때까지 다른 릴레이에 보이지 않으므로 같은 메시지가 두 프로세스에서 동시에 발행되지 않는다는 것이다.
이 사례가 반증한 문장이 그대로 있다.
폐기를 적은 것은 신세대 쪽 javadoc 이다. 옛 메서드는 토큰 없는 레코드를 돌려주므로 호출자가 자기 쓰기가 자기 청구에 속한다는 것을 증명할 수 없고, 조사 경로용으로 남기며 릴레이가 쓰기에는 폐기되었다는 것이다.
그 문장은 산문이다. messaging 트리 전체에 @Deprecated 가 하나도 없다.
두 세대가 AMBIGUOUS 에 남기는 것이 다르다
신세대는 lease_owner 를 비우고 정책이 계산한 next_attempt_at 을 행에 쓴다. 구세대는 둘 다 건드리지 않는다.
AMBIGUOUS 는 청구 술어의 상태 목록에 있고 부분 인덱스의 조건에도 있다. 그래서 구세대가 남긴 next_attempt_at 은 그 행이 다음에 언제 청구되는지를 그대로 바꾼다.
반환 타입도 다르다. 신세대는 전이 결과를 돌려주므로 0행을 이름으로 부를 수 있다. 구세대는 void 라서 같은 일이 일어나도 부를 이름이 없다.
같은 형태가 메시징 아웃박스 밖에도 있었다
파일서버의 청구 펜싱 마이그레이션이 자기 헤더에서 그것을 알림 디스패처가 겪은 것과 같은 펜싱 리스 문제라고 적는다.
분석 문서를 따라가면 이 형태가 이 저장소 안에서만 여섯 곳이다. 메시징 아웃박스와 파일서버 정리와 Mongo 마이그레이션 락은 각자의 마이그레이션으로 펜싱을 얻었다. baseline outbox 와 durable operation, 그리고 파일서버 헤더가 이름을 부른 알림 디스패처는 아직 열려 있다 — 알림 쪽은 리스가 소유자와 펜스를 갖지만 프로바이더 호출 뒤의 투영 쓰기가 그 술어를 우회한다.
확인하지 못한 것
이 순서를 실제로 재현하지 않았다. 두 릴레이를 동시에 돌려 리스 만료 구간에서 이중 발행을 관측한 것은 아니다. 이 저장소 밖에 옛 경로를 실제로 부르는 배포가 있는지까지는 확인하지 않았다.