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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user