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>
6.9 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-cleanup-claim-without-fencing | claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다 | fileserver-state-and-fencing | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a-cleanup-claim-without-fencing | 2026-09-02 |
|
|
|
claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
정리 항목의 청구가 상태만 바꾸고 소유자도 토큰도 리스 만료도 기록하지 않았다. 하나의 누락에서 회수되지 않는 항목과 덮어쓰기라는 두 결과가 나왔다.
관계
- fenced lease — 만료 시각만으로는 부족한 이유 이 사례가 같은 문제의 파일서버 판이다.
- 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다 같은 형태가 메시징 어댑터에서 나타난 사례다.
- cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다 같은 저장소의 다음 마이그레이션이 다룬 문제다.
문제
정리 작업은 파일을 물리적으로 지운 뒤 데이터베이스를 정산한다. 그 사이에 작업자가 죽을 수 있고, 여러 작업자가 같은 항목을 두고 겹칠 수 있다.
청구가 그 두 상황을 구별할 정보를 남기지 않았다. 상태를 진행 중으로 옮기는 것이 전부였다.
결론
수정은 컬럼 넷과 질의 둘이다.
컬럼은 청구 소유자, 청구 토큰, 리스 만료, 그리고 청구 세대 계수기다. 질의는 완료 갱신이 토큰으로 행을 찾게 만든 것과, 회수기가 만료된 청구를 오래된 것부터 가져오되 널 만료는 제외하게 만든 것이다.
두 결과가 어떻게 생겼고 각 컬럼이 무엇을 맡는지는 본문이 다룬다.
검증 환경
데이터베이스 : PostgreSQL 마이그레이션 도구 : Flyway 확인 방식 : V3 마이그레이션의 컬럼 정의와 정리 저장소 질의 두 개 확인, claim_fence 참조 전수 검색 소스 수정 : x
재현 조건
- fileserver 의 V3 마이그레이션 헤더를 읽는다. 두 결과가 나란히 적혀 있다.
- 추가된 컬럼 넷과 각각의 널 허용 여부를 확인한다.
- 완료 갱신 질의가 무엇으로 행을 찾는지 확인한다.
- 회수기 질의가 널 만료를 어떻게 다루는지 확인한다.
- claim_fence 를 읽는 코드가 있는지 검색한다.
본문
정리 항목의 청구가 상태를 IN_PROGRESS 로 옮기고 그 외에는 아무것도 기록하지 않았다. 마이그레이션 헤더가 그 누락에서 나온 결과를 둘로 적는다.
하나는 아무도 손대지 않아서, 하나는 두 손이 겹쳐서
첫째는 회수되지 않는 항목이었다. 물리 삭제를 수행하고 데이터베이스를 정산하기 전에 죽은 작업자가 행을 진행 중 상태로 영원히 남겼다. 어떤 질의도 그 항목을 살아 있는 작업자가 지금 지우고 있는 항목과 구별할 수 없었다. 파일은 이미 사라졌는데 쿼터와 수명주기는 정산되지 않은 채 남았다.
둘째는 덮어쓰기였다. 완료 갱신이 정리 식별자만으로 행을 찾았다. 어떤 합리적 리스보다 오래 멈춰 있던 작업자가 깨어나, 그 사이 다른 작업자가 청구해 반쯤 진행한 항목 위에 완료를 쓸 수 있었다.
같은 누락에서 나왔지만 방향이 반대다.
컬럼 넷과 질의 둘
:::evidence key="a-cleanup-claim-without-fencing" alt="코드베이스에서 V3 마이그레이션의 컬럼 넷과 정리 저장소의 질의, 그리고 claim_fence 참조를 뽑은 출력 21줄. 완료 갱신이 토큰으로 행을 찾고 회수기가 널 만료를 제외한다는 것, claimFence 는 증가만 하고 그 엔티티의 getter 열셋 안에 없다는 것이 그 출력에 그대로 보인다." caption="V3 컬럼 넷 · 토큰 대조 · 널 만료 제외 · claimFence 참조 — 21줄 · exit 0" zoom="true" :::
컬럼은 claim_owner · claim_token · lease_until · claim_fence 다. 앞의 셋은 널을 허용하고 넷째만 NOT NULL DEFAULT 0 이다. 헤더가 그 이유를 하나로 적는다 — 이 마이그레이션 이전에 청구된 항목이 계속 동작해야 하기 때문이다.
스키마만 바뀐 것이 아니다. 저장소의 질의 둘이 그 컬럼을 실제로 쓴다.
토큰이 덮어쓰기를 막는다
완료 갱신의 where 절에 and c.claimToken = :token 이 붙었다. 그 메서드의 javadoc 이 이전 상태를 적는다 — 예전에는 정리 식별자만으로 대조했고, 그래서 오래 멈춰 있던 작업자가 다른 작업자의 진행 중 항목 위에 완료를 쓸 수 있었다.
반환 타입이 int 다. 교체된 작업자는 예외를 받는 것이 아니라 갱신 행 수 0 을 받는다. 행을 못 찾는 것이 실패가 아니라 답이 되는 형태다.
널 만료를 회수기가 건너뛴다
회수기 질의는 진행 중이면서 만료가 지난 항목을 만료 순으로 가져온다. 조건에 and c.leaseUntil is not null 이 들어 있다.
그 메서드의 javadoc 이 이유를 적는다 — 널 리스는 펜싱이 생기기 전에 청구됐다는 뜻이고, 자동으로 넘겨받는 것은 아무도 상태를 기록하지 않은 작업에 대해 추측하는 일이다. 그 항목이 물리 삭제를 마쳤는지 시작도 안 했는지 알 방법이 없다. 그래서 운영자를 필요로 한다.
부분 인덱스가 그 질의 모양 그대로 만들어져 있다 — lease_until 에 걸리고 조건이 status = 'IN_PROGRESS' 다.
넷째 컬럼은 아직 소비자가 없다
claim_fence 는 청구할 때마다 1 씩 오른다. 저장소에서 그 이름이 나오는 곳은 그 증가 한 줄과 엔티티의 필드 선언뿐이다.
엔티티는 getter 열셋을 갖는다. getClaimToken 과 getLeaseUntil 은 있고 claimFence 의 getter 는 없다. 즉 값이 올라가기만 하고 어디에서도 읽히거나 비교되지 않는다.
마이그레이션은 이 컬럼에만 COMMENT 를 붙이지 않았다. 다른 둘에는 무엇을 위한 값인지 적혀 있다.
확인하지 못한 것
작업자를 죽여 진행 중 항목이 남는 것을 재현하지 않았다. 컨테이너 레인을 돌리지 않았다.