충돌 재전송 요청과 복구 근거의 원자적 영속화 — 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· 단일 sendersrc/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 기준으로 대기열에 남아 있다. 연결이 돌아오면:
- 폰이 대기열 행
R1을 보낸다 → 서버가 "개정번호가 다르다(LG409, 지금은 2)" 고 거부한다. - 앱은 오너 결정 D1(나중에 저장한 쪽이 이김)대로 개정번호 2 의 새 요청
R2(새 멱등 키·새 지문) 를 만든다. 이 계약:R2는 보내기 전에 기기 대기열에 후속 행으로 적힌다 — 원 행R1제거 + 후속 행R2쓰기 + 충돌 근거를 원자 교체 한 트랜잭션으로. - 전송 루프가
R2를 집어 보낸다. 서버가 저장해 개정번호 3 이 된다. 답장이 오기 전에 앱이 종료된다. - 재시작 뒤 대기열에는
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,
}| 사본 | 값 | 뜻 |
|---|---|---|
| local | present | 진 쪽(원 요청) 본문 = predecessor.request 그대로 — 유저가 적은 것은 지우지 않는다(원본 불변 §23) |
| base | absent:no_writable_copy_in_row | 편집의 기준 사본은 기기 행에 없다(수정 명령은 사본 역할만 검증하고 본문을 싣지 않는다, S03) |
| remote | revision_only@n | 서버가 거부 상세에 실제 개정번호 숫자만 알려 줬다(Production 의 LG409 는 항상 이 경우) — 내용이 있다고 간주하지 않는다 |
| remote | snapshot@n | 옛 응답(숫자 없음)이라 상세를 다시 읽었고, 읽은 상세가 그 스냅샷이다(pendingSavesDispatchPorts.loadCompletedSessionRevision 이 session 을 함께 돌려준다) |
| historyRef | user_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 화면 invalidation | committed.resolvedConflict 의 뜻이 "이 행이 충돌 재전송의 후속 행" 으로 정확해졌다. 재시작 가능한 충돌 저장 시나리오 = §5 #1·#8 |
| R02 최종 확인 | tests/support/conflictScenarios.mjs(멱등 장부·개정번호 상승·답장 유실 주입·옛 응답·연속 충돌 가짜 서버)로 같은 응답 유실 시나리오를 통합 회귀에서 재사용 |
| U02/U03 | 격리 배지 원인 문구는 새로 늘지 않았다(두 코드는 일시 실패라 격리되지 않는다) |