Add database transaction support with atomic operations
- Create transaction.py with @transactional decorator, atomic() context manager - Add TransactionPropagation modes: REQUIRED, REQUIRES_NEW, NESTED - Add savepoint support for nested transactions with partial rollback - Update connection.py with TransactionManager, get_transactional_session - Update service.py with bulk operations (bulk_mark_downloaded, bulk_delete) - Wrap QueueRepository.save_item() and clear_all() in atomic transactions - Add comprehensive tests (66 transaction tests, 90% coverage) - All 1090 tests passing
This commit is contained in:
@@ -6,6 +6,11 @@ and provides the interface needed by DownloadService for queue persistence.
|
||||
The repository pattern abstracts the database operations from the business
|
||||
logic, allowing the DownloadService to work with domain models (DownloadItem)
|
||||
while the repository handles conversion to/from database models.
|
||||
|
||||
Transaction Support:
|
||||
Compound operations (save_item, clear_all) are wrapped in atomic()
|
||||
context managers to ensure all-or-nothing behavior. If any part of
|
||||
a compound operation fails, all changes are rolled back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +26,7 @@ from src.server.database.service import (
|
||||
DownloadQueueService,
|
||||
EpisodeService,
|
||||
)
|
||||
from src.server.database.transaction import atomic
|
||||
from src.server.models.download import (
|
||||
DownloadItem,
|
||||
DownloadPriority,
|
||||
@@ -45,6 +51,10 @@ class QueueRepository:
|
||||
Note: The database model (DownloadQueueItem) is simplified and only
|
||||
stores episode_id as a foreign key. Status, priority, progress, and
|
||||
retry_count are managed in-memory by the DownloadService.
|
||||
|
||||
Transaction Support:
|
||||
All compound operations are wrapped in atomic() transactions.
|
||||
This ensures data consistency even if operations fail mid-way.
|
||||
|
||||
Attributes:
|
||||
_db_session_factory: Factory function to create database sessions
|
||||
@@ -119,9 +129,12 @@ class QueueRepository:
|
||||
item: DownloadItem,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> DownloadItem:
|
||||
"""Save a download item to the database.
|
||||
"""Save a download item to the database atomically.
|
||||
|
||||
Creates a new record if the item doesn't exist in the database.
|
||||
This compound operation (series lookup/create, episode lookup/create,
|
||||
queue item create) is wrapped in a transaction for atomicity.
|
||||
|
||||
Note: Status, priority, progress, and retry_count are NOT persisted.
|
||||
|
||||
Args:
|
||||
@@ -138,60 +151,62 @@ class QueueRepository:
|
||||
manage_session = db is None
|
||||
|
||||
try:
|
||||
# Find series by key
|
||||
series = await AnimeSeriesService.get_by_key(session, item.serie_id)
|
||||
async with atomic(session):
|
||||
# Find series by key
|
||||
series = await AnimeSeriesService.get_by_key(session, item.serie_id)
|
||||
|
||||
if not series:
|
||||
# Create series if it doesn't exist
|
||||
series = await AnimeSeriesService.create(
|
||||
db=session,
|
||||
key=item.serie_id,
|
||||
name=item.serie_name,
|
||||
site="", # Will be updated later if needed
|
||||
folder=item.serie_folder,
|
||||
)
|
||||
logger.info(
|
||||
"Created new series for queue item: key=%s, name=%s",
|
||||
item.serie_id,
|
||||
item.serie_name,
|
||||
)
|
||||
if not series:
|
||||
# Create series if it doesn't exist
|
||||
# Use a placeholder site URL - will be updated later when actual URL is known
|
||||
site_url = getattr(item, 'serie_site', None) or f"https://aniworld.to/anime/{item.serie_id}"
|
||||
series = await AnimeSeriesService.create(
|
||||
db=session,
|
||||
key=item.serie_id,
|
||||
name=item.serie_name,
|
||||
site=site_url,
|
||||
folder=item.serie_folder,
|
||||
)
|
||||
logger.info(
|
||||
"Created new series for queue item: key=%s, name=%s",
|
||||
item.serie_id,
|
||||
item.serie_name,
|
||||
)
|
||||
|
||||
# Find or create episode
|
||||
episode = await EpisodeService.get_by_episode(
|
||||
session,
|
||||
series.id,
|
||||
item.episode.season,
|
||||
item.episode.episode,
|
||||
)
|
||||
|
||||
if not episode:
|
||||
# Create episode if it doesn't exist
|
||||
episode = await EpisodeService.create(
|
||||
db=session,
|
||||
series_id=series.id,
|
||||
season=item.episode.season,
|
||||
episode_number=item.episode.episode,
|
||||
title=item.episode.title,
|
||||
)
|
||||
logger.info(
|
||||
"Created new episode for queue item: S%02dE%02d",
|
||||
# Find or create episode
|
||||
episode = await EpisodeService.get_by_episode(
|
||||
session,
|
||||
series.id,
|
||||
item.episode.season,
|
||||
item.episode.episode,
|
||||
)
|
||||
|
||||
# Create queue item
|
||||
db_item = await DownloadQueueService.create(
|
||||
db=session,
|
||||
series_id=series.id,
|
||||
episode_id=episode.id,
|
||||
download_url=str(item.source_url) if item.source_url else None,
|
||||
)
|
||||
if not episode:
|
||||
# Create episode if it doesn't exist
|
||||
episode = await EpisodeService.create(
|
||||
db=session,
|
||||
series_id=series.id,
|
||||
season=item.episode.season,
|
||||
episode_number=item.episode.episode,
|
||||
title=item.episode.title,
|
||||
)
|
||||
logger.info(
|
||||
"Created new episode for queue item: S%02dE%02d",
|
||||
item.episode.season,
|
||||
item.episode.episode,
|
||||
)
|
||||
|
||||
if manage_session:
|
||||
await session.commit()
|
||||
# Create queue item
|
||||
db_item = await DownloadQueueService.create(
|
||||
db=session,
|
||||
series_id=series.id,
|
||||
episode_id=episode.id,
|
||||
download_url=str(item.source_url) if item.source_url else None,
|
||||
)
|
||||
|
||||
# Update the item ID with the database ID
|
||||
item.id = str(db_item.id)
|
||||
# Update the item ID with the database ID
|
||||
item.id = str(db_item.id)
|
||||
|
||||
# Transaction committed by atomic() context manager
|
||||
|
||||
logger.debug(
|
||||
"Saved queue item to database: item_id=%s, serie_key=%s",
|
||||
@@ -202,8 +217,7 @@ class QueueRepository:
|
||||
return item
|
||||
|
||||
except Exception as e:
|
||||
if manage_session:
|
||||
await session.rollback()
|
||||
# Rollback handled by atomic() context manager
|
||||
logger.error("Failed to save queue item: %s", e)
|
||||
raise QueueRepositoryError(f"Failed to save item: {e}") from e
|
||||
finally:
|
||||
@@ -383,7 +397,10 @@ class QueueRepository:
|
||||
self,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> int:
|
||||
"""Clear all download items from the queue.
|
||||
"""Clear all download items from the queue atomically.
|
||||
|
||||
This bulk delete operation is wrapped in a transaction.
|
||||
Either all items are deleted or none are.
|
||||
|
||||
Args:
|
||||
db: Optional existing database session
|
||||
@@ -398,23 +415,17 @@ class QueueRepository:
|
||||
manage_session = db is None
|
||||
|
||||
try:
|
||||
# Get all items first to count them
|
||||
all_items = await DownloadQueueService.get_all(session)
|
||||
count = len(all_items)
|
||||
|
||||
# Delete each item
|
||||
for item in all_items:
|
||||
await DownloadQueueService.delete(session, item.id)
|
||||
|
||||
if manage_session:
|
||||
await session.commit()
|
||||
async with atomic(session):
|
||||
# Use the bulk clear operation for efficiency and atomicity
|
||||
count = await DownloadQueueService.clear_all(session)
|
||||
|
||||
# Transaction committed by atomic() context manager
|
||||
|
||||
logger.info("Cleared all items from queue: count=%d", count)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
if manage_session:
|
||||
await session.rollback()
|
||||
# Rollback handled by atomic() context manager
|
||||
logger.error("Failed to clear queue: %s", e)
|
||||
raise QueueRepositoryError(f"Failed to clear queue: {e}") from e
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user