From c4553d937cea1e56a13b168e148343d9abd199bd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 16:42:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(db):=20scheduled=20DB=20maintenance=20?= =?UTF-8?q?=E2=80=94=20daily=20targeted=20VACUUM=20(ANALYZE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/views/SettingsView.vue | 129 ++++++++++++++++++ src/scribe/app.py | 13 ++ src/scribe/routes/admin.py | 62 ++++++++- src/scribe/services/db_maintenance.py | 115 ++++++++++++++++ .../services/db_maintenance_scheduler.py | 107 +++++++++++++++ src/scribe/services/settings.py | 17 +++ tests/test_routes_db_maintenance.py | 43 ++++++ tests/test_services_db_maintenance.py | 87 ++++++++++++ 8 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 src/scribe/services/db_maintenance.py create mode 100644 src/scribe/services/db_maintenance_scheduler.py create mode 100644 tests/test_routes_db_maintenance.py create mode 100644 tests/test_services_db_maintenance.py diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 78d7cef..13110a2 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -150,6 +150,16 @@ const serverMarketplaceUrl = ref(''); const adminMarketplaceUrl = ref(''); const savingMarketplaceUrl = ref(false); const marketplaceUrlSaved = ref(false); + +// DB maintenance (admin) — daily targeted VACUUM (ANALYZE). +interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null } +interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] } +const dbMaintEnabled = ref(true); +const dbMaintHour = ref(4); +const dbMaintLastRun = ref(null); +const savingDbMaint = ref(false); +const dbMaintSaved = ref(false); +const runningDbMaint = ref(false); const pluginInstallCommands = computed(() => { const mkt = (pluginMarketplaceUrl.value || '').trim() || serverMarketplaceUrl.value @@ -448,6 +458,18 @@ onMounted(async () => { // not configured yet } + // DB maintenance config (admin only — endpoint is admin-gated). + if (authStore.isAdmin) { + try { + const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance"); + dbMaintEnabled.value = dm.enabled; + dbMaintHour.value = dm.hour; + dbMaintLastRun.value = dm.last_run; + } catch { + // leave defaults + } + } + // Load admin settings if (authStore.isAdmin) { try { @@ -697,6 +719,42 @@ async function saveMarketplaceUrl() { } } +async function saveDbMaintenance() { + savingDbMaint.value = true; + dbMaintSaved.value = false; + try { + await apiPut("/api/admin/db-maintenance", { + enabled: dbMaintEnabled.value, + hour: dbMaintHour.value, + }); + dbMaintSaved.value = true; + setTimeout(() => (dbMaintSaved.value = false), 2000); + } catch (e) { + const body = (e as { body?: { error?: string } }).body; + toastStore.show(body?.error || "Failed to save maintenance settings", "error"); + } finally { + savingDbMaint.value = false; + } +} + +async function runDbMaintenanceNow() { + runningDbMaint.value = true; + try { + const summary = await apiPost("/api/admin/db-maintenance/run", {}); + dbMaintLastRun.value = summary; + const failed = summary.tables.filter((t) => !t.ok).length; + toastStore.show( + failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete", + failed ? "error" : "success", + ); + } catch (e) { + const body = (e as { body?: { error?: string } }).body; + toastStore.show(body?.error || "Maintenance run failed", "error"); + } finally { + runningDbMaint.value = false; + } +} + async function handleRestoreFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -1724,6 +1782,51 @@ function formatUserDate(iso: string): string { +
+

Database maintenance

+

+ A daily VACUUM (ANALYZE) over the high-churn tables (logs, notifications, + tokens, notes, version history) — on top of Postgres autovacuum — to reclaim space left + by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC), + just after trash purge. +

+
+ +
+
+ + +
+
+ + + Saved! +
+
+ + Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }} + · {{ dbMaintLastRun.elapsed_ms }}ms + +
    +
  • + {{ t.table }} + {{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }} +
  • +
+
+
+

Email / SMTP

Configure SMTP to enable email notifications for all users.

