Files
document-haness/docs/clean-architecture-backend-template/final/evidence/raw/306-redrive-resume-skips-unmoved.txt
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

93 lines
5.2 KiB
Plaintext

# 주제: 재개(resume)된 리드라이브가 "옮기지 못한" 메시지를 건너뛴다.
# 그리고 그 사실을 가리는 것은, 실제 불변식을 재현할 수 없는 테스트 대역이다.
# revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
# severity: P1
# ---- 코드 ----
# RedriveService.java:105-120
# List<MessageId> moved = new ArrayList<>();
# int failed = 0;
# int completed = resumeFrom;
# try {
# // Everything before resumeFrom was moved and settled by the previous attempt. Re-publishing
# // it is not a retry, it is a duplicate.
# for (MessageId messageId :
# candidates.subList(Math.min(resumeFrom, candidates.size()), candidates.size())) {
# if (attempt(messageId, request)) { moved.add(messageId); } else { failed++; }
# completed++; <-- 성공/실패 양쪽에서 증가
# checkpoint.accept(completed);
# }
# }
#
# RedriveService.java:100
# List<MessageId> candidates = source.peek(request.source(), request.batchSize());
# -> 매 시도마다 원본(DLQ)에서 새로 peek 한다.
#
# RedriveService.java:142-156 attempt(...)
# 성공한 것만 source.settle(...) 로 DLQ 에서 제거된다. 실패한 것은 DLQ 에 남는다.
# ---- 주석의 전제가 성립하지 않는다 ----
# 주석: "Everything before resumeFrom was moved and settled by the previous attempt."
# 그러나 `completed` 는 moved + failed 다(:118 이 두 분기 모두에서 실행). 즉 resumeFrom 은
# "옮긴 개수" 가 아니라 "시도한 개수" 다. 실패분은 settle 되지 않아 DLQ 에 남아 있고,
# 다음 시도의 peek 결과에는 그대로 포함된다.
# subList(resumeFrom, …) 는 그 목록의 앞에서부터 resumeFrom 개를 버리므로,
# 버려지는 것은 "이미 옮긴 것" 이 아니라 "아직 옮기지 못한 것" 이다.
# ---- 구체적 실패 시나리오 ----
# DLQ = [m1, m2, m3, m4, m5], batchSize=50
# 1차 시도 (resumeFrom=0):
# peek -> [m1,m2,m3,m4,m5]
# m1 CONFIRMED -> settle -> DLQ 에서 제거. completed=1, checkpoint(1)
# m2 미확인 -> failed++. DLQ 에 남음. completed=2, checkpoint(2)
# 프로세스 사망. 저널 itemsCompleted = 2
# 2차 시도:
# journal.begin -> lease.resumeFrom = 2
# peek -> [m2,m3,m4,m5] (m1 은 settle 되어 사라짐)
# subList(min(2,4), 4) = [m4, m5]
# -> m2(실패했던 것)와 m3(아예 시도되지 않은 것)이 **영구히 건너뛰어진다**
# m4, m5 성공. RedriveReport(candidates=4, moved=2, failed=0)
# journal.complete(lease, 2+0, now) -> 상태 COMPLETED, 승인 소진
# 결과: 운영자에게는 성공으로 보이고, m2·m3 는 DLQ 에 남으며, 어떤 기록도 그 둘을 지목하지 않는다.
# 승인은 소진되었으므로 재실행은 APPROVAL_ALREADY_EXECUTED 로 거절된다.
# ---- 플랫폼은 이것을 감지할 수단을 이미 갖고 있다 ----
# RedriveResult.isFullyAccounted() (messaging-admin-api/RedriveResult.java:48-50)
# return moved + stillParked == candidates;
# javadoc: "An unaccounted message is a bug, not a partial success: it was neither republished
# nor left parked, which means the redrive lost track of it."
# 위 시나리오에서 2 + 0 == 4 -> false. 정확히 이 결함을 잡는 술어다.
# 그러나 isFullyAccounted() 의 프로덕션 호출부는 0건이다(EVD-302). 아무도 묻지 않는다.
# ---- 왜 테스트가 잡지 못하는가 ----
# command: sed -n "183,201p" .../RedriveResumptionTest.java
private static final class RecordingSource implements RedriveService.RedriveSource {
private List<MessageId> staged = List.of();
private final List<MessageId> settled = new ArrayList<>();
private void stage(List<MessageId> ids) { staged = List.copyOf(ids); }
@Override
public List<MessageId> peek(DestinationName destination, int batchSize) {
return staged; <-- 항상 같은 전체 목록
}
@Override
public void settle(DestinationName destination, MessageId messageId) {
settled.add(messageId); <-- staged 에서 제거하지 않는다
}
}
# => 이 대역은 "정산된 메시지는 DLQ 에서 사라진다" 는 실제 불변식을 재현하지 못한다.
# 따라서 재개 시 peek 결과가 줄어드는 상황이 테스트 세계에 존재하지 않는다.
# ---- 테스트 5건이 덮는 조합 ----
# oneThrowingMessageDoesNotAbandonTheRest resumeFrom 없음, 실패 1건
# theAuditRecordIsWrittenEvenOnFailure resumeFrom 없음, 전건 실패
# aResumedPassDoesNotRepublishWhatWasAlreadyMoved resumeFrom=3, **전건 성공**
# progressIsReportedAfterEachItem resumeFrom=0, 전건 성공
# resumingPastTheEndOfTheBatch resumeFrom=10, 전건 성공
# => 실패와 재개를 **동시에** 넣는 테스트가 없다. 결함은 그 교집합에서만 나타난다.
# 참고: DefaultMessagingAdminService:203
# journal.complete(lease, report.moved() + report.failed(), clock.get());
# -> itemsCompleted 가 moved+failed 라는 점은 저장 측에서도 일관된다.
# 즉 이것은 오타가 아니라 "시도 개수" 라는 일관된 선택이며,
# 그 선택이 subList 인덱스로 재사용되는 지점에서만 틀린다.