오프라인 대기 큐 머리 포이즌 수리 — 영구 실패 격리·통지·새 기록 복구 (2026-08-31)
- 기간: 2026-08-31 (세션 1, 오너 go "924 진행해줘")
- 랜딩: PR #1022 — 클라 전용(마이그레이션 0), Vercel 자동 배포
- 설계서: 없음(이슈 #924 본문의 감사 분석 + 착수 댓글의 Phase 계획이 정본, "예상 효과·개선사항" 표 포함)
- 정본:
src/react/services/pendingWorkoutSaves.ts(격리 필드·루프·복구) ·directWorkoutWrite.tsisolatePendingWorkoutSaveFailure(격리 판정) ·pendingWorkoutSavesStore.ts(통지·복구 전이) - 도구: 없음
- 게이트:
tests/react/pendingQueueHeadPoison.test.mjs8건 — 핵심은 이슈가 요구한 "머리 행이 영구 실패해도 꼬리 행이 업로드된다"(종전 0건이던 회귀 고정) - 버그리포트: 없음(감사 발견 결함 — 사용자 보고 아님, 이슈 #924가 기록 정본)
- 계약: 세션 상태 토큰
sync_blocked("동기화 실패") 신설 — designContract status enum·6개 소비 표면 등재. 대기 행 스키마에 격리 메타(blockedAt·blockedCode) 추가(로컬 IndexedDB 전용, 서버 무관)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 큐 서비스 — 격리 필드·update()·루프 continue·복구 함수 | ✅ PR #1022 |
| Phase 2 | 스토어 — 격리 판정 배선·통지 1회·복구 전이(재대기→즉시 플러시) | ✅ PR #1022 |
| Phase 3 | 화면 — sync_blocked 라벨·칩 6지점 + 상세 원인 문구·복구 버튼(모바일·데스크톱) | ✅ PR #1022 |
| Phase 4 | 게이트·작업 기록 | ✅ 본 PR |
1. 배경
출시 전 전면 감사(08-30)가 확정한 real/high 결함. 유저 A가 헬스장 오프라인에서 계획된 운동을 완료하고(대기 1행), 다음날 같은 계획으로 또 완료하면(2행), 온라인 복귀 때 1행이 계획을 완료 처리하는 순간 2행은 "계획이 이미 완료됨"(40001)이라는 영구 거부가 된다. 플러시 루프는 첫 실패에서 전체 중단하고, 정렬(savedAt 오름차순)이 실패 행을 머리에 고정하므로 — 그 뒤에 쌓인 모든 정상 기록이 무기한·무통보로 서버에 못 올라간다. 탈출구는 카드 삭제(=기록 소실)뿐이었다.
2. 문제 제기
- 큐 투입 시점 방어(#968: 영구 실패는 blocked로 큐 미진입)는 있으나, 투입 후 서버 상태 변화로 영구 실패가 된 행은 그 관문 밖 — 큐 자신의 성공 부작용이 전제를 깬다.
- 실패 통지가 업로드 성공 블록에만 있어 사용자는 아무것도 모른다. 카드 상태도 항상 "동기화 대기".
3. 해결 방안
원칙
- 격리는 좁게: 기존 분류기의 영구 코드 집합(conflict/permanent — 40001·LG001·LG409·P0002 등)만 격리, 그 외(네트워크·타임아웃·5xx)는 종전대로 전체 중단·재시도 — 오분류로 정상 행을 잠그는 위험을 코드 집합으로 한정.
- 무통보 금지: 격리 발생 시점에 1회 통지 + 카드 상태 구분.
- 소실 없는 탈출구: 삭제 외에 "새 기록으로 다시 올리기" 복구를 제공 — 값 발명 없이 연결(계획 링크·source_ref)만 끊는다.
4. 적용한 내용
- Phase 1 (서비스): 대기 행에 격리 메타(blockedAt·blockedCode) + 스토어
update()신설(기존 put은 같은 요청이면 갱신하지 않는 멱등 규칙이라 메타 마킹 불가). 플러시 루프는 격리 판정 시 표시 후continue, 격리 행은 이후 플러시에서 건너뜀. 복구recoverBlockedWorkoutSave: 계획 연결 제거 + 새 clientMutationId·source_ref로 재대기(원 savedAt 보존 — 큐 순서 유지). - Phase 2 (스토어): 격리 판정
isolatePendingWorkoutSaveFailure(40001→plan_completed, LG001→source_deleted) 배선. 새 격리 시 "동기화하지 못한 운동 기록이 N건 있어요. 일지에서 확인해 주세요" 토스트 + 행별 적재. 복구 전이: 재대기 → 프로젝션 갱신 → 즉시 플러시. - Phase 3 (화면): 상태
sync_blocked("동기화 실패", 적색 계열 칩) — 모바일 세션 상세·일지 목록·하루 상세, 데스크톱 달력·상세·라벨 맵 6지점. 세션 상세(양 플랫폼)에 원인 문구("연결된 계획이 이미 완료 처리되어 있어 그대로는 올릴 수 없어요…" / "원본 기록이 삭제되어 있어…") + [새 기록으로 다시 올리기] 버튼(session.sync.recover액션 등재). 수정 불가·삭제=업로드 취소 규칙은 대기와 동일.
작업 중 드러난 것
- GitHub Actions 결제 복구 확인: 이 트랙의 PR CI가 하루 종일 이어지던 결제 차단 이후 처음으로 실제 실행됨 — 이후 트랙은 종전 CI 절차로 복귀.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 머리 영구 실패 시 꼬리 기록 | 무기한 차단(시도 0) | 전량 업로드 (회귀 테스트 고정) |
| 사용자 인지 | 무통보(영원한 "동기화 대기") | 토스트 1회 + "동기화 실패" 카드 + 원인 문구 |
| 탈출구 | 삭제(기록 소실)뿐 | 새 기록으로 재업로드(연결만 제거, 값 보존) |
| 전송성 실패 처리 | 전체 중단·재시도 | 불변(의도 유지) |
| 검증 | 회귀 단언 0건 | 신규 8건 · check 전 체인 2,261/2,261 · tsc 0 |
6. 이번 개선으로 향상된 것
- 큐가 "영구 실패 행이 없다"는 깨질 수 있는 전제 대신, **깨졌을 때의 행동(격리·통지·복구)**을 가진다 — 이후 새 영구 실패 원인이 생겨도 같은 경로로 수용.
- 세션 상태 어휘에 "동기화 실패"가 생겨, 로컬 대기 데이터의 이상 상태를 화면이 표현할 수 있다.
남은 것
- 격리·복구의 실기기 확인은 자동 증거(행동 테스트 8건)로 갈음(§14) — 실사용 중 신고 시 재점검.