2424import 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
3036from rq import Retry
3137
3238# App helper functions
3339from 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
3642logger = 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
0 commit comments