feat(db): table-health readout — per-table bloat metrics in admin card
You can't decide what to maintain without seeing what's bloating. Adds a read-only health panel driven by Postgres' own statistics views. - services/db_maintenance.py: get_table_health() queries pg_stat_user_tables + pg_total_relation_size + pg_database_size — per-table size, live/dead tuples, dead-tuple ratio (the bloat signal), and last (auto)vacuum/(auto)analyze. - routes/admin.py: admin-only GET /api/admin/db-maintenance/health. - SettingsView.vue: 'Table health' table in the maintenance card, all tables sorted by dead tuples, rows >=20% dead-ratio flagged; total DB size shown; refreshes after a Run-now so the dead-tuple drop is visible. - Tests: health row/size shaping + null-timestamp passthrough; route + service surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,14 @@ async def get_db_maintenance():
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route("/db-maintenance/health", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_db_maintenance_health():
|
||||
"""Read-only per-table bloat/health stats from Postgres + total DB size."""
|
||||
from scribe.services.db_maintenance import get_table_health
|
||||
return jsonify(await get_table_health())
|
||||
|
||||
|
||||
@admin_bp.route("/db-maintenance", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_db_maintenance():
|
||||
|
||||
@@ -18,7 +18,9 @@ import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.models import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
from scribe.models import async_session, engine
|
||||
from scribe.services.settings import get_admin_setting, set_admin_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -104,6 +106,63 @@ async def run_maintenance(tables: tuple[str, ...] | list[str] | None = None) ->
|
||||
return summary
|
||||
|
||||
|
||||
# Read-only table-health query against Postgres' own statistics views. The key
|
||||
# signal is the dead-tuple ratio (dead / (live + dead)) — that IS bloat; a high
|
||||
# ratio on a large table means autovacuum isn't keeping up. Timestamps confirm
|
||||
# maintenance is actually running on each table.
|
||||
_HEALTH_SQL = text("""
|
||||
SELECT
|
||||
relname AS table_name,
|
||||
n_live_tup AS live,
|
||||
n_dead_tup AS dead,
|
||||
CASE WHEN n_live_tup + n_dead_tup > 0
|
||||
THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)
|
||||
ELSE 0 END AS dead_pct,
|
||||
pg_total_relation_size(relid) AS total_bytes,
|
||||
n_mod_since_analyze AS mod_since_analyze,
|
||||
GREATEST(last_vacuum, last_autovacuum) AS last_vacuum,
|
||||
GREATEST(last_analyze, last_autoanalyze) AS last_analyze
|
||||
FROM pg_stat_user_tables
|
||||
ORDER BY n_dead_tup DESC, total_bytes DESC
|
||||
""")
|
||||
|
||||
|
||||
def _iso(value) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
async def get_table_health() -> dict:
|
||||
"""Per-table health from Postgres statistics + the total database size.
|
||||
|
||||
Read-only (system catalogs only). Returns:
|
||||
{"db_bytes": int, "tables": [
|
||||
{"table": str, "live": int, "dead": int, "dead_pct": float,
|
||||
"total_bytes": int, "mod_since_analyze": int,
|
||||
"last_vacuum": iso|None, "last_analyze": iso|None}
|
||||
]}
|
||||
"""
|
||||
async with async_session() as session:
|
||||
db_bytes = (
|
||||
await session.execute(text("SELECT pg_database_size(current_database())"))
|
||||
).scalar() or 0
|
||||
rows = (await session.execute(_HEALTH_SQL)).mappings().all()
|
||||
|
||||
tables = [
|
||||
{
|
||||
"table": r["table_name"],
|
||||
"live": int(r["live"] or 0),
|
||||
"dead": int(r["dead"] or 0),
|
||||
"dead_pct": float(r["dead_pct"] or 0),
|
||||
"total_bytes": int(r["total_bytes"] or 0),
|
||||
"mod_since_analyze": int(r["mod_since_analyze"] or 0),
|
||||
"last_vacuum": _iso(r["last_vacuum"]),
|
||||
"last_analyze": _iso(r["last_analyze"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"db_bytes": int(db_bytes), "tables": tables}
|
||||
|
||||
|
||||
async def get_last_run() -> dict | None:
|
||||
"""Return the most recent run summary, or None if it has never run."""
|
||||
raw = await get_admin_setting(_LAST_RUN_KEY, "")
|
||||
|
||||
Reference in New Issue
Block a user