Files
DumiFlex e3caa710f9 fix(backups): restore a backup at once, without restarting ComfyUI (#247)
Restore copied the backup file over the library at the next start because swapping a SQLite file under open connections corrupts it. It now copies the backup into the open library through SQLite's online backup API, after checking it and saving the library as a pre-restore backup, then migrates it. The Backups panel refreshes the library and offers Undo.
2026-11-03 19:27:50 +01:00

666 lines
23 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".
- ``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.
Pruning keeps the newest ``keep`` backups of any reason. A backup the user
pins is never pruned and doesn't count toward ``keep``; the pin is a
``<backup name>.pin`` marker file beside it (so the folder stays the only
index, the way Proxmox marks a protected backup). Libraries from before pins
existed kept every manual backup forever, so the first run pins those once
(``_pin_legacy_manual``) rather than pruning them away on update. A manual
backup can carry a short note ("before the outfit rework"), kept the same
way in ``<backup name>.note``.
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 runs live (``restore_now``): it copies the backup INTO the open
library through SQLite's online backup API, the same API that makes backups,
pointed the other way. That is one write transaction on the live database
under SQLite's own locks, so other connections either see the old library or
the restored one, never a mix, and no file is swapped. (Swapping the file out
from under live connections is how databases get corrupted, and Windows
refuses to replace a file another handle holds.) The live library is backed
up as ``pre-restore`` first, the backup is checked before anything is
written, and the restored library is migrated to this build's schema.
The older staged restore (record the choice in the sidecar,
``engine/db/config.py``; ``execute_pending_restore`` swaps the file at the
next start, before any connection opens) still runs a restore an older
build staged.
"""
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")
PIN_SUFFIX = ".pin"
NOTE_SUFFIX = ".note"
NOTE_MAX_LEN = 80
#: Sidecar files that belong to one backup and go when it goes.
SIDECAR_SUFFIXES = (PIN_SUFFIX, NOTE_SUFFIX)
#: Written to the backups dir once existing manual backups have been pinned.
LEGACY_PINS_MARKER = ".pins-v1"
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 _pin_path(path: Path) -> Path:
return path.with_name(path.name + PIN_SUFFIX)
def _is_pinned(path: Path) -> bool:
return _pin_path(path).is_file()
def _note_path(path: Path) -> Path:
return path.with_name(path.name + NOTE_SUFFIX)
def clean_note(note: str | None) -> str | None:
"""One line, trimmed, at most NOTE_MAX_LEN characters; None when empty."""
if not isinstance(note, str):
return None
text = " ".join(note.split())[:NOTE_MAX_LEN].strip()
return text or None
def _read_note(path: Path) -> str | None:
try:
return clean_note(_note_path(path).read_text("utf-8"))
except (OSError, UnicodeDecodeError):
return None
def _pin_legacy_manual(directory: Path) -> None:
"""Pin every manual backup once, the first time pins exist here.
Manual backups used to be kept forever; pruning them on the first run
after an update would delete backups the user made on purpose.
"""
if not directory.is_dir():
return
marker = directory / LEGACY_PINS_MARKER
if marker.exists():
return
for p in directory.iterdir():
parsed = _parse_name(p.name)
if parsed is not None and parsed[1] == "manual" and p.is_file():
try:
_pin_path(p).touch()
except OSError:
logger.exception("wp_db_backup: could not pin %s", p)
return
try:
marker.touch()
except OSError:
logger.exception("wp_db_backup: could not write %s", marker)
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,
"pinned": _is_pinned(path),
"note": _read_note(path),
}
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,
note: str | 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. ``note`` is the user's short label
for it, cleaned by ``clean_note``.
"""
_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)
_pin_legacy_manual(directory)
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
text = clean_note(note)
if text is not None:
try:
_note_path(target).write_text(text, "utf-8")
except OSError:
logger.exception("wp_db_backup: could not save the note for %s", target)
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."""
_pin_legacy_manual(backups_dir(db_path))
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 unpinned backups beyond ``keep``; return their names.
Every reason counts, manual included. Pinned backups neither count toward
``keep`` nor get deleted, and neither does the one staged for restore.
Pin and note files whose backup is gone are cleaned up on the way.
"""
keep = max(0, int(keep))
directory = backups_dir(db_path)
_pin_legacy_manual(directory)
staged = pending_restore_name(db_path)
candidates = [p for p in _backup_paths(db_path)
if not _is_pinned(p) and p.name != staged]
removed: list[str] = []
for p in candidates[keep:]:
try:
p.unlink()
removed.append(p.name)
except OSError:
logger.exception("wp_db_backup: could not prune %s", p)
if directory.is_dir():
for suffix in SIDECAR_SUFFIXES:
for side in directory.glob("*" + suffix):
if not side.with_name(side.name[: -len(suffix)]).exists():
side.unlink(missing_ok=True)
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."""
path = resolve_backup(db_path, name)
path.unlink()
_pin_path(path).unlink(missing_ok=True)
_note_path(path).unlink(missing_ok=True)
def set_pinned(db_path: Path, name: str, pinned: bool) -> dict[str, Any]:
"""Pin or unpin one backup and return its list entry.
Unpinning doesn't prune: a backup past the limit goes at the next
backup, so a slip of the mouse can be undone.
"""
path = resolve_backup(db_path, name)
marker = _pin_path(path)
if pinned:
marker.touch()
else:
marker.unlink(missing_ok=True)
entry = _entry(path)
if entry is None:
raise BackupNotFound(name)
return entry
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
# ── live restore ─────────────────────────────────────────────────────
class RestoreRefused(ValueError):
"""The backup can't be restored here; the message says why, for the user."""
#: One restore at a time; a second request waits for the first.
_RESTORE_LOCK = threading.Lock()
def _open_readonly(path: Path) -> sqlite3.Connection:
return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
def _check_backup(src: sqlite3.Connection) -> None:
"""Refuse a backup that is damaged, isn't a library, or comes from a
newer build (whose schema this build would misread)."""
from engine.db.migrations import SchemaAheadError, assert_schema_compatible
try:
ok = src.execute("PRAGMA quick_check;").fetchone()
tables = {r[0] for r in src.execute(
"SELECT name FROM sqlite_master WHERE type = 'table';").fetchall()}
except sqlite3.DatabaseError as exc:
raise RestoreRefused(f"This backup can't be read ({exc}).") from exc
if not ok or ok[0] != "ok":
raise RestoreRefused(
"This backup is damaged (SQLite's check failed), so it wasn't restored.")
if "modules" not in tables:
raise RestoreRefused("This file isn't a Wildcard Pipeline library, so it wasn't restored.")
try:
assert_schema_compatible(src)
except SchemaAheadError as exc:
raise RestoreRefused(str(exc)) from exc
def _page_size(conn: sqlite3.Connection) -> int:
return int(conn.execute("PRAGMA page_size;").fetchone()[0])
def _copy_into(src_path: Path, live: sqlite3.Connection) -> None:
"""Copy the database at ``src_path`` over ``live``'s, in one step.
A WAL-mode destination only takes pages of its own size, so a backup
with another page size is first rebuilt at the live size in a temp file
(``VACUUM`` applies a new ``page_size`` to a non-WAL database).
"""
src = _open_readonly(src_path)
try:
if _page_size(src) == _page_size(live):
src.backup(live)
return
finally:
src.close()
fd, tmp_str = tempfile.mkstemp(prefix=".restore-", suffix=".db", dir=str(src_path.parent))
os.close(fd)
tmp = Path(tmp_str)
try:
shutil.copyfile(src_path, tmp)
conv = sqlite3.connect(str(tmp))
try:
conv.execute("PRAGMA journal_mode=DELETE;")
conv.execute(f"PRAGMA page_size={_page_size(live)};")
conv.execute("VACUUM;")
conv.backup(live)
finally:
conv.close()
finally:
tmp.unlink(missing_ok=True)
def restore_now(db_path: Path, name: str) -> dict[str, Any]:
"""Restore backup ``name`` into the live library, with no restart.
Order: check the backup (readable, a library, not from a newer build),
back up the live library as ``pre-restore`` (so the restore can be
undone; no safety copy, no restore), copy the backup in, migrate it to
this build's schema. Returns ``{restored, pre_restore}`` (backup
names). Raises ``BackupNotFound``,
``RestoreRefused`` (nothing was changed) or ``sqlite3.Error``.
"""
from engine.db.connection import get_connection
from engine.db.migrations import migrate
db_path = Path(db_path)
src_path = resolve_backup(db_path, name)
with _RESTORE_LOCK:
src = _open_readonly(src_path)
try:
_check_backup(src)
finally:
src.close()
live = get_connection(db_path)
try:
pre = create_backup(db_path, "pre-restore", conn=live)
_copy_into(src_path, live)
migrate(live)
finally:
live.close()
# A restore someone staged earlier is superseded by this one.
if db_config.load().get("pending_restore"):
db_config.clear_pending_restore()
# No prune here: with a low "Backups to keep" it could drop the
# pre-restore backup the user needs to undo this. The next backup prunes.
logger.info("wp_db_restore: restored %s into %s (live)", src_path, db_path)
return {"restored": src_path.name, "pre_restore": pre["name"]}
# ── 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