better lifecycle visibility implementation.

This commit is contained in:
Bryan Van Deusen
2026-01-27 17:33:25 -05:00
parent d2e4d6c588
commit 13b4da5ca0
5 changed files with 132 additions and 24 deletions
+2 -2
View File
@@ -111,8 +111,8 @@ async def retry_download(download_id: int):
if download.status != DownloadStatus.FAILED: if download.status != DownloadStatus.FAILED:
return jsonify({"error": "Can only retry failed downloads"}), 400 return jsonify({"error": "Can only retry failed downloads"}), 400
# Reset status to pending # Reset status to queued
download.status = DownloadStatus.PENDING download.status = DownloadStatus.QUEUED
download.error_type = None download.error_type = None
download.error_message = None download.error_message = None
await session.commit() await session.commit()
+24 -1
View File
@@ -5,8 +5,10 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models.base import utcnow
from app.models.source import Source from app.models.source import Source
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.download import Download, DownloadStatus
from app.tasks.downloads import download_source from app.tasks.downloads import download_source
from app.api.settings import get_setting_value from app.api.settings import get_setting_value
@@ -216,13 +218,34 @@ async def trigger_check(source_id: int):
if not source: if not source:
return jsonify({"error": "Source not found"}), 404 return jsonify({"error": "Source not found"}), 404
# Check for existing queued or running download for this source
existing = await session.execute(
select(Download).where(
Download.source_id == source.id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
if existing.scalar():
return jsonify({"error": "A download is already queued or running for this source"}), 409
# Create download record in QUEUED state for visibility
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
# Queue Celery task for immediate download # Queue Celery task for immediate download
task = download_source.delay(source_id) task = download_source.delay(source_id, download_id=download.id)
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}") current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}")
return jsonify({ return jsonify({
"message": "Check queued", "message": "Check queued",
"source_id": source_id, "source_id": source_id,
"download_id": download.id,
"subscription_name": source.subscription.name, "subscription_name": source.subscription.name,
"platform": source.platform, "platform": source.platform,
"task_id": task.id, "task_id": task.id,
+38 -7
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import selectinload
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.source import Source from app.models.source import Source
from app.models.download import Download, DownloadStatus
from app.tasks.downloads import download_source from app.tasks.downloads import download_source
from app.api.settings import get_setting_value from app.api.settings import get_setting_value
@@ -303,16 +304,46 @@ async def trigger_subscription_check(subscription_id: int):
enabled_sources = [s for s in subscription.sources if s.enabled] enabled_sources = [s for s in subscription.sources if s.enabled]
# Queue Celery tasks for each enabled source # Get source IDs that already have a queued or running download
task_ids = [] active_result = await session.execute(
for source in enabled_sources: select(Download.source_id).where(
task = download_source.delay(source.id) Download.source_id.in_([s.id for s in enabled_sources]),
task_ids.append(task.id) Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
active_source_ids = set(active_result.scalars().all())
current_app.logger.info(f"Triggered check for subscription: {subscription.name} ({len(enabled_sources)} sources)") # Create download records and queue Celery tasks for each eligible source
task_ids = []
download_ids = []
skipped = 0
for source in enabled_sources:
if source.id in active_source_ids:
skipped += 1
continue
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
task = download_source.delay(source.id, download_id=download.id)
task_ids.append(task.id)
download_ids.append(download.id)
current_app.logger.info(
f"Triggered check for subscription: {subscription.name} "
f"({len(download_ids)} queued, {skipped} already active)"
)
return jsonify({ return jsonify({
"message": "Checks queued", "message": "Checks queued",
"subscription_id": subscription_id, "subscription_id": subscription_id,
"source_count": len(enabled_sources), "source_count": len(download_ids),
"skipped_active": skipped,
"task_ids": task_ids, "task_ids": task_ids,
"download_ids": download_ids,
}), 202 }), 202
+2 -1
View File
@@ -11,7 +11,8 @@ from app.models.base import Base, TimestampMixin, format_datetime
class DownloadStatus: class DownloadStatus:
"""Download status constants.""" """Download status constants."""
PENDING = "pending" QUEUED = "queued"
PENDING = "pending" # Legacy: used by retry flow
RUNNING = "running" RUNNING = "running"
COMPLETED = "completed" COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
+66 -13
View File
@@ -41,11 +41,12 @@ async def _cleanup_engine(engine):
await engine.dispose() await engine.dispose()
async def _download_source_async(source_id: int) -> dict: async def _download_source_async(source_id: int, download_id: int = None) -> dict:
"""Async implementation of source download. """Async implementation of source download.
Args: Args:
source_id: ID of the source to download source_id: ID of the source to download
download_id: Optional ID of a pre-created Download record (QUEUED state)
Returns: Returns:
Dict with download results Dict with download results
@@ -68,19 +69,42 @@ async def _download_source_async(source_id: int) -> dict:
if not source.enabled: if not source.enabled:
logger.info(f"Source {source.subscription.name}/{source.platform} is disabled, skipping") logger.info(f"Source {source.subscription.name}/{source.platform} is disabled, skipping")
# If there's a queued download record, mark it as skipped
if download_id:
dl_result = await session.execute(
select(Download).where(Download.id == download_id)
)
dl = dl_result.scalar()
if dl:
dl.status = DownloadStatus.SKIPPED
await session.commit()
return {"source_id": source_id, "status": "skipped", "reason": "disabled"} return {"source_id": source_id, "status": "skipped", "reason": "disabled"}
subscription_name = source.subscription.name subscription_name = source.subscription.name
logger.info(f"Starting download for {subscription_name}/{source.platform}") logger.info(f"Starting download for {subscription_name}/{source.platform}")
# Create download record # Use existing download record or create a new one
download = Download( if download_id:
source_id=source.id, result = await session.execute(
url=source.url, select(Download).where(Download.id == download_id)
status=DownloadStatus.RUNNING, )
started_at=utcnow(), download = result.scalar()
) if download:
session.add(download) download.status = DownloadStatus.RUNNING
download.started_at = utcnow()
else:
logger.warning(f"Download {download_id} not found, creating new record")
download = None
if not download_id or download is None:
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.RUNNING,
started_at=utcnow(),
)
session.add(download)
await session.commit() await session.commit()
await session.refresh(download) await session.refresh(download)
@@ -261,13 +285,41 @@ async def _scheduled_check_async() -> dict:
logger.info(f"Found {len(due_sources)} sources due for checking") logger.info(f"Found {len(due_sources)} sources due for checking")
# Queue download tasks for each source # Get source IDs that already have a queued or running download
due_source_ids = [s.id for s in due_sources]
active_result = await session.execute(
select(Download.source_id).where(
Download.source_id.in_(due_source_ids),
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
active_source_ids = set(active_result.scalars().all())
# Create download records and queue tasks for each eligible source
queued = 0 queued = 0
skipped = 0
for source in due_sources: for source in due_sources:
download_source.delay(source.id) if source.id in active_source_ids:
skipped += 1
logger.debug(f"Skipping {source.subscription.name}/{source.platform} - already active")
continue
download = Download(
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
download_source.delay(source.id, download_id=download.id)
queued += 1 queued += 1
logger.debug(f"Queued download for {source.subscription.name}/{source.platform}") logger.debug(f"Queued download for {source.subscription.name}/{source.platform}")
if skipped:
logger.info(f"Skipped {skipped} sources with active downloads")
return { return {
"checked": len(sources), "checked": len(sources),
"queued": queued, "queued": queued,
@@ -327,14 +379,15 @@ async def _cleanup_old_downloads_async() -> dict:
# Celery tasks that wrap async functions # Celery tasks that wrap async functions
@celery_app.task(bind=True, max_retries=3) @celery_app.task(bind=True, max_retries=3)
def download_source(self, source_id: int): def download_source(self, source_id: int, download_id: int = None):
"""Download new content from a single source. """Download new content from a single source.
Args: Args:
source_id: ID of the source to download from source_id: ID of the source to download from
download_id: Optional ID of a pre-created Download record (QUEUED state)
""" """
try: try:
return asyncio.run(_download_source_async(source_id)) return asyncio.run(_download_source_async(source_id, download_id=download_id))
except Exception as e: except Exception as e:
logger.exception(f"Task failed for source {source_id}: {e}") logger.exception(f"Task failed for source {source_id}: {e}")
# Retry with exponential backoff # Retry with exponential backoff