기기 대기열 저장소 — owner index·bounded mirror·IDB timeout (v0.18.0 S07, 이슈 #1337)
- 상위: ADR §3-1(outbox 원자 전이·claim·fence) · 원자 교체 계약(S02) · claim·후속 행 계약(S04) · 대기열 행 codec(S01) · 도메인 계약 §19
- 코드:
src/react/persistence/idb/**(IDB 공통 adapter) ·src/react/persistence/outbox/rowIndex.ts(메모리 카탈로그) ·src/react/persistence/outbox/idbOutboxStore.ts(키 스캔 재검사·재조회 계약) ·src/react/persistence/mirror/**(미러 v2 계획기·localStorage adapter) ·src/react/services/pendingWorkoutSaves.ts(저장소 facade) ·src/react/services/workoutLocalCacheDb.ts(열기·새 DB 판정) - 검사:
tests/react/idbAdapter.test.mjs·outboxRowIndex.test.mjs·mirrorPlan.test.mjs(단위) ·tests/react/outboxTimeout.browser.mjs·outboxMirrorRecovery.browser.mjs·outboxCost.browser.mjs·idbVersionPin.browser.mjs(실제 Chromium,npm run test:persistence-browser)
0. 유저 이야기
유저 A 가 산속 캠핑장에서 이틀 동안 운동 12개를 기록하고 몇 개를 다시 고쳤다. 기기 대기열(IndexedDB pendingSaves)에 행 15개가 쌓였다. 하산해서 연결이 돌아오면 앱이 15개를 차례로 보낸다.
- 전(S04 까지): 행 하나를 보낼 때마다 15개 전체를 여섯 번 다시 읽고, localStorage 사본(미러) 전체를 두 번 다시 적었다. 100개·1000개가 되면 총비용이 N² 로 늘어 1000개 드레인에 152.8초(실측, §5). 그리고 미러 갱신 전에 탭이 죽고 브라우저가 IndexedDB 를 비우면 이미 서버에 올라간 기록이 "동기화 대기" 로 되살아났다.
- 후(S07): 행 하나를 보낼 때 그 기록의 행만 읽고, 미러는 바뀐 행만 적는다. 1000개 드레인 6.1초. 브라우저가 IndexedDB 를 비우면 우리 사본에서 복원하되, 서버가 받았다고 확인한 행(tombstone)은 되살리지 않고, 근거가 어긋나는 행은 격리해 receipt 확인 대상으로 남긴다.
1. 제약 — IndexedDB 버전은 6 에 고정한다
owner/target 인덱스를 IndexedDB 의 진짜 index(createIndex)로 만들려면 DB 버전을 6 → 7 로 올려야 한다. 그러면:
| 상황 | 결과 (실측 tests/react/idbVersionPin.browser.mjs) |
|---|---|
열려 있던 옛 탭(v0.17.x — 현재 코드와 같은 open(name, 6)) | versionchange 로 연결을 닫은 뒤 다시 열 때 VersionError — 대기열·초안을 영구히 못 쓴다. 새로고침해도 같다 |
| 번들을 옛 버전으로 되돌림(rollback) | 모든 기기에서 같은 일이 난다 — codec 계약 §5 rollback 조건("옛 번들이 그대로 읽고 보내고 지운다")이 깨진다 |
| 연결을 닫지 않는 탭이 있으면 | 업그레이드는 blocked 로 기다린다. DB 삭제·재생성으로 풀지 않는다(이슈 #1337 상세 5) |
그래서 버전 올림·새 object store 는 하지 않는다. 인덱스는 §3 의 메모리 카탈로그로, 미러는 §4 처럼 localStorage 키를 나눠 둔다. 저장 형식은 v1, 새 필드(recovery)는 추가만.
2. IDB 공통 adapter (persistence/idb/adapter.ts)
열기·요청·트랜잭션의 시간 상한을 한 곳에 둔다(기본 8초, 종전 withIdbTimeout 이름은 유지). WKWebView 에서는 요청이 거부되지 않고 영원히 pending 인 행(hang)이 나므로(#887) 상한이 없으면 플러시가 멈춘다. 그런데 "시간 초과 = 실패" 로 보고 다시 쓰면 늦게 커밋된 트랜잭션과 겹쳐 두 번 쓴다. 그래서 시간 초과 뒤 결과를 셋으로 가른다.
| 결과 | 뜻 | 호출자가 하는 일 |
|---|---|---|
committed | 시간 안에 complete | 그대로 |
aborted | 시간 초과 때 tx.abort() 가 받아들여졌다(active/inactive). 스펙상 abort 뒤 커밋은 없다 = 아무것도 안 썼다 | 같은 커밋을 다시 시도해도 안전(outbox 는 1회 더) |
unknown | tx.abort() 가 InvalidStateError = 이미 커밋 중/끝났다. 결과 미상 | 재조회 계약(아래). settled 로 늦은 complete/abort 를 관찰할 수 있다(오지 않을 수도 있다) |
- 열기:
blocked는IdbBlockedError(typed, DB 삭제 없음). 시간 초과 뒤 늦게 열린 연결은 닫아 새지 않게 한다. 결과에createdFresh(onupgradeneeded 의 oldVersion 0 = DB 가 이 열기에서 처음 만들어짐)를 싣는다 — §4 복구의 유일한 "비워짐" 근거. - 부작용 없는 읽기는
withIdbTimeout으로(늦은 완료가 무해). 쓰기 트랜잭션은runIdbTransaction으로.
재조회 계약(idbOutboxStore.judgeUnknownReplace): 결과 미상인 커밋 뒤 저장소를 다시 읽어 셋 중 하나로 판정한다.
| 판정 | 조건 | 처리 |
|---|---|---|
| applied | 쓸 행이 있으면 그 행이 "이 커밋이 썼을 모양"(계획 때 관찰한 같은 id 행과 보존 병합한 결과, 키 순서 무관 비교) 그대로 있고, 지울 행은 전부 없다 | committed 로 돌려준다(저장소의 실제 행이 결과) |
| not-applied | 관찰이 계획 때(expected)와 정확히 같다 = 커밋되지 않았다 | 같은 커밋 1회 더 |
| stale | 둘 다 아니다 = 다른 탭이 사이에 바꿨다 | stale — facade 가 다시 읽고 다시 계획 |
실측(outboxTimeout.browser.mjs): 트랜잭션을 700ms 붙들어 300ms 상한을 넘기면 abort 가 받아들여져 저장소 무변경, 재시도 커밋 반영, 행 1개 · 커밋 완료 뒤 결과 미상(검증 훅)이면 재조회가 "이미 반영됨" 을 판정해 put 1회·중복 0.
3. owner/target/state/time 인덱스 = 메모리 카탈로그 (persistence/outbox/rowIndex.ts)
행의 owner·target·kind·savedAt 은 불변 묶음(codec 계약 §3)이라 한 번 읽으면 영구히 맞다. 저장소 인스턴스가 id → 요약(본문 없음: owner·targetKey·kind·savedAt·시도 메타)을 들고, 저장 port 에 두 연산을 더한다.
| port | 뜻 |
|---|---|
index(ownerKey) | owner 의 행 요약(savedAt 순)과 idsFor(targetKeys). 첫 사용 때 전체를 200개 페이지 로 읽어 만들고, 그 뒤엔 getAllKeys()(uuid 만)로 다른 탭의 추가·삭제를 맞추고 모르는 id 만 본문을 읽는다. 직전 커밋의 재검사가 키 전체를 봤으면 자기 스캔은 건너뛴다 |
readRows(ids) | id 로 행 원문을 읽는다(없는 id 는 생략·카탈로그에서 제거) |
요약은 힌트, 읽은 원문이 진실. 전송 가능 판정(selectSendableRows)·후속 탐색(successorsOf)은 요약으로 돌고, 고른 행은 원문을 읽은 뒤 같은 판정을 다시 한다(다른 탭이 방금 claim·격리했을 수 있다 — 두 탭 테스트 A 가 이것을 잡았다). 계획(claim·ACK·교체·보류 해제·복구)은 그 열쇠의 행 원문으로만 세우고, 커밋 안에서 재검사한다(S02). 카탈로그가 없는 스토어(테스트 주입)는 facade 가 getAll 로 만든다.
커밋 재검사(키 스캔): commitOutboxReplaceInIdb 에 카탈로그를 주면 트랜잭션 안에서 getAll 대신 getAllKeys() → 읽을 id = 계획 때 관찰한 행 ∪ 카탈로그가 모르는 키(다른 탭이 새로 넣은 행) ∪ 카탈로그가 이 열쇠들로 아는 행 → 그 id 만 get. 카탈로그가 "다른 열쇠" 라고 아는 행은 본문을 읽지 않아도 재검사 범위 밖이다(owner·target 불변). 카탈로그는 이 스캔·읽기·커밋 결과로 갱신된다.
4. 미러 v2 (persistence/mirror/**)
IndexedDB 커밋과 localStorage 쓰기는 한 트랜잭션이 될 수 없다 — 모델이 그 사실을 그대로 적는다.
4-1. 키와 세대
| 키 | 내용 | 언제 쓰나 |
|---|---|---|
barbelic:pending-workout-saves:mirror:v2:row:<id> | 불변 본문(version·clientMutationId·userId·savedAt·kind·targetId·dates·request·format) + 세대 | 행이 처음 미러에 들어올 때 한 번 |
…:v2:meta:<id> | 시도 메타(state·sentAt·claim·evidence·writer·모르는 필드…) + 행 전체 지문(h) + 세대 | 시도마다(작다 — 본문 복사 0) |
…:v2:tomb:<id> | 지웠다는 기록(세대·시각·이유 uploaded/replaced/dropped/recovered/deleted/repair) | 행이 지워질 때 |
…:v2:gen | manifest: 세대(미러 쓰기마다 +1)·시각·writer·unmirrored(quota 로 못 쓴 id) | 쓰기마다 마지막에 |
…:v2:preserved | codec 이 못 읽는 원문 배열(개수 보존, S01 §3-2) | 바뀔 때 |
barbelic:pending-workout-saves:mirror (v1) | 옛 형식 전체 배열 — 옛 탭·되돌린 번들이 읽고 쓰는 사본 | 묶어서: 마지막 커밋 뒤 1.5초, 플러시 끝, pagehide/visibilitychange |
4-2. 쓰기 순서
- 성공 ACK(서버가 받았다고 확인):
tomb:<id>를 커밋 전에 적는다(OutboxReplaceCommit.reason = "uploaded") → IndexedDB 삭제 커밋 → row/meta 제거 + manifest. 그 사이 죽어도 "처리된 행" 을 되살리지 않는다. 커밋이 안 되면(stale·오류) tombstone 을 되돌린다. - 그 밖의 쓰기(적재·시도 메타·교체로 물러남·취소·복구): IndexedDB 커밋 → row(처음이면)/meta → tomb(지운 것) → manifest. 커밋이 안 됐는데 미러만 지우면 미전송 편집이 사라질 수 있어 커밋 뒤에 적는다.
- 옛 형식 배열은 위와 별개로 묶어서 적는다(§4-1). 드레인 한 번에 전체 재기록이 O(1) 번.
QuotaExceededError는 원문을 잃게 하지 않는다: IndexedDB 가 정본이고, 못 쓴 id 는 manifestunmirrored에 남긴다. 미러 실패는 대기열을 막지 않는다.
4-3. 부팅 — 수리 또는 복구
저장소 첫 사용 때 전체를 한 번 읽고 미러와 맞춘다.
| 조건 | 처리 |
|---|---|
| DB 가 이미 있었다(createdFresh=false) 또는 행이 있다 | IndexedDB 가 정본. 미러를 IDB 에 맞춘다(수리): 미러에만 있는 행 → tombstone(repair)로 바꾸고 row/meta 제거(커밋 뒤 미러 갱신 전에 죽은 흔적 — 되살리지 않는다) · IDB 와 지문이 다른 meta → IDB 대로 · IDB 에 살아 있는 행의 tombstone → 제거(선행 tombstone 뒤 커밋이 안 된 경우). 옛 배열의 보존 원문은 v2 preserved 로 합친다 |
| DB 가 이 열기에서 새로 만들어졌고(createdFresh) 행이 0개 | 복구. 아래 대조표로 IndexedDB 에 다시 넣는다. 행이 0개인 것만으로는 복구하지 않는다(전부 서버 반영된 정상 상태일 수 있다) |
복구 대조표 — "미러만으로 최신 ACK 여부를 안다" 고 가정하지 않는다:
| 근거 | 처리 | recovery.proof |
|---|---|---|
| tombstone 있음 | 복원하지 않는다(옛 배열에 남아 있어도) | — |
| v2 행 있고 옛 배열(v1) 키가 없다 | 복원 | mirrored |
| v2 행 있고 v1 에도 있다 | 복원 | agreed |
| v2 행 있는데 v1 에는 없다 | 원문 보존 + 격리 blocked(LG_RECOVERY_RECEIPT_UNKNOWN) — v1 을 적은 탭은 그 행이 없어진 것을 봤다 | unverified |
| v1 에만 있고 v2 manifest 가 없다(새 번들 첫 실행 이전) | 복원 — 옛 코드가 믿던 유일한 사본 | mirrored |
| v1 에만 있고 v2 manifest 가 있다 | 원문 보존 + 격리 — 우리 writer 가 본 적 없는 행 | unverified |
복원된 행에는 추가 전용 필드 recovery{source, at, generation, proof} 가 붙는다(옛 번들은 보존만). unverified 행은 전송기가 보내지 않고(selectSendableRows 이유 recovery_unverified) 사용자 복구·receipt 확인만 남는다 — S04 고아 의도 행과 같은 방식. receipt 확인(서버 영수증 조회) 은 S05·서버 트랙 몫.
남는 창(명시): 커밋 뒤 미러 갱신 전에 죽은 마지막 커밋 하나는 미러가 모른다. DB 가 그대로면 수리가 IDB 대로 고치므로 영향이 없고, 그 직후 DB 까지 비워지는 경우에만 (a) 성공 ACK 는 tombstone 이 먼저라 되살림 없음 (b) 그 밖의 삭제 하나가 되살아날 수 있다 — 서버 멱등 장부가 중복 반영을 막고, 옛 배열 대조가 어긋나면 격리된다.
4-4. IDB 를 못 읽을 때
열기·읽기가 실패(시간 초과·VersionError)하면 getAll() 은 미러 사본(v2 → 옛 배열 대조)을 읽기 전용 으로 돌려준다. IndexedDB 에 쓰지 않으므로 전송·수정은 되지 않는다(idbVersionPin.browser.mjs A).
5. 계측 — 1/100/1000 행 드레인 (실제 Chromium, tests/react/outboxCost.browser.mjs)
저장소 코드에 훅 없이 IDBObjectStore·Storage 프로토타입을 세는 shim 으로 잰다(개선 전·후 같은 도구). 행 하나 2,007 bytes(세트 24개 수정 행). 같은 PC(2026-09-08).
| 단계 | N | 벽시계 | ms/행 | IDB getAll | getAllKeys | get | payload 읽기(전체 대비) | 미러 전체 재기록 | 미러 쓰기 bytes(전체 대비) |
|---|---|---|---|---|---|---|---|---|---|
기준선(S04 2abca52c) | 1 | 5 ms | 4.9 | 10 | 0 | 1 | 7배 | 1 | 1.2배 |
| 100 | 1.38 s | 13.8 | 604 | 0 | 100 | 304배(≈3N) | 199(≈2N) | 100배(≈N) | |
| 1000 | 152.8 s | 152.8 | 6004 | 0 | 1000 | 3004배 | 1999 | 1005배 | |
| Phase 2(인덱스만) | 1000 | 120.8 s | 120.8 | 6013 | 10015 | 5000 | 1006배 | 1999 | 1005배 |
| S07 완료 | 1 | 6 ms | 5.8 | 3 | 7 | 6 | 6배 | 0 | v2 2.6 KB |
| 100 | 0.24 s | 2.4 | 3 | 205 | 600 | 6배 | 0 | v2 267 KB(1.3배) | |
| 1000 | 6.1 s | 6.1 | 15 | 2017 | 6000 | 7.8배 | 4 | v1 1.9배 · v2 2.7 MB(1.3배) |
- ms/행이 4.9 → 13.8 → 152.8 로 N 에 비례하던 것(전체 O(N²))이 5.8 → 2.4 → 6.1 로 평평해졌다. 1000행 벽시계 25배 단축.
- 남은 O(N) 항은 키 스캔(
getAllKeys, uuid 만, 행당 ≈2회)과 묶어 쓰는 옛 배열(드레인당 ≤ 4회)이다. 본문(payload) 읽기는 전체의 상수 배(6~8배)다. - 예산(
RELEASE_PERFORMANCE_BUDGETS.outbox, performance-budgets.md §2-1): payload 읽기 배율 ≤ 12 · 미러 전체 재기록 배율 ≤ 6 · 드레인당 전체 읽기 ≤ 40회.outboxCost.browser.mjs가 N=100·1000 에서 판정한다(벽시계는 기록만 — PC·CI 마다 다르다).
6. 조합 검사표 (실제 Chromium, outboxMirrorRecovery.browser.mjs)
| # | 상황 | 확인 |
|---|---|---|
| A | 행 3개 적재 → deleteDatabase(브라우저 정리 흉내) → 다시 열기 | createdFresh=true, v2 에서 3개 복원(agreed), 드레인 3/3 전송 |
| B | 드레인 성공 뒤(tombstone uploaded, row/meta 없음) 옛 탭이 옛 배열을 오래된 내용으로 되적음 → DB 삭제 → 다시 열기 | 복원 0 — tombstone 이 이긴다 |
| C | DB 는 있고 행 0개(다른 경로로 삭제) + 오래된 미러 | 복원 0(createdFresh=false), 미러의 고아 행은 tombstone(repair), 옛 배열은 0행에 맞춰 제거 |
| D | 미러에만 있는 행 + IDB 와 다른 meta(커밋 뒤 죽은 흔적) | 부팅 수리: 고아 행 tombstone, meta 는 IDB 지문으로, 드레인은 IDB 행만 |
| E | v2 에 있는데 v1 에 없는 행 · v1 에만 있는 행 · 깨진 원문 2개(v2 preserved 에 하나) | 둘 다 blocked(LG_RECOVERY_RECEIPT_UNKNOWN), 합의된 행만 전송, 보존 원문 개수 그대로(3) |
| F | setItem 이 QuotaExceededError | IDB 행 그대로, manifest.unmirrored 에 id, 드레인 정상 |
| G | 일시 실패 3회(시도 메타만 변경) | row: 재기록 0, meta: 6회(본문보다 작음), 옛 배열은 플러시마다 1회 |
기존 검사 유지: 원자성 7(S02) · 두 탭 claim 4(S04) · S01 미러 복구 1(옛 배열에서 복원, recovery{source:"mirror-v1", proof:"mirrored"} 표식) · CASE-039/040(실제 v0.17.1 번들 탭 공존).
7. 인계
| 받는 작업 | 가져가는 것 |
|---|---|
| S05 dispatcher | 저장 port index(owner)·readRows(ids) 를 소비한다 — 전체 getAll 을 부르지 않는다. 격리 코드 LG_RECOVERY_RECEIPT_UNKNOWN 행은 receipt 확인(영수증 조회 RPC) 뒤 queued 로 되돌릴 수 있다(원문 그대로, recovery 유지) |
| S06 충돌 사본 | 재전송을 후속 행으로 영속화할 때 OutboxReplaceCommit.reason 을 적는다(replaced). 미러는 커밋 뒤에 따라온다 |
| N01 네이티브·SW 생애주기 | WKWebView·Android WebView 에서 §2 의 시간 초과 세 갈래와 §4-3 복구를 실기기 시나리오로. IdbBlockedError 는 DB 삭제로 풀지 않는다 |
| R02 최종 확인 | CASE-039/040 재실측(옛 탭은 옛 배열만 적는다 — v2 키를 모른다) · 옛 탭이 살아 있는 창에서 unverified 격리 행이 얼마나 생기는지 |
| R04 성능 검증 | §5 의 도구·예산. 실기기(저사양 Android) 에서 1000행 드레인 벽시계 |
| U02/U03 화면 | blocked(LG_RECOVERY_RECEIPT_UNKNOWN) 행의 표시("복구된 기록 — 확인 대기") |
Phase 2 종료 연결 보완 (2026-09-08)
이 문서의 receipt 조회 미구현·직접 API 재조회 인계는 Phase 2 종료 기록으로 갱신한다. 원문 보존·명시 owner·기존 저장 정책은 유지한다. 역사 영수증을 현재 상세로 취급하지 않고, 복구 ACK와 canonical resource 수렴을 구분한다. 이 보완의 병합·staging 상태는 해당 기록에서 확인한다.