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>
112 lines
6.9 KiB
Markdown
112 lines
6.9 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a-cleanup-claim-without-fencing
|
|
title: claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
|
|
topic: fileserver-state-and-fencing
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a-cleanup-claim-without-fencing
|
|
evidenceCapturedOn: 2026-09-02
|
|
assets:
|
|
- key: a-cleanup-claim-without-fencing
|
|
file: ../../../final/evidence/rendered/a-cleanup-claim-without-fencing.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a-cleanup-claim-without-fencing.txt
|
|
source:
|
|
- 컬럼 정의와 두 결과의 사후 기록은 `V3__fileserver_fenced_cleanup_lease.sql` 의 헤더와 두 COMMENT 에 있다. 토큰 대조와 널 만료 제외의 이유는 `FileserverCleanupRepository` 의 두 메서드 javadoc 에 있다. 원본 분석은 `analysis/05` §79 가 이 스키마를 다룬다.
|
|
---
|
|
|
|
# claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
|
|
|
|
정리 항목의 청구가 상태만 바꾸고 소유자도 토큰도 리스 만료도 기록하지 않았다. 하나의 누락에서 회수되지 않는 항목과 덮어쓰기라는 두 결과가 나왔다.
|
|
|
|
## 관계
|
|
|
|
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
|
이 사례가 같은 문제의 파일서버 판이다.
|
|
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
|
|
같은 형태가 메시징 어댑터에서 나타난 사례다.
|
|
- **cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다**
|
|
같은 저장소의 다음 마이그레이션이 다룬 문제다.
|
|
|
|
## 문제
|
|
|
|
정리 작업은 파일을 물리적으로 지운 뒤 데이터베이스를 정산한다. 그 사이에 작업자가 죽을 수 있고, 여러 작업자가 같은 항목을 두고 겹칠 수 있다.
|
|
|
|
청구가 그 두 상황을 구별할 정보를 남기지 않았다. 상태를 진행 중으로 옮기는 것이 전부였다.
|
|
|
|
## 결론
|
|
|
|
수정은 컬럼 넷과 질의 둘이다.
|
|
|
|
컬럼은 청구 소유자, 청구 토큰, 리스 만료, 그리고 청구 세대 계수기다. 질의는 완료 갱신이 토큰으로 행을 찾게 만든 것과, 회수기가 만료된 청구를 오래된 것부터 가져오되 널 만료는 제외하게 만든 것이다.
|
|
|
|
두 결과가 어떻게 생겼고 각 컬럼이 무엇을 맡는지는 본문이 다룬다.
|
|
|
|
## 검증 환경
|
|
|
|
데이터베이스 : PostgreSQL
|
|
마이그레이션 도구 : Flyway
|
|
확인 방식 : V3 마이그레이션의 컬럼 정의와 정리 저장소 질의 두 개 확인, claim_fence 참조 전수 검색
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. fileserver 의 V3 마이그레이션 헤더를 읽는다. 두 결과가 나란히 적혀 있다.
|
|
2. 추가된 컬럼 넷과 각각의 널 허용 여부를 확인한다.
|
|
3. 완료 갱신 질의가 무엇으로 행을 찾는지 확인한다.
|
|
4. 회수기 질의가 널 만료를 어떻게 다루는지 확인한다.
|
|
5. claim_fence 를 읽는 코드가 있는지 검색한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
정리 항목의 청구가 상태를 `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 를 붙이지 않았다. 다른 둘에는 무엇을 위한 값인지 적혀 있다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
작업자를 죽여 진행 중 항목이 남는 것을 재현하지 않았다. 컨테이너 레인을 돌리지 않았다.
|
|
|
|
<!-- body:end -->
|