feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 54s

Adds a daily off-hours VACUUM (ANALYZE) over the high-churn tables the
retention/purge sweeps churn (app_logs, notifications, token tables, notes,
note_versions), on top of Postgres autovacuum, to reclaim bloat left by the
nightly bulk DELETEs and keep planner stats fresh.

- services/db_maintenance.py: run_maintenance() over a closed table allowlist
  via an AUTOCOMMIT connection (VACUUM can't run in a txn); per-table summary
  persisted as the db_maintenance_last_run admin setting.
- services/db_maintenance_scheduler.py: BackgroundScheduler cron (default
  04:00 UTC, after the 03:30 trash purge); enabled-gate checked at fire time;
  live reschedule on hour change. Wired into app.py start/stop.
- routes/admin.py: admin-only GET/PUT /api/admin/db-maintenance + POST /run.
- settings.py: set_admin_setting() (write-side of get_admin_setting) for
  out-of-request writes.
- SettingsView.vue: admin 'Database maintenance' card — enable toggle, run-hour
  (UTC), Run-now, last-run summary.
- Tests: allowlist is closed, VACUUM issued per table, one failure doesn't
  abort the rest, summary persisted; route/scheduler/service surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:42:04 -04:00
parent ee02ed37c1
commit c4553d937c
8 changed files with 572 additions and 1 deletions
+13
View File
@@ -187,6 +187,15 @@ def create_app() -> Quart:
from scribe.services.trash_scheduler import start_trash_scheduler
start_trash_scheduler(asyncio.get_running_loop())
# DB maintenance scheduler (daily targeted VACUUM ANALYZE, default 04:00 UTC)
from scribe.services.db_maintenance_scheduler import (
get_maintenance_hour,
start_db_maintenance_scheduler,
)
start_db_maintenance_scheduler(
asyncio.get_running_loop(), await get_maintenance_hour()
)
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
# exception hook. Cheap (~1 log line/min), high diagnostic value when
# the app crashes mysteriously. See services/diagnostics.py.
@@ -203,6 +212,10 @@ def create_app() -> Quart:
stop_version_pinning_scheduler()
from scribe.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler()
from scribe.services.db_maintenance_scheduler import (
stop_db_maintenance_scheduler,
)
stop_db_maintenance_scheduler()
from scribe.services.diagnostics import stop_diagnostics
stop_diagnostics()
+61 -1
View File
@@ -21,7 +21,12 @@ from scribe.services.backup import (
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email
from scribe.services.settings import set_setting, set_settings_batch
from scribe.services.settings import (
get_admin_setting,
set_admin_setting,
set_setting,
set_settings_batch,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -204,6 +209,61 @@ async def update_base_url():
return jsonify({"status": "ok"})
@admin_bp.route("/db-maintenance", methods=["GET"])
@admin_required
async def get_db_maintenance():
"""Current DB-maintenance config + the last run's summary."""
from scribe.services.db_maintenance import get_last_run
from scribe.services.db_maintenance_scheduler import (
get_maintenance_hour,
is_maintenance_enabled,
)
return jsonify({
"enabled": await is_maintenance_enabled(),
"hour": await get_maintenance_hour(),
"last_run": await get_last_run(),
})
@admin_bp.route("/db-maintenance", methods=["PUT"])
@admin_required
async def update_db_maintenance():
"""Set whether scheduled maintenance runs and at what UTC hour."""
from scribe.services.db_maintenance_scheduler import reschedule_db_maintenance
data = await request.get_json() or {}
enabled = bool(data.get("enabled", True))
try:
hour = int(data.get("hour", 4))
except (TypeError, ValueError):
return jsonify({"error": "hour must be an integer 023"}), 400
if not 0 <= hour <= 23:
return jsonify({"error": "hour must be between 0 and 23"}), 400
await set_admin_setting("db_maintenance_enabled", "true" if enabled else "false")
await set_admin_setting("db_maintenance_hour", str(hour))
reschedule_db_maintenance(hour)
uid = get_current_user_id()
await log_audit(
"db_maintenance_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr, details={"enabled": enabled, "hour": hour},
)
return jsonify({"status": "ok", "enabled": enabled, "hour": hour})
@admin_bp.route("/db-maintenance/run", methods=["POST"])
@admin_required
async def run_db_maintenance_now():
"""Run a VACUUM (ANALYZE) sweep immediately and return its summary."""
from scribe.services.db_maintenance import run_maintenance
uid = get_current_user_id()
await log_audit(
"db_maintenance_run", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
)
summary = await run_maintenance()
return jsonify(summary)
@admin_bp.route("/invitations", methods=["POST"])
@admin_required
async def create_invite():
+115
View File
@@ -0,0 +1,115 @@
"""Basic Postgres maintenance — targeted VACUUM (ANALYZE).
Postgres autovacuum already reclaims dead tuples and refreshes planner stats
in the background. This adds a daily, off-hours top-up over the handful of
tables the retention/purge sweeps churn hardest (log retention, notification
purge, auth-token purge, trash purge, version pruning) — so the bloat those
bulk DELETEs leave behind is collected promptly and the planner keeps good
stats. It is deliberately narrow; the rest of the schema is left to autovacuum.
Security: table names cannot be parameterized in SQL, so VACUUM is only ever
issued against names from the hardcoded MAINTENANCE_TABLES allowlist — there is
no path from user input to the table name.
"""
from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from scribe.models import engine
from scribe.services.settings import get_admin_setting, set_admin_setting
logger = logging.getLogger(__name__)
# High-churn tables — exactly the ones the scheduled delete sweeps hit:
# app_logs ← log retention (hourly)
# notifications ← read-notification purge (hourly)
# password_reset_tokens ← auth-token purge (daily)
# invitation_tokens ← auth-token purge (daily)
# notes ← trash purge (daily) + general churn
# note_versions ← version-pinning prune (daily)
MAINTENANCE_TABLES: tuple[str, ...] = (
"app_logs",
"notifications",
"password_reset_tokens",
"invitation_tokens",
"notes",
"note_versions",
)
_LAST_RUN_KEY = "db_maintenance_last_run"
async def run_maintenance(tables: tuple[str, ...] | list[str] | None = None) -> dict:
"""Run VACUUM (ANALYZE) over the allowlisted high-churn tables.
VACUUM cannot run inside a transaction block, so this uses a dedicated
AUTOCOMMIT connection rather than the usual session. Each table is
vacuumed independently; one failure is logged and does not abort the rest.
Returns a summary dict (also persisted as the db_maintenance_last_run
admin setting) of shape:
{"started_at": iso, "elapsed_ms": int, "tables": [
{"table": str, "ok": bool, "elapsed_ms": int, "error": str|None}
]}
"""
# Only ever operate on the closed allowlist — never an arbitrary name.
requested = tuple(tables) if tables is not None else MAINTENANCE_TABLES
targets = [t for t in requested if t in MAINTENANCE_TABLES]
started = time.monotonic()
started_at = datetime.now(timezone.utc).isoformat()
results: list[dict] = []
async with engine.connect() as conn:
# execution_options() is synchronous on AsyncConnection; it returns the
# connection with AUTOCOMMIT set (VACUUM can't run in a transaction).
autocommit = conn.execution_options(isolation_level="AUTOCOMMIT")
for table in targets:
t0 = time.monotonic()
try:
# table is from the allowlist above — safe to interpolate.
await autocommit.exec_driver_sql(f"VACUUM (ANALYZE) {table}")
results.append({
"table": table,
"ok": True,
"elapsed_ms": int((time.monotonic() - t0) * 1000),
"error": None,
})
except Exception as exc: # noqa: BLE001 — record per-table, keep going
logger.exception("VACUUM (ANALYZE) failed for %s", table)
results.append({
"table": table,
"ok": False,
"elapsed_ms": int((time.monotonic() - t0) * 1000),
"error": str(exc),
})
summary = {
"started_at": started_at,
"elapsed_ms": int((time.monotonic() - started) * 1000),
"tables": results,
}
ok = sum(1 for r in results if r["ok"])
logger.info(
"DB maintenance: vacuumed %d/%d table(s) in %dms",
ok, len(results), summary["elapsed_ms"],
)
try:
await set_admin_setting(_LAST_RUN_KEY, json.dumps(summary))
except Exception: # noqa: BLE001 — persisting the summary is best-effort
logger.warning("Failed to persist db_maintenance last-run summary", exc_info=True)
return summary
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, "")
if not raw:
return None
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
@@ -0,0 +1,107 @@
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
Mirrors trash_scheduler.py: a single global BackgroundScheduler job bridges
into the asyncio loop to run the async maintenance. Scheduled for 04:00 UTC by
default — after the 03:30 trash purge — so it collects the dead tuples that
night's delete sweeps leave behind.
Two things are operator-tunable from the admin Settings card:
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
it needs no reschedule.
- db_maintenance_hour ("0".."23", UTC) — the cron hour. Changing it calls
reschedule_db_maintenance() so the live job moves without a restart.
"""
from __future__ import annotations
import asyncio
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 023), clamped; default 04:00."""
raw = await get_admin_setting("db_maintenance_hour", str(_DEFAULT_HOUR))
try:
hour = int(raw)
except (TypeError, ValueError):
return _DEFAULT_HOUR
return hour if 0 <= hour <= 23 else _DEFAULT_HOUR
async def is_maintenance_enabled() -> bool:
"""Whether the scheduled run is enabled (default on)."""
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
def _run_maintenance_threadsafe() -> None:
"""APScheduler invokes this from a worker thread; bridge into the loop."""
if _loop is None:
logger.warning("db maintenance scheduler: no loop registered")
return
async def _runner():
try:
if not await is_maintenance_enabled():
logger.debug("db maintenance: disabled, skipping scheduled run")
return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
except Exception:
logger.exception("db maintenance run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
def start_db_maintenance_scheduler(
loop: asyncio.AbstractEventLoop, hour: int = _DEFAULT_HOUR
) -> None:
"""Start the daily job. `hour` is the configured UTC run-hour, resolved by
the caller (which has an async context) via get_maintenance_hour() — passed
in rather than read here so we never block the event loop at startup. The
job's enabled-gate is re-checked at every fire, so only the hour is needed
up front."""
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_maintenance_threadsafe,
trigger=CronTrigger(hour=hour, minute=0, timezone="UTC"),
id=_JOB_ID,
replace_existing=True,
)
_scheduler.start()
logger.info("DB maintenance scheduler started (daily %02d:00 UTC)", hour)
def reschedule_db_maintenance(hour: int) -> None:
"""Move the live job to a new UTC hour (called when the admin changes it)."""
if _scheduler is None:
return
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler.reschedule_job(
_JOB_ID, trigger=CronTrigger(hour=hour, minute=0, timezone="UTC")
)
logger.info("DB maintenance scheduler rescheduled to %02d:00 UTC", hour)
def stop_db_maintenance_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("DB maintenance scheduler stopped")
+17
View File
@@ -26,6 +26,23 @@ async def get_admin_setting(key: str, default: str = "") -> str:
return setting.value if setting and setting.value else default
async def set_admin_setting(key: str, value: str) -> None:
"""Write an instance-global setting onto the first admin account.
The write-side counterpart to get_admin_setting, for non-per-user settings
written outside a request context (e.g. a scheduler persisting its last-run
summary) where get_current_user_id() isn't available.
"""
async with async_session() as session:
admin_id = (
await session.execute(
select(User.id).where(User.role == "admin").order_by(User.id)
)
).scalars().first()
if admin_id is not None:
await set_setting(admin_id, key, value)
async def get_setting(user_id: int, key: str, default: str = "") -> str:
async with async_session() as session:
result = await session.execute(