Skip to content

충돌 재전송 요청과 복구 근거의 원자적 영속화 — durable successor·conflict 근거·요약 장부 계약 (v0.18.0 S06, 이슈 #1341)

  • 상위: ADR §3-3(충돌 = 사본 + 명시 복구, 자동 field merge 없음; 충돌 재전송은 durable successor 로 먼저 영속화) · 도메인 계약 §12–§14·§19 · S04 claim·후속 행 계약(후속 행·원자 교체·claim 표를 그대로 쓴다) · S05 dispatcher·reconciler 계약(단일 sender·영수증 대조) · 대기열 행 codec §3·§5(행에 적힌 정체성이 정본, 형식 v1·추가 필드만)
  • 코드: src/react/persistence/conflict/{types,evidence,successorPlan,resolver,ledger,index}.ts · 단일 sender src/react/services/pendingWorkoutMutationFlush.ts(충돌 분기 → successor 포트) · src/react/persistence/dispatch/dispatcher.ts(resolver 배선·superseded·장부) · src/react/persistence/dispatch/failure.ts(기기 저장 일시 실패 두 코드) · 전송 루프 src/react/services/pendingWorkoutSaves.ts(superseded 는 ACK 없이 다음 행) · 행 계약 src/react/contracts/persistence/pendingSaveRow.ts(추가 전용 필드 conflict)
  • 검사: tests/react/conflictSuccessor.test.mjs(Phase 0 결함 재현 → 통과) · conflictSuccessorPlan.test.mjs(근거·순수 계획기·resolver·장부) · conflictSuccessorScenarios.test.mjs(§5 시나리오) · outboxConflict.browser.mjs(실제 Chromium IndexedDB 재시작, npm run test:persistence-browser) · 픽스처 tests/support/conflictScenarios.mjs(R02 재사용)
  • 서버 변경: 없음. 저장 형식 v1·추가 필드 conflict 만. 서버 사본 조회 함수는 만들지 않았다(S09/S10 몫 — §3 가용성 참조).

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

유저 A 가 폰과 태블릿을 같이 쓴다. 태블릿에서 어제 기록을 먼저 고쳐 서버는 개정번호 2 가 됐고, 폰에는 같은 기록의 수정이 개정번호 1 기준으로 대기열에 남아 있다. 연결이 돌아오면:

  1. 폰이 대기열 행 R1 을 보낸다 → 서버가 "개정번호가 다르다(LG409, 지금은 2)" 고 거부한다.
  2. 앱은 오너 결정 D1(나중에 저장한 쪽이 이김)대로 개정번호 2 의 새 요청 R2(새 멱등 키·새 지문) 를 만든다. 이 계약: R2 는 보내기 전에 기기 대기열에 후속 행으로 적힌다 — 원 행 R1 제거 + 후속 행 R2 쓰기 + 충돌 근거를 원자 교체 한 트랜잭션으로.
  3. 전송 루프가 R2 를 집어 보낸다. 서버가 저장해 개정번호 3 이 된다. 답장이 오기 전에 앱이 종료된다.
  4. 재시작 뒤 대기열에는 R2 가 있으므로 R2 를 그대로 다시 보낸다 → 서버 멱등 장부가 "이미 저장했다"(재생 영수증) → 행 삭제. 같은 수정이 두 번 반영되지 않는다.

종전(S05 까지)에는 2 의 새 요청이 메모리에만 있어 4 에서 R1 이 또 나가고 → 또 거부(지금은 3) → 또 다른 새 요청 R3 로 같은 수정이 한 번 더 반영됐다(감사 F07b "재전송 ID 의 비영속성"). 사용자에게 보이는 것은 종전과 같다: 충돌 때문에 묻지 않고(자동 병합 없음), 취소 toast 도 없다(P5·P6 유지). 답장 유실로 기기에 남았던 행이 나중에 동기화되면 "동기화했어요" 한 줄은 종전 정책(쓰기 파이프라인 §4) 그대로다.

이 계약은 다음도 지킨다.

  • 후속 행을 적지 못하면 원 요청이 남고 적히지 않은 요청은 보내지 않는다. 저장소 오류·다른 탭의 인계(stale)·연속 충돌 상한 모두.
  • 충돌이 났다는 사실과 무엇이 덮였는가가 기기에 남는다. 후속 행의 conflict 근거(원 요청 원문·서버가 알려 준 개정번호·근거 종류·사본 가용성) + 확정 뒤에도 남는 요약 장부. 복구 화면(S09)의 입력이다.
  • 서버가 숫자만 알려 줬으면 서버 사본 내용이 있다고 간주하지 않는다(발명 금지). 상세를 실제로 읽은 경우에만 스냅샷을 적는다.

2. 후속 행 영속화 — 언제, 무엇을, 어떤 조건으로

단일 sender(sendDurableMutation)의 충돌 분기 4종(완료 기록 수정·삭제, 계획 저장·삭제)이 재조립한 요청을 보내지 않고 successor.persist(...) 포트로 넘긴다. 포트 구현 = persistence/conflict/resolver.ts(dispatcher 가 전송마다 하나 만든다 — 저장소를 아는 자리는 dispatcher 뿐).

단계하는 일어긋나면
근거 조립(evidence.ts, 순수)원 행·서버 근거·새 요청 → conflict(§3)
계획(successorPlan.ts, 순수)원 행의 claim 이 지금도 내 탭·내 fence(S04 §3 의 ACK 규칙과 같은 검사 verifyOutboxTicket)이고 같은 열쇠 행의 관찰이 그대로일 때만 "후속 행 쓰기 + 원 행 삭제" 커밋 하나claim 없음·다른 탭·옛 fence·행 없음·다른 owner → stale, 아무것도 쓰지 않는다
커밋(resolver)S02 원자 교체 replace(commit) — 새 트랜잭션 종류 없음. 관찰이 어긋나면 다시 읽어 최대 3회저장소 예외 → storage. 같은 기록에 한 전송에서 후속 행이 3개를 넘으면 storm

후속 행의 모양: 원 행의 owner·kind·targetId·dates·savedAt(대기열 순서 유지)·announced·deferredAt 을 잇고, 요청은 새 요청(완료 기록은 S03 조립기가 만든 새 멱등 키·새 지문, 계획은 갱신 시각·개정번호가 바뀐 DTO 에 resolver 가 발급한 행 id), state: "queued", successorOf = 원 행 id, evidence 는 시도 횟수를 이어 세고, conflict = 근거. claim 은 없다(다음 순서에 전송 루프가 집는다).

sender 의 결과: 적혔으면 superseded{predecessorId, successor} — 전송 루프는 이것을 받으면 ACK 없이 다음 행으로 간다(원 행은 커밋에서 이미 제거됐고, 후속 행은 종전 selectSendableRows 규칙으로 바로 집힌다 — 선행이 없으므로 대기 없음). 못 적었으면 PendingWorkoutSuccessorPersistError 를 던진다: 코드 LG_SUCCESSOR_PERSIST_FAILED(stale·storage) 또는 LG_CONFLICT_STORM(상한). 단일 분류기(failure.ts)는 이 둘을 기기 저장 쪽 일시 실패 로 본다 — 행은 대기로 돌아가고(원 행 그대로, S04 requeue ACK) 이번 전송은 멈추며, 네트워크 근거가 아니므로 화면은 연결 상태를 내리지 않고 텔레메트리만 남긴다. 다음 시도는 종전대로 연결 복귀·화면 복귀·새 적재가 부른다.

연속 충돌: 후속 행도 거부되면(다른 기기가 계속 고침) 같은 절차로 다음 후속 행을 만든다 — 원 요청은 계보의 첫 요청으로 보존되고 중간 후속은 lineage 에 들어간다. 한 전송에서 같은 기록에 3개까지. 4번째는 storm 으로 멈춘다 — 3번째 후속 행은 durable 하게 남아 다음 전송(새 resolver)에서 이어간다. tight loop 없음.

후속 행을 보낼 때: 요청에 든 새 멱등 키·지문이 곧 보낸 identity 라 영수증 대조(S05 §4)는 그대로 맞는다. 결과 committed.resolvedConflict 는 행에 conflict 가 있으면 참이다.

3. 충돌 근거(conflict) — 행 계약의 추가 전용 필드

conflict = {
  version: 1,
  predecessor: { clientMutationId, requestHash, expectedRevision, expectedUpdatedAt, recordedAt, request(원문) },   // 계보의 첫 요청
  lineage: [ { clientMutationId, requestHash, expectedRevision, expectedUpdatedAt, recordedAt } … ],          // 중간 후속(원 요청 제외), 오래된 것부터
  failure: { kind: "revision_mismatch", actualRevision, code },                                               // LG409/40001
  basis: "actual_revision_only" | "server_detail",
  copy: { local: present, base: absent(no_writable_copy_in_row), remote: revision_only@n | snapshot@n | absent(reason) },
  historyRef: { kind: "user_fact_history", table: "session", rowId, supersededRevision, access: "server_only" } | null,
  recordedAt,
}
사본
localpresent진 쪽(원 요청) 본문 = predecessor.request 그대로 — 유저가 적은 것은 지우지 않는다(원본 불변 §23)
baseabsent:no_writable_copy_in_row편집의 기준 사본은 기기 행에 없다(수정 명령은 사본 역할만 검증하고 본문을 싣지 않는다, S03)
remoterevision_only@n서버가 거부 상세에 실제 개정번호 숫자만 알려 줬다(Production 의 LG409 는 항상 이 경우) — 내용이 있다고 간주하지 않는다
remotesnapshot@n옛 응답(숫자 없음)이라 상세를 다시 읽었고, 읽은 상세가 그 스냅샷이다(pendingSavesDispatchPorts.loadCompletedSessionRevisionsession 을 함께 돌려준다)
historyRefuser_fact_history:session/<id>@n:server_only덮이는 서버 사본의 불변 참조. 원본 불변 보호 트리거(#1236)가 UPDATE 마다 이전 행을 user_fact_history 에 남기지만 authenticated 는 읽을 수 없다 — "존재하나 서버 함수가 있어야 얻는다". 완료 기록 3종만(계획은 보호 대상 아님 → null)

가용성은 타입으로 보존한다 — 없음·부분·만료를 문자열로 뭉개지 않는다. isFullyRecoverable(G03 §14) 의 판정은 S09 가 이 필드와 서버 함수로 내린다. 옛 번들(v0.17.x)은 이 필드를 모르고 보존만 한다(S01 R4) — 형식 v1·추가 필드만 이라는 rollback 조건(codec 계약 §5) 유지. 행 크기는 원 요청 원문만큼 늘고(수 KB), 연속 충돌은 원문 1벌 + lineage 목록만 유지한다.

4. 요약 장부 — 확정 뒤에도 남는 것

후속 행이 영수증을 받으면 행은 지워진다(S04 ACK). 그 전에(dispatcher 의 send 안, 루프의 성공 ACK 전) owner 별 localStorage 키 barbelic:conflict-ledger:v1:<owner> 에 요약 한 건을 적는다: 후속 id·원 요청 id·kind·targetId·sessionId·덮인 개정번호·확정 개정번호·lineage 길이·사본 가용성 요약(§3 문자열)·기록/확정 시각. 후속 id 로 멱등(답장 유실 → 재생 영수증이 와도 1건), 최신이 앞, owner 당 100건. 본문은 싣지 않는다(서버 canonical 이 됐다). 삭제 대상이 이미 없던 경우(already_gone)도 확정 개정번호 null 로 적는다.

왜 localStorage 인가: IndexedDB 버전은 6 에 고정돼(S07, 올리면 옛 탭이 VersionError) 새 object store 를 만들 수 없고, 항목이 작다. 장부는 보조 기록이라 쓰기 실패(quota)가 전송을 막지 않는다. 이 장부가 S09 RecoveryDescriptor 의 입력이며 보관 정책(건수·기간)은 S09 가 바꿀 수 있다.

5. 증명 시나리오 (2026-09-08 실측)

#시나리오검사결과
1수정: 충돌 → 후속 행 → 서버 확정 → 답장 유실 → 재시작 = 같은 id·지문 재전송 → 재생 영수증, 서버 개정번호 증가 1회, 서로 다른 요청 id 2개(원·후속)뿐conflictSuccessor통과 (Phase 0 에서 현재 코드로 실패 확인: 원 행만 남아 다른 id 로 한 번 더 전송)
2후속 행 커밋이 저장소 예외 → 원 행 그대로 대기, 미저장 전송 0회, 연결 상태 불변, 회복 뒤 이어감conflictSuccessor통과 (Phase 0 실패 확인: 미저장 요청 전송)
3삭제·계획 저장·계획 삭제의 재시작 재생 + 장부 1건(재생 영수증은 다시 적지 않음)conflictSuccessorScenarios통과
4다른 탭이 인계(fence 2)한 뒤 첫 탭의 늦은 충돌 처리 → stale, 원 행·B 의 전송 중 상태 그대로, A 의 새 요청 0회 · B 가 후속 행을 적고 보낸다같은 파일 ②통과
5늦은 선행 ACK(원 요청 성공 답장)는 후속 행을 되살리거나 바꾸지 않음 · 다른 owner 의 충돌 처리는 이 owner 의 후속 행에 무접촉같은 파일 ②통과
6연속 충돌: 한 전송에 후속 3개, 4번째 LG_CONFLICT_STORM 으로 멈춤(행 durable, lineage 2), 다음 전송에서 이어가 canonical 변경 1회 · 보낸 요청 전부 보내기 전에 기기에 있었음같은 파일 ②통과
7숫자만 → revision_only + historyRef server_only, 원 요청 원문 보존 · 옛 응답 → 상세 스냅샷 · 서버가 받은 후속 본문 = 폰의 본문(자동 병합 없음) · toast 0같은 파일 ③통과
8실제 Chromium IndexedDB: 충돌 → 후속 행 적힘(원 행 없음, 근거 그대로) → 전송 → 답장 전 탭 종료 → 새 탭이 lease 만료 뒤 같은 id·지문 재전송 → 재생 영수증 → 행 0·장부 1건, 또 재시작해도 장부 유지outboxConflict.browser A통과 (npm run test:persistence-browser)
9근거 조립·순수 계획기(stale 5종)·resolver(stale/storage 무변경·storm)·장부(멱등·상한·실패 false)conflictSuccessorPlan ①~④통과

기존 스위트: dispatchScenarios ④(충돌 재전송 뒤 영수증이 새 id 와 대조)는 수정 없이 통과 — 화면·영수증 계약은 바뀌지 않았다. pendingWorkoutMutationFlush(sender 단위)는 후속 행 설계로 다시 썼다(7건). 대기열·dispatch·codec·복구 스위트 12파일 76/76.

6. 이 트랙이 바꾼 것 · 남긴 것

  • 바꾼 것: sender 충돌 분기 4종(메모리 직송 → 후속 행 영속화) · dispatcher(resolver 배선·superseded·장부) · 전송 루프 3줄(superseded 처리) · 단일 분류기(기기 저장 일시 실패 2코드) · 행 계약(conflict 추가 전용) · 개정번호 재조회 포트가 상세를 함께 반환.
  • 남긴 것(범위 밖): 후속 행이 대기 중일 때 사용자가 같은 기록을 또 고치면 종전 규칙대로 후속 행이 새 행으로 교체되며 그 행의 conflict 근거는 장부에 남지 않는다(확정 전) — 그 편집도 결국 충돌해 새 근거를 만든다. 근거를 교체 행으로 이어 갈지는 S09 가 정한다. completedWorkoutCommands.save/delete 잔재 정리(S05 §6)는 A09 와 조율 그대로.

7. 인계

받는 작업가져가는 것
S09 복구 화면·명시 undo§3 conflict(행, 확정 전) + §4 장부(확정 뒤) 가 RecoveryDescriptor 의 출처·가용 범위·precondition 입력. 덮인 서버 사본은 historyRef(server_only) — 조회 함수는 S09/S10 이 만든다. 장부 보관 정책(100건/owner)은 바꿀 수 있다
S10 복원 엔진원 요청 원문(predecessor.request)·lineage — 복구는 새 durable mutation 이며 삭제 영수증·tombstone 을 지우지 않는다(ADR §3-3)
A09 화면 invalidationcommitted.resolvedConflict 의 뜻이 "이 행이 충돌 재전송의 후속 행" 으로 정확해졌다. 재시작 가능한 충돌 저장 시나리오 = §5 #1·#8
R02 최종 확인tests/support/conflictScenarios.mjs(멱등 장부·개정번호 상승·답장 유실 주입·옛 응답·연속 충돌 가짜 서버)로 같은 응답 유실 시나리오를 통합 회귀에서 재사용
U02/U03격리 배지 원인 문구는 새로 늘지 않았다(두 코드는 일시 실패라 격리되지 않는다)