check_evidence --repo 이 clean-architecture-backend-template 에서 141건을 세고 있었다.
124건 전부를 고정 리비전 21234e38 에 대조했더니 날조는 0 이었다 — 인용은 맞고 없던
쪽이 SSOT 였다. 그래서 SSOT 를 보강한다.
넣는 것은 기록이 옮겨 적은 문장이 아니라 저장소 원문이다. 기록을 복사해 넣으면
검사기는 초록이 되지만 옮겨 적기가 어긋나도 더는 못 잡는다. 원문을 넣으면 어긋난
기록은 계속 걸린다.
- 코드 124건 → 리프 절 일곱 곳에 원문 30조각(300줄). 자리는 기록의 source 앵커와
소스 파일의 모듈을 교차시켜 정했고, 둘이 갈린 다섯은 모듈을 따르고 그 사실을 적었다
- 식별자 13건 → spring.factories · MethodSecurityConfig 원문과 줄여 적힌 경로의 전체 경로
- source 앵커 4건 → 계약이 이미 적고 있던 SSOT 앵커를 기록 frontmatter 에 맞췄다.
맨 앵커를 한 줄씩 앞에 둔다 — `_record_sources` 가 `- <공백 없는 한 덩어리>` 만 잇달아 읽는다
- 인용 4건 → concept-signed-cursor-structure 의 `{ ... }` 생략을 저장소 원문 형태로 고쳤다.
생략한 자리는 `…` 로 남기고 무엇을 줄였는지 본문에 적는다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
134 lines
10 KiB
Markdown
134 lines
10 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a-lease-without-an-owner
|
|
title: 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다
|
|
topic: owner-safe-state-machines
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a-lease-without-an-owner
|
|
evidenceCapturedOn: 2026-09-02
|
|
assets:
|
|
- key: a-lease-without-an-owner
|
|
file: ../../../final/evidence/rendered/a-lease-without-an-owner.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a-lease-without-an-owner.txt
|
|
source:
|
|
- final/document.md#4-3
|
|
- final/document.md#a19
|
|
- final/document.md#a08
|
|
- 분석 문서는 메시징 플랫폼 편 §7.3 이 V2 헤더를 인용하며 시간과 소유권 토큰의 차이를 짚고, outbox 어댑터 편 §12.3 이 두 세대의 술어와 SET 절과 반환 타입을 표로 대조한다. `@Deprecated` 가 없다는 것은 reliability-api 편이 P2 로 다룬다. 옛 경로가 남아 있는 것은 열린 질문이 아니라 P3 결함이고, 권고는 애너테이션이 아니라 제거다.
|
|
- 같은 형태의 다른 다섯 곳은 JPA 어댑터 편 §70·§71·§89 와 §83.3, mongo 어댑터 편 §55 다. §83.2 는 파일서버 쪽에 남은 구간 — 리스 만료 직후 인수 전에 아직 settle 할 수 있는 창 — 을 finding 으로 올리지 않은 이유와 함께 기록한다.
|
|
---
|
|
|
|
# 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다
|
|
|
|
outbox 리스가 만료 시각만 담고 최종 상태 쓰기가 메시지 식별자만으로 행을 찾았다. 리스를 지나 멈춰 있던 작업자가 이미 발행된 행의 상태를 덮을 수 있었고, 그러면 행은 다시 청구 가능해져 메시지가 두 번 발행된다.
|
|
|
|
## 관계
|
|
|
|
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
|
이 사례가 만든 개념이다.
|
|
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
|
이 결함의 수정 형태를 규칙으로 옮긴 것이다.
|
|
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
|
만료된 claim 과 만료된 실행을 갈라 다루는 판단이다.
|
|
|
|
## 문제
|
|
|
|
V1 스키마는 리스 만료 시각만 기록했다. 청구는 언제 끝나는지를 말했고 누가 들고 있는지는 말하지 않았다.
|
|
|
|
릴레이의 최종 상태 쓰기는 메시지 식별자만으로 행을 찾았다. 그래서 리스를 지나 멈춰 있던 작업자의 쓰기가 다른 작업자의 결과를 덮을 수 있었다.
|
|
|
|
## 결론
|
|
|
|
V2 가 세 컬럼을 더해 고쳤다. 리스 소유자와 리스 토큰과 다음 시도 시각이다. 청구가 토큰을 서버에서 올리고, 최종 쓰기가 소유자와 토큰을 함께 조건으로 건다.
|
|
|
|
같은 마이그레이션이 최종 상태 어휘도 넓혔다. 시도 예산이 확인 없이 소진된 상태는 브로커가 거절한 것과 다르므로 별도 상태가 됐다.
|
|
|
|
수정은 전이 메서드를 두 세대로 남겼다. 이 저장소의 프로덕션 코드는 전부 신세대만 부르지만, 펜싱 없는 옛 경로가 인터페이스와 구현에 그대로 있고 컴파일러가 막지 않는다.
|
|
|
|
## 검증 환경
|
|
|
|
데이터베이스 : PostgreSQL
|
|
확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 스키마·구현 확인
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 메시징 outbox 의 V2 마이그레이션이 더한 컬럼과 제약, 상태 CHECK 변경을 읽는다.
|
|
2. 구현의 청구문이 소유자와 토큰을 어디서 쓰는지, 그 청구가 읽는 술어와 부분 인덱스를 확인한다.
|
|
3. 최종 쓰기의 술어와 0행 처리 방식을 확인한다.
|
|
4. 릴레이가 부르는 전이가 어느 세대인지 센다.
|
|
5. 펜싱 없는 옛 메서드가 남아 있는지, 그 자신의 javadoc 과 신세대 javadoc 이 각각 무엇을 적는지 대조한다.
|
|
6. 두 세대의 AMBIGUOUS 쓰기가 지우는 컬럼을 대조한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## 시간을 늘려도 순서는 막히지 않는다
|
|
|
|
마이그레이션 헤더가 순서를 세 줄로 적어 둔다. 릴레이 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, 그리고 파일서버 헤더가 이름을 부른 알림 디스패처는 아직 열려 있다 — 알림 쪽은 리스가 소유자와 펜스를 갖지만 프로바이더 호출 뒤의 투영 쓰기가 그 술어를 우회한다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
이 순서를 실제로 재현하지 않았다. 두 릴레이를 동시에 돌려 리스 만료 구간에서 이중 발행을 관측한 것은 아니다. 이 저장소 밖에 옛 경로를 실제로 부르는 배포가 있는지까지는 확인하지 않았다.
|
|
|
|
<!-- body:end -->
|