|
@@ -1,14 +1,18 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import logging
|
|
import logging
|
|
|
|
|
+import shutil
|
|
|
import sqlite3
|
|
import sqlite3
|
|
|
from contextlib import contextmanager
|
|
from contextlib import contextmanager
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from typing import Iterator
|
|
from typing import Iterator
|
|
|
|
|
|
|
|
logger = logging.getLogger("exec_mcp")
|
|
logger = logging.getLogger("exec_mcp")
|
|
|
|
|
|
|
|
DB_PATH = Path(__file__).resolve().parents[2] / "data" / "exec_mcp.sqlite3"
|
|
DB_PATH = Path(__file__).resolve().parents[2] / "data" / "exec_mcp.sqlite3"
|
|
|
|
|
+BACKUP_DIR = DB_PATH.parent / "backups"
|
|
|
|
|
+MAX_BACKUPS = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
@contextmanager
|
|
@@ -25,12 +29,71 @@ def get_connection() -> Iterator[sqlite3.Connection]:
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
|
conn.row_factory = sqlite3.Row
|
|
conn.row_factory = sqlite3.Row
|
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
|
|
|
+ conn.execute("PRAGMA journal_mode = WAL")
|
|
|
try:
|
|
try:
|
|
|
yield conn
|
|
yield conn
|
|
|
finally:
|
|
finally:
|
|
|
conn.close()
|
|
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:
|
|
def _migrate_order_records(conn: sqlite3.Connection) -> None:
|
|
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(order_records)").fetchall()}
|
|
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"}
|
|
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)
|
|
logger.info("init_db: table %s has %d rows", table, count)
|
|
|
except Exception:
|
|
except Exception:
|
|
|
logger.debug("init_db: could not count table %s", table)
|
|
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)
|