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