Skip to content

v0.18.0 S06 — 충돌 재전송 요청과 복구 근거의 원자적 영속화: 개정번호 충돌 뒤 재조립한 요청을 보내기 전에 기기 후속 행으로 적고, 답장이 유실돼도 같은 id·지문으로 재생 (2026-09-08)

  • 기간: 2026-09-08 (세션 1개 ae5d574f-72ed-4af2-91e0-e8d2a9994ed9, 오너 지시 "1341 진행해줘" → 분석·계획 게시 → "go, Phase 0 부터 진행해줘")
  • 랜딩: PR #1385 → main 1ebfda64 (Phase 0~4 한 PR, 랜딩 큐 run 34183888729 squash 머지 · staging Deploy run 34183907122 성공) — 마이그레이션·엣지 함수·서버 변경 없음. package.json 한 줄(test:persistence-browser 목록에 브라우저 검사 1파일 추가)이 공용 통합 자원이라 랜딩 큐(npm run landing:request)로 머지. 앱 코드 변경은 src/react/persistence/conflict/{types,evidence,successorPlan,resolver,ledger,index}.ts(신규) + src/react/services/pendingWorkoutMutationFlush.ts(충돌 분기 4종 → 후속 행 영속화 포트) + src/react/persistence/dispatch/dispatcher.ts(resolver 배선·superseded·장부)·failure.ts(기기 저장 일시 실패 2코드) + src/react/services/pendingWorkoutSaves.ts(전송 루프 3줄·타입·export 1) + src/react/contracts/persistence/pendingSaveRow.ts(추가 전용 필드 conflict) + src/react/features/completed-workout/pendingSavesDispatchPorts.ts(개정번호 재조회가 상세를 함께 반환). 총괄 S06 카드 · 계획 ID S06 · Phase 2 스텝 2-4
  • 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1341 착수 댓글
  • 정본: docs/contracts/outbox-conflict-successor.md(유저 이야기·영속화 절차·근거 필드·요약 장부·증명 시나리오·인계) · 도메인 계약 §14·§19 · ADR §3-1 표·§3-3 · S04 계약 §4·§8 · S05 계약 §7
  • 도구: 시나리오 픽스처 tests/support/conflictScenarios.mjs(멱등 장부·개정번호 상승·답장 유실 주입·옛 응답·연속 충돌 가짜 서버, R02 재사용)
  • 게이트: 단위 — tests/react/conflictSuccessor.test.mjs 2(Phase 0 결함 재현 → 통과) · conflictSuccessorPlan.test.mjs 4(근거·순수 계획기·resolver·장부) · conflictSuccessorScenarios.test.mjs 5(증명 시나리오) · pendingWorkoutMutationFlush.test.mjs 7(후속 행 설계로 다시 씀) · 브라우저 — outboxConflict.browser.mjs 1(실제 Chromium IndexedDB 재시작, npm run test:persistence-browser 등록) · 기존 대기열·dispatch·codec·복구 스위트 12파일 76/76(dispatchScenarios ④ 수정 없이 통과). G05 규칙 2줄(테스트 conflict·계약 문서) + 장부 재생성. e2e·pgTAP 변경 없음. npm run ci:local -- --full: ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 119파일/2059 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 12/12 · e2e-empty 통과 8/8 · e2e-cardio 통과 6/6 · e2e-persistence 통과 38/38 · e2e-browser 통과 57/57 · e2e-viewport 통과 14/14 · e2e-evidence 통과 · 18분 24초
  • 버그리포트: 없음(구조 트랙, 감사 보고서 F07·F07b 의 수리). Phase 0 의 결함은 사용자에게 보이는 결함이었다(두 기기에서 같은 기록을 고친 뒤 답장이 유실되면 같은 수정이 두 번 반영) — v0.17.x 부터 있던 구조이나 재현 조건(충돌 + 답장 유실)이 드물어 보고된 사례는 없다
  • 계약: outbox-conflict-successor.md 신설 · 도메인 계약 §14(S06 구현 문단)·§19(conflict 필드·장부 키) · ADR §3-1 표 durable successor 행·§3-3 계약 문장 · S04 계약 §4(재전송 문장)·§8 S06 행 완료 · S05 계약 §7 S06 행 완료

Phase 현황

