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>
11 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 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a05-f027-maximum-attempts | reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다 | state-machines-and-ownership | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a05-f027-maximum-attempts | 2026-09-02 |
|
|
|
reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다
회수 질의의 javadoc 은 매번 죽는 작업자의 항목도 정상 실패와 같은 재시도 예산에 묶이며 영원히 회수되지는 않는다고 적는다. 다섯 줄 아래 질의는 시도를 올리기만 하고 그 예산을 걸지 않는다. 실제 PostgreSQL 에서 아홉 번 반복하면 시도가 아홉이 되고 상태는 여전히 청구 가능한 실패다.
관계
- 만료된 claim과 만료된 실행은 다르게 다뤄야 한다 만료 처리를 갈라야 하는 이유다.
- fenced lease — 만료 시각만으로는 부족한 이유 만료 시각만으로는 회수한 항목의 소유자를 가릴 수 없다고 적은 문서다.
- 재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다 재시도 예산이 어디서 강제되는지의 문제다.
문제
큐의 계약은 클래스 javadoc 에 있다. 계속 실패하는 항목은 영원히 재시도되는 대신 결국 포기된다.
그 계약이 크래시 경로까지 덮는다는 것은 회수 질의 javadoc 이 명시한다. 항목은 대기 중이 아니라 실패로 돌아오고 시도 계수가 오르므로, 매번 죽는 작업자의 항목도 곧바로 실패하는 항목과 같은 예산에 묶이며 영원히 회수되지는 않는다는 것이다.
결론
그 문장 다섯 줄 아래 질의에는 예산이 없다. 저장소가 attempt 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이고, 한 번도 비교하지 않는다.
예산을 끊는 코드가 있는 곳은 한 군데다. 정상 실패 경로가 다음 시도를 여덟과 비교해 포기 상태로 넘긴다.
회수 대상 선정 질의에도 시도 한계 조건이 빠져 있다. 그 조회가 고르는 것은 리스가 만료된 진행 중 행이다.
실제 PostgreSQL 16 에서 청구 문장과 회수 문장을 아홉 번 반복했다. 회차마다 시도가 하나씩 올라 아홉이 되고, 상태는 매번 실패다.
되풀이의 속도는 느리다. 리스가 10분이라 청구된 항목은 그동안 처리 대상 조회에 보이지 않고, 회수된 뒤에도 곧바로 돌아오지 않는다. 배치는 시각을 한 번 잡아 회수와 청구에 같이 쓰는데, 회수 래퍼는 그 시각 대신 시계를 다시 읽어 다음 시도 시각에 넣는다. 처리 대상 조회가 그 시각을 넘지 않은 행만 고르므로 회수된 항목은 다음 배치로 넘어간다.
굶주림이 아니라 종료가 없다는 것이 문제다. 시도가 아홉이 되고 열이 되어도 종료 상태로 가지 않는다.
되풀이가 유지되려면 작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보내고, 시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다.
검증 환경
OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 저장소가 attempt 에 하는 일 전수 확인, 예산 전환 지점 계수, 실제 PostgreSQL 에서 청구와 회수 반복 소스 수정 : x
파일서버 플랫폼 스위치와 정리 스위치가 모두 참인 배포에서 프로덕션 경로다. 고정 지연 스케줄러가 배치를 돌리고, 정리 서비스의 세 지점이 정상 실패 경로를 실제로 탄다. 두 스위치의 출하 기본값은 거짓이다.
재현 조건
- 회수 질의의 javadoc 과 그 아래 질의를 나란히 읽는다.
- 저장소 전체에서 attempt 가 나오는 줄을 전부 뽑는다. 올리는 두 자리와 javadoc 뿐이다.
- 정리 항목 행에 포기 상태를 쓰는 코드를 코드베이스에서 센다.
- 회수 대상을 고르는 조회와 다시 청구되는 조회의 조건을 확인한다.
- 회수 래퍼가 다음 시도 시각에 넣는 값이 배치가 잡아 둔 시각인지 확인한다.
- 실제 PostgreSQL 에 마이그레이션을 적용하고 청구와 회수를 아홉 번 반복해 시도와 상태를 본다.
본문
정리 큐가 스스로 적어 둔 계약은 계속 실패하는 항목이 영원히 재시도되는 대신 결국 포기된다는 것이다.
회수 질의의 javadoc 은 그 계약이 크래시 경로에도 적용된다고 못박는다.
* <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 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이다.
68: c.attempt = c.attempt + 1, ← 정상 실패 정산
121: c.attempt = c.attempt + 1, ← 크래시 회수
한 번도 비교하지 않는다. 예산을 끊는 코드는 코드베이스에 한 군데다.
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" :::
최대 시도 횟수 상수 : 8
...
9 회차 후: 시도 9 상태 FAILED 마지막 오류 CLAIM_LEASE_EXPIRED
되풀이는 느리다. 다만 끝나지 않는다
되돌아간 실패는 청구가 다시 받는 상태다. 다만 관문이 하나 더 있다.
where c.status in ('PENDING', 'FAILED')
and c.nextAttemptAt <= :now
order by c.nextAttemptAt asc
배치는 시각을 한 번 잡아 회수와 청구에 같이 쓴다. 그런데 회수 래퍼는 그 시각 대신 시계를 다시 읽어 넘긴다.
reclaimed +=
items.reclaimExpiredClaim(
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
그래서 회수된 항목의 다음 시도 시각은 배치가 잡아 둔 시각보다 뒤이고, 그 배치의 청구 조회에서 탈락한다. 서비스 주석은 회수를 먼저 도는 이유로 회수된 항목이 같은 배치에서 곧바로 대상이 된다는 것을 들지만, 실제로는 다음 배치에 가서야 대상이 된다.
리스도 10분이다. 청구된 항목은 그동안 처리 대상 조회에 보이지 않는다. 그리고 회수 경로에는 백오프가 없다. 다음 시도 시각을 회수 시각으로 그냥 되돌린다. 주기를 정하는 것은 백오프가 아니라 리스다.
javadoc 이 일어나지 않게 하겠다고 적은 상황은 성공하지 못하는 항목이 매 배치의 자리를 차지하는 것이다. 여기서 일어나는 것은 그보다 느리다. 문제는 굶주림이 아니라 끝나지 않는 것이다.
되풀이가 유지되는 조건
작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보낸다.
} catch (RuntimeException failure) {
markFailed(item, "CLEANUP_ATTEMPT_FAILED", now);
시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다. 예산 우회가 이어지려면 작업자가 조용히 사라져야 한다.
이 경로는 배선되어 있다
스케줄러가 고정 지연으로 배치를 부르고, 정상 실패 경로도 정리 서비스의 세 지점에서 실제로 불린다. 파일서버 플랫폼 스위치와 정리 스위치의 출하 기본값은 둘 다 거짓이므로, 둘 다 켠 배포에서 프로덕션 경로다.
고칠 방향
두 경로가 같은 예산을 봐야 한다. 회수 문장이 증가 후 값을 검사해 한계에서 포기로 넘기는 것이 가장 작은 변경이고, 저장소가 다음 상태를 호출자에게서 받는 쪽이 더 곧다. 그 경우 비교 교환이 토큰과 시도를 함께 봐야 회수와 정산이 같은 행을 두고 엇갈리지 않는다.
회귀는 두 경로를 섞어도 총합이 예산을 넘으면 반드시 포기로 끝나는지를 고정해야 한다.
확인하지 못한 것
실제 작업자 크래시로 재현하지 않았다. 탐침은 저장소의 세 질의 중 청구와 회수 갱신 둘만 네이티브 SQL 로 옮겨 반복했고, 회수 대상을 고르는 조회는 실행하지 않았다. 그 조회에도 시도 한계가 없다는 것은 질의를 읽어 확인했다.