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
co-authored by Claude Opus 4.8
parent ee02ed37c1
commit c4553d937c
8 changed files with 572 additions and 1 deletions
+129
View File
@@ -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<DbMaintRun | null>(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<DbMaintRun>("/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 {
</div>
</section>
<section class="settings-section full-width">
<h2>Database maintenance</h2>
<p class="section-desc">
A daily <code>VACUUM (ANALYZE)</code> 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.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="dbMaintEnabled" />
Run scheduled maintenance daily
</label>
</div>
<div class="field url-field">
<label for="db-maint-hour">Run hour (UTC)</label>
<select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
</select>
</div>
<div class="actions">
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
{{ savingDbMaint ? "Saving..." : "Save" }}
</button>
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
{{ runningDbMaint ? "Running..." : "Run now" }}
</button>
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
</div>
<div v-if="dbMaintLastRun" class="db-maint-last">
<span class="db-maint-last-label">
Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
· {{ dbMaintLastRun.elapsed_ms }}ms
</span>
<ul class="db-maint-table-list">
<li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
<code>{{ t.table }}</code>
<span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
</li>
</ul>
</div>
</section>
<section class="settings-section full-width">
<h2>Email / SMTP</h2>
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
@@ -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;