287 lines
9.7 KiB
Python
287 lines
9.7 KiB
Python
"""Read-only DB introspection + safe maintenance operations.
|
|
|
|
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
|
|
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
|
|
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
|
|
not exceptions, so the API layer can pass results through verbatim.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from engine.db.migrations import _discover, current_version
|
|
|
|
# Map module-row "type" column values to the human/UI-facing names used
|
|
# in the counts response. Kept in one place so future kinds get a single
|
|
# insertion point.
|
|
_KIND_TYPES: dict[str, str] = {
|
|
"wildcards": "wildcard",
|
|
"fixed_values": "fixed_values",
|
|
"combines": "combine",
|
|
"derivations": "derivation",
|
|
"constraints": "constraint",
|
|
}
|
|
|
|
# Maps the UI-facing count key to the real SQLite table name. Some
|
|
# tables have a `module_` prefix in storage but show up unprefixed in
|
|
# the response (e.g. `module_categories` -> "categories") to keep the
|
|
# settings card readable.
|
|
_TABLE_COUNTS: dict[str, str] = {
|
|
"bundles": "bundles",
|
|
"templates": "templates",
|
|
"categories": "module_categories",
|
|
}
|
|
|
|
|
|
def _iso(ts: float) -> str:
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
|
|
|
|
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
|
|
).fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
|
|
def _count_table(conn: sqlite3.Connection, table: str) -> int:
|
|
try:
|
|
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
|
except sqlite3.OperationalError:
|
|
# Table might not exist on very old DBs that haven't migrated yet.
|
|
return 0
|
|
return int(row[0]) if row else 0
|
|
|
|
|
|
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
|
|
row = conn.execute(f"PRAGMA {name}").fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
|
|
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
|
|
row = conn.execute(f"PRAGMA {name}").fetchone()
|
|
return str(row[0]) if row else ""
|
|
|
|
|
|
def _migration_names() -> dict[int, str]:
|
|
"""Discover known migration files and map version -> stem name.
|
|
|
|
The migrations table only stores ``version`` and ``applied_at``;
|
|
the human-readable name lives in the filename
|
|
(``012_templates.sql`` -> ``012_templates``). Walking the same
|
|
discovery routine the migrator uses keeps this in sync.
|
|
"""
|
|
return {version: path.stem for version, path in _discover()}
|
|
|
|
|
|
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|
"""Return all rows from the migrations table joined with the
|
|
on-disk migration name. Empty list if the table doesn't exist
|
|
(pre-migration DB)."""
|
|
table = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
|
|
).fetchone()
|
|
if not table:
|
|
return []
|
|
rows = conn.execute(
|
|
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
|
|
).fetchall()
|
|
names = _migration_names()
|
|
return [
|
|
{
|
|
"version": int(r[0]),
|
|
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
|
|
"applied_at": str(r[1]),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def gather_info(
|
|
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
|
|
) -> dict[str, Any]:
|
|
"""Return a read-only snapshot of the DB suitable for the SPA card.
|
|
|
|
``source`` is passed through verbatim. The resolver in
|
|
``engine.db.connection`` produces one of:
|
|
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
|
|
``"unknown"`` is reserved for callers that don't have a real source
|
|
(e.g. engine unit tests that build a connection directly)."""
|
|
counts: dict[str, int] = {}
|
|
for ui_name, type_value in _KIND_TYPES.items():
|
|
counts[ui_name] = _count_modules_by_type(conn, type_value)
|
|
for ui_name, table_name in _TABLE_COUNTS.items():
|
|
counts[ui_name] = _count_table(conn, table_name)
|
|
|
|
try:
|
|
stat = db_path.stat()
|
|
size_bytes = stat.st_size
|
|
mtime_iso = _iso(stat.st_mtime)
|
|
except OSError:
|
|
size_bytes = 0
|
|
mtime_iso = ""
|
|
|
|
return {
|
|
"path": str(db_path),
|
|
"source": source,
|
|
"size_bytes": size_bytes,
|
|
"mtime_iso": mtime_iso,
|
|
"counts": counts,
|
|
"migration": {
|
|
"current_version": current_version(conn),
|
|
"applied": _applied_migrations(conn),
|
|
},
|
|
"pragma": {
|
|
"journal_mode": _pragma_str(conn, "journal_mode"),
|
|
"foreign_keys": _pragma_int(conn, "foreign_keys"),
|
|
"page_size": _pragma_int(conn, "page_size"),
|
|
"page_count": _pragma_int(conn, "page_count"),
|
|
"freelist_count": _pragma_int(conn, "freelist_count"),
|
|
},
|
|
}
|
|
|
|
|
|
def _db_filename(conn: sqlite3.Connection) -> str | None:
|
|
"""Return the on-disk filename for the main DB attached to this
|
|
connection, or None if it's an in-memory / un-attached connection.
|
|
|
|
Uses ``PRAGMA database_list`` which returns one row per attached
|
|
DB; the ``main`` one is what we care about. The third column is the
|
|
absolute file path (empty string for ``:memory:``)."""
|
|
try:
|
|
for row in conn.execute("PRAGMA database_list"):
|
|
# row order: seq, name, file
|
|
if row[1] == "main":
|
|
fname = row[2]
|
|
return fname if fname else None
|
|
except sqlite3.OperationalError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _file_size(conn: sqlite3.Connection) -> int:
|
|
"""Return current DB file size from the filesystem. Falls back to
|
|
page_size * page_count (logical size) when the connection is
|
|
in-memory or the file can't be stat'd."""
|
|
fname = _db_filename(conn)
|
|
if fname:
|
|
try:
|
|
return Path(fname).stat().st_size
|
|
except OSError:
|
|
pass
|
|
page_size = _pragma_int(conn, "page_size")
|
|
page_count = _pragma_int(conn, "page_count")
|
|
return page_size * page_count
|
|
|
|
|
|
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
|
|
|
|
bytes_reclaimed is the on-disk size delta of the main DB file. In
|
|
WAL mode pending writes live in the ``-wal`` sidecar until a
|
|
checkpoint flushes them into the main DB, so we issue a TRUNCATE
|
|
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
|
|
occupied size including the freelist we're about to reclaim) and
|
|
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
|
|
immediately rather than waiting for the next automatic checkpoint).
|
|
"""
|
|
t0 = time.monotonic()
|
|
try:
|
|
# Pre-checkpoint: flush any pending WAL writes into the main DB
|
|
# file so size_before captures the true on-disk footprint.
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
size_before = _file_size(conn)
|
|
conn.execute("VACUUM")
|
|
conn.commit()
|
|
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
|
|
# so the shrinkage is reflected on the main DB file right now.
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
except sqlite3.OperationalError as e:
|
|
return {
|
|
"ok": False,
|
|
"op": "vacuum",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"error": str(e),
|
|
}
|
|
size_after = _file_size(conn)
|
|
return {
|
|
"ok": True,
|
|
"op": "vacuum",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"bytes_reclaimed": max(0, size_before - size_after),
|
|
}
|
|
|
|
|
|
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
|
|
t0 = time.monotonic()
|
|
try:
|
|
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
|
except sqlite3.OperationalError as e:
|
|
return {
|
|
"ok": False,
|
|
"op": "integrity",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"output": [],
|
|
"error": str(e),
|
|
}
|
|
output = [str(r[0]) for r in rows]
|
|
is_ok = output == ["ok"]
|
|
return {
|
|
"ok": is_ok,
|
|
"op": "integrity",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"output": output,
|
|
}
|
|
|
|
|
|
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
"""Run ANALYZE to refresh the query planner's statistics tables."""
|
|
t0 = time.monotonic()
|
|
try:
|
|
conn.execute("ANALYZE")
|
|
conn.commit()
|
|
except sqlite3.OperationalError as e:
|
|
return {
|
|
"ok": False,
|
|
"op": "analyze",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"error": str(e),
|
|
}
|
|
return {
|
|
"ok": True,
|
|
"op": "analyze",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
}
|
|
|
|
|
|
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
"""Force-run pending migrations. Returns the list of versions applied
|
|
(empty when the DB was already up to date)."""
|
|
from engine.db.migrations import migrate # local import to keep top-level clean
|
|
|
|
before = current_version(conn)
|
|
t0 = time.monotonic()
|
|
try:
|
|
migrate(conn)
|
|
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
|
|
return {
|
|
"ok": False,
|
|
"op": "migrate",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"error": str(e),
|
|
"applied": [],
|
|
}
|
|
after = current_version(conn)
|
|
applied = list(range(before + 1, after + 1)) if after > before else []
|
|
return {
|
|
"ok": True,
|
|
"op": "migrate",
|
|
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
"applied": applied,
|
|
}
|