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>
115 lines
8.4 KiB
Markdown
115 lines
8.4 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a-read-then-delete-race-on-the-upload-lease
|
|
title: cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
|
|
topic: fileserver-state-and-fencing
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a-read-then-delete-race-on-the-upload-lease
|
|
evidenceCapturedOn: 2026-09-02
|
|
assets:
|
|
- key: a-read-then-delete-race-on-the-upload-lease
|
|
file: ../../../final/evidence/rendered/a-read-then-delete-race-on-the-upload-lease.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a-read-then-delete-race-on-the-upload-lease.txt
|
|
source:
|
|
- 이 경합의 1차 기록은 `V4__fileserver_upload_terminal_state.sql` 의 헤더다. 분석 문서는 두 곳에서 이 사건을 다룬다. application-core 편 §11.2 가 취소와 정리의 경합을 청구가 만드는 경계로 정리하고, persistence-jpa 편 §83.3 이 이전 리뷰가 올린 소유자·토큰·최종 상태 부재를 V3·V4 와 현재 저장소 코드가 해소했다고 적는다. 같은 편 §79 의 마이그레이션 목록도 이 컬럼을 언급한다.
|
|
---
|
|
|
|
# cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
|
|
|
|
정리 작업이 쓰기 리스를 읽어 없음을 확인하고 스테이징 바이트를 지웠다. 읽기와 삭제 사이에 쓰기 작업자가 바로 그 리스를 얻을 수 있었고, 정리가 지운 것은 업로드가 이어 쓰고 있던 객체였다.
|
|
|
|
## 관계
|
|
|
|
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
|
이 사례에서 끌어낸 규칙이다.
|
|
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
|
같은 형태를 다른 저장소에서 다룬 규칙이다.
|
|
- **claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다**
|
|
같은 어댑터의 앞선 마이그레이션이 다룬 문제다.
|
|
|
|
## 문제
|
|
|
|
정리 작업은 스테이징 바이트를 지우기 전에 그 업로드에 쓰기 리스가 걸려 있는지 확인했다. 리스가 없으면 아무도 쓰고 있지 않다고 판단하고 지웠다.
|
|
|
|
읽기와 삭제 사이에 쓰기 작업자가 그 리스를 얻을 수 있었다. 업로드가 끝났다는 표시가 데이터베이스에 없었기 때문이다.
|
|
|
|
쓰기 작업자의 획득 문장도 그것을 막지 못했다. 그 문장은 업로드의 만료와 리스만 확인했고, 취소되었는지 검증에 실패했는지는 확인하지 않았다.
|
|
|
|
## 결론
|
|
|
|
수정은 두 쪽이 함께 볼 상태 컬럼을 만드는 것이었다. 취소와 검증 실패 마무리가 정리를 큐에 넣는 같은 트랜잭션 안에서 세션을 최종 상태로 옮기고, 획득과 갱신과 오프셋 커밋은 활성 상태를 요구한다.
|
|
|
|
정리는 조금 전에 읽은 값으로 판단하는 대신 조건부 갱신으로 행을 청구한다. 0행이 돌아오면 삭제를 다음 주기로 넘긴다.
|
|
|
|
기본값이 활성으로 잡혀 있어 이 컬럼이 생기기 전 세션도 영향을 받지 않는다.
|
|
|
|
리스 반납 문장에는 이 조건이 없다. 클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는데, 그 문장 하나는 그렇지 않다.
|
|
|
|
## 검증 환경
|
|
|
|
데이터베이스 : PostgreSQL
|
|
마이그레이션 도구 : Flyway
|
|
확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 구현의 조건절·트랜잭션 경계 확인
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 업로드 최종 상태 마이그레이션의 헤더를 읽는다. 경합 순서와 양쪽의 판단 근거가 적혀 있다.
|
|
2. 추가된 상태 컬럼과 CHECK 제약, 부분 인덱스를 확인한다.
|
|
3. 저장소의 쓰기·전이 문장을 전부 세고, 그중 몇 개가 활성 상태를 조건으로 거는지 확인한다.
|
|
4. 클래스 javadoc 의 주장과 각 문장의 실제 조건을 대조한다.
|
|
5. 최종화와 정리 큐잉을 감싸는 트랜잭션 람다를 두 경로에서 시작부터 끝까지 읽는다.
|
|
6. 정리가 청구 전에 무엇을 하는지, 청구에 실패하면 무엇을 하는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
마이그레이션 헤더가 이 경합을 사후에 기록한다. 정리는 리스의 부재를 봤고, 쓰기의 획득 문장은 업로드의 만료와 리스를 봤다. 취소되었는지 검증에 실패했는지를 묻는 쪽은 없었다.
|
|
|
|
그래서 이미 삭제 예정인 바이트에 대해 획득 문장이 리스를 내주었다. 그 요청을 거를 근거를 갖고 있지 않았다.
|
|
|
|
## 상태 하나를 두 쪽이 같이 본다
|
|
|
|
:::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행이면 지우지 않는다. 항목을 실패로 표시하고 사유를 남기고 결과를 건너뜀으로 돌려준다.
|
|
|
|
다만 청구는 세션 행이 있을 때만 걸린다. 조건이 존재 확인과 청구의 논리곱이라, 세션이 이미 사라진 항목은 청구를 거치지 않고 바로 지운다 — 지킬 리스를 가진 쓰기 작업자가 있을 수 없는 경우다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
읽기와 삭제 사이에서 리스를 가로채는 경합을 재현해 보지는 않았다. 경합 순서는 마이그레이션 헤더의 사후 기록이고, 수정의 현재 형태는 코드로 확인했다.
|
|
|
|
<!-- body:end -->
|