fix: make migration runner resilient to partial-failure recovery
SQLite's executescript() auto-commits each DDL statement individually. If a migration crashes mid-run, prior ALTER TABLE statements are already committed but the migration is never recorded as applied. On restart, the runner re-runs the same file and hits 'duplicate column name' on already-applied statements, breaking subsequent startups permanently. Replace executescript() with per-statement execute() calls. 'Duplicate column name' OperationalErrors are caught and logged as warnings so the migration can complete and be marked as done. All other errors still propagate normally.
This commit is contained in:
parent
19a26e02a0
commit
c9c4828387
1 changed files with 51 additions and 2 deletions
|
|
@ -4,12 +4,22 @@ Applies *.sql files from migrations_dir in filename order.
|
|||
Tracks applied migrations in a _migrations table — safe to call multiple times.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection, migrations_dir: Path) -> None:
|
||||
"""Apply any unapplied *.sql migrations from migrations_dir."""
|
||||
"""Apply any unapplied *.sql migrations from migrations_dir.
|
||||
|
||||
Resilient to partial-failure recovery: if a migration previously failed
|
||||
mid-run (e.g. a crash after some ALTER TABLE statements auto-committed),
|
||||
"duplicate column name" errors on re-run are silently skipped so the
|
||||
migration can complete and be marked as applied. All other errors still
|
||||
propagate.
|
||||
"""
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations "
|
||||
"(name TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP)"
|
||||
|
|
@ -22,8 +32,47 @@ def run_migrations(conn: sqlite3.Connection, migrations_dir: Path) -> None:
|
|||
for sql_file in sql_files:
|
||||
if sql_file.name in applied:
|
||||
continue
|
||||
conn.executescript(sql_file.read_text())
|
||||
|
||||
_run_script(conn, sql_file)
|
||||
|
||||
# OR IGNORE: safe if two Store() calls race on the same DB — second writer
|
||||
# just skips the insert rather than raising UNIQUE constraint failed.
|
||||
conn.execute("INSERT OR IGNORE INTO _migrations (name) VALUES (?)", (sql_file.name,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _run_script(conn: sqlite3.Connection, sql_file: Path) -> None:
|
||||
"""Execute a SQL migration file, statement by statement.
|
||||
|
||||
Splits on ';' so that individual DDL statements can be skipped on
|
||||
"duplicate column name" errors (partial-failure recovery) without
|
||||
silencing real errors. Empty statements and pure-comment chunks are
|
||||
skipped automatically.
|
||||
"""
|
||||
text = sql_file.read_text()
|
||||
|
||||
# Split into individual statements. This is a simple heuristic —
|
||||
# semicolons inside string literals would confuse it, but migration files
|
||||
# should never contain such strings.
|
||||
for raw in text.split(";"):
|
||||
stmt = raw.strip()
|
||||
if not stmt or stmt.startswith("--"):
|
||||
continue
|
||||
# Strip inline leading comments (block of -- lines before the SQL).
|
||||
lines = [l for l in stmt.splitlines() if not l.strip().startswith("--")]
|
||||
stmt = "\n".join(lines).strip()
|
||||
if not stmt:
|
||||
continue
|
||||
|
||||
try:
|
||||
conn.execute(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "duplicate column name" in str(exc).lower():
|
||||
_log.warning(
|
||||
"Migration %s: skipping already-present column (%s) — "
|
||||
"partial-failure recovery",
|
||||
sql_file.name,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
|
|
|||
Loading…
Reference in a new issue