Phase내용상태
Phase 0결함 재현 테스트 2건 먼저 실패(충돌 재전송 뒤 답장 유실 → 재시작하면 원 요청이 또 나가 다른 id 로 한 번 더 반영 · 재조립 저장 실패 시 미저장 요청 전송)b97c68ec
Phase 1persistence/conflict/** — 근거 조립·순수 계획기(claim 표 검사)·resolver(stale/storage/storm)·요약 장부 · 행 계약 추가 전용 필드 conflictb2ea3569
Phase 2sender 충돌 분기 4종 → successor 포트·superseded · dispatcher 배선·장부 · 전송 루프 3줄 · 분류기 2코드 · 재조회 포트 상세 반환 · Phase 0 초록eb928981
Phase 3증명 시나리오 3벌(재시작 재생·모든 중단 지점·사본 가용성/LWW 유지) · 실제 Chromium IndexedDB 재시작 · R02 픽스처8825e96a
Phase 4정본 계약 신설·갱신 5곳 · G05 규칙·장부 · 이 기록 + 등록 2곳 · ci:local --full · PR · CI 1회 · 랜딩 큐✅ PR #1385 → 1ebfda64 (CI 2회: 1회차 CASE-028 flaky, 2회차 전부 통과)

1. 배경

유저 A 가 폰과 태블릿을 같이 쓴다. 태블릿에서 어제 기록을 먼저 고쳐 서버는 개정번호 2 가 됐고, 폰에는 같은 기록의 수정이 개정번호 1 기준으로 기기 대기열에 남아 있다. 연결이 돌아오면 폰의 요청은 "개정번호가 다르다" 고 거부되고, 앱은 오너 결정 D1(나중에 저장한 쪽이 이김, #1173)대로 서버가 알려 준 최신 개정번호로 새 요청을 만들어 다시 보낸다. v0.18.0 총괄(ADR §3-3)은 이 재전송을 후속 행(durable successor)으로 먼저 기기에 적고 보내기로 정했고(S06, 감사 F07 "충돌 사본·명시 복구"·F07b "재전송 ID 의 비영속성"), 선행 S04(후속 행·claim·원자 교체)·S05(단일 sender·dispatcher·영수증 대조)가 끝난 뒤 이 트랙이 그것을 실제로 했다.

2. 문제 제기

재조립한 요청이 메모리에만 있었다

pendingWorkoutMutationFlush.ts 의 충돌 분기가 새 요청(새 멱등 키·새 지문)을 만들어 바로 보냈다. 기기 대기열에는 여전히 원 요청이 적혀 있었다. 서버가 새 요청을 저장했는데 답장이 유실되면(터널·앱 종료) 다음 전송에서 원 요청이 또 나가고 → 또 거부(개정번호는 더 올랐다) → 또 다른 새 요청 이 같은 내용을 한 번 더 저장했다. 새 지문에 기대 개정번호가 들어가므로 서버 개정번호가 오를 때마다 다른 id 가 되어 서버 멱등 장부가 막지 못했다. 삭제·계획 저장·계획 삭제도 같은 구조였다.

충돌의 근거가 어디에도 없었다

충돌이 났다는 사실, 원 요청이 무엇이었는지, 서버가 알려 준 개정번호가 숫자만이었는지 상세를 읽었는지, 덮이는 서버 사본을 어디서 얻을 수 있는지 — 행에도 다른 저장소에도 자리가 없었다. G03 의 Conflict·ConflictCopy 타입은 쓰는 곳이 없었다. 복구 화면(S09)이 보여 줄 근거가 없었다.

어떤 구조라서 가능했나(§22): 대기열의 약속은 "행에 적힌 정체성이 정본이고, 재시작 뒤 같은 id·지문이 다시 나간다"(codec 계약 §3)인데, 충돌 분기만 그 약속 밖에서 메모리 요청을 보냈다. S05 가 "dispatcher 는 durable 행만 보낸다" 고 정했지만 sender 안의 이 한 분기가 예외였다.

3. 해결 방안

원칙

  • 오너 결정 없음 — 계획의 전제로 진행: ① SQL·서버 변경 없음(덮이는 서버 사본은 user_fact_history 참조로만 기록, 조회 함수는 S09/S10) ② 확정 뒤 근거의 장기 보관은 localStorage 요약 장부 100건/owner(정책이 아니라 기술 선택, S09 가 바꿀 수 있음) ③ 전송 루프(pendingWorkoutSaves.ts, S07 소유) 변경은 typed 신호 처리 3줄로 한정.
  • §22(근본 구조): 재전송 id 를 원 id 에서 파생하거나(개정번호가 또 오르면 소용없고 근거도 안 남음) 답장 유실 뒤 상세를 읽어 "내 내용과 같으면 성공으로 간주"(성공 발명, S05 §4 금지)하는 땜질 대신, 충돌 재전송도 다른 모든 재전송과 같은 길("기기 행에 적힌 것만 보낸다")을 지나게 했다.

접근

대안판정
A. 충돌 재전송을 후속 행으로 먼저 영속화 — 원 행 제거 + 후속 행 쓰기 + 근거를 S04 원자 교체 하나로, 전송 루프가 종전 규칙대로 집어 보냄. 근거는 행 필드 conflict(확정 전) + 요약 장부(확정 뒤)채택
B. 재전송 id 를 원 요청 id 에서 결정적으로 파생기각 — 서버 개정번호가 다시 오르면 또 다른 id, 답장 유실 자체를 다루지 않음, 근거 없음
C. 답장 유실 시 상세를 읽어 같으면 성공으로 간주기각 — 성공 발명. 같은 내용이 다른 기기에서 왔을 수도 있다
D. 후속 행을 sender 안에서 claim 까지 잡고 바로 보내기기각 — 전송 루프의 claim·ACK 절차를 sender 가 한 벌 더 갖게 된다. 대신 루프에 superseded 신호 처리 3줄을 두어 후속 행이 종전 절차로 나간다
E. 근거를 새 IndexedDB object store 에기각 — IDB 버전 6 고정(S07, 올리면 옛 탭 VersionError). 행 필드(원자) + localStorage 요약(확정 뒤) 으로

4. 적용한 내용

Phase 0 — 결함 재현 (b97c68ec)

tests/react/conflictSuccessor.test.mjs 2건: ① 충돌 → 재전송 저장 → 답장 유실 → 재시작에서 기기에는 원 요청만 남아 있음(재시작 시 다른 id 로 한 번 더 전송의 원인) ② 재조립 저장 실패 시에도 서버가 요청 2건 수신. 현재 코드로 둘 다 실패 확인.

Phase 1 — persistence/conflict/** (b2ea3569)

  • evidence.ts: 원 행·서버 근거·새 요청 → conflict. 숫자만 받으면 remote revision_only(내용 발명 없음), 상세를 읽었으면 snapshot, base 는 없음, 완료 기록만 user_fact_history 참조(server_only), 연속 충돌은 원 요청 보존 + lineage.
  • successorPlan.ts(순수): 원 행의 claim 이 지금도 내 탭·내 fence(S04 verifyOutboxTicket)이고 관찰이 그대로일 때만 "후속 행 쓰기 + 원 행 삭제" 커밋. 후속 행은 owner·kind·targetId·dates·savedAt·announced 를 잇고 successorOf = 원 행, state: queued, 시도 횟수 이어 셈.
  • resolver.ts: 저장소 읽기(S07 index·readRows) → 계획 → 원자 교체, stale 재읽기 3회, storage 예외, 같은 기록 한 전송에 3개 상한(storm). 계획 후속 행의 id 발급.
  • ledger.ts: localStorage barbelic:conflict-ledger:v1:<owner>, 후속 id 멱등, 최신 앞, 100건.
  • 행 계약 pendingSaveRow.ts 에 추가 전용 필드 conflict(+PENDING_SAVE_ROW_FIELDS).

Phase 2 — 배선 (eb928981)

  • sender: 충돌 분기 4종이 재조립한 요청을 successor.persist 로 적고 superseded 반환(결과 union sent | superseded). 못 적으면 PendingWorkoutSuccessorPersistError(LG_SUCCESSOR_PERSIST_FAILED / LG_CONFLICT_STORM). resolvedConflict = 행에 conflict 있음.
  • dispatcher: 전송마다 resolver 하나(저장소는 여기서만), supersededOutboxSendSuperseded, 확정 전 장부 기록(성공 ACK 전), already_gone 도 기록.
  • 전송 루프: superseded 면 ACK 없이 continue(3줄). defaultPendingWorkoutSaveStore export.
  • 분류기: 두 코드를 기기 저장 일시 실패로(요청 재대기·전송 중단, transientEvidence 아님 → 연결 상태 불변).
  • 포트 배선: loadCompletedSessionRevision 이 읽은 상세 session 도 반환.
  • sender 단위 테스트 7건 다시 씀. 기존 12파일 76/76.

Phase 3 — 증명 (8825e96a)

계약 §5 표 9행. 실제 Chromium IndexedDB 재시작 1건을 test:persistence-browser 에 등록(package.json 한 줄).

작업 중 드러난 것

  • 답장 유실로 기기에 남았던 후속 행이 나중에 동기화되면 "동기화했어요" 1회 — 종전 정책(쓰기 파이프라인 §4 deferredAt) 그대로. 충돌 처리 자체는 무음. 재현 테스트 기대값을 그 정책에 맞췄다.
  • 테스트 도우미 결함 1건: 같은 요청 id 를 두 탭이 보낼 때 답장 문을 덮어써 첫 탭의 약속이 영영 안 풀려 스위트가 300초를 넘겼다 → 보낸 순서 큐로 수정. 제품 코드 결함 아님.
  • 리베이스(main 1d8b9aaf) 뒤 G05 장부 도구(check-coverage-inventory)가 main 의 미등록 파일 8개(#1361 의 e2e/required-coverage.json·e2e 신뢰 도우미 테스트 4·userFacingErrorMonitor.browser, #1382 의 heroNumberFit·verticalScrollContainersContract)로 "미분류 8" 을 냈다(CI 게이트는 아니고 장부 규약, main 의 남의 누락). 이 PR 이 규칙 4줄로 등록하고 장부를 재생성했다.
  • CI 1회차(run 34182547014, head 82fb34df) 빨간불 1건: browser-journeys 샤드 2/4 의 CASE-028(완료 화면 복귀 뒤 재저장)이 첫 시도 실패 → 재시도 통과(failOnFlakyTests 로 잡 실패, e2e-evidence 도 "CASE-028 exactly once" 위반). 첫 시도의 실패 원인 = 영수증 뒤 상세 재조회(refresh_completed_session_after_receipt, main #1361 이 pendingSavesFeatureBinding 에 넣은 코드)가 resource_load_failure 오류 보고 이벤트를 남긴 것 — 저장·영수증·DB 읽기(개정 2)는 전부 정상이었고, 재조회 요청이 테스트의 page.reload() 와 겹쳐 실패한 경쟁으로 보인다. 이 PR 의 변경 밖(충돌이 없는 수정 저장 경로)이며 로컬 ci:local --full 2회에서 CASE-028 은 통과 → 재현 불가(main 의 남의 회귀). 문서 커밋을 더한 head 로 CI 를 다시 돌렸다(아래 §5).
  • 사전 검증 누락: 없음. CI 2회차(head 8fa5db4f) 전부 통과 → 랜딩 큐 squash 머지 1ebfda64 → staging Deploy 성공.

5. 적용 결과

항목
충돌 재전송 뒤 답장 유실 → 재시작: 서버가 받은 서로 다른 요청 id3 (원·재전송·또 다른 재전송)2 (원·후속) — 재시작 뒤 전송은 같은 id·같은 지문(§5 #1·#3·#8)
같은 시나리오의 서버 개정번호 증가2회(같은 수정 두 번 반영)1회(재생 영수증)
재조립 저장 실패 때 미저장 요청 전송1회0회, 원 행 보존·대기·연결 상태 불변(#2)
충돌 근거(원 요청 원문·서버 근거·사본 가용성) 기록0conflict 1 + 확정 뒤 장부 1(#3·#7·#8)
"dispatcher 는 durable 행만 보낸다" 의 예외1(sender 충돌 분기)0
연속 충돌의 tight loop무한(개정번호가 계속 오르면 계속 새 요청)전송당 3개, 4번째는 멈추고 다음 기회(#6)
서버·저장 형식무변경(형식 v1·추가 필드 conflict), IDB 버전 6 그대로
게이트단위 18건 + 브라우저 1건 신설, 기존 76/76, ci:local --full ci:local full · verify 통과 (8단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 119파일/2059 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 12/12 · e2e-empty 통과 8/8 · e2e-cardio 통과 6/6 · e2e-persistence 통과 38/38 · e2e-browser 통과 57/57 · e2e-viewport 통과 14/14 · e2e-evidence 통과 · 18분 24초, CI 1회

미검증(자동 검증 밖): 실제 두 기기의 Production 충돌 — 재현 조건(충돌 + 답장 유실)이 드물어 실측 사례 없음. 서버 쪽 LG409 상세의 actual_revision 은 e2e CASE-040(S04)이 v0.17.1 번들과 함께 실측한 것을 그대로 믿는다.

6. 이번 개선으로 향상된 것

  • 두 기기를 쓰는 사용자의 같은 수정이 답장 유실 뒤 두 번 반영되지 않는다(이력·통계 세대 중복 없음).
  • 충돌 재전송도 "기기 행에 적힌 것만 보낸다" 는 하나의 규칙을 따른다 — 재시작·탭 인계·늦은 답장·다른 owner 의 모든 경우가 S04·S05 의 같은 검사(claim 표·영수증 대조)로 처리된다.
  • 복구 화면(S09)이 쓸 근거가 생겼다: 무엇이(원 요청 원문) 무엇에(개정번호 n) 덮였고 서버 사본은 어디에 있는지(이력 참조, 서버 함수 필요).
  • 연속 충돌이 무한 루프가 되지 않는다.

남은 것

  • S09: conflict·장부 → RecoveryDescriptor, 서버 사본 조회 함수(user_fact_history 는 앱 계정이 읽을 수 없다), 장부 보관 정책. S10: 원 요청 원문·lineage 로 복원 명령.
  • 후속 행이 대기 중일 때 같은 기록을 또 고치면 후속 행이 새 행으로 교체되며 확정 전 conflict 근거는 장부에 남지 않는다(계약 §6) — 이어 갈지는 S09 가 정한다.
  • 총괄 S06 카드의 상태 문구는 HQ 갱신분(다른 트랙과 같이 카드 본문은 그대로 두었다).