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>
8.4 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-read-then-delete-race-on-the-upload-lease | cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다 | fileserver-state-and-fencing | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a-read-then-delete-race-on-the-upload-lease | 2026-09-02 |
|
|
|
cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
정리 작업이 쓰기 리스를 읽어 없음을 확인하고 스테이징 바이트를 지웠다. 읽기와 삭제 사이에 쓰기 작업자가 바로 그 리스를 얻을 수 있었고, 정리가 지운 것은 업로드가 이어 쓰고 있던 객체였다.
관계
- 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다 이 사례에서 끌어낸 규칙이다.
- CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다 같은 형태를 다른 저장소에서 다룬 규칙이다.
- claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다 같은 어댑터의 앞선 마이그레이션이 다룬 문제다.
문제
정리 작업은 스테이징 바이트를 지우기 전에 그 업로드에 쓰기 리스가 걸려 있는지 확인했다. 리스가 없으면 아무도 쓰고 있지 않다고 판단하고 지웠다.
읽기와 삭제 사이에 쓰기 작업자가 그 리스를 얻을 수 있었다. 업로드가 끝났다는 표시가 데이터베이스에 없었기 때문이다.
쓰기 작업자의 획득 문장도 그것을 막지 못했다. 그 문장은 업로드의 만료와 리스만 확인했고, 취소되었는지 검증에 실패했는지는 확인하지 않았다.
결론
수정은 두 쪽이 함께 볼 상태 컬럼을 만드는 것이었다. 취소와 검증 실패 마무리가 정리를 큐에 넣는 같은 트랜잭션 안에서 세션을 최종 상태로 옮기고, 획득과 갱신과 오프셋 커밋은 활성 상태를 요구한다.
정리는 조금 전에 읽은 값으로 판단하는 대신 조건부 갱신으로 행을 청구한다. 0행이 돌아오면 삭제를 다음 주기로 넘긴다.
기본값이 활성으로 잡혀 있어 이 컬럼이 생기기 전 세션도 영향을 받지 않는다.
리스 반납 문장에는 이 조건이 없다. 클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는데, 그 문장 하나는 그렇지 않다.
검증 환경
데이터베이스 : PostgreSQL 마이그레이션 도구 : Flyway 확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 구현의 조건절·트랜잭션 경계 확인 소스 수정 : x
재현 조건
- 업로드 최종 상태 마이그레이션의 헤더를 읽는다. 경합 순서와 양쪽의 판단 근거가 적혀 있다.
- 추가된 상태 컬럼과 CHECK 제약, 부분 인덱스를 확인한다.
- 저장소의 쓰기·전이 문장을 전부 세고, 그중 몇 개가 활성 상태를 조건으로 거는지 확인한다.
- 클래스 javadoc 의 주장과 각 문장의 실제 조건을 대조한다.
- 최종화와 정리 큐잉을 감싸는 트랜잭션 람다를 두 경로에서 시작부터 끝까지 읽는다.
- 정리가 청구 전에 무엇을 하는지, 청구에 실패하면 무엇을 하는지 확인한다.
본문
마이그레이션 헤더가 이 경합을 사후에 기록한다. 정리는 리스의 부재를 봤고, 쓰기의 획득 문장은 업로드의 만료와 리스를 봤다. 취소되었는지 검증에 실패했는지를 묻는 쪽은 없었다.
그래서 이미 삭제 예정인 바이트에 대해 획득 문장이 리스를 내주었다. 그 요청을 거를 근거를 갖고 있지 않았다.
상태 하나를 두 쪽이 같이 본다
:::evidence key="a-read-then-delete-race-on-the-upload-lease" alt="코드베이스에서 V4 마이그레이션이 더한 상태 컬럼과 CHECK 제약과 부분 인덱스, 이 저장소의 쓰기·전이 문장 여섯과 그중 활성 상태를 조건으로 거는 네 줄, 모든 쓰기 문장이 활성을 요구한다고 적은 클래스 javadoc 과 그 조건이 없는 리스 반납 문장, 최종화와 정리 청구가 요구하는 상태, 취소 경로와 검증 실패 경로의 트랜잭션 람다 전체, 그리고 정리가 세션을 먼저 읽고 있을 때만 청구하는 조건을 뽑은 출력. javadoc 의 주장과 리스 반납 문장의 조건이 어긋난다는 것이 그 출력에 나란히 보인다." caption="상태 컬럼과 제약 · 문장 여섯과 활성 조건 넷 · javadoc 과 어긋나는 반납 문장 · 두 경로의 트랜잭션 전체 · 정리의 읽기와 청구" zoom="true" :::
V4 가 세션 테이블에 상태 컬럼을 더한다. 값은 활성과 최종 둘이고 CHECK 로 묶여 있다. 기본값이 활성이라 이 컬럼 이전의 세션은 그대로 동작한다.
같은 마이그레이션이 부분 인덱스도 만든다. 최종 상태인 행만 담고 리스 만료로 정렬한다. 청구 문장의 술어와 같은 조건이지만, 청구 자체는 업로드 아이디 단건 갱신이라 이 인덱스를 타지 않는다. 최종 상태 집합을 만료 순으로 훑을 때를 위한 모양이다.
쓰기 문장 넷 중 셋이 활성을 요구한다
리스 획득과 갱신과 오프셋 커밋의 조건절에 활성 상태가 들어갔다. 최종 상태로 옮겨진 세션에는 이 셋이 걸리지 않는다.
리스 반납 문장에는 없다. 업로드 아이디와 리스 토큰만 본다. 최종 상태로 옮겨진 뒤에도 진행 중이던 쓰기 작업자가 자기 리스를 놓을 수 있어야 하고, 반납은 리스 컬럼을 비울 뿐 바이트를 건드리지 않는다.
클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는다. 넷 중 셋이다.
최종화 문장 자체도 활성일 때만 성립한다. 이미 최종인 행을 다시 최종으로 옮기는 시도는 0행을 갱신한다.
최종화와 정리 큐잉이 한 트랜잭션 안에 있다
취소 경로와 검증 실패 경로가 각각 하나의 트랜잭션 안에서 세 가지를 함께 한다. 기록을 도달 불가로 옮기고, 세션을 최종 상태로 옮기고, 정리 요청을 큐에 넣는다.
셋이 함께 커밋되므로 큐에 항목이 있는데 세션은 아직 활성인 순간이 없다. 취소 경로의 주석이 그 이유를 적는다 — 큐잉만 하면 세션이 활성으로 남아, 정리가 지우려는 바로 그 바이트에 쓰기가 리스를 얻을 수 있었다는 것이다.
첫 번째 쓰기에도 이유가 붙어 있다. 기록이 먼저 도달 불가가 되어야 물리 작업이 예약되고, 둘이 함께 커밋되므로 파일이 도달 불가인데 회수할 것이 큐에 없는 상태가 남지 않는다.
정리는 읽은 값 대신 청구로 판단한다
정리 청구 문장은 최종 상태이고 리스가 없거나 만료된 행만 갱신한다. 그 한 문장이 리스가 걸려 있지 않다는 것을 증명하는 동시에 리스 컬럼을 비운다.
청구가 0행이면 지우지 않는다. 항목을 실패로 표시하고 사유를 남기고 결과를 건너뜀으로 돌려준다.
다만 청구는 세션 행이 있을 때만 걸린다. 조건이 존재 확인과 청구의 논리곱이라, 세션이 이미 사라진 항목은 청구를 거치지 않고 바로 지운다 — 지킬 리스를 가진 쓰기 작업자가 있을 수 없는 경우다.
확인하지 못한 것
읽기와 삭제 사이에서 리스를 가로채는 경합을 재현해 보지는 않았다. 경합 순서는 마이그레이션 헤더의 사후 기록이고, 수정의 현재 형태는 코드로 확인했다.