103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""WAL-safe SQLite snapshots. Direct execution needs only the standard library.
|
|
|
|
Desktop invokes this file before stopping its backend, even when application
|
|
imports cannot load. Full and quick backups use the same SQLite copy operation.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from contextlib import suppress
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _SQLiteBackupTimeout(RuntimeError):
|
|
"""Raised when a SQLite snapshot remains busy past its deadline."""
|
|
|
|
|
|
def _close_quietly(conn: Optional[sqlite3.Connection]) -> None:
|
|
if conn is not None:
|
|
with suppress(Exception):
|
|
conn.close()
|
|
|
|
|
|
def _safe_copy_db(src: Path, dst: Path, *, timeout_seconds: float = 10.0) -> bool:
|
|
"""Copy a SQLite database with the backup() API (WAL-safe consistent snapshot).
|
|
|
|
Fails closed when no consistent snapshot can be made: copying only the main file loses WAL data.
|
|
"""
|
|
conn = backup_conn = None
|
|
try:
|
|
# timeout=0.0 disables sqlite3's implicit busy wait so the progress callback owns the
|
|
# full locked-source deadline instead of adding the default timeout before each callback.
|
|
conn = sqlite3.connect(f"{src.resolve().as_uri()}?mode=ro", uri=True, timeout=0.0)
|
|
backup_conn = sqlite3.connect(str(dst))
|
|
busy_deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
|
|
def _check_backup_progress(status: int, _remaining: int, _total: int) -> None:
|
|
nonlocal busy_deadline
|
|
now = time.monotonic()
|
|
if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
|
|
if now >= busy_deadline:
|
|
raise _SQLiteBackupTimeout(f"database remained locked for {timeout_seconds:g} seconds")
|
|
else:
|
|
busy_deadline = now + max(0.0, timeout_seconds)
|
|
|
|
conn.backup(backup_conn, pages=256, progress=_check_backup_progress, sleep=0.1)
|
|
return True
|
|
except Exception as exc:
|
|
logger.warning("SQLite safe copy failed for %s: %s", src, exc)
|
|
# Windows won't remove the partial destination while SQLite still has it open.
|
|
_close_quietly(backup_conn)
|
|
backup_conn = None
|
|
with suppress(OSError):
|
|
dst.unlink(missing_ok=True)
|
|
return False
|
|
finally:
|
|
_close_quietly(backup_conn)
|
|
_close_quietly(conn)
|
|
|
|
|
|
def preflight_state_db(home: Path) -> dict:
|
|
"""Publish an emergency snapshot; do not prune recovery files on failure."""
|
|
source = home / "state.db"
|
|
if not source.exists():
|
|
return {"path": None, "message": "state.db not found (fresh install?)"}
|
|
prefix = "state.db.pre-update-emergency-"
|
|
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%fZ")
|
|
destination = home / f"{prefix}{stamp}-{os.getpid()}.bak"
|
|
fd, name = tempfile.mkstemp(prefix=prefix, suffix=".partial", dir=home)
|
|
os.close(fd)
|
|
staged = Path(name)
|
|
try:
|
|
if not _safe_copy_db(source, staged):
|
|
raise RuntimeError("SQLite safe copy failed; previous emergency snapshots were retained")
|
|
connection = sqlite3.connect(str(staged))
|
|
try:
|
|
result = connection.execute("PRAGMA quick_check").fetchall()
|
|
if result != [("ok",)]:
|
|
raise RuntimeError(f"SQLite snapshot integrity check failed: {result}")
|
|
finally:
|
|
connection.close()
|
|
size = staged.stat().st_size
|
|
os.replace(staged, destination)
|
|
finally:
|
|
staged.unlink(missing_ok=True)
|
|
for old in sorted(home.glob(f"{prefix}*.bak"), reverse=True)[2:]:
|
|
try:
|
|
old.unlink()
|
|
except OSError as exc:
|
|
logger.warning("Could not prune emergency snapshot %s: %s", old, exc)
|
|
return {"path": str(destination), "bytes": size}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(json.dumps(preflight_state_db(Path(sys.argv[1]))))
|