7cbb02b1ef
Take a consistent, atomic copy of the DB at lifespan startup BEFORE migrations run, so a botched future upgrade is recoverable by restoring a single file instead of a data-loss incident. Uses SQLite's VACUUM INTO — safe under WAL, cannot tear against concurrent writes. Best-effort: failures are logged, never raised — the main DB remains the source of truth. Configurable via NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP (default 5; 0 disables). Snapshots land in ``data_dir/backups/pre-migrate-<ts>.db`` and the N oldest are pruned each boot.
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""Pre-migration snapshot: atomic copy + retention pruning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from notify_bridge_server.database.snapshot import (
|
|
prune_old_snapshots,
|
|
snapshot_and_prune,
|
|
snapshot_database,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def sqlite_engine(tmp_path: Path):
|
|
"""Tiny SQLite DB with one table + one row, closed cleanly after the test."""
|
|
db_path = tmp_path / "app.db"
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
|
async with engine.begin() as conn:
|
|
await conn.execute(text("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"))
|
|
await conn.execute(text("INSERT INTO t (v) VALUES ('seed')"))
|
|
yield engine, db_path, tmp_path / "backups"
|
|
await engine.dispose()
|
|
|
|
|
|
class TestSnapshot:
|
|
@pytest.mark.asyncio
|
|
async def test_creates_consistent_copy(self, sqlite_engine) -> None:
|
|
engine, _db, backups = sqlite_engine
|
|
dest = await snapshot_database(engine, backups)
|
|
assert dest is not None
|
|
assert dest.exists()
|
|
# Can open the snapshot and see the seed row — proves it's a real DB copy.
|
|
copy = create_async_engine(f"sqlite+aiosqlite:///{dest}")
|
|
async with copy.connect() as c:
|
|
result = await c.execute(text("SELECT v FROM t"))
|
|
rows = result.all()
|
|
await copy.dispose()
|
|
assert rows == [("seed",)]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_db_missing(self, tmp_path: Path) -> None:
|
|
# Engine pointing at a path that doesn't exist yet.
|
|
engine = create_async_engine(
|
|
f"sqlite+aiosqlite:///{tmp_path / 'does-not-exist.db'}"
|
|
)
|
|
try:
|
|
dest = await snapshot_database(engine, tmp_path / "backups")
|
|
finally:
|
|
await engine.dispose()
|
|
assert dest is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_unsafe_label(self, sqlite_engine) -> None:
|
|
engine, _db, backups = sqlite_engine
|
|
dest = await snapshot_database(engine, backups, label="bad'; DROP TABLE t;--")
|
|
assert dest is None
|
|
|
|
|
|
class TestPrune:
|
|
def _make_snapshot(self, backups: Path, age_seconds: int) -> Path:
|
|
backups.mkdir(parents=True, exist_ok=True)
|
|
ts = datetime.now(timezone.utc) - timedelta(seconds=age_seconds)
|
|
name = f"pre-migrate-{ts.strftime('%Y-%m-%dT%H-%M-%S')}.db"
|
|
p = backups / name
|
|
p.write_bytes(b"x")
|
|
mtime = ts.timestamp()
|
|
import os
|
|
os.utime(p, (mtime, mtime))
|
|
return p
|
|
|
|
def test_keeps_n_newest(self, tmp_path: Path) -> None:
|
|
backups = tmp_path / "backups"
|
|
for age in (100, 80, 60, 40, 20, 0):
|
|
self._make_snapshot(backups, age)
|
|
|
|
deleted = prune_old_snapshots(backups, keep=3)
|
|
remaining = sorted(backups.glob("pre-migrate-*.db"))
|
|
assert len(deleted) == 3
|
|
assert len(remaining) == 3
|
|
|
|
def test_keep_zero_deletes_all(self, tmp_path: Path) -> None:
|
|
backups = tmp_path / "backups"
|
|
for age in (30, 20, 10):
|
|
self._make_snapshot(backups, age)
|
|
prune_old_snapshots(backups, keep=0)
|
|
assert list(backups.glob("pre-migrate-*.db")) == []
|
|
|
|
def test_missing_dir_is_noop(self, tmp_path: Path) -> None:
|
|
assert prune_old_snapshots(tmp_path / "never-created", keep=5) == []
|
|
|
|
|
|
class TestSnapshotAndPrune:
|
|
@pytest.mark.asyncio
|
|
async def test_keep_zero_disables(self, sqlite_engine) -> None:
|
|
engine, _db, backups = sqlite_engine
|
|
result = await snapshot_and_prune(engine, backups, keep=0)
|
|
assert result is None
|
|
assert not backups.exists() or list(backups.glob("*.db")) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_to_end(self, sqlite_engine) -> None:
|
|
engine, _db, backups = sqlite_engine
|
|
# Run twice — second run should keep both snapshots (keep=5).
|
|
a = await snapshot_and_prune(engine, backups, keep=5)
|
|
# Guarantee distinct filenames (timestamp has second resolution).
|
|
await asyncio.sleep(1.05)
|
|
b = await snapshot_and_prune(engine, backups, keep=5)
|
|
assert a and b and a != b
|
|
assert a.exists() and b.exists()
|