diff --git a/app/routes/api.py b/app/routes/api.py index 0754dee..9e95eeb 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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']) diff --git a/app/static/js/app.js b/app/static/js/app.js index e81834f..7245413 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -6414,25 +6414,66 @@ async function optimizeDatabase() { btn.innerHTML = '
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 = ' 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 = ' Optimize now'; + restoreButton(); } }