Skip to content

Commit 01e404c

Browse files
committed
Code review fix
1 parent d6cf3a5 commit 01e404c

23 files changed

Lines changed: 1379 additions & 462 deletions

app_analysis.py

Lines changed: 86 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,20 @@
2424
import logging
2525

2626
# Import configuration from the main config.py
27-
from config import NUM_RECENT_ALBUMS, TOP_N_MOODS, TASK_STATUS_PENDING, CLEANING_CATALOGUE
27+
from config import (
28+
NUM_RECENT_ALBUMS,
29+
TOP_N_MOODS,
30+
TASK_STATUS_PENDING,
31+
TASK_STATUS_FAILURE,
32+
CLEANING_CATALOGUE,
33+
)
2834

2935
# RQ import
3036
from rq import Retry
3137

3238
# App helper functions
3339
from app_helper import rq_queue_high, save_task_status
34-
from database import clean_up_previous_main_tasks, get_active_main_task
40+
from database import clean_up_previous_main_tasks, get_active_main_task, main_task_start_lock
3541

3642
logger = logging.getLogger(__name__)
3743

@@ -108,17 +114,6 @@ def start_analysis_endpoint():
108114
500:
109115
description: Server error during task enqueue.
110116
"""
111-
# Check for any existing active main task to prevent parallel batch runs.
112-
active_task = get_active_main_task()
113-
if active_task:
114-
return jsonify(
115-
{
116-
"error": "An active batch task is already in progress.",
117-
"task_id": active_task['task_id'],
118-
"status": active_task['status'],
119-
}
120-
), 409
121-
122117
data = request.json or {}
123118
# MODIFIED: Removed jellyfin_url, jellyfin_user_id, and jellyfin_token as they are no longer passed to the task.
124119
# The task now gets these details from the central config.
@@ -130,22 +125,50 @@ def start_analysis_endpoint():
130125

131126
job_id = str(uuid.uuid4())
132127

133-
# Clean up details of previously successful or stale tasks before starting a new one
134-
clean_up_previous_main_tasks()
135-
save_task_status(
136-
job_id, "main_analysis", TASK_STATUS_PENDING, details={"message": "Task enqueued."}
137-
)
128+
# The gate, the archival and the claim are one atomic act. Checked separately,
129+
# two starts (a double click, or a cron tick landing on a manual start) could
130+
# both see "nothing running" before either had written its row, and then both
131+
# launch - or one archival could revoke the row the other had just created.
132+
with main_task_start_lock():
133+
# Check for any existing active main task to prevent parallel batch runs.
134+
active_task = get_active_main_task()
135+
if active_task:
136+
return jsonify(
137+
{
138+
"error": "An active batch task is already in progress.",
139+
"task_id": active_task['task_id'],
140+
"status": active_task['status'],
141+
}
142+
), 409
143+
144+
# Clean up details of previously successful or stale tasks before starting a new one
145+
clean_up_previous_main_tasks()
146+
save_task_status(
147+
job_id, "main_analysis", TASK_STATUS_PENDING,
148+
details={"message": "Task enqueued."}, raise_on_error=True,
149+
)
138150

139151
# Enqueue task using a string path to its function.
140152
# MODIFIED: The arguments passed to the task are updated to match the new function signature.
141-
job = rq_queue_high.enqueue(
142-
'tasks.analysis.run_analysis_task',
143-
args=(num_recent_albums, top_n_moods),
144-
job_id=job_id,
145-
description="Main Music Analysis",
146-
retry=Retry(max=3),
147-
job_timeout=-1, # No timeout
148-
)
153+
# The PENDING row is already committed, so a failed enqueue must not leave it:
154+
# alive it looks like a running task and 409s every later start, and being
155+
# non-terminal the prune can never reclaim it.
156+
try:
157+
job = rq_queue_high.enqueue(
158+
'tasks.analysis.run_analysis_task',
159+
args=(num_recent_albums, top_n_moods),
160+
job_id=job_id,
161+
description="Main Music Analysis",
162+
retry=Retry(max=3),
163+
job_timeout=-1, # No timeout
164+
)
165+
except Exception:
166+
logger.exception("Could not enqueue the analysis task")
167+
save_task_status(
168+
job_id, "main_analysis", TASK_STATUS_FAILURE,
169+
details={"error": "Could not enqueue the task (is Redis reachable?)"},
170+
)
171+
return jsonify({"error": "Could not enqueue the analysis. Check the logs."}), 500
149172
return jsonify(
150173
{"task_id": job.id, "task_type": "main_analysis", "status": job.get_status()}
151174
), 202
@@ -185,40 +208,50 @@ def start_cleaning_endpoint():
185208
# minutes earlier, so an overlap lets cleaning delete the mappings the sweep just
186209
# wrote. Every other task type may run alongside a sweep, so they keep the
187210
# default exclusion.
188-
active_task = get_active_main_task(exclude_task_types=())
189-
if active_task:
190-
return jsonify(
191-
{
192-
"error": "An active batch task is already in progress.",
193-
"task_id": active_task['task_id'],
194-
"status": active_task['status'],
195-
}
196-
), 409
197-
198211
# Per-run opt-in: when the cleaning page's checkbox is ticked (or CLEANING_CATALOGUE
199212
# is the env default) the task also DELETES catalogue rows bound to no server;
200213
# otherwise it only unbinds each server's stale mappings.
201214
data = request.get_json(silent=True) or {}
202215
clean_catalogue = bool(data.get('clean_catalogue', CLEANING_CATALOGUE))
203216

204-
# Clean up any previous cleaning tasks
205-
clean_up_previous_main_tasks()
206-
207217
job_id = str(uuid.uuid4())
208-
save_task_status(
209-
job_id,
210-
"cleaning",
211-
TASK_STATUS_PENDING,
212-
details={"message": "Database cleaning task enqueued."},
213-
)
218+
219+
with main_task_start_lock():
220+
active_task = get_active_main_task(exclude_task_types=())
221+
if active_task:
222+
return jsonify(
223+
{
224+
"error": "An active batch task is already in progress.",
225+
"task_id": active_task['task_id'],
226+
"status": active_task['status'],
227+
}
228+
), 409
229+
230+
# Clean up any previous cleaning tasks
231+
clean_up_previous_main_tasks()
232+
save_task_status(
233+
job_id,
234+
"cleaning",
235+
TASK_STATUS_PENDING,
236+
details={"message": "Database cleaning task enqueued."},
237+
raise_on_error=True,
238+
)
214239

215240
# Enqueue combined cleaning task
216-
job = rq_queue_high.enqueue(
217-
'tasks.cleaning.identify_and_clean_orphaned_albums_task',
218-
clean_catalogue,
219-
job_id=job_id,
220-
description="Database Cleaning (Identify and Delete Orphaned Albums)",
221-
retry=Retry(max=2),
222-
job_timeout=-1, # No timeout
223-
)
241+
try:
242+
job = rq_queue_high.enqueue(
243+
'tasks.cleaning.identify_and_clean_orphaned_albums_task',
244+
clean_catalogue,
245+
job_id=job_id,
246+
description="Database Cleaning (Identify and Delete Orphaned Albums)",
247+
retry=Retry(max=2),
248+
job_timeout=-1, # No timeout
249+
)
250+
except Exception:
251+
logger.exception("Could not enqueue the cleaning task")
252+
save_task_status(
253+
job_id, "cleaning", TASK_STATUS_FAILURE,
254+
details={"error": "Could not enqueue the task (is Redis reachable?)"},
255+
)
256+
return jsonify({"error": "Could not enqueue the cleaning. Check the logs."}), 500
224257
return jsonify({"task_id": job.id, "task_type": "cleaning", "status": job.get_status()}), 202

app_clustering.py

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676

7777
# App helper functions
7878
from app_helper import rq_queue_high, save_task_status
79-
from database import clean_up_previous_main_tasks, get_active_main_task
79+
from database import clean_up_previous_main_tasks, get_active_main_task, main_task_start_lock
8080

8181

8282
logger = logging.getLogger(__name__)
@@ -324,17 +324,6 @@ def start_clustering_endpoint():
324324
status:
325325
type: string
326326
"""
327-
# Check for any existing active main task to prevent parallel batch runs
328-
active_task = get_active_main_task()
329-
if active_task:
330-
return jsonify(
331-
{
332-
"error": "An active batch task is already in progress.",
333-
"task_id": active_task['task_id'],
334-
"status": active_task['status'],
335-
}
336-
), 409
337-
338327
data = request.json
339328
job_id = str(uuid.uuid4())
340329

