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.
666 lines
23 KiB
Python
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
|