Skip to content

v0.18.0 D02 — 정각 cron 과 겹친 정상 저장이 거절되던 세대 경합에서, 영수증 세대가 "이 저장이 배정받은 값" 이 되고 계획 쓰기는 0 을 적으며 2연결 회귀 13건으로 고정되기까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 2개 — 36063ea8 착수·분석·Phase 0~2 구현 뒤 #1345 선행 수리 확인으로 보류, 705670db 이어받아 차이 분석·남은 Phase 완주. 오너 지시 "1327 이어서 진행해줘 묻지말고 phase 끝까지 완주")
  • 랜딩: 선행 수리 PR #1345 2de59d69(v0.17.1 준비 #1324 — enqueue core 반환 계약·완료 쓰기 영수증 세대·2연결 회귀 9건·마이그레이션 20260913010100) · 이 트랙 PR #1352(squash, 자동 랜딩 큐 — 마이그레이션 20260913040100_d02_planned_receipt_generation_zero 함수 2개 재발행, 위험 low). Vercel 배포 없음(앱 코드 무변경). 총괄 D02 카드 · 계획 ID D02 · Phase 2 스텝 2-1
  • 설계서: 없음 — 분석·Phase 계획은 이슈 #1327 댓글(착수)·이어받은 뒤 차이 분석·남은 Phase
  • 정본: 통계 투영 정책 §6 세대 계약 · G03 도메인 계약 §9·§13 · 화면 RPC 계약 영수증 v2 · 원천 supabase/definitions/workout/functions/{save_session_v5_engine,delete_session_v5_engine}.sql · supabase/definitions/stats/functions/enqueue_user_exercise_stats_refresh_job_core.sql
  • 도구: 실제 두 DB 연결 barrier tests/db/support/pgSessions.mjs(G02) · npm run db:upgrade(D13 populated harness) · G04 workload history-4y
  • 게이트: tests/db/workoutGenerationConcurrency.test.mjs 9 → 13건(ci:local --full 과 CI migration-smoke 가 실행) · pgTAP session_write_contract_v5 44 → 45 assert · 로컬 pgTAP 통과 기록 110파일·1,924 assert(ci:local, 2026-09-07 13:02 KST, 리베이스 뒤) · populated upgrade 증거 verdict=passed(4년 workload 1,043세션·원본 33,127행, 적용 376ms, 잠금 대기 0ms, 프로브 오류 0/10)
  • 버그리포트: 없음(v0.18.0 리팩터링 트랙. 제품 결함의 수리 자체는 #1345 v0.17.1)
  • 계약: 통계 투영 정책 §6 — "계획 쓰기는 기존에 관측한 세대를 기록한다" → "계획 쓰기(계획 저장·계획 삭제)는 0 을 기록한다" 정정. 영수증 v2 모양·enqueue UUID API·잠금 순서·수치 정책 무변경

Phase 현황

Phase내용상태
Phase 0인수(#1345 이후 main 2de59d69) · 이 이슈 수락 조건과 #1345 반영분 대조 · 남은 차이 4건 · 계획 게시✅ 이슈 댓글
Phase 1저장·삭제 엔진에서 잠금 밖 유저 전체 세대 읽기 제거, 계획 저장·삭제 영수증 세대 0, 같은 source_ref 생성 재생은 원래 영수증 세대(계획이면 0) · 후보 마이그레이션 · 스냅샷·registry·drift · pgTAP 단언 갱신
Phase 22연결 회귀 4건 추가(같은 세션 수정 vs 삭제 양방향 · 계획→완료 전이 vs 별도 enqueue · 계획 삭제 vs 별도 enqueue · 달력 dirty/자식 세트/통계 잡 세 단계 실패 주입)
Phase 3populated 4년 DB upgrade 증거 · ci:local --full · 정책 문서 §6 정정 · 이 기록 + 등록 2곳 · S05/A09·D04/D08/D11 인계 · PR · 자동 랜딩 큐PR #1352

1. 배경

유저 A 가 완료 운동을 저장하면 서버는 ① 기록을 쓰고 ② 통계 재계산 잡을 큐에 넣으며 A 의 "통계 요청 세대" 를 +1 하고 ③ 그 세대를 영수증 stats_requested_version 에 적는다. 앱은 홈·달력의 적용 세대가 이 값을 따라잡을 때 숫자를 믿는다(G03 §9·§15).

v0.17.x 까지 저장 함수는 ②의 결과를 돌려받지 못했다(enqueue 함수가 잡 UUID 만 반환). 그래서 저장 함수는 잠금 없이 세대를 먼저 읽고, 저장 뒤 다시 읽어 "정확히 +1" 을 단언했다. 이 검사는 "이 저장이 세대를 하나 요청했다" 가 아니라 "이 저장 중에 아무도 세대를 올리지 않았다" 를 검사하고 있었다. 매시 정각 cron(barbelic-stats-backfill-scan)이 같은 유저의 세대를 올리면 정상 저장이 55000 오류로 롤백됐다 — Phase 1 종료 보완(#1322)이 4년 적재 1,043건 중 1건의 거절과 2연결 재현을 기록했고, 총괄이 D02 를 P0 로 배정했다.

착수 세션(36063ea8)이 Phase 0~2 를 구현하던 중 같은 P0 를 v0.17.1 준비 트랙 #1324 의 PR #1345 가 같은 설계로 먼저 수리해 main 에 머지됐다. 중복 랜딩을 피해 그 구현은 버리고, 이 세션은 #1345 가 남긴 차이 를 채우는 것으로 D02 를 마무리한다.

2. 문제 제기

계획 저장·계획 삭제의 영수증 세대가 계약(0)과 달랐다

#1345 뒤에도 저장·삭제 엔진은 시작할 때 user_stats_refresh_state.requested_version잠금 없이 읽어 두고, 통계를 바꾸지 않는 계획 쓰기의 영수증에 그 값을 그대로 적었다. 계약은 네 곳에서 이미 0 을 정본으로 두고 있었다 — G03 §9 "완료 기록 ≥ 1, 계획·보드 0", 화면 RPC 계약 영수증 v2, rpc-catalog.md, session-data-model.md. 앱도 0 을 받는다(barbelicRepository: 완료가 아니면 0 허용 · stageAfterReceipt: 요구 세대 0 이면 통계 대기 없이 server_committed). 즉 유저 B 가 계획을 저장한 순간 cron 이 잡을 남겨 둔 상태라면, B 의 계획 저장은 앱에서 "통계 반영 대기" 로 보일 수 있었다(계획은 통계에 아무 영향이 없는데도). 이슈 상세 작업 2("현재 전체 version 을 나중에 다시 읽는 방식은 제거")가 아직 절반만 된 상태였다.

2연결 회귀에 조합 네 가지가 빠져 있었다

#1345 의 9건은 cron 경합·다른 세션 두 저장·계획 저장 vs cron·완료 삭제 vs cron·같은 세션 같은 revision·영수증 제약 실패·여분 enqueue·잠금 순서·source_ref 동시 생성 재생을 덮는다. 이슈가 요구한 "save/delete 조합"(같은 세션을 한쪽은 수정·한쪽은 삭제), "planned/completed 조합"(계획→완료 전이 중 cron·계획 삭제), "어느 중간 단계에서도 부분 commit 없음"(실패 주입 1지점 → 저장 트랜잭션의 세 단계)은 없었다.

3. 해결 방안

원칙

  • 오너 결정 필요 없음(§26). 전제: 계획 영수증 0 은 계약 4곳에 이미 적힌 값을 SQL 이 따라가는 것이고, 세대의 뜻은 #1345 가 정한 "자기 enqueue 가 배정한 값" 그대로다.
  • 이슈가 금지한 것 그대로: 저장 전체에 무거운 owner 잠금을 잡지 않는다 · 통계 full 계산을 저장에 붙이지 않는다 · 단언을 지우기만 하지 않는다.

접근

내용판정
A. 엔진이 세대를 읽지 않는다 (채택)영수증 세대의 출처를 둘로 고정 — 완료 쓰기 = core 반환값, 계획 쓰기 = 0. 같은 source_ref 생성 재생은 원래 영수증에서 읽는다(계획이면 0). 엔진 2개에서 user_stats_refresh_state 읽기 자체를 지운다잠금 밖 읽기가 사라지므로 "그 순간의 전체 값" 이 영수증에 섞일 길이 없다
B. 계획 쓰기만 0 으로 바꾸고 읽기는 둔다if planned then 0 한 줄읽기가 남아 다음 담당이 다시 쓸 수 있다(구조 미개선). 기각
C. 계약을 SQL 에 맞춘다(계획 = 관측 세대)문서 4곳 수정앱이 계획 저장 뒤 통계 대기를 하게 된다. 기각

4. 적용한 내용

Phase 1 — 엔진 원천·마이그레이션 (20260913040100_d02_planned_receipt_generation_zero)

  • save_session_v5_engine: v_prior_requested_version 선언·잠금 밖 읽기 삭제. v_stats_requested_version 은 0 에서 시작해 완료 쓰기에서만 core 반환값으로 바뀐다. 같은 source_ref 생성 재생 분기의 coalesce(max(receipt.stats_requested_version), v_prior_requested_version)coalesce(…, 0).
  • delete_session_v5_engine: 같은 정리. 계획 삭제 영수증 = 0.
  • 후보 마이그레이션은 sql:candidate 가 만든 함수 2개 재발행뿐(표·열·권한 무변경). migrations:risk 헤더 level=low classes=function.
  • pgTAP session_write_contract_v5: "계획 저장 영수증 = 직전 영수증 세대" → 0, "전이 영수증 = 계획 영수증 + 1" → 마지막 완료 쓰기 + 1 이고 유저의 현재 요청 세대와 같다(단언 1개 추가, 44 → 45). 이 파일은 감사 manifest 에 없어 pending-changes.json 신고 대상이 아니다.

Phase 2 — 2연결 회귀 4건 (tests/db/workoutGenerationConcurrency.test.mjs 9 → 13)

검사두 연결이 하는 일고정하는 것
같은 세션 수정 vs 삭제A 가 수정을 열어 둔 채 B 가 삭제 → A commit. 이어서 A 가 삭제를 열어 둔 채 B 가 수정 → A commit먼저 확정된 쪽만 성공. 늦은 삭제는 40001(revision), 늦은 수정은 P0002(행 없음). 삭제 영수증 = 수정 세대 + 1
계획→완료 전이 vs 별도 enqueueA 가 큐 잠금 보유 → B 의 전이 저장이 advisory 에서 대기(pg_stat_activity 로 확인) → A 가 scheduled_stats_backfill enqueue·commit계획 저장 영수증 0 → 전이 영수증 = 자기 배정 N+2, dirty 날짜 1행, 세션 1행에 영수증 3장
계획 삭제 vs 별도 enqueueA 가 enqueue 를 commit 하지 않은 채(큐 잠금 보유) B 가 계획을 삭제계획 삭제는 큐 잠금을 기다리지 않고 즉시 확정, 영수증 0, 세대는 A 의 +1 뿐
세 단계 실패 주입달력 dirty 삽입 · 자식 세트 삽입 · 통계 잡 삽입 각각에 BEFORE INSERT 트리거로 예외(LG999)6개 표(session·session_exercise·exercise_set·영수증·잡·dirty) 0행·세대 불변, 다음 저장이 정확히 +1, 그 삭제가 +2

기존 "계획 저장 중 별도 완료 세대" 검사에 stats_requested_version === 0 단언을 더했다. 경합 재현에는 trigger 를 쓰지 않는다(이슈 조건) — 실패 주입만 trigger 다.

Phase 3 — 증거·문서·인계

  • populated upgrade: npm run db:upgrade -- --from origin/main --to worktree --sandbox <ci:local 샌드박스> --workload history-4y --empty-replay --check-definitions-- upgrade-evidence: v=2 env=supabase-sandbox fixture=d02-1327 populated=true fact-rows=33127 owner-sessions=1043 from=bf16e910 to=d1c008d1 migrations=1 elapsed=376ms max-elapsed=376ms lock-wait=0/0ms wal=1.96MB probes=0/10 owner-probes=8 unprobed=0 coexistence=0 interrupt=none facts=preserved verdict=passed at=2026-09-07T11:22:33.989Z
  • 검증: ci:local full · verify 통과 (6단계) · db reset(마이그레이션 전체 적용) 통과 · schema.sql 스냅샷 --check 통과 · pgTAP 통과 110파일/1924 assert · 동시 저장 세대·영수증 통과 · e2e-local 통과 11/11 · e2e-empty 통과 7/7 · e2e-cardio 통과 6/6 · e2e-persistence 통과 4/4 · e2e-browser 통과 38/38 · e2e-viewport 통과 14/14 · 14분 23초 (2026-09-07 12:50~13:05 KST, 리베이스 뒤 main c5b3fd66 기준. 첫 실행(11:28, bf16e910 기준)은 다른 세션이 포트 4173 을 써 브라우저 두 묶음을 4179 에서 따로 돌려 38/38·14/14)
  • 정책 문서 §6 정정, 이 기록, 등록 2곳(사이드바·README).

주요 결정과 그 근거

  • #1345 와 겹치는 것은 다시 만들지 않았다. 착수 세션의 _v2 접근은 #1345 의 _core 와 같은 설계였고 main 에 먼저 들어왔다. 같은 객체를 두 번 재발행하면 랜딩 큐에서 나중 쪽이 앞을 되덮는 위험(메모리 "같은 함수 다중 트랙 재발행")만 남는다.
  • 리베이스 2회와 번호 재배정. PR 을 연 뒤 S08(#1350, 20260913020100)·D12(#1358, 20260913030100)가 같은 임시 번호로 먼저 랜딩해 migrations:renumber 로 최종 20260913040100 이 됐다. 문장은 그대로라 위험 헤더 fingerprint(c3673341)와 populated 증거는 유효하다. 리베이스마다 schema:snapshot --resetsql:extract 순서를 지켰다(순서를 바꾸면 sql:extract 가 옛 스냅샷에서 원천을 되돌려 쓴다 — 한 번 실제로 겪고 HEAD 에서 원천을 되살렸다). D12 가 새로 넣은 db:model 장부 게이트가 schema.sql 변경을 잡아 supabase/contracts/db-objects.json·docs/data/db-model-ledger.md 를 재생성했다.
  • --from origin/main 으로 populated 증거를 냈다. 이슈가 "자기 변경의 증거와 기존 전체 릴리스 잔여 위험(R05)을 구분해 기록" 을 요구한다. v0.17.0 → main 전체(반복수 투영 이관 포함)는 #1345·D13 이 이미 실측했고, 이 트랙의 pending 은 함수 재발행 1파일이다.

작업 중 드러난 것

  • 착수 직후·PR 직전 git fetch 로 같은 객체를 만진 PR 이 있는지 본다. 착수 세션이 Phase 2 까지 구현한 뒤에야 #1345 를 봤다(메모리 d02-generation-atomic-superseded-1327). P0 는 v0.17.x 선행 수리로 v0.18.0 트랙보다 먼저 들어올 수 있다.
  • Docker 네트워크 주소 고갈. 이 PC 에 샌드박스 스택 20여 개가 떠 있어 supabase start 가 "all predefined address pools have been fully subnetted" 로 실패했다. 2026-08-20 에 만든 워커 스택 3개(barbelic-e2wt/rpcwt/sqwt, 18일 방치)를 지워 자리를 냈다. 끝난 세션의 스택은 npm run ci:local -- --stop 으로 내리는 것이 맞다.
  • 포트 bind 거짓 실패. 첫 기동 실패의 잔해가 남은 상태에서 재시도하면 "address already in use" 가 한 번 더 난다. 잔해가 정리된 뒤 같은 명령을 다시 돌리면 정상 기동한다(포트 자체는 비어 있었다).
  • Bash 히어독 앞에 cat > "$TMP/…" 처럼 stdin 없는 cat > 을 두면 명령이 영원히 기다린다(타임아웃 → 백그라운드). 파일은 Write 도구로 만든다.

5. 적용 결과

항목
계획 저장 영수증 stats_requested_version유저 전체 세대(≥1, 잠금 밖 읽기)0 (pgTAP·2연결 검사)
계획 삭제 영수증 stats_requested_version유저 전체 세대0 (2연결 검사)
저장·삭제 엔진의 user_stats_refresh_state 잠금 밖 읽기2곳0곳
실제 두 연결 회귀9건13건 (13/13 통과, 샌드박스 단독 실행 13초)
pgTAP109파일·1,900 assert(#1345 기준)110파일·1,924 assert 통과(S08 합류 뒤, 이 트랙 +1)
populated 4년 DB upgrade(이 마이그레이션 1파일)verdict=passed · 376ms · 잠금 0ms · WAL 1.96MB · 프로브 0/10 · facts=preserved
ci:local --full한 번에 전부 통과(14분 23초): verify·DB 재생 177·pgTAP 110파일/1,924·2연결 13/13·e2e 6묶음(브라우저 38/38·viewport 14/14)
Production 반영v0.18.0 릴리스에서(이슈는 릴리스까지 open)

미검증: 앱 화면에서 "계획 저장 뒤 통계 대기 표시가 사라지는 것" 은 자동 검사가 없다(앱 코드 무변경, stageAfterReceipt 단위 테스트가 0 → server_committed 를 이미 고정). Production 실측은 릴리스 뒤 영수증 표에서 mutation_kind='save_session' and status planned 의 세대 분포로 확인할 수 있다.

6. 이번 개선으로 향상된 것

영수증 세대의 뜻이 하나다

"이 저장의 enqueue 가 배정한 세대, 통계를 바꾸지 않으면 0". S05/A09 가 영수증으로 화면 반영을 판정할 때 계획 저장을 특별 취급할 필요가 없다.

엔진이 유저 전체 세대를 읽지 않는다

잠금 밖 읽기가 코드에서 사라져 "그 순간의 값" 이 영수증에 섞이는 부류의 결함이 구조적으로 막혔다.

구조적으로 남는 것

  • enqueue 반환 계약 (job_id, target_version) — D04/D08/D11 이 그대로 쓴다(#1345).
  • 2연결 경합·세 단계 실패 주입 fixture(tests/db) — 같은 파일에 검사를 더하면 ci:local --full·CI migration-smoke 가 돈다.
  • 규칙: "세대는 배정한 쪽이 돌려주고, 통계를 바꾸지 않는 쓰기는 0".

남은 것

  • 이슈 #1327 은 규칙대로 v0.18.0 릴리스까지 open. 릴리스 뒤 Production 프로브(계획 영수증 세대 0 분포)와 [v0.18.0 반영완료] 닫기.
  • 인계: S05(#1336)/A09(#1342) — 영수증 v2 모양 불변·세대 뜻(완료 = 배정값, 계획 = 0). D04/D08/D11(이슈 미생성) — enqueue 반환 계약·dirty 계약·2연결 fixture. D12(#1328) — save/delete_session_v5_engine·enqueue_…_core 는 이 트랙이 재발행했으니 D12 는 다시 만지지 않는다.
  • 총괄 D02 카드 상태 칸 갱신은 HQ 몫(운영 정본 §1). 갱신안: "Staging verified · #1345 2de59d69 + PR #1352 · 기록 [이 문서]".