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>
93 lines
5.2 KiB
Plaintext
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 인덱스로 재사용되는 지점에서만 틀린다.
|