Files
DumiFlex-ComfyUI-Wildcard-P…/engine/db/backups.py
T
Claude f86b554616 feat(engine): server-side settings, runtime ref depth limit and database backups
The Settings page's ref recursion limit was stored in localStorage and read
by nothing: every run stopped at 8. Server-side preferences now live in
user/wildcard-pipeline/settings.json (engine/prefs.py, /wp/api/settings) and
PipelineEngine.run seeds __wp_max_ref_depth__ from them, so canvas runs,
previews and the Test Runner honour it.

Database backups (engine/db/backups.py): a copy before any migration that
changes the schema, a daily copy while ComfyUI runs, pruning of automatic
copies beyond the configured count (manual ones are kept), and a restore
that is staged in the DB sidecar and applied on the next start before any
connection opens, after backing up the current database.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMHPZ4k2kXrePkcgeYJJSF
2026-09-30 09:26:44 +00:00

439 lines
15 KiB
Python

"""Library database backups: create, list, prune, delete, staged restore.
Backups live in ``<db file parent>/backups/`` — next to the database they
protect, so moving the library (Settings → Database location) is the only
thing that separates them, and a user who backs up "the wildcard-pipeline
folder" gets both.
File names carry everything we need to know about a backup, so there is no
index file to drift out of sync with the directory::
wildcard-pipeline-YYYYMMDD-HHMMSS-<reason>[-N].db
(UTC timestamp; ``-N`` only when two backups land in the same second.)
Reasons:
- ``manual`` — the user pressed "Back up now". Never pruned: the user
made it deliberately, so only the user deletes it.
- ``daily`` — the background scheduler (``maybe_daily_backup``).
- ``pre-migration`` — taken by ``engine/db/migrations.py:migrate`` right
before it applies schema changes to an existing library.
- ``pre-restore`` — the live DB, saved right before a restore overwrote it,
so a restore is itself undoable.
Copies go through SQLite's online backup API rather than a file copy: the
library runs in WAL mode, where committed data can sit in ``-wal`` rather
than the main file, and a plain copy of the main file would silently lose it.
Restore is staged, like the DB location move: the request only records the
chosen backup in the sidecar (``engine/db/config.py``), and
``execute_pending_restore`` swaps the file at the next start, before any
connection opens. Swapping a SQLite file out from under live connections is
how databases get corrupted.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import sqlite3
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from engine import prefs
from engine.db import config as db_config
from engine.db.connection import resolve_db_path
logger = logging.getLogger(__name__)
BACKUPS_DIRNAME = "backups"
REASONS = ("manual", "daily", "pre-migration", "pre-restore")
#: Reasons `prune` may delete. Manual backups are the user's, not ours.
AUTOMATIC_REASONS = frozenset({"daily", "pre-migration", "pre-restore"})
DAILY_INTERVAL = timedelta(hours=24)
SCHEDULER_PERIOD_S = 3600.0
_NAME_RE = re.compile(
r"^wildcard-pipeline-(\d{8})-(\d{6})-"
r"(manual|daily|pre-migration|pre-restore)(?:-(\d+))?\.db$"
)
class BackupNotFound(LookupError):
"""The named backup isn't a valid backup file in the backups dir."""
def backups_dir(db_path: Path) -> Path:
"""Where backups of ``db_path`` live. Not created until one is written."""
return Path(db_path).parent / BACKUPS_DIRNAME
def _parse_name(name: str) -> tuple[datetime, str, int] | None:
m = _NAME_RE.fullmatch(name)
if not m:
return None
try:
ts = datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")
except ValueError:
return None
return ts.replace(tzinfo=timezone.utc), m.group(3), int(m.group(4) or 1)
def _new_backup_path(directory: Path, reason: str, now: datetime | None = None) -> Path:
"""First free ``wildcard-pipeline-<ts>-<reason>[-N].db`` in ``directory``."""
stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%d-%H%M%S")
base = f"wildcard-pipeline-{stamp}-{reason}"
candidate = directory / f"{base}.db"
n = 2
while candidate.exists():
candidate = directory / f"{base}-{n}.db"
n += 1
return candidate
def _entry(path: Path) -> dict[str, Any] | None:
parsed = _parse_name(path.name)
if parsed is None or not path.is_file():
return None
ts, reason, _ = parsed
try:
size = path.stat().st_size
except OSError:
return None
return {
"name": path.name,
"size": size,
"created_at": ts.isoformat().replace("+00:00", "Z"),
"reason": reason,
}
def _sort_key(path: Path) -> tuple[datetime, int]:
parsed = _parse_name(path.name)
assert parsed is not None
return parsed[0], parsed[2]
def _check_reason(reason: str) -> None:
if reason not in REASONS:
raise ValueError(f"unknown backup reason {reason!r}; expected one of {REASONS}")
def create_backup(
db_path: Path,
reason: str,
*,
conn: sqlite3.Connection | None = None,
) -> dict[str, Any]:
"""Write a consistent snapshot of ``db_path`` and return its list entry.
``conn`` lets a caller that already holds a connection (the migration
runner) back up through it instead of opening a second one. The snapshot
is written to a temp file and renamed into place, so a half-written
backup never shows up in the list.
"""
_check_reason(reason)
db_path = Path(db_path)
if conn is None and not db_path.is_file():
raise FileNotFoundError(f"database {db_path} does not exist")
directory = backups_dir(db_path)
directory.mkdir(parents=True, exist_ok=True)
target = _new_backup_path(directory, reason)
fd, tmp_str = tempfile.mkstemp(prefix=".backup-", suffix=".db", dir=str(directory))
os.close(fd)
tmp = Path(tmp_str)
try:
src = conn if conn is not None else sqlite3.connect(str(db_path))
try:
dst = sqlite3.connect(str(tmp))
try:
src.backup(dst)
finally:
dst.close()
finally:
if conn is None:
src.close()
os.replace(tmp, target)
except BaseException:
tmp.unlink(missing_ok=True)
raise
entry = _entry(target)
assert entry is not None
logger.info("wp_db_backup: wrote %s", target)
return entry
def _backup_paths(db_path: Path) -> list[Path]:
"""Valid backup files, newest first."""
directory = backups_dir(db_path)
if not directory.is_dir():
return []
paths = [p for p in directory.iterdir() if p.is_file() and _parse_name(p.name)]
paths.sort(key=_sort_key, reverse=True)
return paths
def list_backups(db_path: Path) -> list[dict[str, Any]]:
"""Every backup of ``db_path``, newest first."""
out = []
for p in _backup_paths(db_path):
e = _entry(p)
if e is not None:
out.append(e)
return out
def prune(db_path: Path, keep: int) -> list[str]:
"""Delete the oldest AUTOMATIC backups beyond ``keep``; return their names.
Manual backups neither count toward ``keep`` nor get deleted.
"""
keep = max(0, int(keep))
automatic = [p for p in _backup_paths(db_path)
if _parse_name(p.name)[1] in AUTOMATIC_REASONS] # type: ignore[index]
removed: list[str] = []
for p in automatic[keep:]:
try:
p.unlink()
removed.append(p.name)
except OSError:
logger.exception("wp_db_backup: could not prune %s", p)
return removed
def resolve_backup(db_path: Path, name: str) -> Path:
"""Map a client-supplied backup name to its file, or raise BackupNotFound.
SECURITY: ``name`` comes from an unauthenticated HTTP request and ends up
in ``unlink`` / a file copy over the live DB. It must be a bare file name
matching the backup pattern (which admits no separators or ``..``) and
must resolve to a file directly inside the backups dir.
"""
if not isinstance(name, str) or _parse_name(name) is None:
raise BackupNotFound(name)
directory = backups_dir(db_path).resolve()
candidate = (directory / name).resolve()
if candidate.parent != directory or not candidate.is_file():
raise BackupNotFound(name)
return candidate
def delete_backup(db_path: Path, name: str) -> None:
"""Delete one backup by name; raises BackupNotFound for anything else."""
resolve_backup(db_path, name).unlink()
def _newest_age(db_path: Path, now: datetime) -> timedelta | None:
paths = _backup_paths(db_path)
if not paths:
return None
return now - _sort_key(paths[0])[0]
def maybe_daily_backup(
db_path: Path | None = None,
*,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Take a ``daily`` backup when one is due; return it, or None.
Due = backups + daily backups enabled, the DB exists, and the newest
backup of ANY reason is at least 24h old (a manual backup this morning
already covers today).
"""
cfg = prefs.backups()
if not (cfg["enabled"] and cfg["daily"]):
return None
db_path = Path(db_path) if db_path is not None else resolve_db_path()
if not db_path.is_file():
return None
now = now or datetime.now(timezone.utc)
age = _newest_age(db_path, now)
if age is not None and age < DAILY_INTERVAL:
return None
entry = create_backup(db_path, "daily")
prune(db_path, cfg["keep"])
return entry
def backup_before_migration(conn: sqlite3.Connection) -> dict[str, Any] | None:
"""Called by `migrate` before applying pending migrations to a library
that already has some. Never raises: a failed backup is logged and the
migration goes ahead, because refusing to start is worse than the risk
the backup was insuring against.
"""
try:
if not prefs.backups()["enabled"]:
return None
db_file = _main_db_file(conn)
if db_file is None:
return None
entry = create_backup(db_file, "pre-migration", conn=conn)
prune(db_file, prefs.backups()["keep"])
return entry
except Exception: # noqa: BLE001 - see docstring
logger.exception("wp_db_backup: pre-migration backup failed; migrating anyway")
return None
def _main_db_file(conn: sqlite3.Connection) -> Path | None:
"""The file behind ``conn``'s main schema, or None for in-memory DBs."""
for row in conn.execute("PRAGMA database_list;").fetchall():
if row[1] == "main":
return Path(row[2]) if row[2] else None
return None
# ── staged restore ───────────────────────────────────────────────────
def stage_restore(db_path: Path, name: str) -> Path:
"""Record a restore of backup ``name`` for the next start."""
path = resolve_backup(db_path, name)
db_config.set_pending_restore(path)
return path
def cancel_restore() -> None:
db_config.clear_pending_restore()
def pending_restore_name(db_path: Path) -> str | None:
"""Name of the staged restore's backup, if one is staged for this DB."""
pr = db_config.load().get("pending_restore")
if not pr:
return None
src = Path(pr["from"])
try:
resolve_backup(db_path, src.name)
except BackupNotFound:
return None
return src.name
def _copy_live_db_file(db_path: Path) -> None:
"""Fallback pre-restore snapshot: plain file copy (nothing is open yet)."""
directory = backups_dir(db_path)
directory.mkdir(parents=True, exist_ok=True)
shutil.copy2(db_path, _new_backup_path(directory, "pre-restore"))
def execute_pending_restore() -> None:
"""If a restore is staged, run it and clear the entry. Never raises.
Runs at plugin load, right after ``execute_pending_move`` and before any
connection opens. Order: snapshot the live DB as ``pre-restore`` (so the
restore is undoable), drop the live ``-wal`` / ``-shm`` (they belong to the
file being replaced — left behind, SQLite would replay them onto the
restored one), then copy the backup into place via temp + rename.
"""
try:
_execute_pending_restore()
except Exception: # noqa: BLE001 - see docstring
logger.exception("wp_db_restore: failed")
def _execute_pending_restore() -> None:
pr = db_config.load().get("pending_restore")
if not pr:
return
db_path = resolve_db_path()
src = Path(pr["from"])
try:
src = resolve_backup(db_path, src.name)
except BackupNotFound:
# A backup that is gone (or a sidecar pointing somewhere else) will
# never become restorable; keeping the entry would retry forever.
logger.error("wp_db_restore: backup %s not found in %s, dropping request",
pr["from"], backups_dir(db_path))
db_config.clear_pending_restore()
return
if db_path.is_file():
try:
create_backup(db_path, "pre-restore")
except Exception: # noqa: BLE001 - fall back to a raw copy
logger.exception("wp_db_restore: online pre-restore backup failed, copying file")
try:
_copy_live_db_file(db_path)
except OSError:
# Without a safety copy, overwriting would be irreversible.
logger.exception("wp_db_restore: could not save current DB; not restoring")
return
for suffix in ("-wal", "-shm"):
Path(str(db_path) + suffix).unlink(missing_ok=True)
db_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_str = tempfile.mkstemp(prefix=".restore-", suffix=".db", dir=str(db_path.parent))
os.close(fd)
tmp = Path(tmp_str)
try:
shutil.copyfile(src, tmp)
os.replace(tmp, db_path)
except OSError:
tmp.unlink(missing_ok=True)
logger.exception("wp_db_restore: copying %s over %s failed", src, db_path)
return
db_config.clear_pending_restore()
try:
prune(db_path, prefs.backups()["keep"])
except Exception: # noqa: BLE001 - pruning is housekeeping
logger.exception("wp_db_restore: prune failed")
logger.info("wp_db_restore: restored %s -> %s", src, db_path)
# ── daily scheduler ─────────────────────────────────────────────────
_scheduler_lock = threading.Lock()
_scheduler: threading.Thread | None = None
_scheduler_stop = threading.Event()
def _safe_daily() -> None:
try:
maybe_daily_backup()
except Exception: # noqa: BLE001 - a failed backup must not kill the loop
logger.exception("wp_db_backup: daily backup failed")
def _scheduler_loop(period: float) -> None:
while not _scheduler_stop.wait(period):
_safe_daily()
def start_daily_scheduler(period: float = SCHEDULER_PERIOD_S) -> bool:
"""Check for a due daily backup now, then hourly on a daemon thread.
Hourly checks against a 24h interval, rather than one 24h timer, so a
machine that sleeps or a ComfyUI that restarts often still gets its
backup roughly on schedule. Idempotent: returns False if already running.
"""
global _scheduler
with _scheduler_lock:
if _scheduler is not None and _scheduler.is_alive():
return False
_scheduler_stop.clear()
_safe_daily()
_scheduler = threading.Thread(
target=_scheduler_loop, args=(period,),
name="wp-daily-backup", daemon=True,
)
_scheduler.start()
return True
def stop_daily_scheduler() -> None:
"""Stop the scheduler thread (tests)."""
global _scheduler
with _scheduler_lock:
_scheduler_stop.set()
if _scheduler is not None:
_scheduler.join(timeout=5)
_scheduler = None