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
+124
@@ -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 -->
|
||||
+126
@@ -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 -->
|
||||
+122
@@ -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 -->
|
||||
Reference in New Issue
Block a user