docs(keycloak-session-store): import the session-storage lab as a new project
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-digest-that-covered-who-but-not-what
|
||||
title: transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
|
||||
topic: owner-safe-state-machines
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-digest-that-covered-who-but-not-what
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a-digest-that-covered-who-but-not-what
|
||||
file: ../../../final/evidence/rendered/a-digest-that-covered-who-but-not-what.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a-digest-that-covered-who-but-not-what.txt
|
||||
source:
|
||||
- 이 사례의 사실은 분석 문서가 아니라 다이제스트 정책 클래스의 javadoc — 고친 쪽이 남긴 사후 기록 — 과 현재 구현에서 왔다. 후보 원장이 지정한 `analysis/05` 의 절 번호는 그 파일에 존재하지 않는다. 완료 쪽이 아직 열려 있다는 것은 같은 문서 §59.2 이고, 위에 인용한 탐침 값이 그 절에 있다.
|
||||
---
|
||||
|
||||
# transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
|
||||
|
||||
전이 다이제스트가 전이 종류와 연산 식별자와 소유자와 시도와 상태 리비전을 덮었다. 누가 언제 했는지는 덮고 무엇을 했는지는 덮지 않아서, 결말이 다른 전이가 같은 값을 냈다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **digest는 길이 프레이밍하고 버전을 붙인다**
|
||||
이 사례가 만든 규칙이다.
|
||||
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
||||
같은 저장소의 소유자 안전 규칙이다.
|
||||
- **complete()의 replay 판정이 replayTtl 변경을 무시한다**
|
||||
이 다이제스트가 실제 판정에 쓰이지 않는 경로다.
|
||||
|
||||
## 문제
|
||||
|
||||
다이제스트가 덮던 다섯 성분은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 무엇을 했는지를 말하는 성분이 없었다.
|
||||
|
||||
그래서 재시도 가능한 실패와 포기한 실패가 같은 값을 냈고, 응답이 다른 두 완료와 보존 기간이 다른 두 완료도 그랬다.
|
||||
|
||||
## 결론
|
||||
|
||||
다이제스트를 비교한 재생 판정이 세 쌍을 같은 전이로 결론지었다. 세 쌍에서 갈린 것은 호출자의 행동이 달라지는 자리뿐이었다.
|
||||
|
||||
수정은 두 층으로 왔다. 호출부마다 의미 인자를 넘기게 했고, 실패 쪽은 전이 종류 자체를 성향별로 갈랐다.
|
||||
|
||||
이어 붙이는 방식도 바뀌었다. 성분마다 앞에 길이를 적고, 버전 상수를 다이제스트 입력에 넣는다.
|
||||
|
||||
다만 완료 쪽 재생 분기는 이 다이제스트를 비교하지 않는다. 그래서 응답이 같고 재생 창만 다른 두 완료는 지금도 같은 결과로 판정된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 다이제스트 정책과 다섯 호출부, 완료 재생 분기 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 다이제스트 정책의 javadoc 에서 덮던 성분과 충돌한 쌍을 읽는다.
|
||||
2. 전이 다이제스트를 부르는 다섯 지점에서 각각 무엇을 넘기는지 확인한다.
|
||||
3. 실패 쪽 전이 종류가 어떻게 갈라지는지 확인한다.
|
||||
4. 이어 붙이기가 각 성분 앞에 적는 길이와, 해시가 실제로 먹는 바이트를 확인한다.
|
||||
5. 완료 재생 분기가 무엇을 비교하는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
전이 다이제스트가 덮던 것은 전이 종류와 연산 식별자와 소유자 토큰과 시도와 상태 리비전이었다.
|
||||
|
||||
## 결말을 가르는 성분이 하나도 없었다
|
||||
|
||||
:::evidence key="a-digest-that-covered-who-but-not-what" alt="코드베이스에서 전이 다이제스트가 덮던 다섯 성분과 그때 같은 값을 내던 쌍을 적은 javadoc, 지금 다섯 호출부가 실제로 넘기는 의미 인자, 각 성분 앞에 적는 길이와 해시가 먹는 바이트, 버전 상수, 그리고 완료 재생 분기가 비교하는 값을 뽑은 출력 49줄. 완료 재생 분기가 전이 다이제스트가 아니라 연산 식별자와 응답 다이제스트만 본다는 것이 그 출력에 그대로 보인다." caption="덮던 다섯과 충돌한 쌍 · 호출부별 의미 인자 · 길이 프레이밍과 해시 입력 · 완료 재생 분기가 비교하는 값 — 49줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
다섯은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 결말에 가장 가까운 것이 전이 종류인데, 그때는 실패가 성향과 무관하게 하나의 `FAIL` 이었다.
|
||||
|
||||
javadoc 이 같은 값을 내던 쌍 셋을 든다. 재시도 가능한 실패와 포기한 실패, 응답이 다른 두 완료, 보존 기간이 다른 두 완료다.
|
||||
|
||||
세 쌍이 달랐던 부분은 호출자가 실제로 행동을 바꾸는 부분뿐이었다. 다이제스트를 비교한 재생 판정은 그 셋을 전부 같은 전이라고 답했다.
|
||||
|
||||
## 무엇을 넘길지는 호출부가 정한다
|
||||
|
||||
지금은 다섯 호출부가 각자 인자를 넘긴다.
|
||||
|
||||
갱신은 처리 임차 유효 기간을 넘긴다. 완료는 응답 다이제스트와 재생 유효 기간을 넘기고, 그 자리 주석이 이유를 적는다 — 응답 다이제스트를 전이 다이제스트 대신 쓰면 같은 응답을 낸 서로 다른 연산의 완료가 구별되지 않고, 완료가 함께 결정하는 재생 창도 잃는다.
|
||||
|
||||
실패는 성향과 보존 기간을 넘긴다. 그리고 전이 종류 자체가 `FAIL_RETRYABLE` 과 `FAIL_ABANDONED` 로 갈린다. 첫 번째 쌍은 두 겹으로 갈라진 셈이다.
|
||||
|
||||
시작과 해제는 아무것도 넘기지 않는다. javadoc 이 예시로 든 코덱 신원은 지금 어느 호출부도 넘기지 않는다.
|
||||
|
||||
## 이어 붙이는 방식이 구별을 잃게 할 수 있다
|
||||
|
||||
성분은 전부 가변 폭 텍스트다. 그중 소유자 토큰은 문자 집합을 이 플랫폼이 정하지 않는다 — 소유자 타입이 강제하는 것은 1자 이상 128자 이하와 공백 불가뿐이다.
|
||||
|
||||
구분자로 이으면 서로 다른 성분 목록이 하나의 문자열로 렌더링될 수 있다. 그래서 각 성분 앞에 길이를 적고 콜론을 찍은 뒤 값을 적는다.
|
||||
|
||||
적는 길이는 자바 문자 수다. 해시가 먹는 것은 UTF-8 바이트다. 같은 저장소의 메시징 파티션 키는 같은 목적에 UTF-8 바이트 수를 쓰고, 비 ASCII 골든 벡터로 그 선택을 고정한다.
|
||||
|
||||
## 버전 상수는 다이제스트 입력 안에 있다
|
||||
|
||||
상수 값은 2 이고, 이어 붙이기가 그 값을 `v2` 로 맨 앞에 쓴다. 형식이 바뀌면 값이 바뀌므로 옛 형식으로 계산된 다이제스트와 섞이지 않는다.
|
||||
|
||||
저장소 이력에는 이 파일이 지금 형태 그대로 커밋 하나에 들어와 있다. 값이 언제 몇 번 올랐는지는 남아 있지 않다.
|
||||
|
||||
## 완료 쪽은 아직 이 다이제스트를 보지 않는다
|
||||
|
||||
정책이 고쳐진 것과 그 정책이 판정에 쓰이는 것은 다르다.
|
||||
|
||||
이미 완료된 동일 연산의 재생 분기는 전이 다이제스트를 비교하지 않는다. 상태가 COMPLETED 인지, 마지막 전이 종류가 COMPLETE 인지, 연산 식별자가 같은지를 보고, 그다음 응답 다이제스트만 비교한다.
|
||||
|
||||
그래서 응답은 같고 재생 유효 기간만 다른 두 완료가 같은 결과로 판정된다. 분석 문서가 실제 PostgreSQL 탐침으로 그것을 확인했다 — 첫 호출에 한 시간, 두 번째 호출에 아홉 시간을 넘겼는데 두 번째가 같은 결과로 판정됐고, 저장된 창은 3600초 그대로였다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
수정 이전의 충돌을 실행으로 재현하지 않았다. 지금 정책이 지켜지는지는 다이제스트 정책 전용 테스트가 고정한다 — 성향 차이, 응답 차이, 재생 창 차이, 길이 프레이밍, 그리고 널 인자와 인자 없음의 구분이다. 완료 쪽이 아직 열려 있다는 것은 분석 문서의 실측 탐침이 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
---
|
||||
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:
|
||||
- 분석 문서는 메시징 플랫폼 편 §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 -->
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-active-transaction-check-that-asked-the-wrong-question
|
||||
title: 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다
|
||||
topic: owner-safe-state-machines
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:an-active-transaction-check-that-asked-the-wrong-question
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-an-active-transaction-check-that-asked-the-wrong-question.body.md
|
||||
assets:
|
||||
- key: an-active-transaction-check-that-asked-the-wrong-question
|
||||
file: ../../../final/evidence/rendered/an-active-transaction-check-that-asked-the-wrong-question.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/an-active-transaction-check-that-asked-the-wrong-question.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#4-1 이다.
|
||||
---
|
||||
|
||||
# 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다
|
||||
|
||||
옛 검사는 스레드에 트랜잭션이 열려 있는지만 확인했고 어느 데이터소스의 것인지는 확인하지 않았다. 지금은 `IdempotencyCapabilityGuard:101` 이 `hasResource(dataSource)` 를 뒤에 붙였는데, `dataSource` 가 널이면 그 검사를 건너뛴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **CAS 튜플과 update count가 답이 되는 구조**
|
||||
그 개념에서 답이 되는 갱신 건수는 소유자 튜플을 반복한 문장이 하나의 트랜잭션 안에서 이 저장소의 커넥션 위로 실행됐을 때만 답이 된다. 그 전제를 거는 것이 이 가드다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
같은 세 검사가 가드와 outbox 어댑터와 inbox 어댑터 세 곳에 각각 따로 구현돼 있고, 그중 가드의 사본만 `dataSource != null` 을 앞에 달아 검사를 건너뛸 수 있다.
|
||||
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
|
||||
형제인 inbox 어댑터의 같은 세 검사를 그 기록이 먼저 적었다. 거기서는 셋이 한 조건으로 묶이고 널 가드가 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
멱등성 저장소는 변경을 쓰기 전에 전제 셋을 통과해야 하고, 마지막 하나가 트랜잭션의 소유자를 가린다.
|
||||
|
||||
옛 검사는 그 셋 중 세 번째에 틀린 답을 냈고, 그 사실을 클래스 자바독이 사후 기록으로 남겼다.
|
||||
|
||||
## 결론
|
||||
|
||||
IdempotencyCapabilityGuard 의 클래스 자바독(:12~:21)이 무엇이 틀렸는지 남겨 두었다. 옛 검사는 트랜잭션의 존재만 보아 소유자를 가리지 못했고, 데이터소스가 둘인 배포에서 남의 트랜잭션이 그 검사를 통과했다. 그 변경이 커밋된 곳은 이 저장소의 커넥션이고, 그것을 감싸는 트랜잭션은 없었다.
|
||||
|
||||
지금 requirePrimaryWriteTransaction(:92~:108)에는 검사가 셋이다. :93 활성 트랜잭션, :97 읽기 전용 여부, :101 데이터소스 결속이다. 옛 검사가 첫 줄에 그대로 있고 새 검사가 맨 뒤에 붙었다.
|
||||
|
||||
맨 뒤 검사에는 앞선 조건이 하나 더 있다. dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 생성자(:34~:39)에서 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나는데 dataSource 만 그대로 대입된다.
|
||||
|
||||
그 값은 PostgreSqlOwnerSafeIdempotencyStore:105~:109 의 dataSourceOf 가 정하고, JdbcTemplate 이 아니면 널이다.
|
||||
|
||||
프로덕션에서는 널이 되지 않는다. 조립 자리가 PostgreSqlIdempotencyProviderConfig:60 하나이고, 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스가 0 건이라 그 빈은 JdbcTemplate 이다. 널 경로가 열려 있는 곳은 mock(JdbcOperations.class) 를 넘기는 유닛 시험(OwnerSafeIdempotencyPreconditionTest:45)이다.
|
||||
|
||||
그 검사에는 시험이 붙어 있지 않다. :101 이 던지는 메시지를 저장소에서 찾으면 던지는 줄 하나만 나오고, 전제 시험 셋은 트랜잭션 없음과 다른 벤더와 승인되지 않은 스키마만 단언한다.
|
||||
|
||||
형제 둘은 데이터소스를 생성자로 직접 받고 :158 과 :210 에서 requireNonNull 로 거른다. 그래서 널 가드를 둘 이유가 없다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Spring Boot : 4.0.8
|
||||
근거 : 저장소의 자바독이 사후 기록으로 남긴 회귀
|
||||
확인 방식 : 가드 자바독의 사후 기록 확인, 필드와 생성자의 널 검사 유무 확인, 전제 검사 메서드의 세 검사와 각 실패 메시지 확인, 데이터소스를 정하는 메서드와 그 자바독 확인, 저장소를 만드는 자리 전수와 각각이 넘기는 값 확인, JdbcOperations 구현과 JdbcTemplate 하위 클래스 검색, 세 번째 검사의 메시지를 단언하는 시험 검색, 가드의 두 메서드를 부르는 자리 전수, 형제 어댑터 둘의 데이터소스 주입과 세 검사 형태 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. IdempotencyCapabilityGuard 의 클래스 자바독을 읽는다. 세 질문과 틀린 답이 적혀 있다.
|
||||
2. 필드와 생성자를 읽고 어느 인자가 널 검사를 지나는지 본다.
|
||||
3. 전제를 검사하는 메서드의 본문을 끝까지 읽고 검사가 몇 개인지, 각각 어떤 메시지로 실패하는지 적는다.
|
||||
4. 데이터소스 검사에 붙은 조건을 읽고 그 값이 어디서 오는지 거슬러 올라간다.
|
||||
5. 그 값을 정하는 메서드와 자바독을 읽는다.
|
||||
6. 이 저장소를 만드는 자리를 전부 찾고 각각이 무엇을 넘기는지 확인한다.
|
||||
7. 저장소에 그 인터페이스의 다른 구현이 있는지 찾는다.
|
||||
8. 세 번째 검사가 던지는 메시지를 저장소에서 찾아 그것을 단언하는 시험이 있는지 본다.
|
||||
9. 가드의 두 메서드를 부르는 자리를 전부 찾는다.
|
||||
10. 형제인 outbox 와 inbox 어댑터가 데이터소스를 어떻게 받고 같은 세 검사를 어떤 형태로 거는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`IdempotencyCapabilityGuard` 는 멱등성 저장소가 SQL 을 돌리기 전에 만족해야 할 전제를 모아 둔 타입이다. 클래스 자바독이 이 타입이 따로 생긴 이유를 적는데, 전제가 서로 다른 세 질문이고 저장소가 세 번째를 틀리게 답했다는 것이다.
|
||||
|
||||
## 자바독이 남긴 사후 기록
|
||||
|
||||
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소를 만드는 자리 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
자바독 `:12`\~`:16` 은 세 질문을 나열한다. 스키마가 승인되었는가, 트랜잭션이 있는가, 그것이 이 저장소의 트랜잭션인가.
|
||||
|
||||
이어서 저장소가 세 번째를 틀리게 답했다고 적는다. 스레드에 활성 읽기 쓰기 트랜잭션이 있는지만 확인했는데 그 조건은 어느 데이터소스에서든 트랜잭션이 열려 있으면 참이고, 형제인 outbox 와 inbox 어댑터는 `hasResource(dataSource)` 를 확인하며 그것이 실제로 중요한 질문이라는 것이다.
|
||||
|
||||
`:18`\~`:21` 이 결과를 적는다. 데이터소스가 둘인 애플리케이션에서 다른 쪽의 트랜잭션 안에서 발행된 변경이 옛 검사를 통과했고, 이 저장소의 커넥션에서 트랜잭션 없이 실행됐으며, 원자적이어야 할 작업과 독립적으로 커밋됐다.
|
||||
|
||||
## 지금의 검사는 셋이다
|
||||
|
||||
`requirePrimaryWriteTransaction:92` 가 세 검사를 차례로 건다.
|
||||
|
||||
`:93` 이 `isActualTransactionActive()` 를 본다. 자바독이 틀렸다고 적은 바로 그 검사이고 지금도 첫 줄에 있다. `:97` 이 `isCurrentTransactionReadOnly()` 를 본다. `:101` 이 `dataSource != null && !hasResource(dataSource)` 를 본다.
|
||||
|
||||
`:102`\~`:103` 의 주석이 마지막 것은 outbox 와 inbox 어댑터가 이미 하는 검사이고, 이것이 없을 때 다른 데이터소스의 트랜잭션이 가드를 만족시키는 동안 이 저장소의 작업이 따로 커밋됐다고 적는다.
|
||||
|
||||
옛 검사를 지우지 않고 뒤에 검사 하나를 더했다. 세 실패가 각각 다른 메시지를 낸다.
|
||||
|
||||
## dataSource 가 널이면 \:101 을 건너뛴다
|
||||
|
||||
`:101` 의 조건은 데이터소스를 아는 경우에만 뒤쪽을 평가한다.
|
||||
|
||||
그 값이 어떻게 들어오는지는 생성자에 있다. `:36` 이 `jdbc` 를, `:38` 이 `activeCapabilitySql` 을 각각 `Objects.requireNonNull` 로 받는데 `:37` 의 `this.dataSource = dataSource` 만 그대로 대입한다. 널이 허용된다는 것이 이 세 줄에 나란히 적혀 있다.
|
||||
|
||||
넣는 쪽은 `PostgreSqlOwnerSafeIdempotencyStore:91` 이다. 생성자가 `dataSourceOf(jdbc)` 로 값을 만드는데, `:105`\~`:109` 의 그 메서드는 `jdbc` 가 `JdbcTemplate` 이면 `template.getDataSource()` 를 돌려주고 아니면 널을 돌려준다.
|
||||
|
||||
그 자바독(`:98`\~`:104`)이 절충을 인정한다. `JdbcTemplate` 은 자기 데이터소스를 알지만 손으로 만든 `JdbcOperations` 는 모를 수 있고, 가드는 알 수 없는 데이터소스를 "검사할 수 없음" 으로 다루는데 그것은 outbox 어댑터의 정확한 검사보다 약하고 이전보다는 강하며 협력자를 정말로 식별할 수 없을 때의 정직한 답이라는 것이다.
|
||||
|
||||
## 그 널 경로가 실제로 열리는 곳
|
||||
|
||||
이 저장소를 만드는 자리는 셋이다.
|
||||
|
||||
프로덕션은 `PostgreSqlIdempotencyProviderConfig:60` 하나이고 `JdbcOperations` 빈을 받는다. 그 메서드의 자바독 `:55`\~`:56` 은 저장소의 트랜잭션 가드가 그것으로부터 자기 데이터소스를 식별하므로 다른 데이터소스에서 연 트랜잭션은 통과할 수 없다고 적는다.
|
||||
|
||||
저장소에는 `JdbcOperations` 를 구현하거나 `JdbcTemplate` 을 상속하는 클래스가 0 건이다. 그러므로 이 저장소가 조립하는 배포에서 그 빈은 `JdbcTemplate` 이고 `:101` 은 살아 있다.
|
||||
|
||||
널 경로가 실제로 열려 있는 곳은 저장소 자신의 유닛 시험이다. `OwnerSafeIdempotencyPreconditionTest:45` 가 `mock(JdbcOperations.class)` 를 만들고 `:47` 이 그것으로 저장소를 만든다. 그 시험들이 도는 동안 `:101` 은 매번 건너뛰어진다.
|
||||
|
||||
## 그 검사를 덮는 시험이 없다
|
||||
|
||||
`:101` 이 던지는 메시지를 저장소 전체에서 찾으면 나오는 것은 던지는 줄 하나다. 그것을 단언하는 시험이 없다.
|
||||
|
||||
전제 시험 셋이 단언하는 것은 트랜잭션이 없는 경우(`:57`), 다른 벤더인 경우(`:67`), 승인되지 않은 스키마 스트림인 경우(`:81`)다. 세 번째 질문은 그 목록에 없다.
|
||||
|
||||
## 형제 어댑터와 같은 형태인가
|
||||
|
||||
`PostgreSqlImmutableOutboxAppendAdapter` 는 `:321` 에서 활성 트랜잭션을, `:325` 에서 읽기 전용 여부를, `:328` 에서 `hasResource` 를 각각 다른 `if` 로 검사한다. `PostgreSqlSameStoreInboxAdapter:503`\~`:505` 는 같은 셋을 한 조건으로 묶는다.
|
||||
|
||||
가드도 셋을 각각 다른 `if` 로 나누므로 outbox 와 같은 모양이다.
|
||||
|
||||
갈리는 것은 데이터소스를 얻는 방법이다. 형제 둘은 생성자가 `DataSource` 를 직접 받고 `:158` 과 `:210` 이 `Objects.requireNonNull` 로 거른다. 널일 수 없으므로 널 가드가 필요 없다. 가드는 `JdbcOperations` 에서 추론하고, 추론이 실패하면 널이 된다.
|
||||
|
||||
## 이 가드를 부르는 자리
|
||||
|
||||
`PostgreSqlOwnerSafeIdempotencyStore` 의 여섯 자리 — `:122`, `:176`, `:211`, `:255`, `:303`, `:360` — 가 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction` 으로 넘긴다. `requireActiveCapability` 를 부르는 자리는 `:123` 하나다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 이 수정이 세 번째 질문을 형제와 같은 형태로 바꾼 것이라고 적었다. 바꾼 것이 아니라 더한 것이다. `:93` 의 옛 검사가 그대로 첫 줄에 있다.
|
||||
|
||||
원문은 형제 어댑터의 검사를 `hasResource(dataSource)` 하나로 적었다. outbox 는 셋을 각각 다른 `if` 로 걸고 inbox 는 같은 셋을 한 조건으로 묶는다.
|
||||
|
||||
세 질문과 틀린 답, 데이터소스 둘일 때의 결과는 원문대로다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
데이터소스를 둘 띄워 옛 동작을 재연하지 않았다. 남아 있는 기록과 현재 코드와 형제 구현을 나란히 놓고 읽었다.
|
||||
|
||||
`JdbcTemplate` 이 아닌 다른 `JdbcOperations` 구현을 넘겨 `:101` 이 열린 채 지나가는 것을 실행으로 보이지 않았다. 조건과 그 값을 정하는 메서드와 유닛 시험이 넘기는 값을 읽은 데까지다.
|
||||
|
||||
이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다.
|
||||
|
||||
이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: expired-claim-versus-expired-execution
|
||||
title: 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
|
||||
topic: owner-safe-state-machines
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:expired-claim-versus-expired-execution
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: expired-claim-versus-expired-execution
|
||||
file: ../../../final/evidence/rendered/expired-claim-versus-expired-execution.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/expired-claim-versus-expired-execution.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §10.1, §10.4 이다.
|
||||
---
|
||||
|
||||
# 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
|
||||
|
||||
리스가 만료된 두 상태를 같이 다루면 안 된다. 청구만 하고 실행하지 않은 소유자는 밀어내도 되지만, 실행을 시작한 소유자는 무엇을 했는지 알 수 없으므로 조정으로 넘긴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
||||
이 사례에서 끌어낸 규칙이다.
|
||||
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
||||
조정으로 넘기는 판정이 그 규칙의 적용이다.
|
||||
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
||||
리스 만료를 다루는 맥락이다.
|
||||
|
||||
## 문제
|
||||
|
||||
리스가 만료되면 다른 작업자가 그 레코드를 가져갈 수 있어야 한다. 그러지 않으면 죽은 작업자의 레코드가 영원히 막힌다.
|
||||
|
||||
문제는 만료된 소유자가 무엇을 하다가 만료됐는지에 따라 안전한 처리가 다르다는 점이다.
|
||||
|
||||
## 결론
|
||||
|
||||
청구 결정 트리가 두 상태를 갈라 다르게 답한다.
|
||||
|
||||
같은 소유자 토큰이면 연산 충돌로 답한다
|
||||
상태가 CLAIMED 이고 리스가 지났으면 청구를 재설정한다. 즉 가져간다
|
||||
상태가 재시도 가능 실패면 마찬가지로 재설정한다
|
||||
상태가 EXECUTING 이고 리스가 지났으면 복구 필요로 답한다
|
||||
상태가 포기됨이면 복구 필요로 답한다
|
||||
그 외에는 진행 중으로 답하고 재시도 시각을 준다
|
||||
|
||||
CLAIMED 는 자리를 잡았지만 아직 아무것도 실행하지 않은 상태다. 그 소유자를 밀어내도 외부 효과가 없다.
|
||||
|
||||
EXECUTING 은 실행을 시작한 상태다. 그 소유자가 무엇을 어디까지 했는지 이 저장소는 모른다. 밀어내고 다시 실행하면 그 작업이 두 번 일어날 수 있다.
|
||||
|
||||
그래서 EXECUTING 만료는 자동 처리 대상이 아니라 조정 대상이다. 결과 타입이 복구 필요라는 별도 값을 갖고, 그 값이 시도 번호를 함께 들고 간다.
|
||||
|
||||
같은 구별이 해제 경로에도 있다. 소유자 튜플이 다르면 소유자 아님으로 답하고, 상태가 이미 EXECUTING 이면 실행이 시작되었음으로 답하며, CLAIMED 가 아니면 연산 충돌로 답한다. 해제는 아직 실행하지 않은 청구에 대해서만 허용된다.
|
||||
|
||||
같은 형태가 인박스 어댑터에도 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL
|
||||
확인 방식 : 결정 트리와 그 javadoc 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 소유자 안전 멱등성 저장소의 청구 결정 트리를 읽는다.
|
||||
2. CLAIMED 만료와 EXECUTING 만료가 각각 어떤 결과를 내는지 확인한다.
|
||||
3. 해제 경로의 상태 검사를 확인한다.
|
||||
4. 인박스 어댑터의 같은 형태를 비교한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
만료된 lease를 일률적으로 takeover하면 이미 실행이 시작된 작업을 blind retry하게 된다.
|
||||
|
||||
## 이 저장소는 상태로 나눈다
|
||||
|
||||
만료된 `CLAIMED`는 `resetClaim`으로 takeover하고, 만료된 `EXECUTING`은 `abandonExpiredExecution`으로 `ABANDONED`에 넣고 `RecoveryRequired`를 반환한다.
|
||||
|
||||
## RecoveryRequired 참조 위치
|
||||
|
||||
:::evidence key="expired-claim-versus-expired-execution" alt="코드베이스에서 RecoveryRequired 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RecoveryRequired 코드베이스 검색 — 10줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## inbox도 같은 축을 쓴다
|
||||
|
||||
`RECEIVED`(takeover 가능)와 `PROCESSING`(→ DEAD, recovery-required)로 나눈다. 즉 "claim만 했다"와 "실행에 들어갔다"가 만료 시 다른 결론을 낳는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
만료된 EXECUTING 이 실제로 조정 큐로 흘러가 사람이 처리하는 경로를 따라가지 않았다. 확인한 것은 저장소가 그 상태를 별도 결과로 답한다는 것이다.
|
||||
|
||||
컨테이너 레인 미실행
|
||||
|
||||
<!-- body:end -->
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: native-claim-did-not-bump-the-version
|
||||
title: native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
|
||||
topic: owner-safe-state-machines
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:native-claim-did-not-bump-the-version
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: native-claim-did-not-bump-the-version
|
||||
file: ../../../final/evidence/rendered/native-claim-did-not-bump-the-version.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/native-claim-did-not-bump-the-version.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §11.2 이다.
|
||||
---
|
||||
|
||||
# native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
|
||||
|
||||
네이티브 SQL 로 청구하면서 JPA 버전 컬럼을 올리지 않았다. 청구 전에 로드된 관리 엔티티가 옛 버전을 그대로 들고 있어서, 그 플러시가 성공하면서 리스와 상태를 청구 이전 값으로 덮었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **CAS 튜플과 update count가 답이 되는 구조**
|
||||
네이티브 청구가 쓰는 구조다.
|
||||
- **Atomic 타입의 존재는 원자성의 증거가 아니다**
|
||||
잠금 장치가 있다는 것과 그것이 충돌을 보고한다는 것이 다르다는 점에서 같은 계열이다.
|
||||
|
||||
## 문제
|
||||
|
||||
수신자 전달 청구는 네이티브 SQL 로 이뤄진다. 리스 소유자와 리스 만료와 펜스를 설정하고 상태를 전달 중으로 바꾼다.
|
||||
|
||||
같은 테이블에 JPA 엔티티가 매핑되어 있고 그 엔티티에는 버전 컬럼이 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
네이티브 문장이 버전 컬럼을 함께 올리지 않으면 낙관적 잠금이 무력해진다.
|
||||
|
||||
문장 안의 주석이 그 이유를 정확히 적는다. 이것이 JPA 의 버전 컬럼이고 네이티브 쓰기가 그것을 전진시킨다는 것이다. 이것이 없으면 청구 전에 로드된 관리 엔티티가 여전히 옛 버전을 들고 있고, 그 플러시가 성공하면서 리스와 상태를 청구 이전 값으로 덮는다. 충돌이 없다고 보고하는 낙관적 잠금이 되는 것이다.
|
||||
|
||||
문제의 성격이 특이하다. 낙관적 잠금은 있고 동작하며 예외를 던지지 않는다. 던지지 않는 이유가 네이티브 문장이 버전이 바뀌었다고 알려 주지 않았기 때문이다.
|
||||
|
||||
그래서 증상은 잠금 실패가 아니라 조용한 덮어쓰기다. 청구가 성공했고 다른 트랜잭션의 플러시가 그것을 되돌린다.
|
||||
|
||||
수정은 네이티브 문장의 SET 절에 버전 증가를 넣는 것이다. 같은 문장이 펜스도 함께 올린다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL
|
||||
확인 방식 : 문장과 그 주석 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 수신자 청구 SQL 의 SET 절을 읽는다.
|
||||
2. 버전 컬럼 증가 줄과 그 위 주석을 확인한다.
|
||||
3. 같은 문장이 펜스를 올리는지 확인한다.
|
||||
4. 같은 테이블에 매핑된 엔티티의 버전 컬럼을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
claim이 native `UPDATE`인데 JPA `@Version` 컬럼을 올리지 않으면, claim 전에 로드된 managed 엔티티가 여전히 옛 version을 들고 있다.
|
||||
|
||||
## native UPDATE 와 managed 엔티티
|
||||
|
||||
:::evidence key="native-claim-did-not-bump-the-version" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 충돌을 보고하지 않는 낙관적 잠금이 된다
|
||||
|
||||
그 flush가 **성공하면서 lease와 state를 pre-claim 값으로 덮어쓴다.** native statement가 충돌이 있었다고 말해주지 않았기 때문이다.
|
||||
|
||||
## 수정은 한 줄이고 주석이 붙어 있다
|
||||
|
||||
statement에 `version = d.version + 1`을 넣는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
청구 전에 로드한 엔티티를 플러시해 덮어쓰기를 재현하지 않았다. 이 기록은 문장과 그 주석에 근거한다.
|
||||
|
||||
컨테이너 레인 미실행
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user