Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/commit-ambiguity-as-a-result/concept/concept-commit-evidence-phases.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

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 -->