기기 대기열(outbox) 전송 중 요청 보호 — 행 단위 claim·fence·후속 행 계약 (v0.18.0 S04, 이슈 #1334)
- 상위: ADR §3-1(행별 claim·fence·durable successor 행 = S04) · 원자 교체 계약(claim 의 CAS 는 같은 재검사 방식) · 대기열 행 codec §5(새 writer 활성화 조건 1~5) · 도메인 계약 §12·§17
- 코드:
src/react/persistence/outbox/claimPlan.ts(순수 claim·ACK 계획기·전송 가능 판정) ·replacePlan.ts(후속 행·의도 행 분기) ·src/react/services/pendingWorkoutSaves.ts(전송기retryPendingWorkoutSaves) · 포트 배선src/react/controllers/pendingWorkoutSavesStore.ts - 검사:
tests/react/outboxClaimSuccessor.test.mjs(메모리 스토어·고정 시계·두 탭) ·tests/react/outboxClaimPlan.test.mjs(순수 계획기) ·tests/react/outboxClaim.browser.mjs(실제 Chromium 두 페이지) · e2e CASE-039(v0.17.1 재실측)·CASE-040(옛 탭 전송 중 + 새 탭 후속) - 서버 변경: 없음. 저장 형식 v1, 새 값은 전부 추가 필드(
claim·successorOf·evidence·writer) — rollback 조건(codec 계약 §5) 유지.
1. 유저 이야기 — 이 계약이 지키는 것
유저 A 가 어제 기록의 무게를 60 → 80 으로 고쳤다. 앱은 그 수정을 기기 대기열에 행 하나로 남기고 연결이 돌아오자 서버로 보내기 시작했다. 답장이 오기 전에 A 가 같은 기록의 메모를 하나 더 고쳤다. 이 계약은 다음을 지킨다.
- 보내는 중인 첫 요청은 기기에서 지워지지 않는다. 두 번째 편집은 후속 행 으로 남고, 첫 요청의 결과가 확정된 뒤에 나간다.
- 첫 요청의 늦은 답장(실패든 성공이든)은 그 사이에 바뀐 대기열 상태를 덮지 않는다. 지워진 옛 요청이 되살아나지 않는다.
- A 가 탭을 두 개 열어 두어도 같은 행을 두 탭이 함께 보내지 않는다(한 탭만 집는다). 집은 탭이 죽으면 10초 뒤 다른 탭이 이어받고, 죽은 탭의 늦은 답장은 이어받은 탭의 상태를 바꾸지 못한다.
- 아직 서버 id 가 없는 새 기록을 보내는 중에 그 기록을 고치거나 지우면, 그 의도가 기기에 남는다. 탭이 닫혀도 사라지지 않고, 생성 영수증이 오면 서버 id 로 이어서 처리된다.
2. 행 단위 claim — 누가 언제까지 보내는 중인가
행에 claim{tabId, expiresAt, fence} 를 적는다. 획득은 원자 교체(PendingWorkoutSaveStore.replace) 한 트랜잭션이다: 같은 owner·target 행의 원문을 expected 에 넣고, 커밋 안에서 그대로일 때만 아래를 쓴다(비교 후 교체). 다른 탭이 먼저 집었으면 stale — 아무것도 쓰지 않고 이번 플러시에서는 건너뛴다.
| 쓰는 것 | 값 |
|---|---|
claim.tabId | 이 탭의 id(탭마다 한 번 만든 uuid, 테스트는 주입) |
claim.expiresAt | 지금 + lease(10초 = 종전 sentAt 규칙과 같은 길이) |
claim.fence | 행의 이전 fence + 1 (없으면 1). 인계마다 오른다 |
state·sentAt | "sending"·지금 (종전 표시 그대로 — 옛 탭이 읽는 뜻은 바뀌지 않는다) |
evidence | attemptCount + 1, lastAttemptAt = 지금, lastErrorCode 는 이전 값 유지 |
writer | {bundle: "v0.18.0-s04", at: 지금} — 새 writer 가 만졌다는 표시 |
전송 중 판정(isRowInFlight): ① writer 가 있고 claim 이 있고 claim.expiresAt > 지금 이면 전송 중(그 탭 소유). ② 아니면 종전 규칙으로 후퇴 — state === "sending" 이고 sentAt 10초 안이면 전송 중(옛 탭이 보내는 중). 없음은 오류가 아니다(codec 계약 §5 조건 2·3). 옛 탭은 claim 을 무시하고 보낸다(S01 R5) — claim 은 새 탭들 사이의 중복 전송을 줄이는 것까지이고, 중복 반영의 최종 방어는 서버 멱등 장부다(조건 1).
lease 갱신은 하지 않는다. 한 시도 = RPC 한 번이고, 10초를 넘기면 다른 탭이 이어받아 같은 id·지문으로 다시 보낸다(서버가 같은 영수증을 돌려준다). 전역 리더 선출은 없다.
3. ACK 적용 — 답장은 "내 claim 이 지금도 그대로일 때만" 상태를 바꾼다
답장 처리(성공 삭제·일시 실패 되돌림·로그인 만료 보류·영구 거부 격리) 넷 모두 원자 교체 한 트랜잭션이고, 커밋 안에서 현재 행을 다시 읽어 claim.tabId === 내 탭 && claim.fence === 내가 잡을 때의 fence 일 때만 쓴다.
| 답장 | 행 변경 | claim 이 내 것이 아니면(행 없음 · 다른 탭 · 옛 fence · claim 없음) |
|---|---|---|
| 성공(영수증) | 행 삭제 | 아무것도 안 함. 영수증은 화면 확정에는 쓴다(서버가 반영한 사실) — 행은 현재 소유 탭의 답장이 지운다 |
| 일시 실패 | state:"queued"·sentAt 없음·deferredAt·claim.expiresAt 을 지금으로(만료 처리, fence 유지)·evidence.lastErrorCode | 아무것도 안 함 — 이어받은 탭의 sending 을 덮지 않는다 |
| 로그인 만료 | state:"held"·heldAt·deferredAt·claim 만료 처리 | 아무것도 안 함 |
| 영구 거부 | state:"blocked"·blockedAt·blockedCode·claim 만료 처리 | 아무것도 안 함 |
fence 를 지우지 않고 만료 처리만 하는 이유: fence 가 사라지면 다음 claim 이 1 부터 다시 시작해 옛 답장의 번호와 겹칠 수 있다. 행이 살아 있는 동안 번호는 오르기만 한다.
owner: 전송기는 자기 owner(userId) 의 행만 읽고 집고 지운다. 다른 사용자의 같은 target 행은 열쇠가 달라 재검사·삭제 어디에도 들어가지 않는다(원자 교체 계약 §1).
4. 후속 행(successor) — 전송 중 행은 지우지 않는다
수정·삭제·계획 저장·계획 삭제가 같은 target 의 이전 행을 만났을 때(원자 교체 계약 §1 조건표의 "어떤 상태든 지운다" 행을 이렇게 나눈다):
| 이전 행 상태 | 처리 |
|---|---|
| 전송 중(§2 판정 — claim 유효 또는 옛 탭 sentAt 10초 안) | 지우지 않는다. 새 행에 successorOf: <그 행 id> 를 적는다(여럿이면 가장 최근 sentAt) |
| 그 밖(대기·지연·보류·격리) | 종전대로 지우고 새 행 하나(마지막 하나) |
전송 가능 판정(selectSendableRows, savedAt 순): 격리·보류 행 제외 → 다른 탭이 전송 중인 행 제외 → successorOf 가 가리키는 행이 아직 있고 격리가 아니면 제외(선행 먼저) → 의도 행(§5) 제외. 선행이 답장을 못 받았으면 같은 id·지문으로 다시 나가 영수증을 확정하고(서버 멱등), 그 뒤 후속이 나간다. 후속의 개정번호가 낡았으면 종전 D1 규칙이 흡수한다 — 서버(save_session_v5)가 엔진의 SQLSTATE 40001 을 LG409(HTTP 400, detail 에 actual_revision)로 바꿔 돌려주고, 전송기가 그 개정번호로 새 요청 id·새 지문 의 요청을 한 번 더 보낸다(개정번호가 바뀌면 지문이 바뀌어 같은 id 를 다시 쓸 수 없다 — 서버 멱등 장부의 재사용 거부). 그 재전송은 S06(2026-09-08, 이슈 #1341)부터 보내기 전에 후속 행으로 먼저 영속화된다(원 행 제거 + 후속 행 쓰기 + 충돌 근거 conflict 를 원자 교체 하나로 — 계약). 참고 실측(2026-09-07, 로컬 샌드박스 PostgREST v14.16): SQLSTATE 40001·40P01 을 그대로 raise 하면 PostgREST 가 재시도해 45초 넘게 응답이 없다 — 앱이 보는 충돌은 항상 LG409 여야 한다.
5. 새 기록의 후속 — 의도 행과 변환
겨냥한 생성 행(pending:<생성 id>)이 결과 미상(전송 중·지연·보류)이면 수정·삭제를 합치지 않고 의도 행 을 쓴다: kind 는 그대로, targetId: "pending:<생성 id>", successorOf: <생성 id>, 요청 본문은 호출부가 조립한 그대로(추정 서버 id 를 만들지 않는다). 의도 행은 그대로는 보내지 않는다. 화면에서는 대기 중 카드 위의 겹침(수정) 또는 숨김(삭제)으로 보인다. 한 번도 보내지 않은 생성(미시도 queued)·영구 거부(blocked)는 종전대로 합치기·상쇄다.
생성 영수증을 받은 탭(§3 의 성공 ACK, claim 이 내 것일 때)은 생성 행을 지우기 전에 의도 행을 savedAt 순으로 변환한다: prepareSuccessor({intent, predecessor, response}) 포트(컨트롤러가 prepareSave/prepareDelete 로 서버 id·개정번호·새 지문의 요청을 만든다) → 준비된 행(targetId = 서버 id, savedAt·dates·announced 유지, successorOf 유지) 쓰기 + 의도 행 삭제를 원자 교체 하나로. 그 다음 생성 행을 ACK 삭제한다. 중간에 끊겨도 의도 행이 고아가 되지 않는다(생성 행이 남아 다시 보내지고 같은 영수증이 온다).
포트가 없거나 준비에 실패하면 의도 행을 blocked(LG_SUCCESSOR_UNPREPARED)로 격리하고 원문을 보존한다. 옛 탭이 생성 행을 먼저 지운 창에서는 findCreateReceipt({userId, clientMutationId: successorOf, sourceRef})로 정확한 선행 영수증을 조회한다. 일치하면 기존 S03 조립기와 원자 교체로 후속을 준비한다. 없으면 LG_PREDECESSOR_RECEIPT_UNKNOWN으로 보존하고 다음 flush에 재확인한다. 조회의 네트워크·인증 실패는 null과 구분해 원문 그대로 다음 연결을 기다린다. 이 경로는 Phase 2 종료 보완에서 제품에 연결됐으며, 미확인 미러의 intent를 선행 영수증만으로 실행하지 않는다.
6. 조합 표 — 후속 명령 × 선행 상태 × 소유자 (검사 대응)
| # | 선행 행 | 후속 명령 | 결과 | 검사 |
|---|---|---|---|---|
| 1 | 수정 U1 전송 중(내 탭) | 수정 U2 | U1 보존·U2 successorOf U1. U1 실패 답장 → U1 대기, U2 그대로. 다음 플러시 U1 → U2 순 | claimSuccessor ① |
| 2 | 생성 C1 대기 | 두 탭 동시 플러시 | 한 탭만 claim(fence 1)·전송. 진 탭은 stale → 건너뜀 | claimSuccessor ② |
| 3 | C1 전송 중(탭 A), lease 만료 | 탭 B 플러시 | B 가 인계(fence 2). A 의 늦은 실패 답장 → 무변경. A 의 늦은 성공 답장 → 행 유지(B 가 지움), 영수증은 성공 보고 | claimSuccessor ③ |
| 4 | 생성 C1 전송 중 | 수정(pending:C1) | 의도 행 U1(successorOf C1). C1 영수증 → U1 을 서버 id 의 U2 로 변환 후 C1 삭제. 다음 플러시 U2 전송 | claimSuccessor ④ |
| 5 | 생성 C1 지연(결과 미상) | 삭제(pending:C1) | 의도 행 D1. 플러시 1: C1 같은 id·지문 재전송 → 영수증 → D1 을 서버 id 삭제 U3 로 변환. 플러시 2: U3 | claimSuccessor ⑤ |
| 6 | 다른 사용자의 행 | 내 플러시 | 집지도 지우지도 않는다 | claimSuccessor ⑥ |
| 7 | 수정 U1 전송 중 | 삭제 | U1 보존·삭제 행 successorOf U1. U1 뒤에 삭제(개정번호 충돌은 D1 규칙) | claimPlan |
| 8 | 계획 저장 P1 전송 중 | 계획 저장·삭제 | P1 보존·후속 successorOf P1 | claimPlan |
| 9 | 옛 탭이 sending(claim 없음, sentAt 10초 안) | 새 탭 플러시·수정 | 전송 중으로 본다(후퇴 규칙) — 건너뛰고 후속만 적는다. 10초 뒤 새 탭이 claim 하고 같은 id 로 재전송 | claimPlan · CASE-040 |
| 10 | 의도 행, 선행 생성 행이 사라짐(옛 탭이 지움) | 플러시 | 정확한 선행 receipt를 조회해 준비, 미확인 시 blocked(LG_PREDECESSOR_RECEIPT_UNKNOWN) 보존 | claimPlan |
| 11 | 전송 중 탭 종료 | 다른 탭 | lease 만료 뒤 인계, 재시작 뒤 행 하나 | claim.browser |
7. 실측·검사 결과 (2026-09-08)
| 검사 | 결과 |
|---|---|
tests/react/outboxClaimPlan.test.mjs 순수 계획기 | 5/5 |
tests/react/outboxClaimSuccessor.test.mjs 메모리 스토어·고정 시계·두 탭 (§6 #1~#6 + 스토어 배선·카드) | 8/8 — Phase 0 에서 현재 코드로 5건 실패 확인 뒤 통과 |
tests/react/outboxClaim.browser.mjs 실제 Chromium 두 페이지(같은 IndexedDB, 페이지별 주입 시계) — 동시 claim 하나만 성공 · lease 만료 인계(fence 2)·이전 탭 늦은 실패/성공 답장 무변경 · 전송 중 탭 종료 → 만료 뒤 인계·재시작 뒤 행 0 · 재시작 뒤 claim·writer·evidence 보존 | 4/4 (npm run test:persistence-browser) |
e2e CASE-040 — 실제 v0.17.1 번들 탭이 보내는 중(요청 붙듦)일 때 새 탭이 같은 기록을 고침 → 옛 행 보존·후속 행 successorOf·새 탭 무전송 → 옛 탭 영수증이 자기 행만 삭제(서버 80kg·rev 2) → 후속 전송(LG409 400 → 새 id·rev 2 재전송 200, 서버 90kg·rev 3, 대기열 0) | 3회 연속 통과 (25.2s·26.1s·39.4s), 오류 표면 0 |
| e2e CASE-039 — v0.17.1 태그로 재실측(codec 계약 §5 조건 5) | 통과 (1.4분) — 옛 writer 는 claim·writer·evidence·미지 필드를 행·미러에 보존하고(R4) 다른 탭의 만료 전 claim 을 무시한다(R5), v0.17.0 실측과 같음 |
활성화 판정: 조건 1~5 전부 만족 → 새 writer 켬(이 PR). 실측에서 드러난 것: ① 충돌 재전송은 새 요청 id·새 지문으로 나간다(§4) — S06 이 후속 행으로 영속화한다 ② PostgREST 는 SQLSTATE 40001/40P01 원형을 재시도해 45초 이상 응답이 없다(로컬 샌드박스 v14.16) — 서버 wrapper 의 LG409 번역이 필수이며 새 RPC 도 같은 규칙을 따라야 한다 ③ headless Chromium 에서는 bringToFront 만으로 visibilitychange 가 오지 않아 e2e 는 online·visibilitychange 를 직접 준다 ④ 장애 주입 프로필은 경로마다 선언 장애 하나·연결 상태 이벤트 1개 이상을 요구해, CASE-040 의 연결 상태 이벤트는 새 탭 부팅의 준비 조회 503(CASE-012/014 기법)으로 만든다.
8. 인계
| 받는 작업 | 가져가는 것 |
|---|---|
| S05 dispatcher·receipt reconciler — 완료(2026-09-08, 이슈 #1336, 계약) | claim/ACK 계획기와 전송 가능 판정은 전송 루프(retryPendingWorkoutSaves)가 그대로 쓰고 dispatcher 가 그 루프의 포트를 채운다. prepareSuccessor·findCreateReceipt 구현은 dispatcher 로 옮겼다 — 이 때 §5 의 변환 포트가 실제 배선에서는 completedWorkoutCommands.prepareSave(S03 이 지운 함수)를 부르고 있어 의도 행이 항상 격리되던 결함을 S03 조립기로 수리했다. 컨트롤러의 wait-for-create 분기는 지웠다 |
| S07 index·mirror — 완료(2026-09-08, 이슈 #1337, 계약) | 카탈로그 요약은 claim·writer·successorOf 를 그대로 담고(전송 가능 판정이 요약으로 돈다), 고른 행은 원문을 읽은 뒤 같은 판정을 다시 한다(요약만 믿으면 살아 있는 claim 을 가로챈다 — 두 탭 테스트 A 로 확인). 성공 ACK 는 OutboxReplaceCommit.reason="uploaded" 로 미러 tombstone 을 커밋 전에 적어 되살림·fence 되감김이 없다 |
| S06 충돌 사본·재전송 — 완료(2026-09-08, 이슈 #1341, 계약) | §4 의 충돌 재전송(LG409 → 새 id·새 개정번호 요청)을 보내기 전에 후속 행으로 영속화한다(ADR §3-3). 계획기 planConflictSuccessor 는 이 문서 §3 의 claim 표 검사(verifyOutboxTicket)와 §2 원자 교체를 그대로 쓴다 — claim 이 내 것이 아니면 stale, 아무것도 쓰지 않는다. 후속 행은 successorOf = 원 행이며 원 행은 같은 커밋에서 제거되므로 §4 의 "선행 먼저" 규칙은 대기 없이 통과한다 |
| R02 최종 확인 | 옛 탭 잔존 창의 고아 의도 행(§5)·CASE-039/040 재실측 |
Phase 2 종료 연결 보완 (2026-09-08)
이 문서의 receipt 조회 미구현·직접 API 재조회 인계는 Phase 2 종료 기록으로 갱신한다. 원문 보존·명시 owner·기존 저장 정책은 유지한다. 역사 영수증을 현재 상세로 취급하지 않고, 복구 ACK와 canonical resource 수렴을 구분한다. 이 보완의 병합·staging 상태는 해당 기록에서 확인한다.