Files
chat/chat/services/backup.py
T
2026-04-26 14:18:57 -04:00

107 lines
4.0 KiB
Python

"""Nightly DB backup service (T32, Requirements §12).
A simple in-process scheduler: at 03:00 local time daily, copy
``chat.db`` to ``data/backups/chat-<utc-timestamp>.db`` and prune to the
14 most recent. The BackgroundWorker tick loop calls
:func:`should_take_backup` every 60 seconds; when it returns True the
worker calls :func:`take_backup` then :func:`prune_backups`.
The launchd plist suggested in §12 can replace this later by invoking a
small script that calls :func:`take_backup` directly. For v1 the
in-process loop is enough — the daemon already runs continuously to
serve requests, so there's no extra moving part to install.
Backups capture the live ``.db`` file via :func:`shutil.copy2`. SQLite's
WAL mode means an in-flight transaction's pages might live in the
``-wal`` sidecar rather than the main file, but our codebase commits
every write transaction synchronously, so the .db alone is sufficient
for v1. A truly safe online backup would use
``sqlite3.Connection.backup()``; deferred.
"""
from __future__ import annotations
import shutil
from datetime import datetime, timezone
from pathlib import Path
# 03:00 local time per Requirements §12. Hardcoded for v1 — making this
# configurable via Settings is straightforward but not needed yet.
DEFAULT_BACKUP_HOUR = 3
# Retention window per Requirements §12 ("Last 14 retained").
DEFAULT_KEEP = 14
# Wake interval for should_take_backup's freshness check. We wake the
# tick loop every 60s, so a backup taken in the previous tick within the
# same target hour must NOT trigger another. 23h gives us a generous
# safety margin against scheduling jitter while still allowing a single
# backup per day.
FRESHNESS_HOURS = 23
def take_backup(*, db_path: Path, data_dir: Path) -> Path:
"""Copy ``db_path`` to ``data_dir/backups/chat-<utc-timestamp>.db``.
Returns the new file path. Creates the backup directory if missing.
Uses :func:`shutil.copy2` so the destination's mtime is preserved —
:func:`should_take_backup` reads mtime to gate fresh backups.
"""
backup_dir = data_dir / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup_path = backup_dir / f"chat-{timestamp}.db"
shutil.copy2(db_path, backup_path)
return backup_path
def prune_backups(data_dir: Path, *, keep: int = DEFAULT_KEEP) -> int:
"""Remove all but the most recent ``keep`` backup files.
Returns the number of files removed. Safe when the directory is
missing (returns 0). Sorting is by filename, which is the UTC
timestamp embedded in the name — lexicographic order matches
chronological order.
"""
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return 0
files = sorted(backup_dir.glob("chat-*.db"))
to_remove = files[:-keep] if len(files) > keep else []
for f in to_remove:
f.unlink()
return len(to_remove)
def should_take_backup(
data_dir: Path, *, target_hour: int = DEFAULT_BACKUP_HOUR
) -> bool:
"""Decide whether a nightly backup is due.
Two conditions must hold:
* The current local hour matches ``target_hour``.
* No backup file in ``data_dir/backups/`` has an mtime within the
last :data:`FRESHNESS_HOURS` (23h). The 23h window prevents a
double-backup within the same target hour while still allowing
the next day's run to fire on time.
Local time (not UTC) is used for the hour comparison per the
requirements ("03:00 local time"). The filename embeds a UTC stamp
so file ordering remains unambiguous across DST transitions.
"""
now = datetime.now()
if now.hour != target_hour:
return False
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return True
files = list(backup_dir.glob("chat-*.db"))
if not files:
return True
most_recent = max(files, key=lambda f: f.stat().st_mtime)
age_hours = (
datetime.now().timestamp() - most_recent.stat().st_mtime
) / 3600
return age_hours >= FRESHNESS_HOURS