TASK-023: Make database migrations atomic
Replace non-atomic db.executescript() with explicit transaction control. Wrap each migration's DDL statements and schema_migrations insert in a single BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity. Changes: - Add _parse_migration_statements() to split migration scripts into individual statements while handling comments and string literals - Update _apply_migration() to wrap all statements in a single explicit transaction with rollback on error - Ensure _get_current_schema_version() uses execute() instead of executescript() - Add 9 new tests for migration atomicity and statement parsing - Update Backend-Development.md with migration authoring guidelines If a crash occurs between DDL execution and schema_migrations insert, the next startup will re-apply the entire migration atomically, preventing partial migrations and data corruption. Test coverage: 98% on db.py (up from 55%) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -142,7 +142,7 @@ async def _configure_connection(db: aiosqlite.Connection) -> None:
|
||||
|
||||
async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
|
||||
"""Return the highest applied schema version for the given database."""
|
||||
await db.executescript(_CREATE_SCHEMA_MIGRATIONS)
|
||||
await db.execute(_CREATE_SCHEMA_MIGRATIONS)
|
||||
async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row is None or row[0] is None:
|
||||
@@ -150,12 +150,114 @@ async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
|
||||
return int(row[0])
|
||||
|
||||
|
||||
async def _parse_migration_statements(script: str) -> list[str]:
|
||||
"""Parse a migration script into individual SQL statements.
|
||||
|
||||
Splits on semicolons but ignores semicolons inside string literals and
|
||||
comments. Handles both block (-- comment) and line comments.
|
||||
|
||||
Args:
|
||||
script: The raw migration script.
|
||||
|
||||
Returns:
|
||||
List of SQL statements (stripped of whitespace and comments).
|
||||
"""
|
||||
statements: list[str] = []
|
||||
current_stmt: list[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(script):
|
||||
char = script[i]
|
||||
|
||||
# Skip block comments (-- ...)
|
||||
if i < len(script) - 1 and script[i:i+2] == "--":
|
||||
while i < len(script) and script[i] != "\n":
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Skip line comments (/* ... */)
|
||||
if i < len(script) - 1 and script[i:i+2] == "/*":
|
||||
i += 2
|
||||
while i < len(script) - 1:
|
||||
if script[i:i+2] == "*/":
|
||||
i += 2
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Handle string literals (single or double quotes)
|
||||
if char in ("'", '"'):
|
||||
quote = char
|
||||
current_stmt.append(char)
|
||||
i += 1
|
||||
while i < len(script):
|
||||
if script[i] == quote:
|
||||
if i + 1 < len(script) and script[i + 1] == quote:
|
||||
# Escaped quote
|
||||
current_stmt.append(quote)
|
||||
current_stmt.append(quote)
|
||||
i += 2
|
||||
else:
|
||||
# End of string
|
||||
current_stmt.append(quote)
|
||||
i += 1
|
||||
break
|
||||
else:
|
||||
current_stmt.append(script[i])
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Statement separator
|
||||
if char == ";":
|
||||
stmt = "".join(current_stmt).strip()
|
||||
if stmt:
|
||||
statements.append(stmt)
|
||||
current_stmt = []
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current_stmt.append(char)
|
||||
i += 1
|
||||
|
||||
# Add any remaining statement
|
||||
stmt = "".join(current_stmt).strip()
|
||||
if stmt:
|
||||
statements.append(stmt)
|
||||
|
||||
return statements
|
||||
|
||||
|
||||
async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
|
||||
"""Apply a single migration step and record its completion."""
|
||||
"""Apply a single migration step and record its completion atomically.
|
||||
|
||||
Wraps all DDL statements and the schema_migrations insert in a single
|
||||
BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity. If any
|
||||
statement fails, the entire migration is rolled back.
|
||||
|
||||
Args:
|
||||
db: An open aiosqlite.Connection.
|
||||
version: The migration version number.
|
||||
|
||||
Raises:
|
||||
Any exception from executing the migration statements or inserting
|
||||
the schema migration record will cause a rollback.
|
||||
"""
|
||||
migration_script = _MIGRATIONS[version]
|
||||
await db.executescript(migration_script)
|
||||
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
||||
await db.commit()
|
||||
statements = await _parse_migration_statements(migration_script)
|
||||
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE;")
|
||||
|
||||
for statement in statements:
|
||||
await db.execute(statement)
|
||||
|
||||
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
||||
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_schema(db: aiosqlite.Connection) -> None:
|
||||
|
||||
Reference in New Issue
Block a user