107 lines
4.0 KiB
Python
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
|