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>
149 lines
6.3 KiB
Markdown
149 lines
6.3 KiB
Markdown
---
|
|
kind: CONCEPT
|
|
slug: commit-evidence-phases
|
|
title: 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지
|
|
topic: commit-ambiguity-as-a-result
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: concept:commit-evidence-phases
|
|
evidenceCapturedOn: 2026-09-01
|
|
assets:
|
|
- key: commit-evidence-phases
|
|
file: ../../../final/evidence/rendered/commit-evidence-phases.svg
|
|
- key: commit-evidence-phases-diagram
|
|
file: ../../../final/assets/diagrams/commit-evidence-phases.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/commit-evidence-phases.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#3-2 · analysis/05 §3.4, §2.3 이다.
|
|
---
|
|
|
|
# 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지
|
|
|
|
트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하고, COMMITTING 단계에서 관측된 실패만 completion-unknown 이 될 수 있게 하는 메커니즘이다.
|
|
|
|
## 관계
|
|
|
|
- **커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다**
|
|
이 메커니즘의 저장 구조가 실제로 깨졌던 사례다.
|
|
- **pg_terminate_backend가 57P01로 도착하고 커밋 레코드는 이미 WAL에 있었다**
|
|
이 단계 모델의 다른 절반인 SQLSTATE 판정이 넓어진 사례다.
|
|
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
|
UNKNOWN 이 실제 상태인 이유를 규칙으로 옮긴 것이다.
|
|
- **completion-unknown은 자동으로도 수동으로도 재시도하지 않는다**
|
|
이 모델의 UNKNOWN 을 어떻게 다룰지 정한 결정이다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하는 메커니즘의 설명이다. `NOT_STARTED → ACTIVE → COMMITTING → COMMITTED | ROLLED_BACK | UNKNOWN`이고, **`COMMITTING` 단계에서 관측된 실패만** completion-unknown이 될 수 있다는 것이 전체 모델의 핵심이다.
|
|
|
|
## COMMITTING 에서만 갈리는 세 결과
|
|
|
|
:::evidence key="commit-evidence-phases-diagram" alt="COMMITTING 에서 COMMITTED 와 ROLLED_BACK 과 UNKNOWN 세 갈래가 나온다" caption="COMMITTING 에서만 갈리는 세 결과" zoom="false"
|
|
:::
|
|
|
|
## EvidenceAwareJpaTransactionManager 참조 위치
|
|
|
|
:::evidence key="commit-evidence-phases" alt="코드베이스에서 EvidenceAwareJpaTransactionManager 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="EvidenceAwareJpaTransactionManager 코드베이스 검색 — 3줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
## 표식을 provider commit 직전에 찍는 이유
|
|
|
|
`mark(COMMITTING)`을 provider commit **직전**에 찍는다. 그 안에서 프로세스가 죽으면 마지막 기록이 "물어봤고 모른다"여야 하기 때문이다.
|
|
|
|
## 컨텍스트가 단일 슬롯이 아닌 이유
|
|
|
|
`ArrayDeque` 스택이다 — `REQUIRES_NEW`가 같은 스레드에서 바깥을 suspend한다. `ThreadLocal.withInitial`을 쓰지 않는 이유도 이 개념의 일부다 — 읽을 때마다 값을 설치하면 `clear()`가 방금 제거한 것을 다시 등록한다.
|
|
|
|
:::note
|
|
|
|
없음 — 구조와 근거를 코드로 확인했다
|
|
|
|
:::
|
|
|
|
## 여섯 단계
|
|
|
|
```java
|
|
public enum TransactionCompletionEvidence {
|
|
|
|
/** No transaction was begun for this unit of work. */
|
|
NOT_STARTED,
|
|
|
|
/** A transaction is open and statements are executing. */
|
|
ACTIVE,
|
|
|
|
/** The commit has been handed to the provider and no result has come back yet. */
|
|
COMMITTING,
|
|
|
|
/** The provider confirmed the commit. */
|
|
COMMITTED,
|
|
|
|
/** The provider confirmed the rollback. */
|
|
ROLLED_BACK,
|
|
|
|
/** The commit outcome could not be determined; reconciliation owns the resolution. */
|
|
UNKNOWN
|
|
}
|
|
```
|
|
|
|
전이는 `NOT_STARTED → ACTIVE → COMMITTING → COMMITTED | ROLLED_BACK | UNKNOWN`이다.
|
|
|
|
이 열거형의 javadoc이 모델의 핵심을 한 문장으로 적는다.
|
|
|
|
> This is evidence, not a guess. `UNKNOWN` is a real, reportable state: it means the
|
|
> driver could neither confirm the commit nor confirm the rollback, and the platform refuses to
|
|
> collapse that into either. Only a failure observed while the phase is `COMMITTING` may
|
|
> become `TransactionCompletionUnknownException`.
|
|
|
|
마지막 문장이 판정의 게이트다. SQLSTATE만으로는 부족하고 phase만으로도 부족하며, 둘의 교집합에서만 completion-unknown이 생긴다.
|
|
|
|
## 왜 프레임워크 없는 코어에 있는가
|
|
|
|
```java
|
|
/**
|
|
* <p>The enum lives in the framework-free core rather than in the Spring transaction adapter
|
|
* because the design types the exception's evidence field, and the core error contract may not
|
|
* depend on the adapter.
|
|
*/
|
|
```
|
|
|
|
예외의 증거 필드가 이 타입으로 선언되어 있으므로, 이 타입이 어댑터에 있으면 코어 오류 계약이 어댑터에 의존하게 된다.
|
|
|
|
## 왜 스택인가
|
|
|
|
증거는 스레드에 묶인 `ArrayDeque` 스택으로 보관된다. 단일 슬롯이 아닌 이유는 `REQUIRES_NEW`가 같은 스레드에서 바깥 트랜잭션을 suspend하고 안쪽을 시작하기 때문이다.
|
|
|
|
```java
|
|
/**
|
|
* <p>The state is a stack rather than a single value because {@code REQUIRES_NEW} suspends an outer
|
|
* transaction and begins an inner one on the same thread. With a single slot, the inner
|
|
* transaction's commit would overwrite the outer transaction's phase, and a later commit failure on
|
|
* the outer one would be classified against evidence that belongs to work that already finished.
|
|
*/
|
|
```
|
|
|
|
프레임의 소유권은 스택에서의 깊이로 식별한다. 내용은 phase 전이마다 바뀌지만 깊이는 바뀌지 않는다.
|
|
|
|
## withInitial을 쓰지 않는 이유
|
|
|
|
```java
|
|
/**
|
|
* Plain, not {@code withInitial}. An initialising thread-local installs a value on every read, so
|
|
* a read after the last frame was cleared re-registered exactly what {@code clear()} had removed.
|
|
*/
|
|
private static final ThreadLocal<Deque<TransactionEvidenceFrame>> FRAMES = new ThreadLocal<>();
|
|
```
|
|
|
|
이것도 개념의 일부다. 정리 경로가 아무리 정확해도 읽기 경로가 값을 되살리면 남은 프레임이 생긴다.
|
|
|
|
:::note
|
|
|
|
남은 프레임은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 COMMITTING을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown을 보고한다.
|
|
|
|
:::
|
|
|
|
<!-- body:end -->
|