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>
172 lines
11 KiB
Markdown
172 lines
11 KiB
Markdown
---
|
|
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 -->
|