# 주제: 재개(resume)된 리드라이브가 "옮기지 못한" 메시지를 건너뛴다. # 그리고 그 사실을 가리는 것은, 실제 불변식을 재현할 수 없는 테스트 대역이다. # revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 # severity: P1 # ---- 코드 ---- # RedriveService.java:105-120 # List 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 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 staged = List.of(); private final List settled = new ArrayList<>(); private void stage(List ids) { staged = List.copyOf(ids); } @Override public List 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 인덱스로 재사용되는 지점에서만 틀린다.