mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-10 18:52:55 +02:00
fix(db): run VACUUM in a worker thread to survive proxy timeouts
The reverse proxy fronting mc.wojtaszek.it closes idle HTTP responses
after ~30 s, so the manual Optimize endpoint timed out client-side
even though SQLite finished VACUUM and the Flask handler logged a 200.
The user saw "Optimize failed" while the DB had actually shrunk.
Split the endpoint into kickoff + polling: POST /api/db/vacuum spawns
a daemon worker thread, stores state in a module-level dict guarded by
a lock, and returns 202 immediately. GET /api/db/vacuum/status returns
{running, elapsed_seconds, ...} so the UI can poll every 2 s and show
the same "freed X bytes in Y s" toast once the worker is done. A
second POST while a VACUUM is in flight returns 409 instead of starting
a parallel rewrite.
Client polls for up to 10 minutes (300 × 2 s) before surrendering with
a "still running" warning — well past any real VACUUM duration we'd
expect, but bounded so a server-side crash can't leave the UI
spinning forever.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+88
-16
@@ -4389,26 +4389,98 @@ def update_retention_settings_api():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# VACUUM runs in a worker thread so a multi-second SQLite rewrite can't be
|
||||
# killed by an upstream reverse proxy's response timeout (~30 s on most
|
||||
# defaults). The UI polls /api/db/vacuum/status until 'running' flips false.
|
||||
_vacuum_state_lock = threading.Lock()
|
||||
_vacuum_state = {
|
||||
'running': False,
|
||||
'started_at': None,
|
||||
'finished_at': None,
|
||||
'result': None, # dict from db.vacuum() on success
|
||||
'error': None, # string on failure
|
||||
}
|
||||
|
||||
|
||||
def _run_vacuum_in_thread(app, db):
|
||||
with app.app_context():
|
||||
try:
|
||||
stats = db.vacuum()
|
||||
logger.info(
|
||||
f"Manual VACUUM: {stats['size_before']:,} -> {stats['size_after']:,} "
|
||||
f"bytes (freed {stats['freed']:,} in {stats['elapsed_seconds']}s)"
|
||||
)
|
||||
with _vacuum_state_lock:
|
||||
_vacuum_state['result'] = stats
|
||||
_vacuum_state['error'] = None
|
||||
except Exception as e:
|
||||
logger.error(f"VACUUM failed: {e}", exc_info=True)
|
||||
with _vacuum_state_lock:
|
||||
_vacuum_state['result'] = None
|
||||
_vacuum_state['error'] = str(e)
|
||||
finally:
|
||||
with _vacuum_state_lock:
|
||||
_vacuum_state['running'] = False
|
||||
_vacuum_state['finished_at'] = time.time()
|
||||
|
||||
|
||||
@api_bp.route('/db/vacuum', methods=['POST'])
|
||||
def vacuum_database_api():
|
||||
"""Run SQLite VACUUM to reclaim space freed by DELETE statements.
|
||||
"""Kick off a SQLite VACUUM in the background.
|
||||
|
||||
Returned payload includes size_before / size_after / freed (bytes) and
|
||||
elapsed_seconds so the UI can show how much space was reclaimed.
|
||||
Returns 202 immediately so a slow VACUUM can't be killed by a reverse
|
||||
proxy timeout. Poll /api/db/vacuum/status for the outcome.
|
||||
"""
|
||||
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
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 500
|
||||
|
||||
with _vacuum_state_lock:
|
||||
if _vacuum_state['running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'running': True,
|
||||
'error': 'VACUUM already in progress',
|
||||
}), 409
|
||||
_vacuum_state['running'] = True
|
||||
_vacuum_state['started_at'] = time.time()
|
||||
_vacuum_state['finished_at'] = None
|
||||
_vacuum_state['result'] = None
|
||||
_vacuum_state['error'] = None
|
||||
|
||||
app = current_app._get_current_object()
|
||||
threading.Thread(
|
||||
target=_run_vacuum_in_thread,
|
||||
args=(app, db),
|
||||
name='vacuum-worker',
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return jsonify({'success': True, 'running': True, 'started': True}), 202
|
||||
|
||||
|
||||
@api_bp.route('/db/vacuum/status', methods=['GET'])
|
||||
def vacuum_status_api():
|
||||
"""Return current VACUUM status. UI polls this until running=false."""
|
||||
with _vacuum_state_lock:
|
||||
state = dict(_vacuum_state)
|
||||
payload = {
|
||||
'running': state['running'],
|
||||
'started_at': state['started_at'],
|
||||
'finished_at': state['finished_at'],
|
||||
}
|
||||
if state['running'] and state['started_at']:
|
||||
payload['elapsed_seconds'] = round(time.time() - state['started_at'], 1)
|
||||
if state['result'] is not None:
|
||||
payload['success'] = True
|
||||
payload.update(state['result'])
|
||||
elif state['error'] is not None:
|
||||
payload['success'] = False
|
||||
payload['error'] = state['error']
|
||||
else:
|
||||
# Idle (never run since boot) or running with no result yet
|
||||
payload['success'] = None if state['running'] else True
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@api_bp.route('/db/size', methods=['GET'])
|
||||
|
||||
+53
-12
@@ -6414,25 +6414,66 @@ async function optimizeDatabase() {
|
||||
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();
|
||||
const restoreButton = () => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrows-collapse"></i> Optimize now';
|
||||
};
|
||||
|
||||
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');
|
||||
try {
|
||||
const kickoff = await fetch('/api/db/vacuum', { method: 'POST' });
|
||||
const kickoffData = await kickoff.json().catch(() => ({}));
|
||||
|
||||
if (!kickoff.ok && kickoff.status !== 409) {
|
||||
showNotification('Optimize failed: ' + (kickoffData.error || `HTTP ${kickoff.status}`), 'danger');
|
||||
loadDatabaseSize();
|
||||
restoreButton();
|
||||
return;
|
||||
}
|
||||
// 409 means another VACUUM is already running — we just attach to it.
|
||||
|
||||
// Poll status every 2s. Cap at 10 minutes to avoid an infinite spinner
|
||||
// if something goes really wrong on the server side.
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLLS = 300;
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
||||
let status;
|
||||
try {
|
||||
const resp = await fetch('/api/db/vacuum/status');
|
||||
status = await resp.json();
|
||||
} catch (e) {
|
||||
continue; // transient — try again
|
||||
}
|
||||
|
||||
if (status.running) {
|
||||
if (statusEl) statusEl.textContent = `Running VACUUM… (${status.elapsed_seconds || 0}s)`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Done — either success or error.
|
||||
if (status.success === true && status.size_after !== undefined) {
|
||||
const freed = status.freed > 0 ? `freed ${_formatBytes(status.freed)}` : 'no space to reclaim';
|
||||
showNotification(`Optimized: ${freed} in ${status.elapsed_seconds}s`, 'success');
|
||||
if (statusEl) statusEl.textContent = `Current size: ${_formatBytes(status.size_after)}`;
|
||||
} else if (status.error) {
|
||||
showNotification('Optimize failed: ' + status.error, 'danger');
|
||||
loadDatabaseSize();
|
||||
} else {
|
||||
// No result, no error, not running — odd, just refresh size
|
||||
loadDatabaseSize();
|
||||
}
|
||||
restoreButton();
|
||||
return;
|
||||
}
|
||||
|
||||
showNotification('Optimize is still running after 10 minutes — check container logs', 'warning');
|
||||
loadDatabaseSize();
|
||||
restoreButton();
|
||||
} 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';
|
||||
restoreButton();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user