changes to task scheduling and readability change on downloads view

This commit is contained in:
Bryan Van Deusen
2026-01-29 19:33:04 -05:00
parent 575e20f58c
commit fcccfb24ee
18 changed files with 527 additions and 72 deletions
+166
View File
@@ -23,6 +23,11 @@ settings = get_settings()
# This should be longer than the task_time_limit (1 hour) to account for legitimate long runs
STALE_RUNNING_THRESHOLD_MINUTES = 90
# Maximum time a job can be in "queued" state before considered lost
# If a queued job hasn't been picked up in this time, the Celery task was probably lost
# (e.g., due to Redis DB mismatch or Redis restart)
STALE_QUEUED_THRESHOLD_MINUTES = 30
def get_async_session():
"""Get async session factory for Celery tasks."""
@@ -242,3 +247,164 @@ def reset_all_running_jobs_sync() -> dict:
logger.info("Resetting all running jobs on worker startup...")
result = asyncio.run(_reset_all_running_jobs_async())
return result
async def _requeue_stale_queued_jobs_async(threshold_minutes: int = None) -> dict:
"""Re-queue Celery tasks for downloads stuck in 'queued' status.
Downloads can get stuck in 'queued' status if the Celery task was lost
(e.g., sent to wrong Redis DB, Redis restart, etc.). This function
detects these stale queued jobs and re-sends the Celery tasks.
Only one download per source is re-queued to prevent duplicate work.
Args:
threshold_minutes: Jobs queued longer than this are considered stale.
If None, uses STALE_QUEUED_THRESHOLD_MINUTES.
Returns:
Dict with count of re-queued jobs
"""
if threshold_minutes is None:
threshold_minutes = STALE_QUEUED_THRESHOLD_MINUTES
# Import here to avoid circular imports
from app.tasks.downloads import download_source
session_factory, engine = get_async_session()
try:
async with session_factory() as session:
now = utcnow()
cutoff = now - timedelta(minutes=threshold_minutes)
# Find queued jobs created before the cutoff, ordered by creation time
# so we process older jobs first
query = select(Download).where(
Download.status == DownloadStatus.QUEUED,
Download.created_at < cutoff
).order_by(Download.created_at.asc())
result = await session.execute(query)
stale_jobs = result.scalars().all()
# Track which sources we've already queued to avoid duplicates
queued_source_ids = set()
requeued_count = 0
skipped_duplicates = 0
for job in stale_jobs:
# Skip if we've already queued a job for this source
if job.source_id in queued_source_ids:
# Mark duplicate as skipped so it won't be checked again
job.status = DownloadStatus.SKIPPED
job.error_message = "Duplicate queued job - another job for this source was re-queued"
skipped_duplicates += 1
logger.info(f"Marked duplicate job {job.id} as SKIPPED (source {job.source_id} already re-queued)")
continue
try:
# Re-send the Celery task
download_source.delay(job.source_id, download_id=job.id)
queued_source_ids.add(job.source_id)
requeued_count += 1
logger.info(f"Re-queued stale job {job.id} (source_id: {job.source_id}, queued since: {job.created_at})")
except Exception as e:
logger.error(f"Failed to re-queue job {job.id}: {e}")
# Commit the status changes for skipped duplicates
if skipped_duplicates > 0:
await session.commit()
if requeued_count > 0:
logger.info(f"Re-queued {requeued_count} stale queued jobs (marked {skipped_duplicates} duplicates as SKIPPED)")
else:
logger.debug("No stale queued jobs found")
return {"requeued_count": requeued_count, "skipped_duplicates": skipped_duplicates, "threshold_minutes": threshold_minutes}
finally:
await _cleanup_engine(engine)
async def _requeue_all_queued_jobs_async() -> dict:
"""Re-queue Celery tasks for ALL downloads in 'queued' status.
This is useful on scheduler startup to recover from Redis DB mismatch
or other issues that caused Celery tasks to be lost.
Only one download per source is re-queued to prevent duplicate work.
Returns:
Dict with count of re-queued jobs
"""
# Import here to avoid circular imports
from app.tasks.downloads import download_source
session_factory, engine = get_async_session()
try:
async with session_factory() as session:
# Find all queued jobs, ordered by creation time (oldest first)
query = select(Download).where(
Download.status == DownloadStatus.QUEUED
).order_by(Download.created_at.asc())
result = await session.execute(query)
queued_jobs = result.scalars().all()
# Track which sources we've already queued to avoid duplicates
queued_source_ids = set()
requeued_count = 0
skipped_duplicates = 0
for job in queued_jobs:
# Skip if we've already queued a job for this source
if job.source_id in queued_source_ids:
# Mark duplicate as skipped so it won't be checked again
job.status = DownloadStatus.SKIPPED
job.error_message = "Duplicate queued job - another job for this source was re-queued"
skipped_duplicates += 1
logger.info(f"Marked duplicate job {job.id} as SKIPPED (source {job.source_id} already re-queued)")
continue
try:
download_source.delay(job.source_id, download_id=job.id)
queued_source_ids.add(job.source_id)
requeued_count += 1
logger.info(f"Re-queued job {job.id} (source_id: {job.source_id}) on startup")
except Exception as e:
logger.error(f"Failed to re-queue job {job.id}: {e}")
# Commit the status changes for skipped duplicates
if skipped_duplicates > 0:
await session.commit()
if requeued_count > 0:
logger.info(f"Re-queued {requeued_count} queued jobs on startup (marked {skipped_duplicates} duplicates as SKIPPED)")
return {"requeued_count": requeued_count, "skipped_duplicates": skipped_duplicates}
finally:
await _cleanup_engine(engine)
@celery_app.task(name="tasks.requeue_stale_jobs")
def requeue_stale_jobs(threshold_minutes: int = None) -> dict:
"""Celery task to re-queue stale queued jobs.
Jobs that have been in 'queued' status for longer than the threshold
are considered lost (Celery task missing) and re-queued.
"""
logger.info("Checking for stale queued jobs...")
result = asyncio.run(_requeue_stale_queued_jobs_async(threshold_minutes))
return result
def requeue_all_queued_jobs_sync() -> dict:
"""Synchronous function to re-queue all queued jobs.
Called on scheduler startup to recover lost Celery tasks.
"""
logger.info("Re-queuing all queued jobs on scheduler startup...")
result = asyncio.run(_requeue_all_queued_jobs_async())
return result