Skip to content

기기 대기열(outbox) 교체·합치기의 원자 계약 (v0.18.0 S02, 이슈 #1325)

  • 상위: ADR §3-1(원자 교체 행 = S02) · 대기열 행 codec(형식 v1·보존 병합·§6 인계) · 도메인 계약 §17 OutboxPort
  • 코드: src/react/persistence/outbox/**(순수 계획기·IDB 원자 커밋·메모리 adapter) · src/react/services/pendingWorkoutSaves.ts(facade)
  • 검사: tests/react/outboxAtomicReplace.test.mjs(메모리 adapter) · tests/react/outboxAtomicity.browser.mjs(실제 Chromium IndexedDB, npm run test:persistence-browser)

1. 교체·취소 조건표 — 무엇이 들어올 때 어떤 이전 행을 어떻게 하나

열쇠는 항상 owner + target 이다(pendingSaveTargetKey = ${owner}|${군}|${target}). 다른 owner 의 같은 target 행은 다른 열쇠라 조회·삭제·재검사 어디에도 들어가지 않는다.

들어오는 것같은 열쇠의 이전 행이전 행 상태처리결과 종류
생성(save_workout, putPendingWorkoutSave)같은 id 행내용 같음아무것도 쓰지 않고 기존 행을 돌려준다(재시도 멱등)queued(기존)
내용 다름거부 identity_conflict, 아무것도 안 바뀜오류
수정(서버 기록)삭제 행blocked 아님거부 target_deleted, 아무것도 안 바뀜오류
삭제 행blocked그대로 두고 아래 규칙으로 진행
수정 행(들)어떤 상태든전부 지우고 새 수정 행 하나를 쓴다(마지막 수정 하나). S04(2026-09-08)가 이 행을 나눴다: 전송 중(claim 유효 또는 sentAt 10초 안)인 행은 지우지 않고 새 행에 successorOf 를 적는다 — outbox-claim-successor.md §4queued
수정(미전송 생성 pending: 겨냥)생성 행queued(미시도) · deferred · held · blocked · sending 만료생성 행을 지우고 수정 내용을 합친 새 id·새 지문 의 생성 행을 쓴다(출처 sourceRef 유지). 답을 못 받은 생성이 서버에 이미 있었다면 새 생성은 같은 출처로 LG001 거부 → 격리·복구 경로(유실 아님)merged-into-create
생성 행sending(만료 전)아무것도 쓰지 않는다. 호출부가 답장을 기다린 뒤 서버 id 로 다시 적는다wait-for-create
생성 행 없음오류(합칠 대상 없음)오류
삭제(서버 기록)수정 행(들)어떤 상태든전부 지운다
삭제 행blocked 아님아무것도 쓰지 않고 기존 삭제 행을 돌려준다(삭제→삭제 멱등)queued(기존)
삭제 행blocked지우고 새 삭제 행을 쓴다(사용자의 새 시도 = 복구)queued
삭제(미전송 생성 pending: 겨냥)생성 행queued 이고 한 번도 보내지 않음(sentAt·deferredAt 없음)생성 행을 지우고 아무것도 쓰지 않는다(서버에 아무것도 안 감)dropped-create
생성 행blocked(서버가 영구 거부, 결과 확실)위와 같음 — 사용자가 실패한 기록을 폐기dropped-create
생성 행sending(만료 전·후) · deferred · held — 결과 미상아무것도 지우지 않는다. 호출부가 답장을 기다린 뒤 서버 id 로 삭제를 적는다wait-for-create
생성 행 없음이미 없음 — 아무것도 쓰지 않는다dropped-create
계획 저장(save_plan)계획 저장 행(들)어떤 상태든전부 지우고 새 행 하나queued
계획 삭제(delete_planned_session)계획 저장 행(들)어떤 상태든전부 지우고 삭제 행을 쓴다queued
격리 행 복구(recoverBlockedWorkoutSave)격리된 생성 행blocked새 id·새 출처의 생성 행을 쓰고(원래 savedAt 유지) 격리 행을 지운다복구 행

"결과 미상" 의 뜻: 요청이 한 번이라도 서버로 향했고(sentAt 또는 deferredAt 이 있음) 성공·영구 거부 어느 쪽도 확인되지 않은 상태. 서버가 받았을 수 있으므로 기기에서 흔적을 지우면 서버와 기기가 조용히 갈라진다(전제 ①, 이슈 #1325 수락 조건 ③).

2. 원자 교체 — 한 트랜잭션에서 하는 일

저장소(PendingWorkoutSaveStore)에 원자 연산 하나가 있다: replace(commit). 입력 OutboxReplaceCommit 은 순수 계획기(persistence/outbox/replacePlan.ts)가 만든다.

필드
ownerKey소유자 열쇠(pendingSaveOwnerKey)
targetKeys재검사 범위 — 같은 owner 의 이 열쇠들에 속한 행 전부(완료 기록 군·계획 군·생성 행 자기 id)
expected계획 시점에 그 범위에서 본 행들(id + 원문 JSON)
write쓸 행(없으면 null — 삭제만 하는 교체)
remove지울 이전 행 id. write 와 같은 id 는 계획기가 빼고, adapter 도 지우지 않는다

IndexedDB adapter(idbOutboxStore.ts)는 readwrite 트랜잭션 하나 안에서, await 없이:

  1. getAll() → codec 으로 읽어 targetKeys 범위의 행을 고르고 expected 와 id·원문이 정확히 같은지 확인한다. 다르면 abort() 하고 {status: "stale"} — 아무것도 쓰지 않았다.
  2. write 가 있으면 같은 id 의 기존 행을 get() 해 보존 병합(mergePendingSaveRow, S01)한다. 불변 묶음이 다르면 abort() 하고 {status: "identity_conflict"}. 같으면 put().
  3. removedelete() 한다.
  4. complete 이벤트가 성공이다 → {status: "committed", row}. 요청 하나라도 실패하면(용량 초과 QuotaExceededError, 브라우저의 중단) 트랜잭션 전체가 되돌아가고 Promise 는 그 오류로 거부된다 — 이전 행은 그대로, 새 행은 없다.

메모리 adapter(memoryOutboxStore.ts)는 같은 순서를 임시 사본에 적용하고 마지막에 원본을 교체한다. 단계 도중 예외가 나면 원본은 한 글자도 안 바뀐다. 검사 tests/react/outboxReplacePlan.test.mjs ④.

getAll/put/delete 를 순서대로 부르는 "원자 어댑터" 는 만들지 않는다 — 각 호출이 자기 트랜잭션을 열어 원자성이 없다(이슈 #1325 상세 4).

3. 트랜잭션 밖/안 경계와 재검사

facade(queuePendingWorkoutMutation)
  ① 읽기      store.getAll() → codec decode                       (밖)
  ② 계획      planOutboxReplace(input, rows) — 순수                (밖)
  ③ 준비      생성 합치기면 새 id·요청 지문(sha-256) 계산            (밖, needs-identity → 같은 입력으로 ② 다시)
  ④ 커밋      store.replace(commit) — 안에서 ①을 다시 읽어 재검사    (안, await 없음)
  ⑤ stale 면  ①부터 다시. 최대 3회(OUTBOX_REPLACE_MAX_ATTEMPTS). 넘기면 PendingWorkoutOutboxContentionError — 저장 안 됨
  ⑥ 결과      commit 뒤에만 돌아온다: {action, save, removedClientMutationIds}
  • 해시·시계·난수는 전부 ①~③ 에서 끝난다. ④ 안에는 fetch·hash·await 가 없다(있으면 IndexedDB 가 트랜잭션을 닫는다).
  • 재검사 단위는 행 원문 전체(JSON.stringify(row))다. 같은 열쇠의 행이 추가·삭제·상태 변화(전송 시작·격리·다른 탭의 새 수정) 어느 것이든 stale 이다. 다른 열쇠(다른 target·다른 owner)의 변화는 무시한다.
  • 생성 합치기의 새 id·지문은 합칠 본문이 같은 동안 재사용한다(재시도마다 id 를 바꾸지 않는다). 본문이 바뀌면(옛 탭이 생성 행을 고침) 다시 만든다.
  • 격리 행 복구(recoverBlockedWorkoutSave)도 같은 경계: 새 생성 행 쓰기 + 격리 행 삭제를 planOutboxRecover 한 커밋으로.

4. 미러·화면 반영의 위치

  • 화면: 호출부(pendingWorkoutSavesStore.queueMutationForUser, workoutWriteController.queueCompletedWorkoutMutation)는 queuePendingWorkoutMutation돌아온 뒤 에만 프로젝션을 갱신한다. 거부·stale 초과·저장소 오류는 예외로 올라오고 프로젝션은 그대로다(tests/react/outboxAtomicReplace.test.mjs ⑥). 저장 성공보다 화면이 앞서가지 않는다.
  • 미러(localStorage 사본, #1199 ⑨): 커밋의 일부가 아니다. S07(2026-09-08, 이슈 #1337) 부터 IDB adapter 는 complete 에 바뀐 행만 행별 키로 적고(mirror.noteCommitted), 성공 ACK(reason: "uploaded")의 tombstone 만 커밋 에 적는다. 미러 쓰기가 실패해도 커밋은 성공이다(미러는 보조). 정본 outbox-storage-index-mirror.md §4.

5. 실제 Chromium 검증 — tests/react/outboxAtomicity.browser.mjs (2026-09-07 실측)

메모리 mock 만으로 브라우저 트랜잭션 합격을 대신하지 않는다. esbuild 로 묶은 실제 스토어 코드를 격리된 Chromium 컨텍스트(https origin, 요청은 전부 로컬 응답)에서 돌리고, 중단 지점마다 페이지를 닫고 다시 열어(재시작) IndexedDB 를 읽는다. npm run test:persistence-browser 가 이 파일과 미러 검증(pendingSaveMirror.browser.mjs)을 함께 돌린다 — CI 는 browser-journeys 마지막 샤드, 로컬은 ci:localpersistence 단계.

시나리오주입 방법재시작 뒤 확인
A. 새 행 put 직전 abortcommitOutboxReplaceInIdb 의 검증 전용 onStage("checked") 에서 tx.abort()이전 행 그대로, 새 행 없음
B. put 직후·delete 전 abortonStage("written") 에서 tx.abort()위와 같음
C. delete 발행 뒤 commit 전 abortonStage("removed") 에서 tx.abort()위와 같음
D. 저장 공간 초과CDP Storage.overrideQuotaForOrigin(8 KB) 뒤 512 KB 본문을 facade 로 적재QuotaExceededError 로 거부, 이전 행 그대로, 새 행 없음
E. commit 직전 탭 종료onStage("removed") 에서 get 요청을 계속 이어 트랜잭션을 붙든 채 page.close()이전 행 전부 또는 새 행 하나. 둘 다·둘 다 없음 은 없다
F. 같은 owner·target 동시 교체같은 관찰로 만든 커밋 둘을 Promise.all하나 committed·하나 stale, 행 1개. facade 동시 호출 둘은 stale 쪽이 다시 계획해 둘 다 성공, 행은 마지막 하나
G. 다른 owner 같은 targetOTHER 의 수정 행 + USER 의 교체OTHER 행 그대로
H. 삭제 대기 중 수정·identity 재사용삭제 행 뒤 수정 적재 · 같은 id 다른 내용PendingWorkoutTargetDeletedError·PendingWorkoutSaveIdentityConflictError, 저장소 한 글자도 안 바뀜

실측에서 드러난 것(테스트 작성 조건):

  • quota 는 https origin 에서만 적용된다 — http 문서에는 Chromium 이 저장 공간 제한을 걸지 않는다.
  • Storage.overrideQuotaForOrigin 은 그 origin 이 IndexedDB 를 처음 쓰기 전 에, 그 페이지에 붙인 CDP 세션으로 걸어야 효력이 있다(저장 버킷을 만들 때 quota 를 읽는다). 첫 쓰기 뒤에 걸면 기본값(10 GB)이 그대로다. 64 KB 로 걸었을 때는 512 KB 쓰기가 통과했고 8 KB 에서만 거부됐다 — 재현 조건을 8 KB 로 고정한다.
  • 탭 종료(E)는 트랜잭션이 커밋되기 전이라 실측에서는 항상 "이전 행 전부" 였다. 단언은 계약대로 둘 중 하나를 허용한다.

6. 인계 — 다음 작업이 이 계약에서 가져가는 것

받는 작업가져가는 것
S04 claim·fence·successor — 완료(2026-09-08, 이슈 #1334, 계약)원자 port = PendingWorkoutSaveStore.replace(commit) + 순수 계획기. claim 의 CAS(비교 후 교체)는 같은 재검사 방식(expected 에 관찰한 행 원문을 넣고 커밋 안에서 대조)으로 구현한다 — 새 트랜잭션 종류를 만들지 않는다. "전송 중 수정 행은 지우지 않고 후속 행을 먼저 영속화" 는 조건표 §1 의 "수정 행(들) — 어떤 상태든 전부 지운다" 행을 S04 가 sending 분기로 나누는 것이다. 재검사 결과 종류(committed·stale·identity_conflict)와 보존 fixture(tests/react/outboxAtomicReplace.test.mjs·outboxReplacePlan.test.mjs·outboxAtomicity.browser.mjs)를 그대로 쓴다
S07 index·mirror — 완료(2026-09-08, 이슈 #1337, 계약)트랜잭션 경계는 그대로 commitOutboxReplaceInIdb 한 함수. 재검사는 카탈로그가 있으면 getAll 대신 키 스캔 + 필요한 id 만 get(§3), 시간 초과는 공통 adapter 로 "안 썼음 / 결과 미상 → 재조회" 를 가른다(§2). 미러는 커밋 뒤 바뀐 행만(성공 ACK 의 tombstone 은 커밋 전). 결과 종류 committed·stale·identity_conflict 는 변함없다
S05/S06 dispatcher·충돌wait-for-create 가 돌아오는 조건(§1)과 PendingWorkoutOutboxContentionError(재검사 3회 초과, 저장 안 됨)를 결과 종류로 다룬다
R02 최종 확인저장 형식·필드 집합은 그대로다 — 옛 번들 공존·rollback 조건은 codec 계약 §5 그대로