@@ -422,21 +411,46 @@ def start_clustering_endpoint():
422411
),
423412
}
424413

425-
# Clean up details of previously successful or stale tasks before starting a new one
426-
clean_up_previous_main_tasks()
427-
save_task_status(
428-
job_id, "main_clustering", TASK_STATUS_PENDING, details={"message": "Task enqueued."}
429-
)
414+
# The gate, the archival and the claim are one atomic act, so two starts cannot
415+
# both see "nothing running" before either has written its row.
416+
with main_task_start_lock():
417+
# Check for any existing active main task to prevent parallel batch runs
418+
active_task = get_active_main_task()
419+
if active_task:
420+
return jsonify(
421+
{
422+
"error": "An active batch task is already in progress.",
423+
"task_id": active_task['task_id'],
424+
"status": active_task['status'],
425+
}
426+
), 409
427+
428+
# Clean up details of previously successful or stale tasks before starting a new one
429+
clean_up_previous_main_tasks()
430+
save_task_status(
431+
job_id, "main_clustering", TASK_STATUS_PENDING,
432+
details={"message": "Task enqueued."}, raise_on_error=True,
433+
)
430434

431-
job = rq_queue_high.enqueue(
432-
'tasks.clustering.run_clustering_task', # Enqueue by string path
433-
kwargs=clustering_kwargs,
434-
job_id=job_id,
435-
description="Main Music Clustering",
436-
retry=Retry(max=3),
437-
job_timeout=-1, # No timeout
438-
on_failure=clustering_task_failure_handler,
439-
)
435+
# A failed enqueue must not leave the committed PENDING row behind: it would
436+
# 409 every later start and the prune cannot reclaim a non-terminal row.
437+
try:
438+
job = rq_queue_high.enqueue(
439+
'tasks.clustering.run_clustering_task', # Enqueue by string path
440+
kwargs=clustering_kwargs,
441+
job_id=job_id,
442+
description="Main Music Clustering",
443+
retry=Retry(max=3),
444+
job_timeout=-1, # No timeout
445+
on_failure=clustering_task_failure_handler,
446+
)
447+
except Exception:
448+
logger.exception("Could not enqueue the clustering task")
449+
save_task_status(
450+
job_id, "main_clustering", TASK_STATUS_FAILURE,
451+
details={"error": "Could not enqueue the task (is Redis reachable?)"},
452+
)
453+
return jsonify({"error": "Could not enqueue the clustering. Check the logs."}), 500
440454
return jsonify(
441455
{"task_id": job.id, "task_type": "main_clustering", "status": job.get_status()}
442456
), 202

