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

6.3 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn assets evidence source
CONCEPT commit-evidence-phases 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지 commit-ambiguity-as-a-result clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 concept:commit-evidence-phases 2026-09-01
key file
commit-evidence-phases ../../../final/evidence/rendered/commit-evidence-phases.svg
key file
commit-evidence-phases-diagram ../../../final/assets/diagrams/commit-evidence-phases.svg
../../../final/evidence/raw/commit-evidence-phases.txt
원본 분석 절은 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 을 어떻게 다룰지 정한 결정이다.

본문

트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하는 메커니즘의 설명이다. 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

없음 — 구조와 근거를 코드로 확인했다

:::

여섯 단계

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이 생긴다.

왜 프레임워크 없는 코어에 있는가

/**
 * <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하고 안쪽을 시작하기 때문이다.

/**
 * <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을 쓰지 않는 이유

/**
 * 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을 보고한다.

:::