feat(retention): add count-based and days-based retention policies
This commit is contained in:
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models import Job, JobExecution, Backup
|
||||
from backup.adapters import get_adapter
|
||||
from backup.retention import RetentionPolicy
|
||||
|
||||
class BackupEngine:
|
||||
def __init__(self, db: AsyncSession):
|
||||
@@ -109,6 +110,13 @@ class BackupEngine:
|
||||
execution.bytes_processed = total_processed
|
||||
execution.bytes_backed_up = total_backed_up
|
||||
|
||||
# Apply retention policy
|
||||
retention = RetentionPolicy(self.db)
|
||||
keep_count = getattr(job, 'retention_count', None)
|
||||
keep_days = getattr(job, 'retention_days', None)
|
||||
if keep_count or keep_days:
|
||||
await retention.apply_retention_for_job(job_id, keep_count, keep_days)
|
||||
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import shutil
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Backup
|
||||
|
||||
|
||||
class RetentionPolicy:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def apply_retention_for_job(
|
||||
self,
|
||||
job_id: int,
|
||||
keep_count: Optional[int] = None,
|
||||
keep_days: Optional[int] = None
|
||||
) -> List[Backup]:
|
||||
"""
|
||||
Apply retention policy for a job's backups.
|
||||
|
||||
Args:
|
||||
job_id: The job ID to apply retention for
|
||||
keep_count: Maximum number of backups to keep (oldest deleted first)
|
||||
keep_days: Delete backups older than this many days
|
||||
|
||||
Returns:
|
||||
List of deleted backups
|
||||
"""
|
||||
deleted_backups = []
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Backup)
|
||||
.where(Backup.execution.has(job_id=job_id))
|
||||
.order_by(Backup.created_at.asc())
|
||||
)
|
||||
backups = result.scalars().all()
|
||||
|
||||
if not backups:
|
||||
return deleted_backups
|
||||
|
||||
backups_to_delete = set()
|
||||
|
||||
if keep_count is not None and len(backups) > keep_count:
|
||||
backups_to_delete.update(backups[:-keep_count])
|
||||
|
||||
if keep_days is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days)
|
||||
for backup in backups:
|
||||
if backup.created_at < cutoff_date:
|
||||
backups_to_delete.add(backup)
|
||||
|
||||
for backup in list(backups_to_delete):
|
||||
await self._delete_backup(backup)
|
||||
deleted_backups.append(backup)
|
||||
|
||||
await self.db.commit()
|
||||
return deleted_backups
|
||||
|
||||
async def _delete_backup(self, backup: Backup):
|
||||
"""Delete a backup and its storage."""
|
||||
try:
|
||||
storage_path = Path(backup.storage_path)
|
||||
if storage_path.exists():
|
||||
shutil.rmtree(storage_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self.db.delete(backup)
|
||||
|
||||
async def cleanup_orphaned_backups(self) -> int:
|
||||
"""
|
||||
Remove backup records whose storage no longer exists.
|
||||
|
||||
Returns:
|
||||
Number of orphaned backups removed
|
||||
"""
|
||||
result = await self.db.execute(select(Backup))
|
||||
backups = result.scalars().all()
|
||||
|
||||
removed_count = 0
|
||||
for backup in backups:
|
||||
storage_path = Path(backup.storage_path)
|
||||
if not storage_path.exists():
|
||||
await self.db.delete(backup)
|
||||
removed_count += 1
|
||||
|
||||
await self.db.commit()
|
||||
return removed_count
|
||||
Reference in New Issue
Block a user