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
+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():