mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-06-11 01:04:56 +02:00
feat(db): VACUUM after retention and an Optimize button in Backup modal
SQLite DELETE marks pages free but doesn't shrink the file, so the new retention job would keep DBs at their bloated size forever without a follow-up VACUUM. Add db.vacuum() that runs PRAGMA-free VACUUM and reports size_before/size_after/elapsed so callers can surface results. The retention job now calls vacuum() automatically when it deleted at least 1000 rows. Threshold avoids the multi-second VACUUM cost on quiet days. Failure is logged, not raised — a missed VACUUM never crashes the scheduler. Power-user override: new "Optimize now" button in the Database Backup modal triggers VACUUM on demand via POST /api/db/vacuum, alongside a GET /api/db/size that drives the live "Current size" label. This way users don't have to wait until 03:30 to reclaim space after the first big retention pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -562,6 +562,22 @@ def _retention_job():
|
||||
total = sum(result.values())
|
||||
logger.info(f"Retention job completed: {total} rows deleted ({result})")
|
||||
|
||||
# Reclaim free pages so the DB file actually shrinks. SQLite DELETE
|
||||
# only marks pages free; without VACUUM the file size never drops.
|
||||
# Threshold avoids a multi-second VACUUM on quiet days when nothing
|
||||
# meaningful was deleted.
|
||||
VACUUM_THRESHOLD = 1000
|
||||
if total >= VACUUM_THRESHOLD:
|
||||
try:
|
||||
stats = _db.vacuum()
|
||||
logger.info(
|
||||
f"Post-retention VACUUM: {stats['size_before']:,} → "
|
||||
f"{stats['size_after']:,} bytes (freed {stats['freed']:,} "
|
||||
f"in {stats['elapsed_seconds']}s)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-retention VACUUM failed: {e}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Retention job failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -1349,6 +1349,30 @@ class Database:
|
||||
|
||||
return result
|
||||
|
||||
def vacuum(self) -> dict:
|
||||
"""Run VACUUM to reclaim space freed by DELETEs.
|
||||
|
||||
VACUUM rewrites the entire database file, so it briefly takes an
|
||||
exclusive lock — writers wait, readers continue in WAL mode. Caller
|
||||
gets back the size before/after and the duration so it can be
|
||||
surfaced to the user.
|
||||
"""
|
||||
size_before = self.db_path.stat().st_size if self.db_path.exists() else 0
|
||||
started = time.time()
|
||||
conn = sqlite3.connect(str(self.db_path), timeout=30, isolation_level=None)
|
||||
try:
|
||||
conn.execute("VACUUM")
|
||||
finally:
|
||||
conn.close()
|
||||
elapsed = time.time() - started
|
||||
size_after = self.db_path.stat().st_size if self.db_path.exists() else 0
|
||||
return {
|
||||
'size_before': size_before,
|
||||
'size_after': size_after,
|
||||
'freed': size_before - size_after,
|
||||
'elapsed_seconds': round(elapsed, 2),
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Backup
|
||||
# ================================================================
|
||||
|
||||
@@ -4389,6 +4389,44 @@ def update_retention_settings_api():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/db/vacuum', methods=['POST'])
|
||||
def vacuum_database_api():
|
||||
"""Run SQLite VACUUM to reclaim space freed by DELETE statements.
|
||||
|
||||
Returned payload includes size_before / size_after / freed (bytes) and
|
||||
elapsed_seconds so the UI can show how much space was reclaimed.
|
||||
"""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
stats = db.vacuum()
|
||||
logger.info(
|
||||
f"Manual VACUUM: {stats['size_before']:,} -> {stats['size_after']:,} "
|
||||
f"bytes (freed {stats['freed']:,} in {stats['elapsed_seconds']}s)"
|
||||
)
|
||||
return jsonify({'success': True, **stats})
|
||||
except Exception as e:
|
||||
logger.error(f"VACUUM failed: {e}", exc_info=True)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/db/size', methods=['GET'])
|
||||
def get_database_size_api():
|
||||
"""Return current database file size in bytes (for the Optimize DB UI)."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
from pathlib import Path
|
||||
path = Path(db.db_path)
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
return jsonify({'success': True, 'size': size, 'path': str(path)})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read DB size: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Read Status (Server-side message read tracking)
|
||||
# =============================================================================
|
||||
|
||||
+61
-1
@@ -6373,7 +6373,67 @@ document.addEventListener('DOMContentLoaded', initializeSearch);
|
||||
// =============================================================================
|
||||
|
||||
function initializeBackup() {
|
||||
document.getElementById('backupModal')?.addEventListener('shown.bs.modal', loadBackupList);
|
||||
const modal = document.getElementById('backupModal');
|
||||
if (!modal) return;
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
loadBackupList();
|
||||
loadDatabaseSize();
|
||||
});
|
||||
}
|
||||
|
||||
function _formatBytes(n) {
|
||||
if (!Number.isFinite(n) || n < 0) return '?';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function loadDatabaseSize() {
|
||||
const statusEl = document.getElementById('vacuumDbStatus');
|
||||
if (!statusEl) return;
|
||||
try {
|
||||
const response = await fetch('/api/db/size');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
statusEl.textContent = `Current size: ${_formatBytes(data.size)}`;
|
||||
} else {
|
||||
statusEl.textContent = 'Size: unknown';
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.textContent = 'Size: unknown';
|
||||
}
|
||||
}
|
||||
|
||||
async function optimizeDatabase() {
|
||||
const btn = document.getElementById('vacuumDbBtn');
|
||||
const statusEl = document.getElementById('vacuumDbStatus');
|
||||
if (!btn) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner-border spinner-border-sm"></div> Optimizing…';
|
||||
if (statusEl) statusEl.textContent = 'Running VACUUM…';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/db/vacuum', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const freed = data.freed > 0 ? `freed ${_formatBytes(data.freed)}` : 'no space to reclaim';
|
||||
showNotification(`Optimized: ${freed} in ${data.elapsed_seconds}s`, 'success');
|
||||
if (statusEl) statusEl.textContent = `Current size: ${_formatBytes(data.size_after)}`;
|
||||
} else {
|
||||
showNotification('Optimize failed: ' + (data.error || 'unknown'), 'danger');
|
||||
loadDatabaseSize();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error running VACUUM:', error);
|
||||
showNotification('Optimize failed', 'danger');
|
||||
loadDatabaseSize();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrows-collapse"></i> Optimize now';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackupList() {
|
||||
|
||||
@@ -1212,6 +1212,18 @@
|
||||
<div class="spinner-border spinner-border-sm"></div> Loading...
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<h6 class="mb-2"><i class="bi bi-arrows-collapse"></i> Optimize database</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
Reclaim space freed by message retention. Runs SQLite <code>VACUUM</code>;
|
||||
readers keep working but writes are paused for a few seconds.
|
||||
</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button class="btn btn-outline-primary btn-sm" id="vacuumDbBtn" onclick="optimizeDatabase()">
|
||||
<i class="bi bi-arrows-collapse"></i> Optimize now
|
||||
</button>
|
||||
<span id="vacuumDbStatus" class="text-muted small">Current size: …</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user