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:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -0,0 +1,124 @@
---
kind: CONCEPT
slug: capability-schema-registry
title: capability_schema_registry — 스키마 적용과 사용 승인의 분리
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:capability-schema-registry
evidenceCapturedOn: 2026-09-01
assets:
- key: capability-schema-registry
file: ../../../final/evidence/rendered/capability-schema-registry.svg
- key: capability-schema-registry-diagram
file: ../../../final/assets/diagrams/capability-schema-registry.svg
evidence:
- ../../../final/evidence/raw/capability-schema-registry.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §8.4, §11.3 이다.
---
# capability_schema_registry — 스키마 적용과 사용 승인의 분리
테이블이 만들어졌다는 것과 그 능력을 써도 된다는 것을 별개의 사실로 기록한다. 레지스트리 테이블이 능력별 스키마 스트림과 설치 출처와 에포크를 담는다.
## 관계
- **capability는 스키마 적용과 사용 승인을 분리한다**
이 구조를 채택한 결정이다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
같은 원칙의 능력 등급 판이다.
## 본문
<!-- body:start -->
독립 Flyway 스트림 설계의 접착제다. 각 capability 스트림의 V1이 세 단계를 밟는다 — (1) 선행조건 검사(`DO $$ ... RAISE EXCEPTION`으로 core epoch가 ACTIVE인지), (2) 테이블 생성, (3) **자기를 `INSTALLED_INACTIVE`로 등록**.
## 적용과 사용 승인이 갈리는 자리
:::evidence key="capability-schema-registry-diagram" alt="선행조건 검사에서 테이블 생성으로 core epoch 확인이 건너가고 테이블 생성에서 레지스트리 등록으로 INSTALLED_INACTIVE 가 건너간다" caption="적용과 사용 승인이 갈리는 자리" zoom="false"
:::
## 어댑터가 런타임에 다시 묻는다
어댑터가 `capability_id` + `core_epoch` + `feature_revision` + `lifecycle_state='ACTIVE'`를 조회해 확인한다. 그래서 "스키마가 적용됐다"와 "capability를 써도 된다"가 분리된다.
## 각 스트림 V1 이 밟는 세 단계
:::evidence key="capability-schema-registry" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 호출마다 묻지 않는 이유
확인은 startup에서만 하고 호출마다 하지 않는다 — "승격되지 않은 스트림은 배포 상태이고, 매 호출마다 묻는 것은 프로세스가 도는 동안 바뀔 수 없는 질문에 round trip을 넣는 것"이다.
:::note
없음
:::
## 왜 두 사실을 나누는가
스키마가 설치되었다는 것은 마이그레이션이 돌았다는 뜻이다. 그 능력을 써도 된다는 것은 운영자가 그렇게 정했다는 뜻이다.
둘을 하나로 보면 마이그레이션을 돌리는 행위가 곧 승인이 된다. 그러면 롤백이나 단계적 활성화 같은 운영 판단이 스키마 배포와 묶인다.
## 다리 마이그레이션
```sql
-- Bridge migration: preserve the immutable V1/V3/V4/V5 legacy history and record its
-- installation origin before independent JPA capability streams are adopted.
```
이 마이그레이션의 역할은 두 가지다. 이전의 불변 이력을 보존하는 것과 그 설치 출처를 기록하는 것이다.
## 전제를 먼저 검사한다
```sql
DO $$
BEGIN
IF to_regclass('public.idempotency_record') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires idempotency_record';
END IF;
IF to_regclass('public.outbox_event') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires outbox_event';
END IF;
IF to_regclass('public.int_lock') IS NULL THEN
RAISE EXCEPTION 'legacy adoption requires INT_LOCK';
END IF;
END
$$;
```
레거시 채택은 그 레거시가 실제로 있을 때만 의미가 있다. 없는데 진행하면 빈 레지스트리가 만들어지고, 그 뒤의 판단이 전부 그 빈 값 위에 선다.
:::note
마이그레이션이 자기 전제를 검사하고 실패하는 것은, 잘못된 상태를 만들어 놓고 나중에 발견되는 것보다 낫다. 여기서는 세 테이블의 존재를 각각 이름으로 확인한다.
:::
## 레지스트리가 담는 것
```sql
CREATE TABLE capability_schema_registry (
capability_id varchar(128) NOT NULL,
schema_stream varchar(32) NOT NULL,
installation_origin varchar(32) NOT NULL,
core_epoch integer NOT NULL,
```
능력 식별자와 스키마 스트림과 설치 출처와 코어 에포크다.
설치 출처가 있다는 것이 이 설계의 요점이다. 같은 스키마라도 레거시 채택으로 들어온 것과 새 스트림으로 설치된 것이 구별된다.
## 능력 스트림
각 능력이 자기 V1 마이그레이션을 갖는다. 능력별로 스키마 이력이 독립적이므로, 한 능력의 스키마 변경이 다른 능력의 마이그레이션 번호를 밀지 않는다.
승인 쪽은 별도 코드가 판정한다. 알림 능력의 스키마 활성화가 그 예다.
<!-- body:end -->
@@ -0,0 +1,126 @@
---
kind: CONCEPT
slug: cas-tuple-and-update-count
title: CAS 튜플과 update count가 답이 되는 구조
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:cas-tuple-and-update-count
evidenceCapturedOn: 2026-09-01
assets:
- key: cas-tuple-and-update-count
file: ../../../final/evidence/rendered/cas-tuple-and-update-count.svg
- key: cas-tuple-and-update-count-diagram
file: ../../../final/assets/diagrams/cas-tuple-and-update-count.svg
evidence:
- ../../../final/evidence/raw/cas-tuple-and-update-count.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §10.1, §10.3 이다.
---
# CAS 튜플과 update count가 답이 되는 구조
상태를 전이시키는 모든 문장이 소유자 튜플 전체를 where 절에 반복한다. 그래서 갱신 건수가 곧 답이 된다. 한 건이면 이 소유자가 이 리비전에서 여전히 소유자였다는 뜻이고, 0 이면 다른 무언가가 레코드를 움직였다는 뜻이다.
## 관계
- **fenced lease — 만료 시각만으로는 부족한 이유**
이 구조가 강제하는 소유권 모델이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
이 개념을 규칙으로 옮긴 것이다.
- **native claim이 Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다**
같은 구조가 JPA 버전 컬럼과 만나는 지점의 사례다.
## 본문
<!-- body:start -->
상태 전이를 "읽고 → 판단하고 → PK로 update"하면 그 사이에 takeover한 worker의 상태를 덮어쓴다. 이 저장소의 형태는 소유권 튜플 전체(scope · owner token · attempt · claim operation id · state revision)를 where 절에 반복하고 **update count 자체를 답으로 쓰는** 것이다.
## 판정이 되는 갱신 행 수
:::evidence key="cas-tuple-and-update-count-diagram" alt="소유권 튜플 조건부 UPDATE 에서 한 행 갱신과 영 행 갱신 두 갈래가 나온다" caption="판정이 되는 갱신 행 수" zoom="false"
:::
1이면 이 owner가 그 revision에서 여전히 owner였고, 0이면 다른 무언가가 record를 움직였으니 caller는 자기 view를 현재로 취급하면 안 된다.
## where 절에 반복되는 소유권 튜플
:::evidence key="cas-tuple-and-update-count" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## polling delivery가 더 얹는 조건
authority `EXISTS` 서브쿼리를 더해 cutover를 가로지르지 못하게 한다.
:::note
컨테이너 레인 미실행 — 동시 claim에서 실제로 0행이 나오는지 관측하지 않았다
:::
## 문장이 답을 만든다
```java
/**
* The statements that advance a claim its owner already holds.
*
* <p>Every one repeats the complete owner tuple — scope, token, attempt, claim operation and state
* revision — in its {@code where} clause, so the update count <em>is</em> the answer: one row means
* this owner was still the owner at this revision, zero means something else moved the record and
* the caller must not treat its own view as current.
*/
```
튜플은 다섯이다. 스코프, 토큰, 시도 번호, 청구 연산, 상태 리비전.
## 읽고 나서 쓰면 안 되는 이유
```java
/**
* Reading the row and then updating on the scope
* alone would let a worker whose lease expired overwrite the state of the one that took over.
*/
```
읽기와 쓰기 사이에 다른 작업자가 들어올 수 있다. 스코프만으로 갱신하면 그 사이의 변화를 보지 못한다.
## 상태 리비전이 함께 오르는 이유
```sql
update idempotency_record
set status = 'EXECUTING',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'START',
last_transition_result_digest = ?,
```
전이마다 리비전이 오른다. 그래서 같은 소유자라도 자기가 본 리비전이 아니면 갱신이 0 건이 된다. 소유권만으로는 부족하고 시점까지 맞아야 한다.
## 한 자리에 모으는 이유
```java
/**
* <p>Collected here rather than in the store because they are one family: same guard, same
* interpretation of the count, same reason a caller may not skip the guard. The store decides which
* of them a given outcome permits.
*/
```
같은 가드와 같은 해석을 공유하는 문장들을 한 타입에 둔다. 저장소는 어떤 결과에 어떤 전이가 허용되는지만 정한다.
이 분리가 하는 일은 가드를 건너뛰는 문장이 새로 생기지 않게 하는 것이다. 문장이 저장소에 흩어져 있으면 하나가 where 절을 짧게 쓰는 것을 막을 방법이 없다.
## 같은 구조가 다른 곳에도 있다
outbox 폴링 전달 어댑터의 완료 CAS 문장 셋이 같은 형태다. 최종 상태 쓰기가 자기 획득 토큰을 지목하고, 밀려난 작업자의 쓰기는 0 건이 된다.
:::tip
이 구조에서 예외는 실패를 뜻하지 않는다. 갱신 건수 0 은 정상적인 답이고, 그 답을 어떻게 해석할지는 호출자가 정한다. 그래서 경합이 예외 처리 경로가 아니라 정상 경로에 있다.
:::
<!-- body:end -->
@@ -0,0 +1,122 @@
---
kind: CONCEPT
slug: fenced-lease
title: fenced lease — 만료 시각만으로는 부족한 이유
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:fenced-lease
evidenceCapturedOn: 2026-09-01
assets:
- key: fenced-lease
file: ../../../final/evidence/rendered/fenced-lease.svg
evidence:
- ../../../final/evidence/raw/fenced-lease.txt
source:
- 원본 분석 절은 final/document.md#4-1, #4-3 · analysis/05 §10 · analysis/19 §7.3 이다.
---
# fenced lease — 만료 시각만으로는 부족한 이유
리스에 만료 시각만 기록하면 언제 끝나는지는 알아도 누가 들고 있는지는 모른다. 소유자와 증가하는 토큰을 함께 기록하면 만료된 작업자의 쓰기가 아무 행에도 맞지 않게 된다.
## 관계
- **lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다**
이 개념이 필요해진 사례다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
펜싱 토큰을 실제로 강제하는 방법이다.
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
리스가 만료됐을 때의 처리를 갈라야 하는 이유다.
## 본문
<!-- body:start -->
lease가 "언제 끝나는가"만 기록하고 "누가 들고 있는가"를 기록하지 않으면 만료를 지난 worker가 여전히 쓸 수 있다. V2 마이그레이션 헤더가 그 시나리오를 3단계로 적는다 — relay A가 claim하고 브로커를 부름 / lease 만료, relay B가 재claim하고 발행하고 PUBLISHED 기록 / relay A가 타임아웃 후 그 위에 AMBIGUOUS를 씀.
## V2 헤더가 적은 3단계 시나리오
:::evidence key="fenced-lease" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## lease를 늘리는 것이 해법이 아닌 이유
"**Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall**..."
## 해법과 그 반복
소유자와 fencing token을 행에 기록하고 terminal write가 그 튜플로 매칭하는 것이다. 같은 결함이 이 저장소에서 최소 세 곳(messaging outbox·fileserver cleanup·notification dispatcher)에 나타났다.
:::note
없음 — 마이그레이션과 claim SQL을 코드로 확인했다
:::
## 만료 시각만 있을 때 일어나는 일
메시징 outbox 의 V1 스키마는 리스 만료 시각만 기록했다. 릴레이의 최종 상태 쓰기는 메시지 식별자만으로 행을 찾았다.
마이그레이션 헤더가 그 결과를 순서대로 적는다.
```text
relay A claims the row and calls the broker
the lease expires; relay B reclaims it, publishes, and records PUBLISHED
relay A finally times out and records AMBIGUOUS over the top
```
세 줄이 끝나면 행은 다시 청구 가능한 상태가 되고 메시지는 두 번째로 발행된다.
## 리스를 늘리는 것은 해법이 아니다
```text
Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a
scheduler stall or a slow broker into a data constraint.
```
확률을 낮추는 것과 불변식을 만드는 것은 다르다. GC 정지나 스케줄러 지연이나 느린 브로커는 시간 여유로 없앨 수 있는 것이 아니다.
## 토큰이 하는 일
```text
A token does: every terminal write names the acquisition it belongs to, and a superseded worker's
write matches nothing.
```
모든 최종 쓰기가 자기가 속한 획득을 지목한다. 밀려난 작업자의 쓰기는 어떤 행에도 맞지 않는다. 실패가 아니라 갱신 건수 0 이 되고, 그것이 답이 된다.
## 스키마가 담는 것
```sql
ALTER TABLE messaging_outbox
ADD COLUMN lease_owner VARCHAR(160),
ADD COLUMN lease_token BIGINT NOT NULL DEFAULT 0,
ADD COLUMN next_attempt_at TIMESTAMPTZ;
ALTER TABLE messaging_outbox
ADD CONSTRAINT ck_messaging_outbox_lease_token CHECK (lease_token >= 0);
```
백필이 정확성에 필요하지 않다는 것도 헤더가 적는다. 기본값이 0 이고 첫 청구가 그것을 올린다. 제약은 코드가 의존하는 불변식을 문장으로 남기기 위한 것이다.
## 같은 마이그레이션이 함께 고친 것
```sql
-- EXHAUSTED is a new terminal state: the attempt budget ran out without any confirmation, which is
-- not the same as the broker rejecting the message. V1's CHECK listed five states, so writing the
-- sixth failed at the constraint rather than at review.
```
:::note
상태 목록을 CHECK 제약으로 닫아 두면, 새 상태를 추가하는 변경이 리뷰가 아니라 제약에서 실패한다. 그것이 의도된 동작이다 — 상태 어휘의 확장이 조용히 일어나지 않는다.
:::
## 같은 형태가 다른 곳에도 있다
파일서버 리프의 V3 마이그레이션이 같은 문제를 같은 방식으로 푼다. 리스 소유자와 펜스를 함께 기록한다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: PROJECT_DECISION
slug: capability-separates-installation-from-activation
title: capability는 스키마 적용과 사용 승인을 분리한다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:capability-separates-installation-from-activation
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V6__capability_schema_registry_adoption.sql
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java
- analysis/05-adapter-outbound-persistence-jpa.md
---
# capability는 스키마 적용과 사용 승인을 분리한다
## 결정문
능력의 스키마가 설치되었다는 사실과 그 능력을 사용해도 된다는 승인을 별개의 기록으로 둔다.
## 판단 이유
두 사실은 서로 다른 주체가 만든다. 설치는 마이그레이션이 돌면 일어나고, 승인은 운영자가 정한다.
하나로 묶으면 마이그레이션 배포가 곧 활성화가 된다. 그러면 단계적 활성화나 롤백 같은 운영 판단이 스키마 배포 일정에 묶인다.
그래서 레지스트리 테이블이 능력별로 스키마 스트림과 설치 출처와 코어 에포크를 기록한다. 설치 출처가 별도 컬럼인 것이 요점이다. 같은 스키마라도 레거시 채택으로 들어온 것과 새 스트림으로 설치된 것이 구별된다.
능력마다 자기 스키마 스트림을 두므로 한 능력의 스키마 변경이 다른 능력의 마이그레이션 번호를 밀지 않는다.
다리 마이그레이션은 자기 전제를 먼저 검사한다. 레거시 채택은 그 레거시가 실제로 있을 때만 의미가 있고, 없는데 진행하면 빈 레지스트리 위에 이후 판단이 전부 선다.
## 영향
감수하는 것
능력을 쓰려면 두 단계를 거쳐야 한다. 스키마만 설치하고 승인을 잊으면 그 능력은 동작하지 않는다.
레지스트리 자체가 관리 대상이 된다. 능력이 늘 때마다 항목이 늘고 그 정합성을 지켜야 한다.
마이그레이션이 전제 검사에서 실패할 수 있다. 그것이 의도이지만 배포 절차가 그 실패를 다룰 줄 알아야 한다.
얻는 것
스키마 배포와 능력 활성화가 독립적이다. 스키마를 먼저 깔아 두고 나중에 켤 수 있다.
설치 출처가 남아 있어 나중에 이 스키마가 어디서 왔는지 물을 수 있다.
## 근거
- **capability_schema_registry — 스키마 적용과 사용 승인의 분리**
이 결정이 만든 구조다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
같은 원칙이 능력 등급에 적용된 결정이다.
@@ -0,0 +1,56 @@
---
kind: PROJECT_DECISION
slug: state-machines-carry-no-stereotype
title: 상태 기계 구현은 Spring stereotype을 갖지 않는다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:state-machines-carry-no-stereotype
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
- analysis/05-adapter-outbound-persistence-jpa.md
---
# 상태 기계 구현은 Spring stereotype을 갖지 않는다
## 결정문
소유자 안전 상태 기계 구현 클래스에는 컴포넌트 스캔 대상이 되는 애너테이션을 붙이지 않고, 조립은 명시적으로 한다.
## 판단 이유
이 클래스들은 어떤 데이터소스와 어떤 트랜잭션 경계에 묶이는지가 정확해야 한다. 스캔으로 들어오면 그 결정이 스캔 범위와 조건에 흩어진다.
같은 리프에서 그 흩어짐이 실제로 문제가 된 사례가 있다. 활성 트랜잭션 검사가 어느 데이터소스인지를 묻지 않아, 다른 데이터소스의 트랜잭션 안에서 발행된 변경이 이 저장소의 커넥션에 대해 트랜잭션 밖에서 커밋됐다.
명시적 조립은 그 결정을 한 자리에 모은다. 어떤 데이터소스가 어떤 저장소에 들어가는지가 코드로 보인다.
그리고 스캔되지 않으면 조건을 반복할 필요도 없다. 능력이 꺼진 배포에서 이 클래스들이 후보가 되는 일 자체가 없다.
## 영향
감수하는 것
조립 코드를 사람이 써야 한다. 새 상태 기계를 추가하면 조립 지점도 함께 고쳐야 한다.
조립을 잊으면 그 상태 기계가 없는 채로 배포된다. 스캔은 그 실수를 자동으로 막아 주지만 명시 조립은 그렇지 않다.
얻는 것
데이터소스와 트랜잭션 경계가 조립 지점에서 명시된다.
능력이 꺼진 배포에서 이 클래스들이 조건 평가 대상이 되지 않는다.
## 근거
- **활성 트랜잭션 검사가 data source를 묻지 않아 다른 커넥션에서 커밋됐다**
조립 결정이 흩어졌을 때의 결과다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
같은 계열의 조립 원칙이다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
명시 조립을 확인할 때 쓰는 규칙이다.
@@ -0,0 +1,72 @@
---
kind: QUESTION
slug: v2-state-machine-lanes-not-executed
title: V2 상태 기계 넷의 컨테이너 레인이 실행되지 않았다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:v2-state-machine-lanes-not-executed
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# V2 상태 기계 넷의 컨테이너 레인이 실행되지 않았다
소유자 안전 상태 기계의 불변식은 실제 데이터베이스에서만 확인된다. 그 레인들을 이 리비전에서 돌리지 않았다.
## 사실
상태 기계 넷의 CAS 문장과 결정 트리를 코드로 확인했다.
리스 펜싱 마이그레이션과 그 제약을 확인했다.
이 컨테이너에 Docker 가 있고 기본 test 레인은 전부 돌았다. persistence-jpa 는 477 테스트가 통과했다.
특수 레인과 릴리스 게이트 태스크는 돌리지 않았다.
## 가정
기본 test 레인이 통과했으므로 상태 기계의 단위 수준 동작은 검증되었다고 전제하고 있다. 그러나 경합과 리스 만료는 단위 테스트에서 재현되지 않는 경우가 많다.
## 미지수
실제 PostgreSQL 에 대고 두 작업자가 같은 행을 경합할 때 CAS 튜플이 의도대로 동작하는가.
리스 만료 구간에서 인계와 조정 요구가 결정 트리대로 갈리는가.
펜싱 토큰이 밀려난 작업자의 최종 쓰기를 실제로 0 건으로 만드는가.
## 제약
컨테이너가 필요하다.
major 버전마다 별도 잡으로 나눠 돌려야 한다. 다중 선택은 거부된다.
애플리케이션 소스를 수정하지 않는다.
## 선택지
상태 기계 레인을 개별로 돌린다
실패를 격리하기 좋다.
릴리스 게이트를 돌려 한 번에 확인한다
게이트의 조립까지 함께 검증되지만 실패 지점을 좁히는 데 시간이 더 든다.
## 다음 검증
persistence-jpa 의 상태 기계 관련 컨테이너 레인을 실행하고 종료 코드와 실패 목록을 기록한다. 릴리스 게이트를 함께 돌리면 다른 미해결 질문도 같이 닫힌다.
레인이 통과하면 이 질문을 닫는다.
실패가 나오면 각각이 새 Case 후보가 되고, 그 실패가 CAS 튜플의 문제인지 리스 판정의 문제인지 구분해 기록한다.
## 관계
- **컨테이너가 필요한 특수 레인의 실제 결과를 실행으로 확인하지 않았다**
이 질문의 상위 항목이다.
- **CAS 튜플과 update count가 답이 되는 구조**
이 질문이 검증하려는 메커니즘이다.
- **만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다**
이 질문이 검증하려는 결정 트리다.
@@ -0,0 +1,62 @@
---
kind: REFERENCE
slug: cas-tuple-in-the-where-clause
title: CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:cas-tuple-in-the-where-clause
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
## 목적
행을 읽고 나서 갱신하는 형태를 없애, 리스가 만료된 작업자가 인계받은 작업자의 상태를 덮는 것을 막는다.
## 규칙
1. 소유자 튜플 전체를 where 절에 반복한다
스코프만으로 갱신하지 않는다. 소유자와 토큰과 시도와 상태 리비전을 전부 조건에 넣는다.
2. 갱신 건수가 답이다
한 건이면 이 소유자가 이 리비전에서 여전히 소유자였다는 뜻이고, 0 이면 다른 무언가가 레코드를 움직였다는 뜻이다.
3. 0 건은 예외가 아니라 정상 경로다
경합을 예외 처리로 다루면 그 경로가 테스트되지 않는다. 건수를 값으로 받아 호출자가 해석한다.
4. 전이마다 상태 리비전을 올린다
소유권만으로는 부족하다. 자기가 본 시점까지 맞아야 한다.
5. 같은 가드를 쓰는 문장을 한 자리에 모은다
흩어져 있으면 그중 하나가 조건을 짧게 쓰는 것을 막을 수 없다.
## 적용 조건
여러 작업자가 같은 행을 놓고 경합하는 모든 상태 기계
리스나 청구로 소유권을 표현하는 테이블
## 예외
단일 작업자만 접근하는 것이 구조적으로 보장되는 테이블은 대상이 아니다. 그 보장이 무엇인지 적혀 있어야 한다.
## 예시
멱등성 전이 문장들이 스코프와 토큰과 시도와 청구 연산과 상태 리비전을 전부 조건에 반복한다.
outbox 폴링 전달 어댑터의 완료 문장 셋이 같은 형태다.
네이티브 청구 문장이 JPA 버전 컬럼을 함께 올린다. 그러지 않으면 청구 이전에 로드된 엔티티의 플러시가 청구를 덮는다.
## 관계
- **CAS 튜플과 update count가 답이 되는 구조**
이 규칙이 나온 개념이다.
- **lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다**
이 규칙이 없을 때의 결과다.
- **native claim이 Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다**
튜플을 반복해도 다른 잠금 장치와 어긋날 수 있다는 사례다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: digest-must-be-length-framed-and-versioned
title: digest는 길이 프레이밍하고 버전을 붙인다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:digest-must-be-length-framed-and-versioned
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# digest는 길이 프레이밍하고 버전을 붙인다
## 목적
다이제스트가 서로 다른 입력에 대해 같은 값을 내거나, 구성이 바뀐 뒤 옛 값과 비교되는 것을 막는다.
## 규칙
1. 무엇을 덮는지가 정책이다
다이제스트가 빠뜨린 입력은 두 개의 다른 대상이 같은 값을 낼 수 있는 입력이다. 그 목록은 구현 세부가 아니라 정책이므로 자기 타입을 갖는다.
2. 결과를 덮는다
누가 언제 했는지만 덮으면 무엇을 했는지가 다른 두 전이가 같아진다.
3. 길이 프레이밍한다
구성 요소가 가변 길이 텍스트이고 그중 하나라도 이 플랫폼이 제약할 수 없는 값이면, 구분자로 이었을 때 서로 다른 목록이 한 문자열로 렌더링될 수 있다.
4. 버전을 붙이고 구성이 바뀌면 올린다
저장된 다이제스트가 구성 경계를 넘어 비교되지 않게 한다.
5. 비교 실패를 조용히 처리하지 않는다
버전이 다르면 같다고도 다르다고도 결론 내리지 않는다.
## 적용 조건
재생 판정이나 중복 판정에 쓰이는 모든 다이제스트
멱등성 키와 요청 지문
## 예외
캐시 키처럼 충돌이 성능 문제일 뿐 정확성 문제가 아닌 경우는 이 규칙이 과하다.
## 예시
전이 다이제스트가 전이 종류와 연산과 소유자와 시도와 리비전만 덮어, 재시도 가능한 실패와 포기한 실패가 같은 값을 냈다. 서로 다른 응답을 담은 두 완료도 마찬가지였다.
소유자 토큰은 이 플랫폼이 형식을 제약하는 값이 아니므로 길이 프레이밍이 필요하다.
## 관계
- **transition digest가 누가와 언제만 덮고 무엇을 덮지 않아 다른 전이를 같다고 보고했다**
이 규칙을 만든 사례다.
- **서명된 커서의 구조와 검증 순서**
같은 계열의 형식 결정을 다룬다.
@@ -0,0 +1,58 @@
---
kind: REFERENCE
slug: expired-claim-and-expired-execution-differ
title: 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:expired-claim-and-expired-execution-differ
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
## 목적
리스 만료를 한 가지로 처리해, 실행을 시작했던 소유자의 작업을 두 번 수행하는 것을 막는다.
## 규칙
1. 만료된 청구는 인계한다
자리를 잡았지만 아직 아무것도 실행하지 않은 소유자를 밀어내도 외부 효과가 없다.
2. 만료된 실행은 조정으로 넘긴다
그 소유자가 무엇을 어디까지 했는지 알 수 없다. 다시 실행하면 그 작업이 두 번 일어날 수 있다.
3. 조정 결과는 별도 값이어야 한다
성공이나 실패로 접으면 그 구별이 사라진다. 결과 타입에 세 번째 변형이 필요하다.
4. 해제도 같은 구별을 따른다
실행이 시작된 청구는 해제할 수 없다. 해제는 아직 실행하지 않은 청구에만 허용한다.
5. 재시도 가능 실패는 인계 대상이다
그 상태는 이미 결과가 확정된 것이므로 새 소유자가 처음부터 시작해도 된다.
## 적용 조건
청구와 실행을 별도 상태로 갖는 모든 상태 기계
멱등성 저장소와 인박스와 아웃박스
## 예외
실행이 외부 효과를 남기지 않는 것이 구조적으로 보장되면 두 상태를 같이 다뤄도 된다. 그 보장을 적어 둔다.
## 예시
청구 결정 트리가 만료된 청구는 재설정하고 만료된 실행은 복구 필요로 답한다. 복구 필요 결과는 시도 번호를 함께 들고 간다.
해제 경로는 이미 실행이 시작된 경우 실행 시작됨으로 답하고 해제하지 않는다.
## 관계
- **만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다**
이 규칙을 만든 사례다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
세 번째 규칙이 기대는 상위 규칙이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: read-the-clock-after-the-lock
title: 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:read-the-clock-after-the-lock
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
## 목적
애플리케이션 시계로 리스 만료를 판단하거나 잠그기 전의 시각으로 판단해, 서로 다른 노드가 같은 행에 대해 다른 답을 내는 것을 막는다.
## 규칙
1. 시각은 데이터베이스에서 읽는다
여러 노드의 시계는 서로 다르다. 리스 만료 판정의 기준 시각이 노드마다 다르면 두 노드가 동시에 소유자가 될 수 있다.
2. 행을 잠근 다음에 읽는다
잠그기 전의 시각으로 판단하면 잠금을 기다리는 동안 리스가 만료될 수 있다.
3. 판정과 갱신을 한 문장 안에 둔다
시각 비교를 where 절에 넣으면 판정과 갱신 사이에 시간이 흐르지 않는다.
4. 만료 시각을 계산할 때도 같은 시계를 쓴다
읽은 시각과 쓰는 시각의 출처가 다르면 리스 길이가 의도와 달라진다.
## 적용 조건
리스와 청구와 예약처럼 시각이 소유권을 정하는 모든 상태 기계
여러 인스턴스가 같은 테이블을 폴링하는 구조
## 예외
단일 인스턴스만 접근하고 그 보장이 구조적인 경우는 애플리케이션 시계로 충분하다. 그 보장을 적어 둔다.
## 예시
청구 결정 트리가 데이터베이스에서 읽은 현재 시각으로 리스 만료를 판정한다.
리스를 발행 타임아웃보다 길게 두는 것은 확률을 낮출 뿐이고, GC 정지나 스케줄러 지연을 데이터 제약으로 바꾸지 않는다.
## 관계
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 문장 안에서 함께 쓰이는 규칙이다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
시각만으로 부족한 이유를 다룬 개념이다.