feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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 0–23"}), 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():
|
||||
|
||||
@@ -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, 0–23), 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")
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user