Skip to content

기기 대기열(outbox) 전송·영수증 대조·화면 반응의 책임 분리 — Dispatcher·ReceiptReconciler·feature binding 계약 (v0.18.0 S05, 이슈 #1336)

  • 상위: ADR §3-2(dispatch → receipt typed 경계) · 도메인 계약 §13·§14·§15·§17 · 쓰기 파이프라인 §2 ⑤·§4·§6·§7 · S04 claim·후속 행 계약(전송 루프는 그대로 쓴다) · D02 영수증 세대
  • 코드: src/react/persistence/dispatch/{failure,dispatcher,receiptReconciler,types,index}.ts · 단일 sender src/react/services/pendingWorkoutMutationFlush.ts · 화면 반응 src/react/features/completed-workout/pendingSavesFeatureBinding.ts · 포트 배선 src/react/features/completed-workout/pendingSavesDispatchPorts.ts · 스토어 src/react/controllers/pendingWorkoutSavesStore.ts(프로젝션·답장 핸들·비행만)
  • 검사: tests/react/dispatchReconcile.test.mjs(결함 재현 → 통과) · tests/react/dispatchScenarios.test.mjs(같은 명령의 온라인·오프라인·일시 실패·인증 만료·영구 거부·영수증 유실·중복 / wrong owner·kind·hash·id·version 거부 / 중복 영수증 1회·다른 owner 0회) · tests/react/dispatchBoundaries.test.mjs(persistence 의 화면 import 0·ownDataReplica 쓰기 호출처 repository 뿐·이중 sender 부재) · 기존 대기열 스위트(pendingWorkoutMutationFlush·pendingWorkoutOutboxStates·pendingQueueHeadPoison·completedWorkoutReceiptApply·outboxClaimSuccessor ⑦ 등)는 하네스 tests/support/pendingSavesHarness.mjs 로 이전
  • 서버 변경: 없음. 저장 형식·영수증 v2·RPC 는 그대로. 격리 코드 두 개(LG_RECEIPT_MISSING·LG_RECEIPT_MISMATCH)가 대기열 행의 blockedCode 값으로 추가된다(기기 행, 서버 무관).

1. 유저 이야기 — 이 계약이 지키는 것

유저 A 가 지하철에서 어제 기록을 고치고 새 기록도 하나 남겼다. 연결이 돌아오면 앱은 기기 대기열의 행을 서버로 보내고, 서버가 준 영수증으로 달력·홈 통계·피드·열린 상세를 갱신하고 "동기화했어요" 를 띄운다. 이 계약은 다음을 지킨다.

  1. 새 기록을 보내는 중에 그 기록을 고쳐도 격리되지 않는다. 수정 의도는 기기에 남았다가 생성 영수증이 오면 서버 id 의 수정으로 이어서 나간다(S04 §5 의 약속을 실제 배선이 지킨다 — 종전에는 배선이 끊겨 항상 "동기화 실패" 였다, §6 ㉥).
  2. 다른 요청의 영수증은 A 의 화면을 바꾸지 못한다. 멱등 키·지문·종류·개정번호·세대가 이 행의 것일 때만 확정으로 그린다. 영수증이 없거나 어긋나면 "결과 미상" 으로 두고 상세를 다시 읽는다 — 성공을 발명하지 않는다.
  3. 같은 영수증이 두 번 와도 화면 갱신·안내는 한 번이다.
  4. 온라인 즉시 저장·오프라인 재전송·충돌 재전송이 한 sender 를 지난다. 재시도 정책(무엇을 대기·보류·격리로 보낼지, 언제 다시 보낼지)의 주인은 dispatcher 하나다.
  5. 저장 모듈은 화면을 모른다. 달력·프로필·피드·토스트·편집기 함수는 feature binding 이 갖고, 저장 모듈은 typed 결과만 낸다.

2. 책임 넷

파일하는 일모르는 것
Outbox (S02·S04)services/pendingWorkoutSaves.ts · persistence/outbox/**기기 행 적재·합치기·claim·ACK·전송 루프(retryPendingWorkoutSaves)서버 API·영수증 뜻·화면
Dispatcherpersistence/dispatch/dispatcher.ts① 보류 해제 ② 전송 루프에 send(단일 sender + 대조)·isolate·hold(단일 분류기)·prepareSuccessor(S03 조립기)·findCreateReceipt 포트를 채워 돌림 ③ 결과를 DispatchOutcome 으로. durable 행만 보낸다(입력은 저장소 읽기뿐 — 메모리 객체를 보내는 경로가 없다)화면 함수
ReceiptReconcilerpersistence/dispatch/receiptReconciler.ts영수증 대조(§4)·채택 장부(중복)화면·서버 API
Feature bindingfeatures/completed-workout/pendingSavesFeatureBinding.tsDispatchResult 를 읽어 확정 사본·무효화·배경 재조회·통계 재검증·안내·연결 상태대기열 저장소·전송

스토어(controllers/pendingWorkoutSavesStore.ts)는 프로젝션(화면이 보는 행)·답장 핸들(awaitReceipt, wait 정책 동작이 기다리는 약속)·비행(한 사용자의 전송은 한 번에 하나, 전송 중 적재는 비행 뒤 한 번 더)만 갖는다. env 는 { isCurrentRemoteUser, dispatch: { sender, findCreateReceipt?, findRecoveryReceipt? }, features: FeatureInvalidationPort } 셋뿐이다.

포트 배선(pendingSavesDispatchPorts.ts): 완료 기록 3종 → BarbelicApi(G03 WorkoutWritePort) + 10초 타임아웃 · 계획 2종 → workoutPlanCommands(DTO 조립·멱등 키는 계획 명령·repository 몫) · 충돌 재전송·의도 행 변환 조립 → S03 prepareCompletedSessionUpdateResend/prepareCompletedSessionDelete · 개정번호 재조회 → BarbelicApi 상세 조회.

3. 실패 분류 — 정본 구현 하나

persistence/dispatch/failure.tsclassifyDispatchFailure(error, {isDelete}) 가 오류 객체(코드·HTTP 상태·상세·메시지·cause 사슬)를 G03 신호로 바꾸고 참조 구현 classifyWriteFailure(contracts/commands/conflict.ts, 쓰기 파이프라인 §6 표)로 판정한다. 우선순위는 표 그대로(영구 거부 > 충돌 > 인증 > 일시).

  • 코드가 하나도 없는 실패는 메시지 근거(네트워크 문구·revision conflict·idempotency conflict·삭제의 not found)로만 일시·충돌로 본다. 근거가 없으면 permanent{LG_WORKOUT_UNCLASSIFIED}(격리) — 대기열 머리에서 무한히 다시 보내며 뒤 행을 막는 것보다 격리해 사용자가 복구하게 두는 쪽이 #924 규칙이다.
  • 종전 세 벌(directWorkoutWrite.classifyDirectWorkoutWriteError·isolatePendingWorkoutSaveFailure·holdPendingWorkoutSaveFailure / pendingWorkoutMutationFlush.isRevisionConflictError·isTargetMissingError / G03)은 이 하나로 모였다. 직접 쓰기(온라인 명령)의 옛 4분류(offline|conflict|retryable|blocked)는 directWriteDispositionOf 가 같은 판정에서 옮겨 만든다 — 온라인 명령에는 "보류" 가 없으므로 인증 실패는 함께 온 전송 근거(오프라인·5xx·네트워크 메시지)가 있으면 그쪽으로, 없으면 거부.
  • 대기열 격리 코드(#924 화면 원인 문구): plan_completed(40001) · source_deleted(LG001) · conflict(그 밖의 충돌·P0002) · blocked · LG_RECEIPT_MISSING·LG_RECEIPT_MISMATCH(§4).

재시도 정책의 소유자 = dispatcher. 일시 실패면 행을 대기로 되돌리고 그 전송을 멈춘다(stoppedBy). 다음 시도는 연결 복귀(online)·화면 복귀(visibilitychange)·로그인 ready 복귀·새 적재·복구가 다시 부른다. 타이머 backoff 는 없다(쓰기 파이프라인 §11 "flush 실패는 다음 probe 성공 전까지 자동 flush 안 함" — binding 이 연결 상태를 내린다). 한 탭·한 사용자의 전송은 한 번에 하나(스토어 비행).

4. 영수증 대조 — 무엇이 맞아야 채택하는가

receiptReconciler.reconcile({ownerId, row, sent, response}). 순서대로 검사하고 처음 어긋난 자리(field)를 남긴다.

검사규칙어긋나면
ownerrow.userId = 이 전송의 ownerLG_RECEIPT_MISMATCH/owner
receipt응답에 영수증 객체가 있다LG_RECEIPT_MISSING/receipt
mutationKindWIRE_MUTATION_KIND[행 kind](save_session/delete_session). 옛 영수증 v1 은 행 kind 이름도 허용LG_RECEIPT_MISMATCH/mutationKind
멱등 키·지문완료 기록 3종: receipt.clientMutationId = 보낸 요청(충돌 재전송이면 새 요청)의 id, clientRequestHash = 보낸 지문. 계획 2종: repository 가 DTO 에서 identity 를 만들어 행은 모른다(sent.repositoryDerived) — repository 가 이미 대조했으므로 생략LG_RECEIPT_MISMATCH/clientMutationId·clientRequestHash
sessionId서버 기록을 겨냥한 행(targetIdpending: 이 아님)이면 영수증 sessionId 와 같다LG_RECEIPT_MISMATCH/sessionId
serverRevision양의 정수. 수정·삭제는 보낸 기대 개정번호보다 작지 않다LG_RECEIPT_MISMATCH/serverRevision
세대statsRequestedVersion 0 이상 정수. 완료 기록(생성·수정·삭제) ≥ 1, 계획 0 허용(D02 — 재생은 원래 영수증의 세대)LG_RECEIPT_MISMATCH/statsRequestedVersion

어긋나면: 전송 루프에는 영구 거부로 보이게 해 행을 blocked(코드)보존한다(유저 원본 불변 §23 — 지우지 않는다). 결과는 unknown{rejection} — 답장 핸들은 unknown 으로 풀리고, binding 은 상세·목록을 무효화하고 배경 재조회로 수렴하며 확정 사본·통계 재검증·"동기화했어요" 를 만들지 않는다. 격리 안내("동기화하지 못한 운동 기록이 n건") 에는 포함한다(배지가 보이므로).

채택 장부: 탭(스토어 인스턴스)마다 (멱등 키 | 개정번호 | 확정 시각) 열쇠 64개를 기억한다. 같은 열쇠가 다시 오면 duplicate: true — 답장 핸들은 정상으로 풀리지만 화면 반영·안내는 0회. 재생 영수증(replayed)도 같은 열쇠다.

5. typed 결과와 화면 반응

DispatchOutcome = committed{row, accepted, resolvedConflict, duplicate, noticed}
               | already_gone{row} | deferred{row, failure, error} | held{…} | blocked{row, failure, code, error}
               | unknown{row, rejection}
DispatchResult  = { ownerId, outcomes, remaining(재읽은 행 전부), stoppedBy: {failure, error} | null }
FeatureInvalidationPort = { applyDispatchResult(result) }
결과답장 핸들binding 이 하는 것
committed + recovered:true일치한 역사 receipt를 확인한 뒤 원자 ACKuploaded{response}현재 canonical 상세·카드 재조회. 과거 원문 합성·과거 삭제 재실행·과거 통계 세대 폴링·동기화 toast 없음. missing 확인 후만 제거
committed (중복 아님)삭제됨uploaded{response}생성: 확정 사본(줄 번호표 채택·writeSource:'receipt') → plans, 상세 무효화, 프로필·달력·피드 배경 재조회, 통계 재검증. 수정: 확정 사본으로 plans·열린 상세 교체(뒤이은 수정·삭제가 재조회 없이 그 개정번호로 조립). 삭제·계획: 상세 무효화·목록에서 떼기. 안내(§4): noticed(적재 때 확정 문구를 못 봤거나 한 번이라도 못 보냈던 행)만 "동기화했어요"
committed (중복)삭제됨uploaded없음
already_gone삭제됨already_gone목록·상세만 정리, 통계 재검증 없음
unknownblocked(LG_RECEIPT_*) 보존unknown{rejection}텔레메트리 1건 · 상세·목록 무효화 · 배경 재조회 · 격리 안내 수에 포함. 확정 0
blockedblocked(code)isolated{code}텔레메트리 행별 + 안내 1회. 배지는 프로젝션이 그린다
heldheldheld무음(경고 로그)
deferredqueued(deferredAt)deferred무음. stoppedBy 가 일시·오프라인이면 연결 상태 강등, 그 밖은 텔레메트리

G03 매핑(persistence/dispatch/types.ts): receiptOutcomeOfReceiptOutcome(receipt·already_gone·unknown·held·deferred·blocked 전부 대응) · persistenceStageOfPersistenceStage(server_committed/device_persisted/unknown; stats_published 는 읽기 모델 세대와 대조해야 알므로 여기서 올리지 않는다).

다른 사용자의 행(result.ownerId ≠ row.userId)은 reconciler 가 거부하고 binding 도 걸러 어떤 화면도 바꾸지 않는다. ownDataReplica(관문 사본)는 서버 조회 응답으로만 채운다 — writeOwnDataReplica 호출처는 barbelicRepository 하나뿐이며 저장 모듈은 그 이름을 모른다(dispatchBoundaries).

6. 이 트랙이 고친 것 · 남긴 것

  • S04 의도 행 변환 포트 결함: 스토어의 prepareSuccessorcompletedWorkoutCommands.prepareSave/prepareDelete 를 부르고 있었는데 S03(#1333)이 그 함수를 지웠다(env 가 AnyRecord 라 컴파일 통과, 실행 시 not a function → 의도 행 항상 LG_SUCCESSOR_UNPREPARED). dispatcher 가 S03 조립기로 구현한다(dispatchReconcile ①).
  • 죽은 코드 제거: workoutWriteControllerwait-for-create 분기 2곳, completedWorkoutCommandsretryUpdate·retryDelete·save({mode:"retry"})(이중 sender).
  • 남긴 것(범위 밖): completedWorkoutCommands.save({mode:"create"|"edit"})·delete 는 제품 호출처가 없다(테스트만 부른다) — 온라인 직접 쓰기 시절의 잔재, 정리는 A09/S06 과 조율. findCreateReceipt·findRecoveryReceiptPhase 2 종료 보완에서 owner 전용 read-only RPC에 연결됐다. queuePendingWorkoutMutation 결과 union 의 wait-for-create 멤버는 queue 담당(S07) 파일에 있어 손대지 않았다.

7. 인계

받는 작업가져가는 것
S06 충돌 재전송 영속화 — 완료(2026-09-08, 이슈 #1341, 계약)단일 sender 의 충돌 분기 4종이 재조립한 요청을 보내지 않고 dispatcher 가 준 successor 포트(persistence/conflict resolver)로 후속 행에 먼저 적은 뒤 superseded 를 돌려준다 — 전송 루프는 ACK 없이 다음 행(그 후속 행)으로 간다. 후속 행의 새 identity 가 곧 보낸 identity 라 §4 대조는 그대로다. 후속 행을 못 적으면 LG_SUCCESSOR_PERSIST_FAILED·LG_CONFLICT_STORM = 기기 저장 일시 실패(§3 분류기, 연결 상태 불변). 확정 전에는 행의 conflict 근거, 확정 뒤에는 요약 장부가 S09 입력
A09 화면 invalidation — 이행(2026-09-08, 이슈 #1342)"무엇을 다시 읽을지" 는 순수 표 features/workout/queries/receiptInvalidation.ts(receiptInvalidationPlanOf)가 결과 종류 × 행 종류로 고르고, binding 은 표를 순서대로 실행만 한다. 계획 영수증(세대 0)은 통계 폴링에 걸리지 않고, already_gone 은 목록·상세만, unknown 은 상세 강제 조회로 수렴. env 함수 13개는 실행 도구로 남았고(A10/A11 이 읽기 모델을 resource 로 옮기며 줄인다) 표는 첫 수직 통합 계약 §5
S07 index·mirror결과 미상 행의 blockedCode 두 값(LG_RECEIPT_MISSING·LG_RECEIPT_MISMATCH)이 종결이 아니라 격리임을 index 가 보존한다
U02/U03격리 배지의 원인 문구에 LG_RECEIPT_* 두 코드("서버 답장을 확인하지 못했어요") 추가

Phase 2 종료 연결 보완 (2026-09-08)

이 문서의 receipt 조회 미구현·직접 API 재조회 인계는 Phase 2 종료 기록으로 갱신한다. 원문 보존·명시 owner·기존 저장 정책은 유지한다. 역사 영수증을 현재 상세로 취급하지 않고, 복구 ACK와 canonical resource 수렴을 구분한다. 이 보완의 병합·staging 상태는 해당 기록에서 확인한다.