- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
(19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
`create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
112 lines
3.2 KiB
Python
112 lines
3.2 KiB
Python
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _backup_dir() -> Path:
|
|
d = Path(settings.backup_dir)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _db_path() -> Path:
|
|
return settings.db_path
|
|
|
|
|
|
def backup_db(now: datetime | None = None) -> str | None:
|
|
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
|
|
|
|
Returns the backup filename, or None when backup is disabled or the
|
|
database file does not exist.
|
|
"""
|
|
if not settings.backup_enabled:
|
|
return None
|
|
db_path = _db_path()
|
|
if str(db_path) == ":memory:" or not Path(db_path).is_file():
|
|
return None
|
|
|
|
now = now or datetime.now()
|
|
# Checkpoint the WAL so the backup is consistent.
|
|
try:
|
|
import sqlite3
|
|
with sqlite3.connect(str(db_path)) as conn:
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
except Exception:
|
|
logger.exception("backup_db")
|
|
|
|
dest_dir = _backup_dir()
|
|
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
|
dest = dest_dir / filename
|
|
shutil.copy2(db_path, dest)
|
|
|
|
# Prune old backups, keeping ``backup_keep`` most recent files.
|
|
prune_old_backups()
|
|
logger.info("Backup created: %s", dest)
|
|
return filename
|
|
|
|
|
|
def list_backups() -> list[dict]:
|
|
"""List existing backup files (name, size bytes, mtime)."""
|
|
files = []
|
|
for p in _backup_dir().glob("flowdeck-*.db"):
|
|
stat = p.stat()
|
|
files.append({
|
|
"filename": p.name,
|
|
"size": stat.st_size,
|
|
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
})
|
|
files.sort(key=lambda f: f["filename"], reverse=True)
|
|
return files
|
|
|
|
|
|
def prune_old_backups(keep: int | None = None) -> int:
|
|
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
|
|
keep = keep if keep is not None else settings.backup_keep
|
|
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
|
|
removed = 0
|
|
for p in files[keep:]:
|
|
try:
|
|
p.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
logger.warning("Could not prune backup %s", p)
|
|
return removed
|
|
|
|
|
|
def last_backup_age_hours() -> float | None:
|
|
"""Hours since the most recent backup (None if none exists)."""
|
|
files = list(_backup_dir().glob("flowdeck-*.db"))
|
|
if not files:
|
|
return None
|
|
newest = max(files, key=lambda p: p.stat().st_mtime)
|
|
age = time.time() - newest.stat().st_mtime
|
|
return age / 3600
|
|
|
|
|
|
def backup_due() -> bool:
|
|
"""True when a backup should run now (interval elapsed since last one)."""
|
|
age = last_backup_age_hours()
|
|
if age is None:
|
|
return True
|
|
return age >= settings.backup_interval_hours
|
|
|
|
|
|
async def backup_scheduler():
|
|
"""Background loop: run a backup once per interval (default daily)."""
|
|
while True:
|
|
try:
|
|
if backup_due():
|
|
backup_db()
|
|
except Exception as exc: # never let the loop die
|
|
logger.warning("backup_scheduler error: %s", exc)
|
|
await __import__("asyncio").sleep(3600) # re-check hourly
|