app_cron.py

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
save_task_status,
3636
get_active_main_task,
3737
clean_up_previous_main_tasks,
38+
prune_task_status_history,
39+
main_task_start_lock,
3840
INLINE_FLASK_TASK_TYPES,
3941
)
4042
from tasks.task_details import stamp
@@ -541,6 +543,7 @@ def run_due_cron_jobs():
541543
rows = cur.fetchall()
542544
now_ts = time.time()
543545
minute_start = now_ts - (now_ts % 60)
546+
fired_any = False
544547
for r in rows:
545548
try:
546549
if cron_matches_now(r['cron_expr'], now_ts):
@@ -553,6 +556,7 @@ def run_due_cron_jobs():
553556
# dashboard's Last-run display.
554557
if not _claim_cron_minute(db, r['id'], minute_start):
555558
continue
559+
fired_any = True
556560
task_type = r['task_type']
557561
# Batch work always covers every configured server, one server at
558562
# a time. There is no per-schedule scope: a "default server only"
@@ -563,33 +567,37 @@ def run_due_cron_jobs():
563567
if task_type in ('analysis', 'clustering'):
564568
# The manual endpoints 409 while any main task is live; cron used
565569
# to enqueue regardless, so a nightly row could start a second
566-
# full run on top of one still in progress.
567-
active = get_active_main_task()
568-
if active:
569-
logger.info(
570-
"Cron: skipping %s, main task %s is still %s",
571-
task_type, active['task_id'], active['status'],
572-
)
573-
continue
574-
# The same archival the manual Start endpoints run. A headless
575-
# install never presses Start, so without this every nightly
576-
# run left its per-album child rows behind for good.
577-
# Best effort on purpose: housekeeping must never be the
578-
# reason tonight's analysis does not run.
579-
try:
580-
clean_up_previous_main_tasks()
581-
except Exception:
582-
logger.exception(
583-
"Cron: could not archive previous main tasks; enqueueing anyway"
570+
# full run on top of one still in progress. The gate, the
571+
# archival and the claim are taken under one lock so a cron tick
572+
# and a manual Start cannot both pass the gate before either has
573+
# written its row.
574+
with main_task_start_lock():
575+
active = get_active_main_task()
576+
if active:
577+
logger.info(
578+
"Cron: skipping %s, main task %s is still %s",
579+
task_type, active['task_id'], active['status'],
580+
)
581+
continue
582+
# The same archival the manual Start endpoints run. A headless
583+
# install never presses Start, so without this every nightly
584+
# run left its per-album child rows behind for good.
585+
# Best effort on purpose: housekeeping must never be the
586+
# reason tonight's analysis does not run.
587+
try:
588+
clean_up_previous_main_tasks()
589+
except Exception:
590+
logger.exception(
591+
"Cron: could not archive previous main tasks; enqueueing anyway"
592+
)
593+
save_task_status(
594+
job_id,
595+
f"main_{task_type}",
596+
TASK_STATUS_PENDING,
597+
details={"message": _ENQUEUED_BY_CRON},
598+
raise_on_error=True,
584599
)
585600
if task_type == 'analysis':
586-
# mark queued in task_status
587-
save_task_status(
588-
job_id,
589-
f"main_{task_type}",
590-
TASK_STATUS_PENDING,
591-
details={"message": _ENQUEUED_BY_CRON},
592-
)
593601
try:
594602
rq_queue_high.enqueue(
595603
'tasks.analysis.run_analysis_task',
@@ -608,13 +616,6 @@ def run_due_cron_jobs():
608616
details={"error": "Could not enqueue the task (is Redis reachable?)"},
609617
)
610618
elif task_type == 'clustering':
611-
# mark queued in task_status
612-
save_task_status(
613-
job_id,
614-
f"main_{task_type}",
615-
TASK_STATUS_PENDING,
616-
details={"message": _ENQUEUED_BY_CRON},
617-
)
618619
clustering_kwargs = {
619620
"clustering_method": CLUSTER_ALGORITHM,
620621
"num_clusters_min": int(NUM_CLUSTERS_MIN),
@@ -786,3 +787,13 @@ def run_due_cron_jobs():
786787
db.rollback()
787788
logger.exception(f"Error processing cron row {r}")
788789
cur.close()
790+
791+
# Every cron task type writes a top-level task_status row, and the ones that
792+
# never reach clean_up_previous_main_tasks (alchemy_radio, sonic_fingerprint,
793+
# plugin tasks) would otherwise leave one behind per tick, for good. Only on a
794+
# minute that actually fired, so a quiet scheduler does no DB work.
795+
if fired_any:
796+
try:
797+
prune_task_status_history()
798+
except Exception:
799+
logger.exception("Cron: could not prune task_status history")

0 commit comments

Comments
 (0)