From 274d2009c4afe443950d2d0459f55c2f9da4c042 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sat, 27 Jun 2026 06:54:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(migration):=20fresh=20DB/DR=20=EB=B6=80?= =?UTF-8?q?=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EA=B9=A8=EC=A7=90=203?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95=20(validator=20=EC=98=A4?= =?UTF-8?q?=ED=83=90=20+=20multi-statement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification env(ephemeral postgres + init_db) 실측으로 fresh DB 부트스트랩이 359~376 replay 중 깨지는 3건 발견·수정: 1. _validate_sql_content 가 인라인 주석(SQL -- ...) 미제거 → 365 의 '-- commit 시 ...' 설명주석을 트랜잭션 제어문 오탐. 줄별 -- 이후 제거. 2. raw '"schema_migrations" in sql.lower()' 체크도 주석 미제외 → 365 의 '-- ... schema_migrations 건드리지 않음' 오탐. _validate_sql_content 로 통합(주석 제외). 3. 마이그 루프가 exec_driver_sql(prepared)이라 multi-statement(365=테이블+시드+인덱스) 불허 → baseline 적재와 동일한 raw asyncpg simple execute 로 통일. (에이전트가 P0로 본 320/326 enum-same-txn 은 오탐 — baseline 0358 이 이미 방어.) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/database.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/app/core/database.py b/app/core/database.py index cbf4052..c8f6552 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -57,12 +57,12 @@ def _parse_migration_files(migrations_dir: Path) -> list[tuple[int, str, Path]]: def _validate_sql_content(name: str, sql: str) -> None: """migration SQL에 BEGIN/COMMIT이 포함되어 있으면 에러 (외부 트랜잭션 깨짐 방지)""" - # 주석(-- ...) 라인 제거 후 검사 - lines = [ - line for line in sql.splitlines() - if not line.strip().startswith("--") - ] - stripped = "\n".join(lines).upper() + # 주석(전체 줄 + 인라인 `-- ...`) 제거 후 검사. ★인라인 주석을 안 지우면 설명 주석의 + # 'commit/begin' 단어(예 365_scan_jobs 의 `-- commit 시 documents.title 로 전파`)를 + # 트랜잭션 제어문으로 false-positive 로 잡아 fresh DB/DR 부트스트랩이 깨진다(verification + # 실측 2026-06). 줄별로 `--` 이후를 잘라 주석 텍스트를 검사에서 제외. + cleaned = [re.sub(r"--.*$", "", line) for line in sql.splitlines()] + stripped = "\n".join(cleaned).upper() for keyword in ("BEGIN", "COMMIT", "ROLLBACK"): # 단어 경계로 매칭 (예: BEGIN_SOMETHING은 제외) if re.search(rf"\b{keyword}\b", stripped): @@ -70,6 +70,13 @@ def _validate_sql_content(name: str, sql: str) -> None: f"migration {name}에 {keyword} 포함됨 — " f"migration SQL에는 트랜잭션 제어문을 넣지 마세요" ) + # schema_migrations 수정 금지 (runner 가 스탬프 관리) — 주석 제외(stripped) 검사. + # (구: _run_migrations 의 raw `"schema_migrations" in sql.lower()` 가 주석 미제외라 + # 365 의 '-- ... schema_migrations 를 건드리지 않음' 주석을 false-positive 로 잡았음.) + if "SCHEMA_MIGRATIONS" in stripped: + raise RuntimeError( + f"Migration {name} must not modify schema_migrations table" + ) # R1: baseline 스냅샷이 대표하는 마지막 마이그레이션 버전 (이하 버전은 baseline 에 포함). @@ -167,16 +174,15 @@ async def _run_migrations(conn) -> None: for version, name, path in pending: sql = path.read_text(encoding="utf-8") - _validate_sql_content(name, sql) - if "schema_migrations" in sql.lower(): - raise ValueError( - f"Migration {name} must not modify schema_migrations table" - ) + _validate_sql_content(name, sql) # BEGIN/COMMIT + schema_migrations 검사(주석 제외) logger.info(f"[migration] {name} 실행 중...") - # raw driver SQL 사용 — text() 의 :name bind parameter 해석으로 - # SQL 주석/literal 에 콜론이 들어가면 InvalidRequestError 발생. - # exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달. - await conn.exec_driver_sql(sql) + # raw asyncpg simple 프로토콜로 실행 — baseline 적재(_load_baseline_if_fresh)와 동일. + # ★exec_driver_sql 은 prepared 프로토콜이라 multi-statement 불허("cannot insert multiple + # commands into a prepared statement"). 365_scan_jobs 처럼 테이블+시드+인덱스를 한 파일에 + # 담은 마이그(컨벤션상 1-statement 권장이나 이미 prod 적재)도 fresh DB/DR replay 되게 + # simple execute 사용. text() :name 콜론-binding 이슈도 동일하게 회피(raw 전달). + raw = await conn.get_raw_connection() + await raw.driver_connection.execute(sql) await conn.execute( text("INSERT INTO schema_migrations (version, name) VALUES (:v, :n)"), {"v": version, "n": name},