179 lines
8.2 KiB
Markdown
179 lines
8.2 KiB
Markdown
---
|
|
id:
|
|
kind: CONCEPT
|
|
slug: transaction-result-algebra
|
|
title: 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문
|
|
topic: commit-ambiguity-as-a-result
|
|
topicName: 커밋 모호성 — 「모른다」를 결과로 유지하기
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
studio: ""
|
|
basisVersion: Spring Boot 4.0.8 · Java 21 · 리비전 21234e38
|
|
source:
|
|
- final/document.md#3-2
|
|
- final/document.md#a05
|
|
- final/document.md#a05 §3.4
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
assets:
|
|
- key: commit-evidence-phase-machine
|
|
file: ../../../final/assets/commit-evidence-phase-machine/commit-evidence-phase-machine.svg
|
|
---
|
|
|
|
# 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문
|
|
|
|
정책 기반 트랜잭션 실행기는 결과를 sealed interface 다섯 변형으로 돌려준다. 변형마다 호출자에게 허용하는 행동이 다르고, 그중 둘은 boolean 이나 예외로 표현되지 않는다. 어느 변형이 나올지는 커밋 증거 단계가 정한다.
|
|
|
|
## 관계
|
|
|
|
- **pg_terminate_backend 가 57P01 로 도착하고 커밋 레코드는 이미 WAL 에 있었다**
|
|
이 대수의 Indeterminate 변형이 실제로 필요해진 사례다.
|
|
- **completion-unknown 은 자동으로도 수동으로도 재시도하지 않는다**
|
|
Indeterminate 를 받은 호출자가 무엇을 해도 되는지 정한 결정이다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## 다섯 변형이 각각 답하는 질문
|
|
|
|
`TransactionResult<T>` 의 javadoc 두 문장이 이 타입의 전부를 말한다.
|
|
|
|
```java
|
|
/**
|
|
* Outcome algebra for policy-based transactions.
|
|
*
|
|
* <p>A participant result never claims commit. An indeterminate result never grants replay
|
|
* authority.
|
|
*/
|
|
public sealed interface TransactionResult<T> {
|
|
|
|
TransactionOutcome outcome();
|
|
```
|
|
|
|
참여 결과는 커밋을 주장하지 않고, 불확정 결과는 재실행 권한을 주지 않는다. 나머지 셋은 그 두 문장 사이를 채운다.
|
|
|
|
| 변형 | 답하는 질문 | 호출자가 할 수 있는 것 |
|
|
|---|---|---|
|
|
| `Committed` | 물리 커밋이 확인됐는가 | 후속 작업 진행 |
|
|
| `Participating` | 바깥 트랜잭션에 참여했는가 | 아무것도 확정하지 않고 반환 |
|
|
| `DeterminateRollback` | 확정적으로 롤백됐는가 | 재시도 또는 실패 보고 |
|
|
| `Indeterminate` | 결과를 알 수 없는가 | 조정으로 넘김. 재실행 금지 |
|
|
| `CommittedWithPostCommitFailure` | 커밋은 됐는데 이후가 실패했는가 | 커밋을 되돌리지 않고 운영 실패만 보고 |
|
|
|
|
## boolean 으로 접히지 않는 두 변형
|
|
|
|
`Participating` 은 이 호출이 바깥 트랜잭션 안에서 실행됐고 커밋 여부를 말할 위치에 있지 않다는 뜻이다.
|
|
|
|
```java
|
|
record Participating<T>(T value) implements TransactionResult<T> {
|
|
|
|
@Override
|
|
public TransactionOutcome outcome() {
|
|
return TransactionOutcome.PARTICIPATING_PENDING_OUTER;
|
|
}
|
|
}
|
|
```
|
|
|
|
성공으로 접으면 커밋되지 않은 작업을 커밋으로 보고하고, 실패로 접으면 정상 경로를 실패로 보고하므로 두 방향 모두 사실과 어긋난다.
|
|
|
|
`Indeterminate` 는 마지막으로 관측된 단계와 조정 참조를 함께 들고 다닌다.
|
|
|
|
```java
|
|
record Indeterminate<T>(
|
|
Optional<OperationId> operationId,
|
|
TransactionPhase lastObservedPhase,
|
|
Optional<ReconciliationReference> reconciliationReference)
|
|
implements TransactionResult<T> {
|
|
```
|
|
|
|
세 성분 가운데 조정 참조는 지금 채워지지 않는다. `SpringPolicyTransactionPort` 가 만드는 `Indeterminate` 는 두 생성 경로에서 모두 `Optional.empty()` 를 넣는다. 타입은 durable reconciliation reference 를 담을 수 있는데 이 어댑터가 그 값을 만들지 않아서, 조정으로 넘어간 쪽이 지목할 수 있는 것은 `operationId` 뿐이다.
|
|
|
|
값이 표현 불가능한 조합을 거부하는 기법은 실패 컨텍스트에 걸려 있다.
|
|
|
|
```java
|
|
// JpaFailureContext
|
|
if (completionUnknown && retryable) {
|
|
throw new IllegalArgumentException("completion unknown failures are never retryable");
|
|
}
|
|
```
|
|
|
|
`Indeterminate` 를 만드는 쪽과 그 결과를 재시도 정책에 넘기는 쪽이 서로 다른 타입이어서, 같은 불변식이 양쪽 생성자에 따로 서 있다.
|
|
|
|
## 커밋 증거 단계가 변형을 고른다
|
|
|
|
핵심 실행 루틴은 `COMMIT_REQUESTED` 를 관측한 뒤 provider commit 을 부르고, 돌아온 것을 두 번 물어 네 변형 중 하나를 고른다.
|
|
|
|
:::evidence key="commit-evidence-phase-machine" alt="COMMIT_REQUESTED 에서 provider commit 을 거쳐 Committed 와 CommittedWithPostCommitFailure 와 DeterminateRollback 과 Indeterminate 네 결과로 갈라지는 흐름도" caption="커밋 요청 뒤의 결과 판정" zoom="false"
|
|
:::
|
|
|
|
`lastObservedPhase` 가 담는 값은 여섯 단계짜리 열거형이다.
|
|
|
|
```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 판정과 단계 판정을 잇는다. SQLSTATE 만으로도 단계만으로도 completion-unknown 이 되지 않고, 둘의 교집합에서만 생긴다. 그래서 `mark(COMMITTING)` 은 provider commit 직전에 찍힌다 — 그 안에서 프로세스가 죽으면 마지막 기록이 「물어봤고 모른다」여야 한다.
|
|
|
|
## 증거를 단일 슬롯이 아니라 스택으로 두는 이유
|
|
|
|
```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.
|
|
*/
|
|
```
|
|
|
|
프레임의 소유권은 스택에서의 깊이로 식별한다. 내용은 단계 전이마다 바뀌지만 깊이는 바뀌지 않는다.
|
|
|
|
읽기 경로에도 같은 제약이 걸린다.
|
|
|
|
```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<>();
|
|
```
|
|
|
|
정리 경로가 아무리 정확해도 읽기 경로가 값을 되살리면 프레임이 남는다. 남은 프레임은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 `COMMITTING` 을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown 을 보고한다.
|
|
|
|
## 단계를 기록하는 쪽을 물리 소유자로 제한한다
|
|
|
|
`SpringPolicyTransactionPort` 의 private 중첩 클래스 `PhaseSentinel` 이 `TransactionSynchronization` 으로 등록된다. 등록은 물리 소유자일 때만 일어나고 우선순위는 `Ordered.HIGHEST_PRECEDENCE` 다.
|
|
|
|
참여 트랜잭션이 단계를 기록하면 바깥의 단계를 덮어서, `Participating` 이 커밋을 주장하지 않는다는 규칙이 코드 수준에서 깨진다.
|
|
|
|
## 이 개념이 보장하지 않는 것
|
|
|
|
다섯 변형은 결과를 무엇으로 부를지 정하고, 그 결과가 실제로 무엇이었는지를 알아내지는 않는다. `Indeterminate` 를 받은 호출자는 커밋 여부를 여전히 모른다. 그 값이 하는 일은 모른다는 사실을 잃지 않고 조정으로 넘기는 것까지다.
|
|
|
|
<!-- body:end -->
|