기기 대기열(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 §4 | queued | |
수정(미전송 생성 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 없이:
getAll()→ codec 으로 읽어targetKeys범위의 행을 고르고expected와 id·원문이 정확히 같은지 확인한다. 다르면abort()하고{status: "stale"}— 아무것도 쓰지 않았다.write가 있으면 같은 id 의 기존 행을get()해 보존 병합(mergePendingSaveRow, S01)한다. 불변 묶음이 다르면abort()하고{status: "identity_conflict"}. 같으면put().remove를delete()한다.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:local 의 persistence 단계.
| 시나리오 | 주입 방법 | 재시작 뒤 확인 |
|---|---|---|
| A. 새 행 put 직전 abort | commitOutboxReplaceInIdb 의 검증 전용 onStage("checked") 에서 tx.abort() | 이전 행 그대로, 새 행 없음 |
| B. put 직후·delete 전 abort | onStage("written") 에서 tx.abort() | 위와 같음 |
| C. delete 발행 뒤 commit 전 abort | onStage("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 같은 target | OTHER 의 수정 행 + 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 그대로 |