@@ -2352,6 +2455,32 @@ function formatUserDate(iso: string): string { } .btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); } .btn-secondary:disabled { opacity: 0.6; cursor: default; } + +/* DB maintenance last-run summary */ +.db-maint-last { margin-top: 1rem; } +.db-maint-last-label { + display: block; + font-size: 0.8rem; + color: var(--color-text-muted); + margin-bottom: 0.4rem; +} +.db-maint-table-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.db-maint-table-list li { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.82rem; + padding: 0.2rem 0; +} +.db-maint-table-list .dm-status { color: var(--color-text-muted); } +.db-maint-table-list li.dm-failed .dm-status { color: var(--color-danger); } .btn-warn:hover:not(:disabled) { background: var(--color-warning); color: #fff; diff --git a/src/scribe/app.py b/src/scribe/app.py index 44c1d59..84b1a49 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -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() diff --git a/src/scribe/routes/admin.py b/src/scribe/routes/admin.py index de9cf1b..fe948b5 100644 --- a/src/scribe/routes/admin.py +++ b/src/scribe/routes/admin.py @@ -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(): diff --git a/src/scribe/services/db_maintenance.py b/src/scribe/services/db_maintenance.py new file mode 100644 index 0000000..d28eb3f --- /dev/null +++ b/src/scribe/services/db_maintenance.py @@ -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 diff --git a/src/scribe/services/db_maintenance_scheduler.py b/src/scribe/services/db_maintenance_scheduler.py new file mode 100644 index 0000000..83cc6ff --- /dev/null +++ b/src/scribe/services/db_maintenance_scheduler.py @@ -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") diff --git a/src/scribe/services/settings.py b/src/scribe/services/settings.py index 20dcbbc..a914916 100644 --- a/src/scribe/services/settings.py +++ b/src/scribe/services/settings.py @@ -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( diff --git a/tests/test_routes_db_maintenance.py b/tests/test_routes_db_maintenance.py new file mode 100644 index 0000000..6718577 --- /dev/null +++ b/tests/test_routes_db_maintenance.py @@ -0,0 +1,43 @@ +"""Structural tests for the DB-maintenance admin routes + scheduler surface.""" + + +def test_admin_handlers_callable(): + from scribe.routes import admin as admin_routes + for name in ("get_db_maintenance", "update_db_maintenance", "run_db_maintenance_now"): + assert callable(getattr(admin_routes, name)) + + +def test_db_maintenance_routes_registered(): + from scribe.app import create_app + app = create_app() + rules = {r.rule for r in app.url_map.iter_rules()} + assert "/api/admin/db-maintenance" in rules + assert "/api/admin/db-maintenance/run" in rules + + +def test_scheduler_surface(): + from scribe.services import db_maintenance_scheduler as sched + for fn in ( + "start_db_maintenance_scheduler", + "stop_db_maintenance_scheduler", + "reschedule_db_maintenance", + "get_maintenance_hour", + "is_maintenance_enabled", + ): + assert callable(getattr(sched, fn)), f"scheduler missing {fn}" + + +def test_service_surface_and_allowlist(): + from scribe.services.db_maintenance import MAINTENANCE_TABLES, get_last_run, run_maintenance + assert callable(run_maintenance) and callable(get_last_run) + # The allowlist is a closed tuple covering exactly the high-churn tables. + assert isinstance(MAINTENANCE_TABLES, tuple) + assert set(MAINTENANCE_TABLES) == { + "app_logs", "notifications", "password_reset_tokens", + "invitation_tokens", "notes", "note_versions", + } + + +def test_set_admin_setting_exists(): + from scribe.services.settings import set_admin_setting + assert callable(set_admin_setting) diff --git a/tests/test_services_db_maintenance.py b/tests/test_services_db_maintenance.py new file mode 100644 index 0000000..f495a3f --- /dev/null +++ b/tests/test_services_db_maintenance.py @@ -0,0 +1,87 @@ +"""Tests for services/db_maintenance.py — mocks the engine (no real DB). + +Verifies that VACUUM (ANALYZE) is issued once per allowlisted table, that the +allowlist is closed (an arbitrary name can't be vacuumed), and that a single +table failure doesn't abort the rest of the sweep. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from scribe.services.db_maintenance import MAINTENANCE_TABLES, run_maintenance + + +def _mock_engine(exec_side_effect=None): + """An engine whose connect() yields a conn with a recording exec_driver_sql.""" + conn = MagicMock() + autocommit = MagicMock() + autocommit.exec_driver_sql = AsyncMock(side_effect=exec_side_effect) + conn.execution_options.return_value = autocommit # sync on AsyncConnection + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=False) + engine = MagicMock() + engine.connect.return_value = cm + return engine, autocommit + + +@pytest.mark.asyncio +async def test_vacuums_each_allowlisted_table_once(): + engine, autocommit = _mock_engine() + with patch("scribe.services.db_maintenance.engine", engine), \ + patch("scribe.services.db_maintenance.set_admin_setting", AsyncMock()): + summary = await run_maintenance() + + issued = [c.args[0] for c in autocommit.exec_driver_sql.await_args_list] + assert issued == [f"VACUUM (ANALYZE) {t}" for t in MAINTENANCE_TABLES] + assert all(r["ok"] for r in summary["tables"]) + assert len(summary["tables"]) == len(MAINTENANCE_TABLES) + # AUTOCOMMIT was requested — VACUUM cannot run inside a transaction. + conn = engine.connect.return_value.__aenter__.return_value + conn.execution_options.assert_called_once_with(isolation_level="AUTOCOMMIT") + + +@pytest.mark.asyncio +async def test_allowlist_is_closed(): + """A caller-supplied name not on the allowlist is silently ignored.""" + engine, autocommit = _mock_engine() + with patch("scribe.services.db_maintenance.engine", engine), \ + patch("scribe.services.db_maintenance.set_admin_setting", AsyncMock()): + summary = await run_maintenance(tables=["app_logs", "users; DROP TABLE notes"]) + + issued = [c.args[0] for c in autocommit.exec_driver_sql.await_args_list] + assert issued == ["VACUUM (ANALYZE) app_logs"] + assert [r["table"] for r in summary["tables"]] == ["app_logs"] + + +@pytest.mark.asyncio +async def test_one_table_failure_does_not_abort_the_rest(): + # First table raises, the remaining tables still get vacuumed. + calls = {"n": 0} + + def flaky(_sql): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("boom") + return MagicMock() + + engine, autocommit = _mock_engine(exec_side_effect=flaky) + with patch("scribe.services.db_maintenance.engine", engine), \ + patch("scribe.services.db_maintenance.set_admin_setting", AsyncMock()): + summary = await run_maintenance() + + assert autocommit.exec_driver_sql.await_count == len(MAINTENANCE_TABLES) + assert summary["tables"][0]["ok"] is False + assert summary["tables"][0]["error"] == "boom" + assert all(r["ok"] for r in summary["tables"][1:]) + + +@pytest.mark.asyncio +async def test_summary_is_persisted_as_admin_setting(): + engine, _ = _mock_engine() + setter = AsyncMock() + with patch("scribe.services.db_maintenance.engine", engine), \ + patch("scribe.services.db_maintenance.set_admin_setting", setter): + await run_maintenance() + setter.assert_awaited_once() + assert setter.await_args.args[0] == "db_maintenance_last_run"