Skip to content

데이터가 있는 DB 의 migration·잠금·backfill 안전성 (v0.18.0 D13, 이슈 #1287)

이 문서는 populated DB upgrade 절차의 정본 이다. 빈 DB 재생(CI migration-smoke·npm run db:preflight)은 "문법과 순서가 맞다" 를 증명할 뿐, 이미 사용자가 기록을 쌓은 DB 에서 같은 마이그레이션이 얼마나 잠그고·얼마나 걸리고·중간에 죽으면 어떻게 되고·옛 앱이 그동안 살아 있는지는 말하지 않는다. D13 은 그 물음에 답하는 위험 분류·upgrade harness·백필 primitive·공존 조건·증거 형식 을 만들고 기존 랜딩 게이트에 잇는다. 총괄 D13 카드 · 선행 G05 장부 §4-2 · G02 두 DB 연결 · D03 SQL 원천.

1. 한 줄 정의 — 세 가지 증거, 두 가지 환경

증거무엇을 증명하나누가 만드나어디서
빈 DB replay마이그레이션 전체가 빈 DB 에 순서대로 적용되고 schema.sql 이 그 결과와 바이트까지 같다CI migration-smoke · npm run db:preflight · harness --empty-replaySupabase 샌드박스
populated upgrade직전 릴리스 스키마 위에 데이터가 쌓인 DB 에 새 마이그레이션을 적용해도 사실 값·canonical ID·부모/자식 간선·source identity 가 그대로 이고, 적용 시간·WAL·잠금 대기·옛 앱 프로브 오류가 예산 안이며, 중간에 끊어도 재실행으로 같은 곳에 닿는다npm run db:upgrade(§3)Supabase 샌드박스(env=supabase-sandbox, 랜딩 증거) / bare Postgres + shim(env=bare-postgres-shim, 도구 검증용)
구/신 공존직전 릴리스의 앱·Edge·worker 가 새 DB 위에서 깨지지 않는다(§6 지원 조건)harness 가 두 ref 의 schema.sql 로 계산(순수 node) + 적용 중 프로브Docker 불필요

낮은 위험의 변경에 프레임워크를 강제하지 않는다. 함수·정책·권한·새 표·새 표의 인덱스만 있는 마이그레이션은 §2 의 헤더 한 줄(기계가 쓴다)이 전부다. populated 증거는 §2 가 high 로 분류한 파일에만 요구하고, §5 의 배치 백필은 대표 fixture 실측이 예산을 넘긴 백필에만 쓴다.

2. 위험 분류 — 문장이 정한다 (npm run migrations:risk)

scripts/migrations/migration-risk.mjs 가 마이그레이션 텍스트만으로(Docker 불필요) 문장마다 부류를 붙인다. 부류는 여섯 차원을 가진다: 잠금(표 단위 Postgres 잠금 모드) · 재작성(표를 통째로 다시 쓰는가) · 전체 스캔 · WAL/디스크 급 · 재실행(중단 뒤 다시 돌려도 되는가) · 공존(옛 앱·worker 가 그동안 깨지는가). 파일의 등급은 문장 부류의 최댓값이다.

2-1. 부류표 (정본은 코드의 RISK_CLASSES, 테스트 tests/react/migrationRisk.test.mjs ①)

부류등급잠금재작성스캔WAL/디스크재실행공존비고
create-table · index-new-table · add-column(상수 기본값 포함) · column-default · drop-constraint · add-constraint-not-valid · rls · policy · trigger · function · view · grant · revoke-door · comment · extension · schema-object · type · sequence · setting · cron · insert-values · self-check · storage-parameterlow짧은 ACCESS EXCLUSIVE 또는 없음아니오아니오small멱등(if not exists / or replace / drop-then-create)없음·의미 검토헤더 한 줄이면 끝. revoke-door 는 옛 앱의 42501 가능성을 공존 diff(§6)가 판정
create-table-as · validate-constraint · insert-select · update-dml(where 있음) · partition · drop-view · execute-function · dynamic-sql · othermedium원천 ACCESS SHARE / SHARE UPDATE EXCLUSIVE / ROW EXCLUSIVE아니오rows술어·on conflict 에 달림writers-wait실측 권장. execute-function·dynamic-sql·other 는 정적으로 모르는 것 — 사람이 본문을 읽고, 대표 fixture 로 잰다
create-index(기존 표)highSHARE(쓰기 차단)아니오index멱등writes-blocked트랜잭션 안에서는 CONCURRENTLY 불가(db push)
add-column-volatile-default(now()·gen_random_uuid()…) · add-column-generated-stored · alter-column-type · set-logged · refresh-matview · rewrite-maintenancehighACCESS EXCLUSIVE / EXCLUSIVEtable(+indexes)멱등 또는 manualall-blocked재작성 시간·디스크 2배
add-column-not-null-no-default · set-not-null · add-check · add-fk · add-unique · add-excludehighACCESS EXCLUSIVE(FK 는 양쪽 SHARE ROW EXCLUSIVE)아니오small~indexguardedall-blockedNOT VALID + VALIDATE CONSTRAINT 로 나누면 low+medium
backfill-dml(where 없는 전체 UPDATE) · delete-dml · truncatehighROW EXCLUSIVE(행 잠금 전체·DDL 차단) / ACCESS EXCLUSIVE아니오rows×2술어에 달림 / destructivewriters-wait / data-loss원본 표 DML 은 원본 불변 계약 티켓이 먼저. 실측이 예산을 넘으면 §5
drop-column · rename · drop-table · drop-function(같은 파일에서 다시 만들지 않음) · drop-type · lock-tablehigh짧은 ACCESS EXCLUSIVE아니오아니오small멱등옛 앱이 이름·열·함수를 쓴다행 수와 무관하게 high — 릴리스 뒤 후속 마이그레이션으로 미루거나 두 릴리스로 나눈다(§6)

행이 없는 표(--sizes, harness 의 evidence.fixture.sizes)의 표 단위 부류는 medium 으로 내려간다. 공존 부류(마지막 행)는 내려가지 않는다. 등급 목록에 없고 이름 힌트도 없는 표는 정적으로 medium(실측 권장). DO $$ … $$ 블록은 안의 문장을 같은 규칙으로 보고, EXECUTE 가 있으면 dynamic-sql 이다.

2-2. 헤더 — 기계가 쓰고 기계가 확인한다

sql
-- migration-risk: v1 level=high classes=add-column:exercise_set_part,backfill-dml:exercise_set_part,function fingerprint=ee46194a
-- upgrade-evidence: v=2 env=supabase-sandbox fixture=history-4y populated=true fact-rows=35000 owner-sessions=1043 from=478ddf12 to=beff760b migrations=2 elapsed=2036ms max-elapsed=1951ms lock-wait=1/1893ms wal=4.06MB probes=0/12 owner-probes=8 unprobed=0 coexistence=0 interrupt=20260913000000:5/11 facts=preserved verdict=passed at=2026-09-07T02:48:34.721Z
  • fingerprint실행 문장(주석 제외) 의 해시다. migrations:renumber 로 파일 이름·자기 주석이 바뀌어도 그대로이고, 문장이 한 글자라도 바뀌면 검사가 "낡은 헤더" 로 잡는다.
  • npm run migrations:risk -- --write [파일…](파일 없이 --neworigin/main 에 없는 새 파일 전부)이 헤더를 첫 주석 블록 뒤에 넣거나 갱신한다. D03 sql:candidate 로 만든 후보에도 같은 명령을 한 번 돌린다. --template 은 PR 본문에 붙일 문장별 표(§7)를 낸다.
  • -- upgrade-evidence: 줄은 §3 harness 가 낸 마지막 줄을 그대로 붙인다. 위 수치는 형식 예시이며 실측 증거가 아니다. v2는 facts=preserved(사실 보존)와 verdict=passed(populated·공존·예산 합격)를 별도로 기록한다. v1 증거는 새 high 마이그레이션의 랜딩에 재사용하지 않고 다시 측정한다.
  • v2 실측 필드: populated=true, 양수 fact-rows·owner-sessions·migrations·owner-probes, probes=0/<양수>, unprobed=0, 파일별 최대 시간 max-elapsed, 최대 lock-wait, coexistence=0. 필드 누락·잘못된 수치·probe 미실행/오류·§4-2 시간/잠금 예산 초과는 harness와 gate가 같은 기준으로 거부한다. 전체 적용 시간 합계 elapsed와 WAL은 함께 보고한다.

3. upgrade harness (npm run db:upgrade)

bash
npm run db:upgrade -- --from v0.17.0 --sandbox <sbx> --workload scripts/performance/evidence/workloads/history-4y \
  --interrupt 20260913000000 --empty-replay --check-definitions --out <증거>

scripts/migrations/upgrade/harness.mjs 의 흐름:

  1. from 상태의 격리 DB--sandbox: from ref 의 마이그레이션만 담은 임시 workdir 로 supabase db reset --no-seed(Supabase 이미지 = Production 이미지, env=supabase-sandbox). --db-url: 빈 Postgres 에 §3-1 shim 을 깔고 from 마이그레이션을 psql 로 재생(env=bare-postgres-shim). --prepare none: 이미 from 상태인 DB. Production 으로 보이는 URL 은 거부한다.
  2. fixture--fixture user-data.sql(매일 데이터 사본, pg_dump --data-only --column-inserts; 기본 session_replication_role=replica 로 트리거·FK 를 건너뛰고 넣는다, --fixture-mode triggers 로 켤 수 있다) 또는 --workload <G04 폴더>(결정적 fixture 를 저장 문으로 적재). 둘 다 없으면 참조 데이터뿐이라 populated 증거가 아니다 라고 증거에 표시한다.
  3. digest(전) — §3-3. 표별 행 수·기본 키 집합 해시·원본 열 해시·부모 간선(자식 수·고아 수·간선 해시)·source identity 해시.
  4. 공존 diff — from·to ref 의 schema.sql 로 §6 을 계산. 기본 owner는 session.user_idauth.users의 실제 소유자를 고르고, 그 ID로 authenticated RLS를 거쳐 fixture 세션을 읽을 수 있는지 확인한다. --probe-owner도 같은 검사를 받는다. 적용 중에는 옛 앱 프로브(기본: session 읽기 + owner 로서 get_home_dashboard·get_calendar_month_summary·get_volume_overview(date, 4)·세션 검색; --probes <json> 으로 교체)를 다른 세션에서 계속 부르고, 세 번째 세션이 100ms 마다 pg_stat_activity 의 잠금 대기를 샘플링한다. 날짜 인자는 그 소유자의 마지막 fixture 세션 날짜다. 적용 전 probe 실패는 자동 제외하지 않고 중단하며, custom probe에도 owner RLS 호출이 필요하다. 파일마다 새 probe 순회를 열고 진행 중 호출까지 완료·기록하므로, 짧은 파일이나 늦게 끝난 오류를 누락하지 않는다.
  5. 적용 — to 에만 있는 마이그레이션을 번호 순으로 파일 단위 한 트랜잭션(psql --single-transaction)으로. 파일마다 시간·WAL(pg_wal_lsn_diff)·표 크기 델타·잠금 대기 최대 세션 수/최대 ms·프로브 p95/max/오류·§2 등급을 남기고 supabase_migrations.schema_migrations 에 장부 행을 넣는다. --interrupt <version>[:<n>] 은 그 파일의 첫 n 문장(기본 절반)을 실행한 뒤 롤백(프로세스 사망 모형)하고 digest 가 전과 같은지 본 다음 파일 전체를 다시 적용한다(재실행).
  6. digest(후) 비교 — 다르면 표본(행 20만 이하: 바뀐·사라진·생긴 기본 키 5개씩). 열 이름을 바꾼 마이그레이션은 --renames session.note=memo 로 번역한다. --check-definitions 는 upgrade 뒤 DB 의 public 덤프를 D03 모델로 읽어 supabase/definitions/** 와 문장 단위로 대조한다(플랫폼 객체 제외).
  7. 증거<out>/upgrade-evidence.json(schema v2: env·from·to·fixture.sizes·fixture.ownerSessions·fixture.probeAsOfDate·migrations[]·digest·interrupt·coexistence·definitions·emptyReplay·totals·verdict·prLine) + upgrade-evidence.md + 마지막 줄 -- upgrade-evidence: …. 종료 코드 0 = 사실 보존·전부 적용·populated fixture·실제 소유자 probe 실행·probe 오류 0·시간/잠금 예산 이내·공존 break 0. 빈 fixture와 probe가 없던 실행도 결과를 남길 수 있지만 verdict=failed이고 랜딩 증거로 받지 않는다.

Supabase 버전 고정: prepareSandbox의 임시 workdir에도 package.jsonsupabaseToolchain.postgresVersion.temp/postgres-version으로 전달한다. db reset은 로컬 DB 컨테이너를 다시 시작할 수 있으므로 준비 전후 실제 Docker 이미지가 정본 핀과 같은지 검사한다. 다른 이미지는 ci:local에서 다시 기동하며, 검증된 이미지 문자열은 evidence.env.postgresImage에 남긴다. 임시 workdir은 실패 시에도 정리한다.

3-1. 두 환경의 뜻

supabase-sandboxbare-postgres-shim
무엇로컬 Supabase 스택(랜딩 절차 0-1) — Production 과 같은 Postgres 이미지·확장·역할. 호스트에 psql 이 없어도 된다 — 컨테이너(supabase_db_<project_id>) 안의 psql 을 docker exec 로 부르고 SQL 파일은 stdin 으로 흘린다(pgSessions.mjs·build-schema-snapshot.mjs 와 같은 규칙, Windows 개발 PC 실측 2026-09-07)빈 Postgres 한 대 + scripts/migrations/upgrade/platform-shim.sql(역할·auth/storage/vault/cron 스키마·auth.uid()·기본 권한·realtime publication·장부) + 순수 SQL 가짜 확장 shim-extensions/(pg_cron·supabase_vault — 백그라운드 워커 없음)
언제랜딩 증거(§4). R05 리허설Docker 가 없는 PC·클라우드 세션에서 도구 자체를 검증할 때. PG16 이면 MAINTAIN 권한 토큰을 빼고 재생하며 그 사실을 env.rewrites 에 남긴다
게이트env=supabase-sandboxdb:preflight·landing:lock acquire 가 받는다거부(테스트 migrationRiskGate ③)

shim 확장 설치(도구 개발 PC 1회): cp scripts/migrations/upgrade/shim-extensions/* $(pg_config --sharedir)/extension/.

3-2. fixture 의 두 원천

  • 실제 이전 릴리스 데이터: GitHub Actions User data daily copy artifact(user-data-copy-<날짜>, 90일, 복원 절차). 등급 목록의 유저 표 데이터만 있으므로 통계·투영 표는 비어 있다 — 그래도 원본·ID·간선·source 보존 검증에는 충분하고, 투영을 다시 만드는 마이그레이션의 백필 시간은 작게 나온다(투영 표가 비어 있으니). 투영 표까지 채운 값이 필요하면 workload 를 쓴다.
  • 결정적 workload(G04): node scripts/performance/workload/generate.mjs --profile history-4y--workload 로 넘긴다. 저장 문을 지나므로 투영·트리거·영수증까지 만들어진다. 실측 최대 사용자(4.8년·1,023세션)와 같은 급은 history-4y, 상한은 history-10y.

3-3. digest 가 지키는 네 가지

정본은 등급 목록 supabase/contracts/user-fact-columns.json 이고 harness 는 그것을 그대로 읽는다(scripts/migrations/upgrade/digest.mjs, 별도 목록 없음).

지키는 것계산어긋남 종류
사실 값원본(fact) 등급 열만 이어 붙인 행 해시를 기본 키 순으로 모은 md5 — 투영·시스템 열은 넣지 않는다(시스템이 자유롭게 다시 계산한다는 계약 R4)facts-changed
canonical ID기본 키 집합 해시 · 행 수ids-changed · rows-changed
부모/자식등급 목록 parents 마다 자식 수·고아(부모 없는 자식) 수·자식→부모 간선 해시edge-orphans · edge-changed
source identitysource·source_ref 열의 해시와 source 별 행 수source-changed

aliases(#1215 전 이름)로 옛 릴리스의 표 이름을 푼다. 등급 목록에 있는데 DB 에 없는 표는 digestSkipped 에 남긴다(추측하지 않는다).

4. 랜딩 게이트 연결 — 누가 무엇을 언제 요구하나

게이트검사실패하면
npm run check:migrations(CI verify, 정적)규칙 도입 꼬리(20260913000100) 뒤 번호의 마이그레이션은 -- migration-risk: 헤더가 있고 문장과 맞아야 한다. level=high 면 v2 증거 줄의 형식과 populated·소유자 probe·오류·시간/잠금 예산, facts=preserved·verdict=passed·coexistence=0npm run migrations:risk -- --write / npm run db:upgrade
npm run db:preflight(랜딩 0-1)위와 같은 판정을 origin/main 에 없는 새 파일에 대해 + 증거의 env=supabase-sandbox. 통과 기록에 risk(파일별 등급·증거) 요약기록을 남기지 않는다
npm run landing:request -- --pr <N>(자동 랜딩, #1314)새 마이그레이션이 있는 PR 의 랜딩 요청 직전에 같은 판정을 한다 — 기록 출처(db:preflight·ci:local)와 무관요청을 보내지 않는다
npm run landing:lock -- acquire(PC 내부 준비 자원의 호환 도구)잠금 직전에 같은 판정. statusrisk: 줄을 보여 준다거부. LANDING_PREFLIGHT_BYPASS=1 은 통과하되 GATE BYPASSED 를 잠금에 찍는다(작업 기록 §4 에 적어야 한다)

낮은 위험(low) 은 헤더 한 줄이면 세 게이트를 다 지난다. medium 은 헤더만 필수이고 실측은 권장(PR 본문에 적는다). high 만 Supabase 샌드박스 populated 증거가 필수다.

4-1. 각 마이그레이션 PR 이 제출하는 것 (템플릿: npm run migrations:risk -- --template <파일>)

### 마이그레이션 위험·증거 — <파일>
- 위험 등급: high · 분류 … · 문장 n · fingerprint …
- (문장별 잠금·재작성·스캔·WAL·재실행·공존 표)
- 빈 DB replay: 로컬 pgTAP N파일·M assert 통과 (시각)          ← db:preflight 출력
- populated upgrade: -- upgrade-evidence: v=2 env=supabase-sandbox … verdict=passed ← db:upgrade 출력 (high 필수)
- 중단·재개: interrupt=<version>:<n>/<total> facts=preserved
- 구/신 공존: coexistence=0 · 프로브 오류 0/N
- 적용 시간 / lock wait / WAL: elapsed·lock-wait·wal 값 + 예산(§4-2) 대비

4-2. 잠금·자원 예산 (2026-09-07 초기값, 넓히려면 오너 결정)

항목상한근거
마이그레이션 파일 1개 적용 시간 (Production 급 fixture)60초Production statement_timeout 120s 의 절반(성능 기준선)
옛 앱 프로브 최대 잠금 대기5,000ms앱 RPC 예산의 상한급(릴리스 예산)
옛 앱 프로브 오류0공존 조건 C1~C4
WAL표 크기의 3배 이하(재작성 부류) / 갱신 행 × 2 급(백필)재작성·백필의 WAL 급
넘으면백필은 §5 배치로, DDL 은 NOT VALID/VALIDATE·두 릴리스 분할·USING INDEX 등 §2-1 비고의 형태로 다시 쓴다

시간·잠금 상한은 scripts/migrations/upgrade/evidence.mjs의 공통 판정으로 강제한다. 파일별 시간은 합계가 아닌 최댓값으로 비교하며 60,000ms·5,000ms까지 허용하고 초과는 실패다. WAL의 비례 예산은 파일의 갱신 범위/표 크기를 읽어 검토한다. 기존 #1318의 sandbox 잠금 5,269ms는 사실 보존 증거이지만 v2 upgrade 합격이 아니며, 값을 낮춰 적거나 예산을 넓혀 합격으로 바꾸지 않는다.

5. 긴 백필의 primitive (npm run db:backfill)

scripts/migrations/backfill-runner.mjs — DB 에 아무 객체도 만들지 않는다(운영 DB drift 0). 진행 상태는 러너 PC 의 checkpoint 파일, 동시 실행 차단은 pg_advisory_lock, 멱등성은 spec 의 술어(where)에 있다.

json
{ "version": 1, "name": "reps_stats_projection", "table": "public.exercise_set_part", "key": ["id"],
  "where": "stats_reps is distinct from reps",
  "batch": "update public.exercise_set_part set stats_load_kg = stats_load_kg where {{range}}",
  "batchSize": 1000, "lockTimeoutMs": 2000, "statementTimeoutMs": 30000, "pauseMs": 50, "maxRetries": 20 }
  • 배치 = 트랜잭션 하나: set local lock_timeout → 술어 + 커서 뒤 키 N 개(키 순, 출력 열 별칭으로 정렬 함정 회피) → (첫~마지막 키 + 술어) 또는 를 채운 문장 → commit → checkpoint(마지막 키·배치 수·행 수·lock_timeout 수).
  • 재개: 같은 spec(지문 = name·table·key·where·batch)·같은 DB 의 checkpoint 만 이어 간다. 배치 크기·pause 는 지문 밖이라 바꿔도 된다. --stop-after n 으로 중단을 주입해 리허설한다(종료 코드 5). --reset 만 checkpoint 를 지운다.
  • 공존: 옛 앱이 행을 쥐고 있으면 그 배치만 lock_timeout 으로 물러났다가(재시도 수·lock_timeouts 기록) 풀린 뒤 지나가고, 술어 밖 행(이미 처리됐거나 옛 앱이 다시 쓴 행)은 건드리지 않는다.
  • 증거: 배치 수·행 수·배치 p50/p95/max ms·WAL·재시도·남은 행 → <out>/backfill-<name>.evidence.json + PR 줄.
  • 마이그레이션과의 관계: DDL(열 추가·투영 트리거)은 마이그레이션에 두고 전체 UPDATE 를 빼며, 그 자리에 -- backfill: <spec 경로> 주석과 "술어가 참인 행은 통계에서 어떻게 보이는가"(예: stats_reps is null = 숨김)를 적는다. 러너는 릴리스 뒤 db:backfill 로 돌리고 완료 증거를 릴리스 이슈에 붙인다. 이 판단은 §4-2 예산을 넘긴 백필에만 한다.

실 Postgres 검증: tests/db/backfillRunner.test.mjs(1,000행 배치 100 · 중단 3배치 뒤 재개 · 다른 세션 FOR UPDATE 와의 공존 · advisory lock).

6. 구/신 앱·worker 공존 — 지원 조건과 확인 방법

main 머지 = staging, 릴리스 = main → production 이 앱·DB 를 한 몸으로 내보내지만(HQ 운영 정본 §4-6), DB 마이그레이션은 앱 배포보다 먼저 끝나고 사용자의 기기에는 옛 앱이 캐시돼 있다. 그 사이 옛 앱·Edge·worker 가 새 DB 위에서 살아 있어야 한다.

조건확인
C1 door 유지옛 앱이 부르는 door 함수(anon/authenticated EXECUTE)는 같은 시그니처로 남고 반환형이 같다공존 diff door-removed·door-return-type-changed = 0
C2 읽는 모양 유지옛 앱이 읽는 표·뷰·열이 남아 있다(열 삭제·표 삭제·이름 변경 없음)column-removed·table-removed·view-removed = 0
C3 쓰는 모양 유지옛 앱이 INSERT 하는 표에 기본값 없는 NOT NULL 열이 생기지 않는다new-not-null-column-without-default = 0
C4 권한 유지door 의 EXECUTE 가 회수되지 않는다. 정책 삭제·RLS 변경은 의미 검토door-execute-revoked = 0, policy-removed·rls-changed 는 경고로 읽는다
C5 잠금적용 중 옛 앱 프로브가 §4-2 예산 안에서 기다리고 오류가 없다harness lock-wait·probes

break 가 있는 변화(열·표·함수 삭제, 이름 변경, 시그니처 교체)는 두 릴리스로 나눈다: 릴리스 N 에 새 이름/새 시그니처를 추가하고 앱을 옮긴 뒤, 릴리스 N+1 의 마이그레이션에서 옛 것을 지운다. 옛 것을 지우는 마이그레이션은 §2 가 high 로 분류하고, 그 PR 은 공존 diff 의 break 를 "앞 릴리스에서 앱이 이미 옮겨 갔다" 는 근거(릴리스 태그·앱 커밋)와 함께 적는다. 직전 릴리스(production) → main 의 break 가 0 인 것은 tests/react/upgradeHarness.test.mjs ⑤ 가 매번 확인한다.

worker(pg_cron 잡·Edge 함수)는 door 가 아니라 service_role 로 부른다 — 잡이 부르는 함수를 지우거나 시그니처를 바꾸면 cron.jobcommand 도 같은 마이그레이션에서 cron.schedule 로 바꾼다(D03 생성기가 cron 변경을 만든다).

7. 중단·재실행·forward repair 절차

상황무엇이 일어나나절차
마이그레이션 파일이 중간에 죽음(프로세스·연결·statement_timeout)파일 = 트랜잭션 하나 → 전부 롤백, 장부에 안 남음. harness --interrupt 가 이 모형이다원인을 고치고 같은 파일을 다시 적용한다(db push 가 미적용으로 본다). 파일이 멱등(§2 재실행 차원)이므로 앞선 부분 실행이 있어도 안전하다
배치 백필이 중간에 죽음완료된 배치는 커밋됨, checkpoint 에 마지막 키db:backfill 을 같은 spec 으로 다시 → 커서 뒤부터. 술어가 이미 처리된 행을 걸러 두 번 갱신하지 않는다
적용 뒤 결과가 틀림(투영 오류·함수 버그)DB 를 되돌리지 않는다(rollback.md §0)forward repair: 새 마이그레이션(또는 배치 백필)으로 투영을 다시 계산한다. harness 로 "잘못된 상태의 fixture" 위에서 repair 마이그레이션을 다시 잰다(--prepare none 으로 그 DB 를 그대로 쓰거나 from=잘못된 릴리스 태그)
원본이 바뀜(digest facts-changed)계약 위반 — 랜딩 불가마이그레이션을 고친다. 이미 Production 에 갔으면 값 복원 §8(user_fact_history / 데이터 사본) 으로 그 행만 되살리고 수리 티켓으로 기록한다

8. R05 — 릴리스 전체 리허설 템플릿

D13 은 도구·대표 fixture 검증으로 끝난다. 0.18.0 실제 마이그레이션 전체의 최신 데이터 upgrade 는 R05 가 다시 요구한다.

  1. git fetch origin production — from = origin/production(직전 릴리스), to = 릴리스 후보 SHA.
  2. fixture = 그날의 User data daily copy + history-4y workload 둘 다(두 번 돌린다: 원본 보존은 사본으로, 투영·시간은 workload 로).
  3. npm run db:upgrade -- --from origin/production --to <후보 SHA> --sandbox <sbx> --fixture user-data.sql --empty-replay --check-definitions --out <폴더>--workload … 로 한 번 더. 가장 무거운 파일에 --interrupt 한 번.
  4. 증거 두 벌의 verdict.ok · coexistence.breaks=0 · §4-2 예산 · definitions.ok 를 릴리스 이슈에 붙인다. 하나라도 어긋나면 후보 SHA 를 바꾼다(도구 통과 = 완료가 아니다).
  5. 배치 백필이 있으면 db:backfill --stop-after 로 중단·재개까지 리허설하고 evidence JSON 을 같이 붙인다.

9. 소유·인계

자원담당
scripts/migrations/migration-risk.mjs · backfill-runner.mjs · upgrade/** · db-preflight·landing-lock 의 위험 게이트 · tests/db/{backfillRunner,upgradeHarness} · 이 문서D13(DB 이관 도구)
각 마이그레이션의 DDL/backfill 내용, 헤더·증거 제출그 SQL object 담당(D02·D09·D12 …)
예산 §4-2 변경, env 허용 목록HQ + 오너
릴리스 전체 리허설 §8R05

한계(명시): 정적 분류는 함수 본문·DO 블록의 동적 SQL 을 모른다(execute-function·dynamic-sql 은 실측으로). digest 는 등급 목록의 유저 표만 본다 — 통계·투영 표는 계약상 재계산 대상이라 대상이 아니다. bare shim 은 pg_cron 워커·realtime·PostgREST 가 없고 PG16 이면 MAINTAIN 을 뺀다 — 그래서 랜딩 증거가 아니다.