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:
@@ -9,6 +9,15 @@ Services:
|
||||
- DownloadQueueService: CRUD operations for download queue
|
||||
- UserSessionService: CRUD operations for user sessions
|
||||
|
||||
Transaction Support:
|
||||
All services are designed to work within transaction boundaries.
|
||||
Individual operations use flush() instead of commit() to allow
|
||||
the caller to control transaction boundaries.
|
||||
|
||||
For compound operations spanning multiple services, use the
|
||||
@transactional decorator or atomic() context manager from
|
||||
src.server.database.transaction.
|
||||
|
||||
All services support both async and sync operations for flexibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -438,6 +447,51 @@ class EpisodeService:
|
||||
)
|
||||
return deleted
|
||||
|
||||
@staticmethod
|
||||
async def bulk_mark_downloaded(
|
||||
db: AsyncSession,
|
||||
episode_ids: List[int],
|
||||
file_paths: Optional[List[str]] = None,
|
||||
) -> int:
|
||||
"""Mark multiple episodes as downloaded atomically.
|
||||
|
||||
This operation should be wrapped in a transaction for atomicity.
|
||||
All episodes will be updated or none if an error occurs.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
episode_ids: List of episode primary keys to update
|
||||
file_paths: Optional list of file paths (parallel to episode_ids)
|
||||
|
||||
Returns:
|
||||
Number of episodes updated
|
||||
|
||||
Note:
|
||||
Use within @transactional or atomic() for guaranteed atomicity:
|
||||
|
||||
async with atomic(db) as tx:
|
||||
count = await EpisodeService.bulk_mark_downloaded(
|
||||
db, episode_ids, file_paths
|
||||
)
|
||||
"""
|
||||
if not episode_ids:
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
|
||||
for i, episode_id in enumerate(episode_ids):
|
||||
episode = await EpisodeService.get_by_id(db, episode_id)
|
||||
if episode:
|
||||
episode.is_downloaded = True
|
||||
if file_paths and i < len(file_paths):
|
||||
episode.file_path = file_paths[i]
|
||||
updated_count += 1
|
||||
|
||||
await db.flush()
|
||||
logger.info(f"Bulk marked {updated_count} episodes as downloaded")
|
||||
|
||||
return updated_count
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Download Queue Service
|
||||
@@ -448,6 +502,10 @@ class DownloadQueueService:
|
||||
"""Service for download queue CRUD operations.
|
||||
|
||||
Provides methods for managing the download queue.
|
||||
|
||||
Transaction Support:
|
||||
All operations use flush() for transaction-safe operation.
|
||||
For bulk operations, use @transactional or atomic() context.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -623,6 +681,63 @@ class DownloadQueueService:
|
||||
)
|
||||
return deleted
|
||||
|
||||
@staticmethod
|
||||
async def bulk_delete(
|
||||
db: AsyncSession,
|
||||
item_ids: List[int],
|
||||
) -> int:
|
||||
"""Delete multiple download queue items atomically.
|
||||
|
||||
This operation should be wrapped in a transaction for atomicity.
|
||||
All items will be deleted or none if an error occurs.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
item_ids: List of item primary keys to delete
|
||||
|
||||
Returns:
|
||||
Number of items deleted
|
||||
|
||||
Note:
|
||||
Use within @transactional or atomic() for guaranteed atomicity:
|
||||
|
||||
async with atomic(db) as tx:
|
||||
count = await DownloadQueueService.bulk_delete(db, item_ids)
|
||||
"""
|
||||
if not item_ids:
|
||||
return 0
|
||||
|
||||
result = await db.execute(
|
||||
delete(DownloadQueueItem).where(
|
||||
DownloadQueueItem.id.in_(item_ids)
|
||||
)
|
||||
)
|
||||
|
||||
count = result.rowcount
|
||||
logger.info(f"Bulk deleted {count} download queue items")
|
||||
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def clear_all(
|
||||
db: AsyncSession,
|
||||
) -> int:
|
||||
"""Clear all download queue items.
|
||||
|
||||
Deletes all items from the download queue. This operation
|
||||
should be wrapped in a transaction.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of items deleted
|
||||
"""
|
||||
result = await db.execute(delete(DownloadQueueItem))
|
||||
count = result.rowcount
|
||||
logger.info(f"Cleared all {count} download queue items")
|
||||
return count
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Session Service
|
||||
@@ -633,6 +748,10 @@ class UserSessionService:
|
||||
"""Service for user session CRUD operations.
|
||||
|
||||
Provides methods for managing user authentication sessions with JWT tokens.
|
||||
|
||||
Transaction Support:
|
||||
Session rotation and cleanup operations should use transactions
|
||||
for atomicity when multiple sessions are involved.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -764,6 +883,9 @@ class UserSessionService:
|
||||
async def cleanup_expired(db: AsyncSession) -> int:
|
||||
"""Clean up expired sessions.
|
||||
|
||||
This is a bulk delete operation that should be wrapped in
|
||||
a transaction for atomicity when multiple sessions are deleted.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
@@ -778,3 +900,66 @@ class UserSessionService:
|
||||
count = result.rowcount
|
||||
logger.info(f"Cleaned up {count} expired sessions")
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def rotate_session(
|
||||
db: AsyncSession,
|
||||
old_session_id: str,
|
||||
new_session_id: str,
|
||||
new_token_hash: str,
|
||||
new_expires_at: datetime,
|
||||
user_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Optional[UserSession]:
|
||||
"""Rotate a session by revoking old and creating new atomically.
|
||||
|
||||
This compound operation revokes the old session and creates a new
|
||||
one. Should be wrapped in a transaction for atomicity.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
old_session_id: Session ID to revoke
|
||||
new_session_id: New session ID
|
||||
new_token_hash: New token hash
|
||||
new_expires_at: New expiration time
|
||||
user_id: Optional user identifier
|
||||
ip_address: Optional client IP
|
||||
user_agent: Optional user agent
|
||||
|
||||
Returns:
|
||||
New UserSession instance, or None if old session not found
|
||||
|
||||
Note:
|
||||
Use within @transactional or atomic() for atomicity:
|
||||
|
||||
async with atomic(db) as tx:
|
||||
new_session = await UserSessionService.rotate_session(
|
||||
db, old_id, new_id, hash, expires
|
||||
)
|
||||
"""
|
||||
# Revoke old session
|
||||
old_revoked = await UserSessionService.revoke(db, old_session_id)
|
||||
if not old_revoked:
|
||||
logger.warning(
|
||||
f"Could not rotate: old session {old_session_id} not found"
|
||||
)
|
||||
return None
|
||||
|
||||
# Create new session
|
||||
new_session = await UserSessionService.create(
|
||||
db=db,
|
||||
session_id=new_session_id,
|
||||
token_hash=new_token_hash,
|
||||
expires_at=new_expires_at,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Rotated session: {old_session_id} -> {new_session_id}"
|
||||
)
|
||||
|
||||
return new_session
|
||||
|
||||
|
||||
Reference in New Issue
Block a user