Skip to content

기기 대기열 저장소 — 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회 더)
unknowntx.abort()InvalidStateError = 이미 커밋 중/끝났다. 결과 미상재조회 계약(아래). settled 로 늦은 complete/abort 를 관찰할 수 있다(오지 않을 수도 있다)
  • 열기: blockedIdbBlockedError(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:genmanifest: 세대(미러 쓰기마다 +1)·시각·writer·unmirrored(quota 로 못 쓴 id)쓰기마다 마지막에
…:v2:preservedcodec 이 못 읽는 원문 배열(개수 보존, S01 §3-2)바뀔 때
barbelic:pending-workout-saves:mirror (v1)옛 형식 전체 배열 — 옛 탭·되돌린 번들이 읽고 쓰는 사본묶어서: 마지막 커밋 뒤 1.5초, 플러시 끝, pagehide/visibilitychange

4-2. 쓰기 순서

  1. 성공 ACK(서버가 받았다고 확인): tomb:<id>커밋 전에 적는다(OutboxReplaceCommit.reason = "uploaded") → IndexedDB 삭제 커밋 → row/meta 제거 + manifest. 그 사이 죽어도 "처리된 행" 을 되살리지 않는다. 커밋이 안 되면(stale·오류) tombstone 을 되돌린다.
  2. 그 밖의 쓰기(적재·시도 메타·교체로 물러남·취소·복구): IndexedDB 커밋 → row(처음이면)/meta → tomb(지운 것) → manifest. 커밋이 안 됐는데 미러만 지우면 미전송 편집이 사라질 수 있어 커밋 뒤에 적는다.
  3. 옛 형식 배열은 위와 별개로 묶어서 적는다(§4-1). 드레인 한 번에 전체 재기록이 O(1) 번.
  4. QuotaExceededError 는 원문을 잃게 하지 않는다: IndexedDB 가 정본이고, 못 쓴 id 는 manifest unmirrored 에 남긴다. 미러 실패는 대기열을 막지 않는다.

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 getAllgetAllKeysgetpayload 읽기(전체 대비)미러 전체 재기록미러 쓰기 bytes(전체 대비)
기준선(S04 2abca52c)15 ms4.910017배11.2배
1001.38 s13.86040100304배(≈3N)199(≈2N)100배(≈N)
1000152.8 s152.86004010003004배19991005배
Phase 2(인덱스만)1000120.8 s120.860131001550001006배19991005배
S07 완료16 ms5.83766배0v2 2.6 KB
1000.24 s2.432056006배0v2 267 KB(1.3배)
10006.1 s6.115201760007.8배4v1 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 이 이긴다
CDB 는 있고 행 0개(다른 경로로 삭제) + 오래된 미러복원 0(createdFresh=false), 미러의 고아 행은 tombstone(repair), 옛 배열은 0행에 맞춰 제거
D미러에만 있는 행 + IDB 와 다른 meta(커밋 뒤 죽은 흔적)부팅 수리: 고아 행 tombstone, meta 는 IDB 지문으로, 드레인은 IDB 행만
Ev2 에 있는데 v1 에 없는 행 · v1 에만 있는 행 · 깨진 원문 2개(v2 preserved 에 하나)둘 다 blocked(LG_RECOVERY_RECEIPT_UNKNOWN), 합의된 행만 전송, 보존 원문 개수 그대로(3)
FsetItem 이 QuotaExceededErrorIDB 행 그대로, 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 상태는 해당 기록에서 확인한다.