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
+174
@@ -0,0 +1,174 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a05-f020-inspect-claim
|
||||
title: 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다
|
||||
topic: state-machines-and-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a05-f020-inspect-claim
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a05-f020-inspect-claim
|
||||
file: ../../../final/evidence/rendered/a05-f020-inspect-claim.svg
|
||||
- key: a05-f020-inspect-claim-postgres
|
||||
file: ../../../final/evidence/rendered/a05-f020-inspect-claim-postgres.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a05-f020-inspect-claim.txt
|
||||
- ../../../final/evidence/raw/a05-f020-inspect-claim-postgres.txt
|
||||
source:
|
||||
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §59.1 이다. 두 경로가 만료된 완료 행을 다르게 해석한다는 판정과 그 실행 탐침 값이 그 절에 있다. 같은 §59 의 나머지 한 군데는 §59.2 이고 별도 사례가 담당한다.
|
||||
- 소비자 세 자리의 분기별 동작과 시험 범위, 레디스 구현과의 대조는 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다
|
||||
|
||||
멱등성 저장소의 청구 경로는 행을 잠근 뒤 데이터베이스 시각을 읽어 재생 유효 기간이 지난 완료 행을 인계로 보낸다. 조회 경로는 그 시각을 한 번도 읽지 않고 완료 상태에 응답이 있으면 재생으로 답한다. 실제 PostgreSQL 에서 만료 뒤 같은 행에 두 답이 나온다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
||||
같은 상태 기계의 만료 처리 규칙이다.
|
||||
- **시간은 DB에서, 그리고 행을 잠근 다음에 읽는다**
|
||||
두 경로가 같은 시각 기준을 써야 하는 이유다.
|
||||
- **전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다**
|
||||
같은 분석 절이 짚은 나머지 한 군데 재생 경계다.
|
||||
|
||||
## 문제
|
||||
|
||||
멱등성 저장소에는 두 진입 경로가 있다. 조회는 이 연산이 이미 처리됐는지 묻고, 청구는 지금 처리해도 되는지 묻는다.
|
||||
|
||||
같은 행에 대한 두 답이 갈리면 어느 쪽을 믿을지 정하는 규칙이 코드에 없다.
|
||||
|
||||
## 결론
|
||||
|
||||
청구가 데이터베이스 시각을 읽는 것은 행을 잠근 다음이고 한 번뿐이다. 그 시각으로 완료 상태이면서 재생 유효 기간이 지난 행을 먼저 걸러 인계로 보내고, 그 검사가 재생 응답 분기보다 앞에 있다.
|
||||
|
||||
조회 쪽에는 그 호출이 없다. replayUntil 은 응답에 실려 나갈 뿐 어디에서도 읽히지 않는다. 만료를 비교하는 헬퍼가 하나 있고, 그것을 부르는 것은 청구뿐이다. 조회가 쓰는 조회 SQL 에도 시간 술어가 없다.
|
||||
|
||||
실제 PostgreSQL 16 에 이 리프의 마이그레이션을 적용하고 재생 유효 기간 2초로 완료했다. 만료 시각 전 조회는 재생으로 답하고, 만료 뒤 조회도 같은 답과 같은 옛 응답을 준다. 같은 행을 다시 청구하면 인계가 돌아온다.
|
||||
|
||||
멱등성 실행기는 조회의 재생 결과를 세 곳에서 받고, 셋 다 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려주고 행동을 실행하지 않는다. 그 둘에서 만료가 소비자에게 번진다.
|
||||
|
||||
나머지 하나는 저장된 응답을 호출자가 방금 만든 결과와 비교하고 다르면 던진다. 그 경로는 호출자가 이미 실행한 뒤에만 닿고 돌려주는 값도 자기 결과이므로 이 문제가 번지지 않는다.
|
||||
|
||||
재생 창 만료를 짚는 시험은 없다. 통합 시험에 이름이 만료인 시험이 둘 있지만 둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다.
|
||||
|
||||
같은 계약의 레디스 구현에는 이 비교가 없다. 완료가 키에 재생 유효 기간을 그대로 만료로 걸어서, 창이 끝나면 해시가 사라지고 조회는 부재로 답한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL 16.15, 실제 실행
|
||||
확인 방식 : 두 경로의 시각 참조 계수, 실제 PostgreSQL 에 마이그레이션 적용 후 만료 전후 호출, 소비자 세 자리 추적
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 청구 경로에서 데이터베이스 시각을 읽는 줄과 만료 판정 헬퍼를 찾고, 그것이 재생 응답 분기보다 앞인지 본다.
|
||||
2. 조회 경로 전문에서 데이터베이스 시각 호출을 세고, replayUntil 이 어디에 쓰이는지 본다.
|
||||
3. 조회가 쓰는 조회 SQL 에 시간 술어가 있는지 본다.
|
||||
4. 실제 PostgreSQL 에 이 리프의 마이그레이션을 적용하고, 짧은 재생 유효 기간으로 완료한 뒤 만료 전후로 조회와 청구를 부른다.
|
||||
5. 조회의 재생 결과를 받는 세 자리가 각각 무엇을 하는지 읽는다.
|
||||
6. 통합 시험의 만료 시험이 무엇을 만료시키는지, 요청 헬퍼의 재생 유효 기간이 무엇인지 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
청구는 행을 잠근 뒤 데이터베이스 시각을 한 번 읽는다. 조회는 그 호출이 0 이다.
|
||||
|
||||
## 청구는 만료를 먼저 본다
|
||||
|
||||
:::evidence key="a05-f020-inspect-claim" alt="청구 경로가 데이터베이스 시각을 읽고 만료된 완료 행을 먼저 걸러 내는 구간과 그 판정 헬퍼, 청구 본문의 시각 참조 수, 조회 경로 전문과 그 본문의 시각 참조 수와 replayUntil 이 쓰이는 자리, 만료 비교 헬퍼를 부르는 곳, 조회가 쓰는 조회 SQL, 조회 결과를 받는 세 자리, 그리고 출하 통합 시험의 만료 시험이 무엇을 만료시키는지와 요청 헬퍼의 재생 유효 기간을 출력한 터미널 기록." caption="청구는 databaseNow 1회와 만료 선분기 · 조회는 시각 0회, replayUntil 은 생성자 인자로만 · 만료 비교 헬퍼는 청구만 호출 · 조회 SQL 에 시간 술어 없음 · 만료 시험 둘은 처리 임차, 재생 창은 늘 24시간 — 95줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
Instant dbNow = rows.databaseNow();
|
||||
...
|
||||
if (isExpiredCompleted(row, dbNow)) {
|
||||
return resetClaim(request, row);
|
||||
}
|
||||
...
|
||||
if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) {
|
||||
return new IdempotencyClaimOutcome.CompletedReplay(
|
||||
new StoredResponse(row.responsePayload()), row.replayUntil());
|
||||
}
|
||||
```
|
||||
|
||||
만료 판정이 재생 응답 분기보다 앞에 있다.
|
||||
|
||||
## 조회는 만료된 완료 행도 재생으로 답한다
|
||||
|
||||
```java
|
||||
if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) {
|
||||
return new IdempotencyInspection(
|
||||
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
|
||||
...
|
||||
Optional.ofNullable(row.replayUntil()));
|
||||
}
|
||||
```
|
||||
|
||||
`replayUntil` 은 응답 생성자에 한 번 실려 나갈 뿐 비교되지 않는다. 만료 비교를 하는 헬퍼는 이 파일에 하나뿐이고 청구만 부른다. 조회가 쓰는 조회 SQL 도 범위 해시와 레코드 버전만 술어로 쓴다.
|
||||
|
||||
## 실제 PostgreSQL 에서 두 답이 갈린다
|
||||
|
||||
:::evidence key="a05-f020-inspect-claim-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용해 만들어진 테이블 목록, 그리고 재생 유효 기간 2초로 완료한 뒤 저장된 만료 시각과 만료 전 조회 결과, 만료 뒤 조회 결과와 그때 돌아온 응답, 같은 행에 대한 청구 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 재생 창 2초로 완료 · 만료 전 조회는 재생 · 만료 뒤 조회도 재생과 옛 응답 · 같은 행의 청구는 인계 — 12줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
replay_until : 2026-09-02 04:35:51.753222+00
|
||||
지금 : 2026-09-02 04:35:49.768587+00
|
||||
만료 전 조회 : COMPLETED_REPLAY
|
||||
지금 : 2026-09-02 04:35:53.285753+00
|
||||
만료 후 조회 : COMPLETED_REPLAY 응답={"v":"OLD-RESPONSE"}
|
||||
만료 후 청구 : TakenOverClaimed
|
||||
```
|
||||
|
||||
만료 시각을 지난 뒤에도 조회는 같은 답과 같은 옛 응답을 준다. 같은 행에 대한 청구는 인계를 돌려준다.
|
||||
|
||||
## 조회 결과를 그대로 돌려주는 두 자리
|
||||
|
||||
멱등성 실행기는 조회의 재생 결과를 세 곳에서 받는다. 모두 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려준다.
|
||||
|
||||
```java
|
||||
case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
|
||||
```
|
||||
|
||||
두 경로 모두 행동을 실행하지 않는다. 그래서 재생 유효 기간이 지나 청구라면 인계했을 행에서도, 청구나 시작이 불확정으로 끝난 호출자는 만료된 이전 응답을 자기 답으로 받는다.
|
||||
|
||||
세 번째는 다르다.
|
||||
|
||||
```java
|
||||
StoredResponse stored = requireResponse(inspection);
|
||||
if (stored.payload().equals(codec.serialize(result))) {
|
||||
yield result;
|
||||
}
|
||||
throw recovery("the completed response conflicts with the one this caller produced");
|
||||
```
|
||||
|
||||
이 경로는 호출자가 이미 행동을 실행한 뒤에만 닿고, 돌려주는 값도 저장된 응답이 아니라 호출자 자신의 결과다. 만료가 번지는 자리는 앞의 둘이다.
|
||||
|
||||
## 재생 창 만료를 짚는 시험은 없다
|
||||
|
||||
통합 시험에 이름이 만료인 시험이 둘 있다. 만료된 청구를 인계하되 낡은 소유자는 시작하지 못한다는 것과, 만료된 실행 중 행은 조정을 요구한다는 것이다.
|
||||
|
||||
둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다. 만료 판정 헬퍼가 참이 되는 분기는 이 파일의 어느 시험도 밟지 않는다.
|
||||
|
||||
조회는 그 파일 전체에서 한 번 불리고, 포기 상태를 확인한다.
|
||||
|
||||
## 같은 계약의 레디스 구현에는 이 비교가 없다
|
||||
|
||||
완료가 키에 재생 유효 기간을 그대로 만료로 건다. JPA 쪽이 완료 SQL 에서 `replay_until` 로 적는 값과 같은 값이다. 창이 끝나면 해시가 사라지고 조회는 부재로 답한다.
|
||||
|
||||
시작과 갱신은 만료를 건드리지 않고, 실패 표시는 보존 기간을 건다.
|
||||
|
||||
## 고칠 방향
|
||||
|
||||
조회도 청구와 같은 데이터베이스 시각 기준을 써야 한다. 그리고 재생 유효 기간이 지난 뒤 조회가 무엇을 답하는지 고정하는 경계 시험이 있어야 한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
두 답이 공존하는 창이 얼마나 지속되는지는 재지 않았다. 탐침에서 인계가 끝나면 조회는 다른 답으로 바뀌므로, 갈림은 먼저 도는 쪽이 상태를 바꿀 때까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a05-f021-complete-replayttl
|
||||
title: 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다
|
||||
topic: state-machines-and-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a05-f021-complete-replayttl
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a05-f021-complete-replayttl-postgres
|
||||
file: ../../../final/evidence/rendered/a05-f021-complete-replayttl-postgres.svg
|
||||
- key: a05-f021-complete-replayttl
|
||||
file: ../../../final/evidence/rendered/a05-f021-complete-replayttl.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a05-f021-complete-replayttl-postgres.txt
|
||||
- ../../../final/evidence/raw/a05-f021-complete-replayttl.txt
|
||||
source:
|
||||
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §59.2 다. 등급은 P2 이고, 만료 해석 불일치인 §59.1 은 형제 기록이 다룬다.
|
||||
- 디지스트 정책 단위 시험이 이 값을 이미 고정한다는 것은 그 절이 짚는다. 레디스 구현의 같은 자리와 공용 헬퍼를 쓰는 전이 넷은 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다
|
||||
|
||||
이미 완료된 같은 연산의 재생 분기는 응답 다이제스트만 비교한다. 완료가 다이제스트에 넣어 열에 저장한 재생 창은 읽히지 않는다. 나머지 네 전이는 그 열을 비교하는 공용 헬퍼를 쓰고 다른 인자를 들고 온 재시도를 충돌로 돌려보낸다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다**
|
||||
같은 다이제스트가 무엇을 덮어야 하는지 다룬 사례다.
|
||||
- **digest는 길이 프레이밍하고 버전을 붙인다**
|
||||
이 다이제스트의 형식 규칙이다.
|
||||
- **만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다**
|
||||
같은 분석 절이 짚은 나머지 한 군데 재생 경계다.
|
||||
|
||||
## 문제
|
||||
|
||||
완료가 정하는 것은 둘이다. 응답으로 무엇을 남길지, 그리고 그것을 언제까지 재생할지다.
|
||||
|
||||
첫 완료는 둘 다 다이제스트에 넣는다. 두 번째 완료는 앞의 것만 본다.
|
||||
|
||||
## 결론
|
||||
|
||||
실제 PostgreSQL 16 에서 같은 연산과 같은 응답에 1시간과 9시간을 차례로 넣었다. 두 번째는 이미 완료된 같은 결과로 답하고, 행에는 첫 1시간이 남고, 전이 다이제스트도 그대로다.
|
||||
|
||||
값이 다르게 계산된다는 것은 이미 단위 시험이 고정하고 있다. 60000 과 90000 을 넣은 완료 다이제스트가 다르다는 시험이 같은 모듈에 있다. 값은 계산되고, 열에 저장되고, 시험으로 지켜진다. 그것을 읽지 않는 쪽이 재생 판정이다.
|
||||
|
||||
나머지 네 전이는 다르게 한다. 시작과 갱신과 실패 표시와 해제가 공용 헬퍼에 자기 다이제스트를 넘기고, 그 헬퍼가 행의 전이 다이제스트와 비교한다. 완료는 그 헬퍼를 부르지 않는다.
|
||||
|
||||
갱신 경로의 주석이 왜 그래야 하는지 적는다. 임대 유효 기간이 갱신이 결정한 것의 일부이므로 다이제스트에 들어가고, 그것이 없으면 다른 임대를 요청한 재시도가 이미 적용된 갱신으로 확인된다는 것이다.
|
||||
|
||||
레디스 구현도 같은 자리에서 멈춘다. 완료 재생에서 응답 페이로드만 비교하고, 전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없다. 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
|
||||
|
||||
갈리는 조건은 좁다. 멱등성 실행기 쪽은 주입 시점의 재생 창을 끝까지 들고 간다. 창이 갈리는 조건은 둘이다. 설정 변경 뒤의 재시도이거나, 이 포트를 직접 부르는 별도 호출자다. 원본 분석이 이것을 만료 해석 불일치와 달리 P2 로 둔 자리도 거기다.
|
||||
|
||||
고치려면 주의가 필요하다. 헬퍼는 어긋났다는 답만 주고, 응답 때문인지 창 때문인지는 말하지 않는다. 완료가 지금 돌려주는 응답 충돌을 그대로 두려면, 헬퍼의 어긋남 판정 뒤에 다이제스트 비교를 한 번 더 넣어 두 경우를 갈라야 한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL 16.15, 실제 실행
|
||||
확인 방식 : 다섯 전이의 재생 판정 경로 대조, 실제 PostgreSQL 에서 같은 응답에 두 재생 창으로 완료 호출, 형제 구현 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 완료의 재생 분기와 첫 완료의 다이제스트 인자를 나란히 읽는다.
|
||||
2. 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열, 그리고 그 열의 마이그레이션을 확인한다.
|
||||
3. PostgreSQL 을 띄우고 청구와 시작을 거쳐 1시간으로 완료한 뒤, 같은 연산·같은 응답에 9시간으로 다시 완료한다.
|
||||
4. 두 번째 결과와 행의 재생 창, 그리고 전이 다이제스트를 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
완료는 두 가지를 정한다. 무엇을 응답으로 남길지, 그리고 그 응답을 언제까지 재생할지다.
|
||||
|
||||
## 같은 응답에 다른 창을 넣으면
|
||||
|
||||
:::evidence key="a05-f021-complete-replayttl-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용한 뒤, 같은 연산과 같은 응답에 재생 창만 1시간과 9시간으로 바꿔 완료를 두 번 부르고 각 호출의 결과와 행에 저장된 재생 창, 그리고 전이 다이제스트가 그대로인지를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 1시간으로 완료 뒤 저장 3600초 · 같은 응답에 9시간을 넣은 둘째 완료는 이미 완료된 같은 결과 · 저장은 3600초 그대로, 전이 다이제스트도 그대로 — 8줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
첫 완료 (재생 창 1시간) : COMPLETED
|
||||
저장된 재생 창(초) : 3600
|
||||
둘째 완료 (재생 창 9시간) : ALREADY_COMPLETED_SAME_RESULT
|
||||
저장된 재생 창(초) : 3600
|
||||
전이 다이제스트 그대로인가 : true
|
||||
```
|
||||
|
||||
두 번째 호출은 다른 인자를 들고 왔는데 같은 결과로 확인됐다.
|
||||
|
||||
## 두 번째 완료가 비교하는 것
|
||||
|
||||
:::evidence key="a05-f021-complete-replayttl" alt="이미 완료된 같은 연산의 재생 분기가 비교하는 값, 첫 완료가 전이 다이제스트에 넣는 인자와 그 위 주석, 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열과 그 열의 마이그레이션, 그 헬퍼를 쓰는 네 전이와 완료 본문에서의 호출 수, 재생 창이 다르면 완료 다이제스트가 다르다는 단위 시험, 갱신 경로의 주석, 출하 통합 시험이 덮는 인자 재생과 완료 호출 수, 그리고 레디스 구현의 완료 재생 분기와 전이 스크립트를 출력한 터미널 기록." caption="재생 분기는 응답 다이제스트만 비교 · 첫 완료는 재생 창을 다이제스트에 넣음 · 헬퍼는 행의 전이 다이제스트를 비교하고 그 열은 마이그레이션에 있음 · 그 헬퍼를 쓰는 전이 넷, 완료 0 · 창이 다르면 다이제스트가 다르다는 단위 시험 · 레디스도 응답만 비교 — 81줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
if (row.state() == IdempotencyState.COMPLETED
|
||||
&& "COMPLETE".equals(row.lastTransitionKind())
|
||||
&& operationId.value().equals(row.lastTransitionOperationId())) {
|
||||
return responseDigest.equals(row.responseDigest())
|
||||
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
|
||||
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
|
||||
}
|
||||
```
|
||||
|
||||
## 첫 완료가 다이제스트에 넣는 것
|
||||
|
||||
```java
|
||||
// The transition digest, not the response digest. Reusing the response digest here made
|
||||
// two completions of different operations with identical payloads indistinguishable,
|
||||
// and lost the replay window the completion also decided.
|
||||
transitionDigest(
|
||||
"COMPLETE",
|
||||
operationId,
|
||||
owner,
|
||||
responseDigest,
|
||||
Long.toString(replayTtl.toMillis())),
|
||||
```
|
||||
|
||||
주석은 응답 다이제스트를 쓰던 때에 무엇을 잃었는지 과거형으로 적는다. 재생 창도 그 목록에 있다.
|
||||
|
||||
## 값이 다르다는 것은 이미 시험이 고정한다
|
||||
|
||||
같은 모듈의 단위 시험에 완료 다이제스트가 재생 창에 따라 달라진다는 것을 고정하는 시험이 있다.
|
||||
|
||||
```java
|
||||
assertThat(IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "60000"))
|
||||
.isNotEqualTo(
|
||||
IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "90000"));
|
||||
```
|
||||
|
||||
값은 계산되고, `last_transition_result_digest` 열에 저장되고, 시험으로 지켜진다. 재생 판정만 그것을 읽지 않는다.
|
||||
|
||||
## 공용 헬퍼와 그것을 쓰는 네 전이
|
||||
|
||||
```java
|
||||
if (!transitionKind.equals(row.lastTransitionKind())
|
||||
|| !operationId.value().equals(row.lastTransitionOperationId())) {
|
||||
return ReplayVerdict.NOT_A_REPLAY;
|
||||
}
|
||||
return expectedDigest.equals(row.lastTransitionResultDigest())
|
||||
? ReplayVerdict.SAME_ARGUMENTS
|
||||
: ReplayVerdict.DIFFERENT_ARGUMENTS;
|
||||
```
|
||||
|
||||
```text
|
||||
182: switch (replayVerdict(row, "START", operationId, startDigest)) {
|
||||
221: switch (replayVerdict(row, "RENEW", operationId, renewDigest)) {
|
||||
319: switch (replayVerdict(row, transitionKind, operationId, failDigest)) {
|
||||
366: switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) {
|
||||
```
|
||||
|
||||
완료 본문에서 그것을 부르는 줄은 0 이다.
|
||||
|
||||
## 갱신 경로의 주석
|
||||
|
||||
```text
|
||||
216: // The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a
|
||||
217: // retry asking for a different lease was confirmed as the renewal already applied, and the
|
||||
218: // caller went on believing it held the record for longer than the row says it does.
|
||||
```
|
||||
|
||||
재생 창도 호출자가 나중에 읽는 지속 상태다.
|
||||
|
||||
## 출하 통합 시험이 덮는 인자 재생
|
||||
|
||||
같은 연산 식별자에 다른 보존 기간이 오면 충돌이라는 시험, 다른 임대가 오면 충돌이라는 시험, 같은 인자면 확인이라는 시험이 있다. 완료를 두 번 부르는 시험은 그 파일에 없다.
|
||||
|
||||
## 레디스 구현도 같은 자리에서 멈춘다
|
||||
|
||||
```java
|
||||
case "ALREADY" ->
|
||||
reply.payload().equals(response.payload())
|
||||
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
|
||||
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
|
||||
```
|
||||
|
||||
전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없으므로, 이것은 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
|
||||
|
||||
## 언제 갈리는가
|
||||
|
||||
애플리케이션의 멱등성 실행기는 주입받은 재생 창 하나를 계속 쓴다. 창이 달라지려면 설정이 바뀐 뒤 재시도가 넘어오거나, 이 포트를 직접 부르는 다른 호출자가 있어야 한다.
|
||||
|
||||
## 고칠 방향
|
||||
|
||||
완료의 재생 분기도 완료 전이 다이제스트를 먼저 계산해 공용 헬퍼에 넘기면 창이 다른 호출을 걸러낼 수 있다.
|
||||
|
||||
다만 그 헬퍼의 판정은 응답이 달라서 어긋난 경우와 창이 달라서 어긋난 경우를 구분하지 않는다. 지금 완료가 돌려주는 응답 충돌을 유지하려면, 헬퍼가 어긋났다고 답한 뒤 응답 다이제스트를 한 번 더 비교해 두 답을 나눠야 한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
첫 창이 남은 뒤 실제 재생 요청이 어떻게 처리되는지는 관측하지 않았다. 확인한 것은 두 번째 완료의 답과 행에 남은 값까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a05-f025-filequotaservice-commit
|
||||
title: 만료 조건이 연장에는 있고 확정에는 없다
|
||||
topic: state-machines-and-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a05-f025-filequotaservice-commit
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a05-f025-filequotaservice-commit
|
||||
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit.svg
|
||||
- key: a05-f025-filequotaservice-commit-postgres
|
||||
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit-postgres.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit.txt
|
||||
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit-postgres.txt
|
||||
source:
|
||||
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §81 이다. 등급은 P2 이고 판정 문구는 프로덕션 API 계약 결함이다. 확정 질의에 만료 조건이 없다는 판정과 그 실행 탐침, 그리고 게이트웨이의 정산 경로가 수정 경계라는 지적이 그 절에 있다.
|
||||
- 포트의 네 메서드 중 확정을 부르는 프로덕션 호출자가 0 이라는 것, 저장소 인터페이스 javadoc 의 두 절이 어긋난다는 것, 정산 행의 만료 시각이 생성 시각과 같다는 것은 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 만료 조건이 연장에는 있고 확정에는 없다
|
||||
|
||||
저장소 인터페이스의 javadoc 은 연장과 확정과 해제가 모두 예약이 아직 살아 있기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다고 적는다. 확정 질의에는 만료 조건이 없고, 실제 PostgreSQL 에서 만료된 예약을 확정하면 1행이 바뀐다. 다만 그 확정 메서드를 부르는 프로덕션 호출자는 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
||||
읽은 값으로 판단하지 말고 조건부 갱신의 결과로 판단하라는 규칙이다.
|
||||
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
||||
만료 처리를 갈라야 하는 이유다.
|
||||
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
|
||||
같은 리프 계열의 만료 처리 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
JpaFileQuotaService 는 클래스 javadoc 에서 네 연산이 조건부 문장이므로 만료되거나 해제된 예약은 연장도 확정도 될 수 없다고 선언한다.
|
||||
|
||||
저장소 인터페이스 javadoc 쪽은 여기서 더 나아간다. 연장과 확정과 해제가 예약이 기대한 버전에서 아직 예약됨 상태이기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다는 것이다.
|
||||
|
||||
## 결론
|
||||
|
||||
연장 질의에는 expiresAt > :now 가 있다. 확정 질의의 조건은 예약 식별자와 상태뿐이다. 만료와 해제 두 사유에 연장과 확정 두 연산을 곱한 네 조합 중 0행을 돌려주지 않는 것은 만료된 예약의 확정 하나다.
|
||||
|
||||
저장소 javadoc 의 다른 절반도 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없다.
|
||||
|
||||
만료된 사실이 어디에도 기록되지 않는다. 예약 상태 enum 이 만료됨을 선언해 두었는데 main 에서 그 값을 쓰지 않고, 낡은 예약을 정리 대상으로 삼는 코드도 없다. 만료된 예약은 계속 예약됨으로 남으므로 상태만 보는 질의는 둘을 구분할 방법이 없다.
|
||||
|
||||
실제 PostgreSQL 16 에서 만료 시각이 한 시간 전인 예약을 만들고 두 질의를 돌렸다. 연장은 0행, 확정은 1행이다. 그 행은 확정됨이 되고 바이트가 기록된다.
|
||||
|
||||
그 확정을 부르는 프로덕션 호출자는 없다. 포트의 네 메서드 중 main 코드가 부르는 것은 예약과 해제 둘뿐이다. 업로드 확정이 실제로 지나는 것은 별도 게이트웨이이고, 그 게이트웨이는 살아 있는 예약만 이 질의에 넘긴다. 그래서 결함은 포트 계약과 그 구현 쪽에 있다.
|
||||
|
||||
수정에는 경계가 있다. 살아 있는 예약이 없으면 게이트웨이는 사용량을 새 행으로 만들고 바로 확정한다. 그 행의 만료 시각이 생성 시각이므로, 확정 질의에 expiresAt > :now 를 무조건 붙이면 등호 하나 차이로 이 행만 걸린다. 조회와 확정이 같은 시각을 쓰기 때문에 살아 있는 예약 쪽은 걸리지 않는다.
|
||||
|
||||
걸렸을 때 나타나는 결과가 조용하다. 확정의 반환값을 게이트웨이가 받지 않아서, 행은 만들어지고 확정만 0행으로 끝난다. 남은 행은 만료된 예약됨이라 예약 합계에도 확정 합계에도 잡히지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL 16.15, 실제 실행
|
||||
확인 방식 : 두 질의의 조건 대조, 포트 호출자 계수, 실제 PostgreSQL 에 만료된 예약과 정산 행을 만들어 질의 실행
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 서비스와 저장소 인터페이스의 javadoc 을 나란히 읽는다.
|
||||
2. 연장 질의와 확정 질의의 조건, 그리고 세 질의의 시그니처를 확인한다.
|
||||
3. 예약 상태 enum 의 만료됨을 쓰는 코드를 센다.
|
||||
4. 포트의 네 메서드를 부르는 main 소스 호출자를 각각 센다.
|
||||
5. 실제 PostgreSQL 에 파일서버 마이그레이션을 적용하고 만료 시각이 과거인 예약을 만든다.
|
||||
6. 두 질의를 그 행에 돌려 바뀐 행 수와 최종 상태를 본다.
|
||||
7. 게이트웨이가 살아 있는 예약이 없을 때 만드는 행에 만료 조건을 붙인 확정을 돌려 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`JpaFileQuotaService` 는 클래스 javadoc 첫 문단에서 네 연산의 조건을 선언한다.
|
||||
|
||||
```text
|
||||
Reservation, extension, commit, and release are conditional statements, so a reservation
|
||||
that already expired or was released can never be extended or committed.
|
||||
```
|
||||
|
||||
## 연장에는 있고 확정에는 없는 조건
|
||||
|
||||
:::evidence key="a05-f025-filequotaservice-commit" alt="서비스와 저장소 인터페이스의 javadoc, 세 질의 시그니처의 버전 파라미터 수, 연장 질의와 확정 질의 전문, 예약 상태 enum 의 만료됨을 쓰는 코드와 낡은 예약을 정리 대상으로 넣는 코드 수, 포트의 네 메서드를 부르는 프로덕션 호출자 수와 실제 호출 두 줄, 프로덕션 확정이 지나는 게이트웨이와 그 조회 조건, 살아 있는 예약이 없을 때 만드는 행, 그리고 그 행을 만드는 생성자의 인자 순서를 출력한 터미널 기록." caption="두 javadoc 의 선언 · 세 질의에 버전 파라미터 0 · 연장에는 만료 조건, 확정에는 없음 · EXPIRED 를 쓰는 코드 0 · 포트 호출자는 예약과 해제뿐, 확정 0 · 게이트웨이는 살아 있는 예약만 넘김 — 93줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
같은 리프의 저장소 인터페이스 javadoc 은 한 걸음 더 나간다.
|
||||
|
||||
```text
|
||||
* <p>Extend, commit, and release all require the reservation to still be {@code RESERVED} at the
|
||||
* expected version, so a reservation reclaimed by expiry cannot be resurrected.
|
||||
```
|
||||
|
||||
두 절이 다 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없고, 만료로 회수됐어야 할 예약을 되살리는 것이 바로 확정 질의다.
|
||||
|
||||
연장 질의에는 만료 조건이 있다.
|
||||
|
||||
```sql
|
||||
where q.reservationId = :reservationId
|
||||
and q.status = 'RESERVED'
|
||||
and q.expiresAt > :now
|
||||
```
|
||||
|
||||
확정 질의의 조건은 둘뿐이다.
|
||||
|
||||
```sql
|
||||
where q.reservationId = :reservationId
|
||||
and q.status = 'RESERVED'
|
||||
```
|
||||
|
||||
만료는 상태로 남지 않는다. 예약 상태 enum 에 `EXPIRED` 가 선언되어 있지만 그 값을 쓰는 main 코드가 없고, 낡은 예약을 정리 대상으로 넣는 코드도 없다. 만료된 예약은 계속 `RESERVED` 다.
|
||||
|
||||
## 만료된 예약에서 0행과 1행이 갈린다
|
||||
|
||||
:::evidence key="a05-f025-filequotaservice-commit-postgres" alt="실제 PostgreSQL 컨테이너에 파일서버 마이그레이션을 적용해 쿼터 예약 테이블의 상태와 만료 열을 확인하고, 만료 시각이 한 시간 전인 예약에 저장소의 연장 질의와 확정 질의를 각각 돌려 바뀐 행 수와 최종 상태를 본 결과, 그리고 게이트웨이가 만드는 정산 행과 같은 모양의 행에 만료 조건을 붙인 확정을 돌린 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 파일서버 마이그레이션 적용 · 만료된 예약에 연장 0행, 확정 1행 · 결과는 COMMITTED 600 · 만료 시각이 생성 시각인 행에 조건을 붙이면 0행 — 11줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
만료된 예약을 하나 만든다 (expires_at = 한 시간 전)
|
||||
연장 질의가 바꾼 행 : 0
|
||||
확정 질의가 바꾼 행 : 1
|
||||
결과 행 : status=COMMITTED committed_bytes=600
|
||||
```
|
||||
|
||||
저장소 질의만 놓고 보면 두 javadoc 이 금지한 전이가 그대로 일어난다.
|
||||
|
||||
## 다만 그 질의에 만료된 행을 넘기는 호출자가 없다
|
||||
|
||||
포트의 네 메서드 중 main 소스가 부르는 것은 둘이다.
|
||||
|
||||
```text
|
||||
DefaultUploadApplicationService.java:128 quotaService.reserve(scope, reservationBytes, uploadPolicy.reservationTtl());
|
||||
DefaultUploadApplicationService.java:157 quotaService.release(created.reservation());
|
||||
```
|
||||
|
||||
확정과 연장은 0곳이다. 업로드 확정이 실제로 지나는 것은 `JpaQuotaCommitGateway` 이고, 그 게이트웨이는 살아 있는 예약을 먼저 조회한다. 그 조회에 만료 조건이 이미 들어 있다.
|
||||
|
||||
```sql
|
||||
and q.status = 'RESERVED'
|
||||
and q.expiresAt > :now
|
||||
```
|
||||
|
||||
그래서 이 결함은 포트 계약과 그 구현에 있고, 오늘의 업로드 경로에서 관측되는 사건은 아니다.
|
||||
|
||||
## 정산 행의 만료 시각은 생성 시각이다
|
||||
|
||||
게이트웨이는 살아 있는 예약이 없으면 사용량을 새 행으로 만들어 곧바로 확정한다. 업로드가 유효 기간보다 오래 걸렸더라도 실제로 저장된 바이트를 적게 세지 않기 위한 경로다.
|
||||
|
||||
```java
|
||||
QuotaReservationEntity settled =
|
||||
new QuotaReservationEntity(
|
||||
UUID.randomUUID(), scope.type(), scope.value(), actualBytes, now, "RESERVED", now);
|
||||
reservations.save(settled);
|
||||
reservations.commit(settled.getReservationId(), actualBytes, now);
|
||||
```
|
||||
|
||||
생성자의 다섯째 인자가 만료 시각이고 거기 들어간 값이 `now` 다. 저장과 확정이 같은 `now` 를 쓰므로 이 행은 `expiresAt > :now` 를 등호 하나 차이로 통과하지 못한다.
|
||||
|
||||
같은 이유로 살아 있는 예약을 확정하는 쪽은 엄격한 조건을 붙여도 통과한다. 조회가 이미 같은 `now` 로 걸러 냈기 때문이다. 걸리는 것은 정산 행 하나다.
|
||||
|
||||
```text
|
||||
게이트웨이의 정산 행은 expires_at = now 로 만들어진다
|
||||
만료 조건을 붙인 확정이 그 행을 바꾼 수 : 0
|
||||
```
|
||||
|
||||
걸렸을 때 결과는 조용하다. 게이트웨이는 확정의 반환값을 받지 않으므로 행은 만들어지고 확정만 0행이 된다. 남은 행은 만료된 `RESERVED` 라서 예약 합계는 만료 조건에 걸려 세지 않고, 확정 합계는 상태가 달라 세지 않는다. 저장된 바이트가 장부 어디에도 잡히지 않는다.
|
||||
|
||||
## 고칠 방향
|
||||
|
||||
`commit` 하나가 두 의미를 겸하고 있다. 저장소에 만료 조건을 건 확정 문과 걸지 않은 정산 문을 따로 두고, 포트도 확정과 정산으로 나눈다. 지금은 정산이 확정과 같은 문을 쓰기 때문에 조건 하나를 고치면 다른 쪽이 깨진다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
정산 행이 예약됨으로 남았을 때 회수되는지는 확인하지 않았다. 낡은 예약을 정리 대상으로 넣는 코드가 없다는 것까지만 봤다.
|
||||
|
||||
<!-- body:end -->
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a05-f027-maximum-attempts
|
||||
title: reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다
|
||||
topic: state-machines-and-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a05-f027-maximum-attempts
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a05-f027-maximum-attempts
|
||||
file: ../../../final/evidence/rendered/a05-f027-maximum-attempts.svg
|
||||
- key: a05-f027-maximum-attempts-reclaim
|
||||
file: ../../../final/evidence/rendered/a05-f027-maximum-attempts-reclaim.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a05-f027-maximum-attempts.txt
|
||||
- ../../../final/evidence/raw/a05-f027-maximum-attempts-reclaim.txt
|
||||
source:
|
||||
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §82.1 이다. 등급은 P2 이고, 회수 문장에 종료 조건이 없다는 판정과 리스 만료를 최대값보다 많이 반복한 탐침, 그리고 회수가 배치 시작에 먼저 불린다는 관찰이 그 절에 있다.
|
||||
- 회수 질의 javadoc 의 원문, 저장소가 `attempt` 를 한 번도 비교하지 않는다는 것, 회수 래퍼가 시계를 다시 읽어 같은 배치의 재청구를 막는다는 것은 이 기록에서 덧붙였다.
|
||||
---
|
||||
|
||||
# reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다
|
||||
|
||||
회수 질의의 javadoc 은 매번 죽는 작업자의 항목도 정상 실패와 같은 재시도 예산에 묶이며 영원히 회수되지는 않는다고 적는다. 다섯 줄 아래 질의는 시도를 올리기만 하고 그 예산을 걸지 않는다. 실제 PostgreSQL 에서 아홉 번 반복하면 시도가 아홉이 되고 상태는 여전히 청구 가능한 실패다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
|
||||
만료 처리를 갈라야 하는 이유다.
|
||||
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
||||
만료 시각만으로는 회수한 항목의 소유자를 가릴 수 없다고 적은 문서다.
|
||||
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
|
||||
재시도 예산이 어디서 강제되는지의 문제다.
|
||||
|
||||
## 문제
|
||||
|
||||
큐의 계약은 클래스 javadoc 에 있다. 계속 실패하는 항목은 영원히 재시도되는 대신 결국 포기된다.
|
||||
|
||||
그 계약이 크래시 경로까지 덮는다는 것은 회수 질의 javadoc 이 명시한다. 항목은 대기 중이 아니라 실패로 돌아오고 시도 계수가 오르므로, 매번 죽는 작업자의 항목도 곧바로 실패하는 항목과 같은 예산에 묶이며 영원히 회수되지는 않는다는 것이다.
|
||||
|
||||
## 결론
|
||||
|
||||
그 문장 다섯 줄 아래 질의에는 예산이 없다. 저장소가 attempt 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이고, 한 번도 비교하지 않는다.
|
||||
|
||||
예산을 끊는 코드가 있는 곳은 한 군데다. 정상 실패 경로가 다음 시도를 여덟과 비교해 포기 상태로 넘긴다.
|
||||
|
||||
회수 대상 선정 질의에도 시도 한계 조건이 빠져 있다. 그 조회가 고르는 것은 리스가 만료된 진행 중 행이다.
|
||||
|
||||
실제 PostgreSQL 16 에서 청구 문장과 회수 문장을 아홉 번 반복했다. 회차마다 시도가 하나씩 올라 아홉이 되고, 상태는 매번 실패다.
|
||||
|
||||
되풀이의 속도는 느리다. 리스가 10분이라 청구된 항목은 그동안 처리 대상 조회에 보이지 않고, 회수된 뒤에도 곧바로 돌아오지 않는다. 배치는 시각을 한 번 잡아 회수와 청구에 같이 쓰는데, 회수 래퍼는 그 시각 대신 시계를 다시 읽어 다음 시도 시각에 넣는다. 처리 대상 조회가 그 시각을 넘지 않은 행만 고르므로 회수된 항목은 다음 배치로 넘어간다.
|
||||
|
||||
굶주림이 아니라 종료가 없다는 것이 문제다. 시도가 아홉이 되고 열이 되어도 종료 상태로 가지 않는다.
|
||||
|
||||
되풀이가 유지되려면 작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보내고, 시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
데이터베이스 : PostgreSQL 16.15, 실제 실행
|
||||
확인 방식 : 저장소가 attempt 에 하는 일 전수 확인, 예산 전환 지점 계수, 실제 PostgreSQL 에서 청구와 회수 반복
|
||||
소스 수정 : x
|
||||
|
||||
파일서버 플랫폼 스위치와 정리 스위치가 모두 참인 배포에서 프로덕션 경로다. 고정 지연 스케줄러가 배치를 돌리고, 정리 서비스의 세 지점이 정상 실패 경로를 실제로 탄다. 두 스위치의 출하 기본값은 거짓이다.
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 회수 질의의 javadoc 과 그 아래 질의를 나란히 읽는다.
|
||||
2. 저장소 전체에서 attempt 가 나오는 줄을 전부 뽑는다. 올리는 두 자리와 javadoc 뿐이다.
|
||||
3. 정리 항목 행에 포기 상태를 쓰는 코드를 코드베이스에서 센다.
|
||||
4. 회수 대상을 고르는 조회와 다시 청구되는 조회의 조건을 확인한다.
|
||||
5. 회수 래퍼가 다음 시도 시각에 넣는 값이 배치가 잡아 둔 시각인지 확인한다.
|
||||
6. 실제 PostgreSQL 에 마이그레이션을 적용하고 청구와 회수를 아홉 번 반복해 시도와 상태를 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
정리 큐가 스스로 적어 둔 계약은 계속 실패하는 항목이 영원히 재시도되는 대신 결국 포기된다는 것이다.
|
||||
|
||||
회수 질의의 javadoc 은 그 계약이 크래시 경로에도 적용된다고 못박는다.
|
||||
|
||||
```text
|
||||
* <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
|
||||
* whose worker dies every time is then bounded by the same retry budget as one that fails
|
||||
* outright, instead of being reclaimed forever.
|
||||
```
|
||||
|
||||
## 그 아래 다섯 줄에 예산이 없다
|
||||
|
||||
:::evidence key="a05-f027-maximum-attempts" alt="회수 질의의 javadoc 과 질의 전문, 저장소 전체에서 attempt 가 나오는 줄, 정리 항목에 포기 상태를 쓰는 코드와 정상 실패 경로의 비교, 회수 대상을 고르는 조회와 다시 청구되는 조회의 조건, 회수 래퍼가 다음 시도 시각에 넣는 값과 배치가 시각을 한 번 잡는 구간과 리스 길이, 정상 실패 경로가 불리는 지점, 그리고 스케줄러 배선과 두 스위치의 출하 기본값을 출력한 터미널 기록." caption="회수 javadoc 은 같은 예산에 묶인다고 적음 · 저장소는 attempt 를 두 자리에서 올리기만 함 · ABANDONED 전환은 markFailed 한 곳 · 회수 대상 조회에도 한계 없음 · 회수 래퍼는 시계를 다시 읽음 · 리스 10분 · 두 스위치 기본값 false — 113줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
저장소가 `attempt` 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이다.
|
||||
|
||||
```text
|
||||
68: c.attempt = c.attempt + 1, ← 정상 실패 정산
|
||||
121: c.attempt = c.attempt + 1, ← 크래시 회수
|
||||
```
|
||||
|
||||
한 번도 비교하지 않는다. 예산을 끊는 코드는 코드베이스에 한 군데다.
|
||||
|
||||
```java
|
||||
boolean exhausted = item.attempt() + 1 >= MAXIMUM_ATTEMPTS;
|
||||
```
|
||||
|
||||
회수 대상을 고르는 조회에도 시도 한계가 없다. 그 조회는 리스가 만료된 진행 중 행만 고른다.
|
||||
|
||||
## 실제 PostgreSQL 에서 아홉 회차
|
||||
|
||||
:::evidence key="a05-f027-maximum-attempts-reclaim" alt="실제 PostgreSQL 컨테이너에 마이그레이션을 적용한 뒤 저장소의 청구 문장과 회수 갱신 문장을 아홉 번 반복하며 회차마다 시도 횟수와 상태와 마지막 오류 코드를 출력한 터미널 기록. 회수 대상을 고르는 조회는 실행하지 않았고 시계 출처만 데이터베이스로 바꿨다는 단서가 함께 적혀 있다." caption="PostgreSQL 16.15 · 최대 시도 상수 8 · 청구와 회수 갱신을 9회 반복 · 시도는 1부터 9까지 오르고 상태는 매번 FAILED · 회수 대상 조회는 태우지 않음 — 15줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
최대 시도 횟수 상수 : 8
|
||||
...
|
||||
9 회차 후: 시도 9 상태 FAILED 마지막 오류 CLAIM_LEASE_EXPIRED
|
||||
```
|
||||
|
||||
## 되풀이는 느리다. 다만 끝나지 않는다
|
||||
|
||||
되돌아간 실패는 청구가 다시 받는 상태다. 다만 관문이 하나 더 있다.
|
||||
|
||||
```sql
|
||||
where c.status in ('PENDING', 'FAILED')
|
||||
and c.nextAttemptAt <= :now
|
||||
order by c.nextAttemptAt asc
|
||||
```
|
||||
|
||||
배치는 시각을 한 번 잡아 회수와 청구에 같이 쓴다. 그런데 회수 래퍼는 그 시각 대신 시계를 다시 읽어 넘긴다.
|
||||
|
||||
```java
|
||||
reclaimed +=
|
||||
items.reclaimExpiredClaim(
|
||||
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
|
||||
```
|
||||
|
||||
그래서 회수된 항목의 다음 시도 시각은 배치가 잡아 둔 시각보다 뒤이고, 그 배치의 청구 조회에서 탈락한다. 서비스 주석은 회수를 먼저 도는 이유로 회수된 항목이 같은 배치에서 곧바로 대상이 된다는 것을 들지만, 실제로는 다음 배치에 가서야 대상이 된다.
|
||||
|
||||
리스도 10분이다. 청구된 항목은 그동안 처리 대상 조회에 보이지 않는다. 그리고 회수 경로에는 백오프가 없다. 다음 시도 시각을 회수 시각으로 그냥 되돌린다. 주기를 정하는 것은 백오프가 아니라 리스다.
|
||||
|
||||
javadoc 이 일어나지 않게 하겠다고 적은 상황은 성공하지 못하는 항목이 매 배치의 자리를 차지하는 것이다. 여기서 일어나는 것은 그보다 느리다. 문제는 굶주림이 아니라 끝나지 않는 것이다.
|
||||
|
||||
## 되풀이가 유지되는 조건
|
||||
|
||||
작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보낸다.
|
||||
|
||||
```java
|
||||
} catch (RuntimeException failure) {
|
||||
markFailed(item, "CLEANUP_ATTEMPT_FAILED", now);
|
||||
```
|
||||
|
||||
시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다. 예산 우회가 이어지려면 작업자가 조용히 사라져야 한다.
|
||||
|
||||
## 이 경로는 배선되어 있다
|
||||
|
||||
스케줄러가 고정 지연으로 배치를 부르고, 정상 실패 경로도 정리 서비스의 세 지점에서 실제로 불린다. 파일서버 플랫폼 스위치와 정리 스위치의 출하 기본값은 둘 다 거짓이므로, 둘 다 켠 배포에서 프로덕션 경로다.
|
||||
|
||||
## 고칠 방향
|
||||
|
||||
두 경로가 같은 예산을 봐야 한다. 회수 문장이 증가 후 값을 검사해 한계에서 포기로 넘기는 것이 가장 작은 변경이고, 저장소가 다음 상태를 호출자에게서 받는 쪽이 더 곧다. 그 경우 비교 교환이 토큰과 시도를 함께 봐야 회수와 정산이 같은 행을 두고 엇갈리지 않는다.
|
||||
|
||||
회귀는 두 경로를 섞어도 총합이 예산을 넘으면 반드시 포기로 끝나는지를 고정해야 한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 작업자 크래시로 재현하지 않았다. 탐침은 저장소의 세 질의 중 청구와 회수 갱신 둘만 네이티브 SQL 로 옮겨 반복했고, 회수 대상을 고르는 조회는 실행하지 않았다. 그 조회에도 시도 한계가 없다는 것은 질의를 읽어 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user