Browse Source

feat: WAL journaling + automatic DB backup on account changes + auto-restore

- PRAGMA journal_mode=WAL on every connection for crash safety
- backup_db() uses SQLite online backup API for consistent snapshots
- Automatic backup after every account create/update/delete
- Backups stored in data/backups/ (inside mounted volume), max 10
- init_db() auto-restores from latest backup if DB file exists but
  accounts table is empty (protects against volume mount loss)
- Startup diagnostics log DB path, size, and table row counts
Lukas Goldschmidt 1 tuần trước cách đây
mục cha
commit
34eb991d76
2 tập tin đã thay đổi với 78 bổ sung1 xóa
  1. 4 1
      src/exec_mcp/repo.py
  2. 74 0
      src/exec_mcp/storage.py

+ 4 - 1
src/exec_mcp/repo.py

@@ -8,7 +8,7 @@ from uuid import uuid4
 
 from fastapi import HTTPException
 
-from .storage import get_connection
+from .storage import get_connection, backup_db
 
 
 def utc_now_iso() -> str:
@@ -116,6 +116,7 @@ def create_account(*, display_name: str, venue: str, venue_account_ref: str, api
             if "api_key" in str(exc).lower():
                 raise HTTPException(status_code=409, detail="api key already exists") from exc
             raise
+    backup_db(reason="account_created")
     return {"id": account_id, "display_name": display_name, "venue": venue}
 
 
@@ -132,6 +133,7 @@ def update_account(*, account_id: str, display_name: str | None = None, descript
         if enabled is not None:
             conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
         conn.commit()
+    backup_db(reason="account_updated")
     return {"id": account_id, "updated": True}
 
 
@@ -141,6 +143,7 @@ def delete_account(*, account_id: str) -> dict:
         conn.commit()
     if not deleted:
         raise HTTPException(status_code=404, detail="account not found")
+    backup_db(reason="account_deleted")
     return {"id": account_id, "deleted": True}
 
 

+ 74 - 0
src/exec_mcp/storage.py

@@ -1,14 +1,18 @@
 from __future__ import annotations
 
 import logging
+import shutil
 import sqlite3
 from contextlib import contextmanager
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Iterator
 
 logger = logging.getLogger("exec_mcp")
 
 DB_PATH = Path(__file__).resolve().parents[2] / "data" / "exec_mcp.sqlite3"
+BACKUP_DIR = DB_PATH.parent / "backups"
+MAX_BACKUPS = 10
 
 
 @contextmanager
@@ -25,12 +29,71 @@ def get_connection() -> Iterator[sqlite3.Connection]:
     conn = sqlite3.connect(DB_PATH)
     conn.row_factory = sqlite3.Row
     conn.execute("PRAGMA foreign_keys = ON")
+    conn.execute("PRAGMA journal_mode = WAL")
     try:
         yield conn
     finally:
         conn.close()
 
 
+def backup_db(reason: str = "manual") -> Path | None:
+    """Copy the DB file to the backup directory.
+
+    Backups are named exec_mcp_YYYYMMDD_HHMMSS_{reason}.sqlite3.
+    The oldest backups are pruned to keep at most MAX_BACKUPS.
+    Returns the backup path, or None if the DB file doesn't exist yet.
+    """
+    if not DB_PATH.exists():
+        return None
+
+    BACKUP_DIR.mkdir(parents=True, exist_ok=True)
+    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+    dest = BACKUP_DIR / f"exec_mcp_{ts}_{reason}.sqlite3"
+
+    # Use SQLite's online backup API for a consistent snapshot instead of
+    # a raw file copy which could capture a partial write.
+    src_conn = sqlite3.connect(str(DB_PATH))
+    try:
+        dst_conn = sqlite3.connect(str(dest))
+        try:
+            src_conn.backup(dst_conn)
+        finally:
+            dst_conn.close()
+    finally:
+        src_conn.close()
+
+    logger.info("backup_db: created %s", dest.name)
+
+    # Prune oldest backups
+    backups = sorted(BACKUP_DIR.glob("exec_mcp_*.sqlite3"))
+    while len(backups) > MAX_BACKUPS:
+        oldest = backups.pop(0)
+        oldest.unlink(missing_ok=True)
+        logger.info("backup_db: pruned %s", oldest.name)
+
+    return dest
+
+
+def restore_latest_backup() -> bool:
+    """Restore the most recent backup to DB_PATH.
+
+    Called from init_db when the accounts table is empty after a fresh
+    start that should have had data. Returns True if a backup was
+    restored, False if no backups exist.
+    """
+    if not BACKUP_DIR.exists():
+        return False
+
+    backups = sorted(BACKUP_DIR.glob("exec_mcp_*.sqlite3"), reverse=True)
+    if not backups:
+        return False
+
+    latest = backups[0]
+    logger.warning("restore_latest_backup: restoring %s to %s", latest, DB_PATH)
+    shutil.copy2(latest, DB_PATH)
+    return True
+
+
 def _migrate_order_records(conn: sqlite3.Connection) -> None:
     columns = {row[1] for row in conn.execute("PRAGMA table_info(order_records)").fetchall()}
     canonical = {"id", "account_id", "market", "side", "order_type", "amount", "price", "expire_time", "status", "bitstamp_order_id", "client_id", "client_order_id", "raw_json", "created_at", "updated_at"}
@@ -185,3 +248,14 @@ def init_db() -> None:
                 logger.info("init_db: table %s has %d rows", table, count)
             except Exception:
                 logger.debug("init_db: could not count table %s", table)
+
+    # After bootstrap: if the DB file existed but accounts table is empty,
+    # attempt automatic restore from the latest backup. This protects
+    # against container rebuilds that lose the volume mount.
+    if db_exists and db_size > 0:
+        with get_connection() as conn:
+            account_count = conn.execute("SELECT count(*) FROM accounts").fetchone()[0]
+        if account_count == 0 and restore_latest_backup():
+            with get_connection() as conn:
+                account_count = conn.execute("SELECT count(*) FROM accounts").fetchone()[0]
+            logger.info("init_db: auto-restore recovered %d accounts", account_count)