데이터가 있는 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-replay | Supabase 샌드박스 |
| 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-parameter | low | 짧은 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 · other | medium | 원천 ACCESS SHARE / SHARE UPDATE EXCLUSIVE / ROW EXCLUSIVE | 아니오 | 예 | rows | 술어·on conflict 에 달림 | writers-wait | 실측 권장. execute-function·dynamic-sql·other 는 정적으로 모르는 것 — 사람이 본문을 읽고, 대표 fixture 로 잰다 |
create-index(기존 표) | high | SHARE(쓰기 차단) | 아니오 | 예 | 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-maintenance | high | ACCESS EXCLUSIVE / EXCLUSIVE | 예 | 예 | table(+indexes) | 멱등 또는 manual | all-blocked | 재작성 시간·디스크 2배 |
add-column-not-null-no-default · set-not-null · add-check · add-fk · add-unique · add-exclude | high | ACCESS EXCLUSIVE(FK 는 양쪽 SHARE ROW EXCLUSIVE) | 아니오 | 예 | small~index | guarded | all-blocked | NOT VALID + VALIDATE CONSTRAINT 로 나누면 low+medium |
backfill-dml(where 없는 전체 UPDATE) · delete-dml · truncate | high | ROW EXCLUSIVE(행 잠금 전체·DDL 차단) / ACCESS EXCLUSIVE | 아니오 | 예 | rows×2 | 술어에 달림 / destructive | writers-wait / data-loss | 원본 표 DML 은 원본 불변 계약 티켓이 먼저. 실측이 예산을 넘으면 §5 |
drop-column · rename · drop-table · drop-function(같은 파일에서 다시 만들지 않음) · drop-type · lock-table | high | 짧은 ACCESS EXCLUSIVE | 아니오 | 아니오 | small | 멱등 | 옛 앱이 이름·열·함수를 쓴다 | 행 수와 무관하게 high — 릴리스 뒤 후속 마이그레이션으로 미루거나 두 릴리스로 나눈다(§6) |
행이 없는 표(--sizes, harness 의 evidence.fixture.sizes)의 표 단위 부류는 medium 으로 내려간다. 공존 부류(마지막 행)는 내려가지 않는다. 등급 목록에 없고 이름 힌트도 없는 표는 정적으로 medium(실측 권장). DO $$ … $$ 블록은 안의 문장을 같은 규칙으로 보고, EXECUTE 가 있으면 dynamic-sql 이다.
2-2. 헤더 — 기계가 쓰고 기계가 확인한다
-- 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.721Zfingerprint는 실행 문장(주석 제외) 의 해시다.migrations:renumber로 파일 이름·자기 주석이 바뀌어도 그대로이고, 문장이 한 글자라도 바뀌면 검사가 "낡은 헤더" 로 잡는다.npm run migrations:risk -- --write [파일…](파일 없이--new면origin/main에 없는 새 파일 전부)이 헤더를 첫 주석 블록 뒤에 넣거나 갱신한다. D03sql: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)
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 의 흐름:
- 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 은 거부한다. - fixture —
--fixture user-data.sql(매일 데이터 사본,pg_dump --data-only --column-inserts; 기본session_replication_role=replica로 트리거·FK 를 건너뛰고 넣는다,--fixture-mode triggers로 켤 수 있다) 또는--workload <G04 폴더>(결정적 fixture 를 저장 문으로 적재). 둘 다 없으면 참조 데이터뿐이라 populated 증거가 아니다 라고 증거에 표시한다. - digest(전) — §3-3. 표별 행 수·기본 키 집합 해시·원본 열 해시·부모 간선(자식 수·고아 수·간선 해시)·source identity 해시.
- 공존 diff — from·to ref 의
schema.sql로 §6 을 계산. 기본 owner는session.user_id와auth.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 순회를 열고 진행 중 호출까지 완료·기록하므로, 짧은 파일이나 늦게 끝난 오류를 누락하지 않는다. - 적용 — to 에만 있는 마이그레이션을 번호 순으로 파일 단위 한 트랜잭션(
psql --single-transaction)으로. 파일마다 시간·WAL(pg_wal_lsn_diff)·표 크기 델타·잠금 대기 최대 세션 수/최대 ms·프로브 p95/max/오류·§2 등급을 남기고supabase_migrations.schema_migrations에 장부 행을 넣는다.--interrupt <version>[:<n>]은 그 파일의 첫 n 문장(기본 절반)을 실행한 뒤 롤백(프로세스 사망 모형)하고 digest 가 전과 같은지 본 다음 파일 전체를 다시 적용한다(재실행). - digest(후) 비교 — 다르면 표본(행 20만 이하: 바뀐·사라진·생긴 기본 키 5개씩). 열 이름을 바꾼 마이그레이션은
--renames session.note=memo로 번역한다.--check-definitions는 upgrade 뒤 DB 의 public 덤프를 D03 모델로 읽어supabase/definitions/**와 문장 단위로 대조한다(플랫폼 객체 제외). - 증거 —
<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.json의 supabaseToolchain.postgresVersion을 .temp/postgres-version으로 전달한다. db reset은 로컬 DB 컨테이너를 다시 시작할 수 있으므로 준비 전후 실제 Docker 이미지가 정본 핀과 같은지 검사한다. 다른 이미지는 ci:local에서 다시 기동하며, 검증된 이미지 문자열은 evidence.env.postgresImage에 남긴다. 임시 workdir은 실패 시에도 정리한다.
3-1. 두 환경의 뜻
supabase-sandbox | bare-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-sandbox 만 db: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 copyartifact(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 identity | source·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=0 | npm 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 내부 준비 자원의 호환 도구) | 잠금 직전에 같은 판정. status 가 risk: 줄을 보여 준다 | 거부. 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)에 있다.
{ "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.job 의 command 도 같은 마이그레이션에서 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 가 다시 요구한다.
git fetch origin production— from =origin/production(직전 릴리스), to = 릴리스 후보 SHA.- fixture = 그날의
User data daily copy+history-4yworkload 둘 다(두 번 돌린다: 원본 보존은 사본으로, 투영·시간은 workload 로). npm run db:upgrade -- --from origin/production --to <후보 SHA> --sandbox <sbx> --fixture user-data.sql --empty-replay --check-definitions --out <폴더>→--workload …로 한 번 더. 가장 무거운 파일에--interrupt한 번.- 증거 두 벌의
verdict.ok·coexistence.breaks=0· §4-2 예산 ·definitions.ok를 릴리스 이슈에 붙인다. 하나라도 어긋나면 후보 SHA 를 바꾼다(도구 통과 = 완료가 아니다). - 배치 백필이 있으면
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 + 오너 |
| 릴리스 전체 리허설 §8 | R05 |
한계(명시): 정적 분류는 함수 본문·DO 블록의 동적 SQL 을 모른다(execute-function·dynamic-sql 은 실측으로). digest 는 등급 목록의 유저 표만 본다 — 통계·투영 표는 계약상 재계산 대상이라 대상이 아니다. bare shim 은 pg_cron 워커·realtime·PostgREST 가 없고 PG16 이면 MAINTAIN 을 뺀다 — 그래서 랜딩 증거가 아니다.