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
+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;