feat(download): persist retry state and dead-letter
Retry count and queue status were in-memory only and lost on restart, so failed downloads could not be safely resumed and permanently-failed episodes silently blocked re-queueing via the episode-id unique index. - Add status + retry_count columns to DownloadQueueItem - Replace unique(episode_id) with unique(episode_id, status) so permanently_failed rows do not block new pending entries - Add PERMANENTLY_FAILED to DownloadStatus enum - Persist retry_count on each failure; mark permanently_failed once max_retries reached - QueueRepository reads status/retry_count from DB instead of defaulting to PENDING/0 - Stop double-incrementing retry_count in retry_failed_items; increment only happens in _process_download on failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -748,6 +748,8 @@ class DownloadQueueService:
|
||||
episode_id: int,
|
||||
download_url: Optional[str] = None,
|
||||
file_destination: Optional[str] = None,
|
||||
status: str = "pending",
|
||||
retry_count: int = 0,
|
||||
) -> DownloadQueueItem:
|
||||
"""Add item to download queue.
|
||||
|
||||
@@ -757,6 +759,8 @@ class DownloadQueueService:
|
||||
episode_id: Foreign key to Episode
|
||||
download_url: Optional provider download URL
|
||||
file_destination: Optional target file path
|
||||
status: Queue item status (default: "pending")
|
||||
retry_count: Number of retry attempts (default: 0)
|
||||
|
||||
Returns:
|
||||
Created DownloadQueueItem instance
|
||||
@@ -766,13 +770,15 @@ class DownloadQueueService:
|
||||
episode_id=episode_id,
|
||||
download_url=download_url,
|
||||
file_destination=file_destination,
|
||||
status=status,
|
||||
retry_count=retry_count,
|
||||
)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
await db.refresh(item)
|
||||
logger.info(
|
||||
f"Added to download queue: episode_id={episode_id} "
|
||||
f"for series_id={series_id}"
|
||||
f"for series_id={series_id}, status={status}"
|
||||
)
|
||||
return item
|
||||
|
||||
@@ -799,21 +805,24 @@ class DownloadQueueService:
|
||||
async def get_by_episode(
|
||||
db: AsyncSession,
|
||||
episode_id: int,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> Optional[DownloadQueueItem]:
|
||||
"""Get download queue item by episode ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
episode_id: Foreign key to Episode
|
||||
status_filter: Optional status to filter by (e.g., "pending")
|
||||
|
||||
Returns:
|
||||
DownloadQueueItem instance or None if not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DownloadQueueItem).where(
|
||||
DownloadQueueItem.episode_id == episode_id
|
||||
)
|
||||
query = select(DownloadQueueItem).where(
|
||||
DownloadQueueItem.episode_id == episode_id
|
||||
)
|
||||
if status_filter:
|
||||
query = query.where(DownloadQueueItem.status == status_filter)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
@@ -873,6 +882,95 @@ class DownloadQueueService:
|
||||
logger.debug("Set error on download queue item %s", item_id)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
async def set_status(
|
||||
db: AsyncSession,
|
||||
item_id: int,
|
||||
status: str,
|
||||
) -> Optional[DownloadQueueItem]:
|
||||
"""Set status on download queue item.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item_id: Item primary key
|
||||
status: New status value
|
||||
|
||||
Returns:
|
||||
Updated DownloadQueueItem instance or None if not found
|
||||
"""
|
||||
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.status = status
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(item)
|
||||
logger.debug("Set status on download queue item %s to %s", item_id, status)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
async def increment_retry_count(
|
||||
db: AsyncSession,
|
||||
item_id: int,
|
||||
) -> Optional[DownloadQueueItem]:
|
||||
"""Increment retry count on download queue item.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item_id: Item primary key
|
||||
|
||||
Returns:
|
||||
Updated DownloadQueueItem instance or None if not found
|
||||
"""
|
||||
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.retry_count += 1
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(item)
|
||||
logger.debug(
|
||||
"Incremented retry count on download queue item %s to %s",
|
||||
item_id, item.retry_count
|
||||
)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
async def set_status_and_error(
|
||||
db: AsyncSession,
|
||||
item_id: int,
|
||||
status: str,
|
||||
error_message: Optional[str] = None,
|
||||
) -> Optional[DownloadQueueItem]:
|
||||
"""Set status and error message on download queue item atomically.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item_id: Item primary key
|
||||
status: New status value
|
||||
error_message: Optional error description
|
||||
|
||||
Returns:
|
||||
Updated DownloadQueueItem instance or None if not found
|
||||
"""
|
||||
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.status = status
|
||||
if error_message is not None:
|
||||
item.error_message = error_message
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(item)
|
||||
logger.debug(
|
||||
"Set status=%s on download queue item %s, error=%s",
|
||||
status, item_id, error_message
|
||||
)
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, item_id: int) -> bool:
|
||||
"""Delete download queue item.
|
||||
|
||||
Reference in New Issue
Block a user