#859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar)
This commit is contained in:
parent
0384de7c8d
commit
f5787764d4
5 changed files with 275 additions and 7 deletions
82
core/database_update_health.py
Normal file
82
core/database_update_health.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Stall detection for the database-update job.
|
||||||
|
|
||||||
|
The DB updater keeps a single in-memory state dict whose ``status`` is set to
|
||||||
|
``running`` at start and only flipped to ``finished``/``error`` by the worker's
|
||||||
|
completion/error callbacks. If the worker thread hangs — e.g. a media-server API
|
||||||
|
call with no timeout, a DB lock — those callbacks never fire, so ``status`` stays
|
||||||
|
``running`` forever and the UI shows a frozen progress bar with no way to recover
|
||||||
|
(GitHub #859).
|
||||||
|
|
||||||
|
This module is the single, *pure* decision for "is a running job stalled?". It
|
||||||
|
takes the state dict plus the current wall-clock time and a timeout, and answers
|
||||||
|
yes/no — no DB, no globals, no clock of its own. That keeps it unit-testable and
|
||||||
|
lets the watchdog wiring in web_server.py stay a thin call. The job carries a
|
||||||
|
``last_progress_at`` epoch timestamp that the start path and every progress/phase
|
||||||
|
callback bump; staleness is simply "running, and that timestamp is older than the
|
||||||
|
timeout".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
# 5 minutes with zero forward progress = presumed hung. A healthy scan ticks
|
||||||
|
# progress (per-artist) far more often than this even for large libraries, so
|
||||||
|
# the timeout won't false-positive a slow-but-working run.
|
||||||
|
DEFAULT_STALL_TIMEOUT_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def is_db_update_stalled(
|
||||||
|
state: Mapping[str, Any],
|
||||||
|
now: float,
|
||||||
|
timeout_seconds: float = DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when the job is ``running`` but has made no progress within
|
||||||
|
``timeout_seconds``.
|
||||||
|
|
||||||
|
Conservative by design — it only ever reports a stall it can prove:
|
||||||
|
- Only a ``running`` job can stall (idle/finished/error never do).
|
||||||
|
- With no usable ``last_progress_at`` timestamp we cannot judge, so we return
|
||||||
|
False rather than risk killing a job we have no clock for.
|
||||||
|
- A non-positive timeout is treated as "disabled" (never stalls).
|
||||||
|
"""
|
||||||
|
if not isinstance(state, Mapping):
|
||||||
|
return False
|
||||||
|
if state.get("status") != "running":
|
||||||
|
return False
|
||||||
|
if timeout_seconds is None or timeout_seconds <= 0:
|
||||||
|
return False
|
||||||
|
last = state.get("last_progress_at")
|
||||||
|
if not last:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
elapsed = float(now) - float(last)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return elapsed >= float(timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def stalled_error_message(state: Mapping[str, Any], now: float) -> str:
|
||||||
|
"""Build a clear, human-facing message for a stalled job, including how long
|
||||||
|
it has been silent and the phase it died in."""
|
||||||
|
last = state.get("last_progress_at") if isinstance(state, Mapping) else None
|
||||||
|
phase = state.get("phase") if isinstance(state, Mapping) else None
|
||||||
|
try:
|
||||||
|
secs = int(float(now) - float(last)) if last else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
secs = 0
|
||||||
|
msg = "Update appears stuck — no progress"
|
||||||
|
if secs > 0:
|
||||||
|
msg += f" for {secs}s"
|
||||||
|
if phase:
|
||||||
|
msg += f" (last phase: {phase})"
|
||||||
|
msg += (". The worker may be hung on the media server. Start a new update "
|
||||||
|
"to try again, or restart SoulSync if it keeps stalling.")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_STALL_TIMEOUT_SECONDS",
|
||||||
|
"is_db_update_stalled",
|
||||||
|
"stalled_error_message",
|
||||||
|
]
|
||||||
92
tests/test_database_update_health.py
Normal file
92
tests/test_database_update_health.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Seam tests for the database-update stall watchdog (GitHub #859).
|
||||||
|
|
||||||
|
A DB-update job can hang (media-server call with no timeout, DB lock) and sit at
|
||||||
|
status='running' forever because the worker's finished/error callbacks never
|
||||||
|
fire. `is_db_update_stalled` is the pure decision that lets the watchdog flip
|
||||||
|
such a job to 'error' so the UI recovers. These tests pin that decision —
|
||||||
|
including the conservative cases where it must NOT false-positive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.database_update_health import (
|
||||||
|
DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||||
|
is_db_update_stalled,
|
||||||
|
stalled_error_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(**over):
|
||||||
|
base = {"status": "running", "phase": "Incremental: scanning",
|
||||||
|
"processed": 2, "total": 3, "progress": 66.7, "last_progress_at": 1000.0}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_and_heartbeat_stale_is_stalled():
|
||||||
|
# last tick at t=1000, now=1000+timeout → exactly at the boundary counts.
|
||||||
|
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS
|
||||||
|
assert is_db_update_stalled(_state(), now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_and_heartbeat_fresh_is_not_stalled():
|
||||||
|
now = 1000.0 + 5 # ticked 5s ago, well within timeout
|
||||||
|
assert is_db_update_stalled(_state(), now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_just_under_timeout_is_not_stalled():
|
||||||
|
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS - 0.001
|
||||||
|
assert is_db_update_stalled(_state(), now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_running_statuses_never_stall():
|
||||||
|
now = 1000.0 + 10_000 # very stale heartbeat
|
||||||
|
for status in ("idle", "finished", "error"):
|
||||||
|
assert is_db_update_stalled(_state(status=status), now) is False, status
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_heartbeat_cannot_judge():
|
||||||
|
# No usable timestamp → we refuse to kill a job we have no clock for.
|
||||||
|
now = 1_000_000.0
|
||||||
|
assert is_db_update_stalled(_state(last_progress_at=0), now) is False
|
||||||
|
assert is_db_update_stalled(_state(last_progress_at=None), now) is False
|
||||||
|
s = _state()
|
||||||
|
del s["last_progress_at"]
|
||||||
|
assert is_db_update_stalled(s, now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_positive_timeout_disables_watchdog():
|
||||||
|
now = 1000.0 + 10_000
|
||||||
|
assert is_db_update_stalled(_state(), now, timeout_seconds=0) is False
|
||||||
|
assert is_db_update_stalled(_state(), now, timeout_seconds=-1) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_inputs_are_safe():
|
||||||
|
assert is_db_update_stalled(None, 123.0) is False
|
||||||
|
assert is_db_update_stalled("not a dict", 123.0) is False
|
||||||
|
assert is_db_update_stalled(_state(last_progress_at="oops"), 1_000_000.0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_timeout_respected():
|
||||||
|
now = 1000.0 + 120
|
||||||
|
assert is_db_update_stalled(_state(), now, timeout_seconds=60) is True
|
||||||
|
assert is_db_update_stalled(_state(), now, timeout_seconds=180) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stalled_message_is_informative():
|
||||||
|
now = 1000.0 + 360
|
||||||
|
msg = stalled_error_message(_state(phase="Incremental: scanning"), now)
|
||||||
|
assert "stuck" in msg.lower()
|
||||||
|
assert "360s" in msg
|
||||||
|
assert "Incremental: scanning" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_859_frozen_running_job_is_caught():
|
||||||
|
"""Regression: the reported state — running, frozen at 2/3 (66.7%), heartbeat
|
||||||
|
long stale — is detected as stalled so the card can self-heal."""
|
||||||
|
state = _state(status="running", processed=2, total=3, progress=66.7,
|
||||||
|
last_progress_at=1000.0)
|
||||||
|
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS + 30
|
||||||
|
assert is_db_update_stalled(state, now) is True
|
||||||
|
# And a healthy job that's actively ticking is left alone.
|
||||||
|
assert is_db_update_stalled(_state(last_progress_at=now - 2), now) is False
|
||||||
|
|
@ -973,6 +973,10 @@ db_update_state = {
|
||||||
"removed_artists": 0,
|
"removed_artists": 0,
|
||||||
"removed_albums": 0,
|
"removed_albums": 0,
|
||||||
"removed_tracks": 0,
|
"removed_tracks": 0,
|
||||||
|
# Heartbeat epoch (seconds): bumped at start and on every progress/phase
|
||||||
|
# callback. The stall watchdog (core.database_update_health) flips a job to
|
||||||
|
# 'error' when this goes stale while status is still 'running' (#859).
|
||||||
|
"last_progress_at": 0,
|
||||||
}
|
}
|
||||||
_db_update_automation_id = None # Set when automation triggers DB update, used by callbacks
|
_db_update_automation_id = None # Set when automation triggers DB update, used by callbacks
|
||||||
db_update_lock = threading.Lock()
|
db_update_lock = threading.Lock()
|
||||||
|
|
@ -8099,7 +8103,8 @@ def request_incremental_database_update():
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
db_update_state.update({
|
db_update_state.update({
|
||||||
"status": "running", "phase": "Initializing...",
|
"status": "running", "phase": "Initializing...",
|
||||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
|
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "",
|
||||||
|
"last_progress_at": time.time(), # seed heartbeat for the stall watchdog
|
||||||
})
|
})
|
||||||
db_update_executor.submit(_run_db_update_task, False, active_server)
|
db_update_executor.submit(_run_db_update_task, False, active_server)
|
||||||
|
|
||||||
|
|
@ -15723,7 +15728,8 @@ def _db_update_progress_callback(current_item, processed, total, percentage):
|
||||||
"current_item": current_item,
|
"current_item": current_item,
|
||||||
"processed": processed,
|
"processed": processed,
|
||||||
"total": total,
|
"total": total,
|
||||||
"progress": percentage
|
"progress": percentage,
|
||||||
|
"last_progress_at": time.time(), # heartbeat for the stall watchdog
|
||||||
})
|
})
|
||||||
_update_automation_progress(_db_update_automation_id,
|
_update_automation_progress(_db_update_automation_id,
|
||||||
progress=percentage, processed=processed, total=total,
|
progress=percentage, processed=processed, total=total,
|
||||||
|
|
@ -15733,6 +15739,7 @@ def _db_update_phase_callback(phase):
|
||||||
logger.info(f"[DB Phase] {phase}")
|
logger.info(f"[DB Phase] {phase}")
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
db_update_state["phase"] = phase
|
db_update_state["phase"] = phase
|
||||||
|
db_update_state["last_progress_at"] = time.time() # heartbeat for the stall watchdog
|
||||||
_update_automation_progress(_db_update_automation_id, phase=phase)
|
_update_automation_progress(_db_update_automation_id, phase=phase)
|
||||||
|
|
||||||
def _db_update_artist_callback(artist_name, success, details, album_count, track_count):
|
def _db_update_artist_callback(artist_name, success, details, album_count, track_count):
|
||||||
|
|
@ -15853,6 +15860,44 @@ def _db_update_error_callback(error_message):
|
||||||
# Add activity for database update error
|
# Add activity for database update error
|
||||||
add_activity_item("", "Database Update Failed", error_message, "Now")
|
add_activity_item("", "Database Update Failed", error_message, "Now")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_db_update_stall():
|
||||||
|
"""Watchdog: flip a hung 'running' DB-update job to 'error' so the UI can
|
||||||
|
recover (#859). A worker that blocks indefinitely (media-server call with no
|
||||||
|
timeout, DB lock) never fires its finished/error callback, so the job would
|
||||||
|
otherwise sit at 'running' forever with a frozen progress bar.
|
||||||
|
|
||||||
|
Idempotent — only acts on the running→stalled transition (after the flip,
|
||||||
|
status != 'running' so the pure check returns False and we don't re-fire).
|
||||||
|
Safe to call from the status endpoint and the 1s broadcast loop. Returns True
|
||||||
|
only on the transition."""
|
||||||
|
from core.database_update_health import (
|
||||||
|
DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||||
|
is_db_update_stalled,
|
||||||
|
stalled_error_message,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timeout = config_manager.get('database.update_stall_timeout_seconds',
|
||||||
|
DEFAULT_STALL_TIMEOUT_SECONDS)
|
||||||
|
except Exception:
|
||||||
|
timeout = DEFAULT_STALL_TIMEOUT_SECONDS
|
||||||
|
now = time.time()
|
||||||
|
with db_update_lock:
|
||||||
|
if not is_db_update_stalled(db_update_state, now, timeout):
|
||||||
|
return False
|
||||||
|
msg = stalled_error_message(db_update_state, now)
|
||||||
|
db_update_state["status"] = "error"
|
||||||
|
db_update_state["error_message"] = msg
|
||||||
|
db_update_state["phase"] = "Stalled"
|
||||||
|
logger.error(f"[DB Update Watchdog] {msg}")
|
||||||
|
# The hung worker paused enrichment/maintenance workers and won't resume them
|
||||||
|
# itself — resume here so a stall doesn't leave them parked indefinitely.
|
||||||
|
try:
|
||||||
|
_resume_workers_after_scan()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[DB Update Watchdog] resume workers failed: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
_workers_paused_by_scan = set() # Track which workers WE paused (don't resume manually-paused ones)
|
_workers_paused_by_scan = set() # Track which workers WE paused (don't resume manually-paused ones)
|
||||||
|
|
||||||
def _pause_workers_for_scan():
|
def _pause_workers_for_scan():
|
||||||
|
|
@ -16719,7 +16764,10 @@ def start_database_update():
|
||||||
db_update_state.update({
|
db_update_state.update({
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"phase": f"{scan_type}: Initializing...",
|
"phase": f"{scan_type}: Initializing...",
|
||||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
|
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "",
|
||||||
|
# Seed the heartbeat now so a worker that hangs during init (before the
|
||||||
|
# first progress/phase callback) is still caught by the stall watchdog.
|
||||||
|
"last_progress_at": time.time(),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add activity for database update start
|
# Add activity for database update start
|
||||||
|
|
@ -16737,6 +16785,7 @@ def start_database_update():
|
||||||
@app.route('/api/database/update/status', methods=['GET'])
|
@app.route('/api/database/update/status', methods=['GET'])
|
||||||
def get_database_update_status():
|
def get_database_update_status():
|
||||||
"""Endpoint to poll for the current update status."""
|
"""Endpoint to poll for the current update status."""
|
||||||
|
_check_db_update_stall() # self-heal a hung job before reporting (#859)
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
# Debug: Log current state occasionally
|
# Debug: Log current state occasionally
|
||||||
if db_update_state["status"] == "running":
|
if db_update_state["status"] == "running":
|
||||||
|
|
@ -37158,6 +37207,7 @@ def _emit_tool_progress_loop():
|
||||||
logger.debug(f"Error emitting duplicate cleaner status: {e}")
|
logger.debug(f"Error emitting duplicate cleaner status: {e}")
|
||||||
# DB Update
|
# DB Update
|
||||||
try:
|
try:
|
||||||
|
_check_db_update_stall() # self-heal a hung job, then broadcast (#859)
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
socketio.emit('tool:db-update', dict(db_update_state))
|
socketio.emit('tool:db-update', dict(db_update_state))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -4063,7 +4063,11 @@ async function handleDbUpdateButtonClick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
button.disabled = true;
|
// Leave the button ENABLED while "Starting..." so it doubles as a
|
||||||
|
// cancel affordance — a wedged start (#859) must stay clickable. A
|
||||||
|
// second click reads "Starting..." and falls through to the stop
|
||||||
|
// branch below, so there's no double-start risk.
|
||||||
|
button.disabled = false;
|
||||||
button.textContent = 'Starting...';
|
button.textContent = 'Starting...';
|
||||||
const response = await fetch('/api/database/update', {
|
const response = await fetch('/api/database/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -4072,14 +4076,19 @@ async function handleDbUpdateButtonClick() {
|
||||||
// scan takes precedence server-side, so send only its flag.
|
// scan takes precedence server-side, so send only its flag.
|
||||||
body: JSON.stringify(isDeepScan ? { deep_scan: true } : { full_refresh: isFullRefresh })
|
body: JSON.stringify(isDeepScan ? { deep_scan: true } : { full_refresh: isFullRefresh })
|
||||||
});
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.ok) {
|
// Check BOTH the HTTP status and the body's success flag — a 200 with
|
||||||
|
// success:false must not be mistaken for a started job (#859).
|
||||||
|
if (response.ok && data.success !== false) {
|
||||||
showToast('Database update started!', 'success');
|
showToast('Database update started!', 'success');
|
||||||
// Start polling immediately to get live status
|
// Start polling immediately to get live status
|
||||||
checkAndUpdateDbProgress();
|
checkAndUpdateDbProgress();
|
||||||
|
// Socket-independent safety net: recovers the card from
|
||||||
|
// "Starting..." even if the WebSocket goes quiet/half-open (#859).
|
||||||
|
armDbUpdateSafetyPoll();
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
showToast(`Error: ${data.error || 'Failed to start update.'}`, 'error');
|
||||||
showToast(`Error: ${errorData.error}`, 'error');
|
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Update Database';
|
button.textContent = 'Update Database';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8280,6 +8280,30 @@ function updateDbProgressFromData(data) {
|
||||||
updateDbProgressUI(data);
|
updateDbProgressUI(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Socket-independent safety net for the DB-updater card. The 1s WebSocket
|
||||||
|
// broadcast normally drives the card, but if the socket goes quiet/half-open the
|
||||||
|
// card can wedge on "Starting..." with a frozen bar and no recovery (#859). This
|
||||||
|
// polls /status directly, applies the same idempotent UI update, and stops itself
|
||||||
|
// once the job is no longer running.
|
||||||
|
let _dbUpdateSafetyPoll = null;
|
||||||
|
function armDbUpdateSafetyPoll() {
|
||||||
|
if (_dbUpdateSafetyPoll) { clearInterval(_dbUpdateSafetyPoll); _dbUpdateSafetyPoll = null; }
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/database/update/status');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const state = await r.json();
|
||||||
|
updateDbProgressUI(state);
|
||||||
|
if (state.status !== 'running') {
|
||||||
|
clearInterval(_dbUpdateSafetyPoll);
|
||||||
|
_dbUpdateSafetyPoll = null;
|
||||||
|
}
|
||||||
|
} catch (e) { /* transient — keep the safety net armed */ }
|
||||||
|
};
|
||||||
|
tick(); // immediate: flip off "Starting..." as soon as the server confirms state
|
||||||
|
_dbUpdateSafetyPoll = setInterval(tick, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
function updateDbProgressUI(state) {
|
function updateDbProgressUI(state) {
|
||||||
const button = document.getElementById('db-update-button');
|
const button = document.getElementById('db-update-button');
|
||||||
const phaseLabel = document.getElementById('db-phase-label');
|
const phaseLabel = document.getElementById('db-phase-label');
|
||||||
|
|
@ -8311,6 +8335,17 @@ function updateDbProgressUI(state) {
|
||||||
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
|
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the bar/label so a finished/idle/error card doesn't keep showing a
|
||||||
|
// frozen partial bar from the previous run (e.g. "2/3 artists 66.7%" left
|
||||||
|
// over after completion) — the #859 confusion. Finished → full bar; any
|
||||||
|
// other terminal state → cleared bar. The phase label carries the summary.
|
||||||
|
if (state.status === 'finished') {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
} else {
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
}
|
||||||
|
progressLabel.textContent = '';
|
||||||
|
|
||||||
if (state.status === 'finished' || state.status === 'error') {
|
if (state.status === 'finished' || state.status === 'error') {
|
||||||
// Final stats refresh after completion/error
|
// Final stats refresh after completion/error
|
||||||
setTimeout(fetchAndUpdateDbStats, 500);
|
setTimeout(fetchAndUpdateDbStats, 500);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue