Skip to content

오프라인 대기 큐 머리 포이즌 수리 — 영구 실패 격리·통지·새 기록 복구 (2026-08-31)

  • 기간: 2026-08-31 (세션 1, 오너 go "924 진행해줘")
  • 랜딩: PR #1022 — 클라 전용(마이그레이션 0), Vercel 자동 배포
  • 설계서: 없음(이슈 #924 본문의 감사 분석 + 착수 댓글의 Phase 계획이 정본, "예상 효과·개선사항" 표 포함)
  • 정본: src/react/services/pendingWorkoutSaves.ts(격리 필드·루프·복구) · directWorkoutWrite.ts isolatePendingWorkoutSaveFailure(격리 판정) · pendingWorkoutSavesStore.ts(통지·복구 전이)
  • 도구: 없음
  • 게이트: tests/react/pendingQueueHeadPoison.test.mjs 8건 — 핵심은 이슈가 요구한 "머리 행이 영구 실패해도 꼬리 행이 업로드된다"(종전 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) — 실사용 중 신고 시 재점검.