from datetime import datetime, timedelta, timezone from typing import List, Set from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models.models import BackupJob, BackupFile, Client from app.storage.local import storage_provider from app.services.event_service import log_event async def apply_retention_policy(db: AsyncSession, job_id: int) -> int: """ Applies retention rules (daily, weekly, monthly) for a specific backup job. Safely deletes obsolete backup files from storage and database. Returns the number of pruned backup files. """ # Fetch job details result = await db.execute(select(BackupJob).where(BackupJob.id == job_id)) job = result.scalar_one_or_none() if not job: return 0 # Fetch all active backup files for this job ordered by creation date descending result = await db.execute( select(BackupFile) .where(BackupFile.job_id == job_id, BackupFile.is_active == True) .order_by(BackupFile.created_at.desc()) ) backup_files: List[BackupFile] = result.scalars().all() if not backup_files: return 0 now = datetime.now(timezone.utc) protected_file_ids: Set[int] = set() # Always keep the most recent backup regardless of age to avoid empty repo protected_file_ids.add(backup_files[0].id) # 1. Daily retention: Keep 1 backup per calendar day for the last `job.keep_daily` days seen_days = set() for bf in backup_files: day_key = bf.created_at.strftime("%Y-%m-%d") age_days = (now - bf.created_at).total_seconds() / 86400.0 if age_days <= job.keep_daily: if day_key not in seen_days: seen_days.add(day_key) protected_file_ids.add(bf.id) # 2. Weekly retention: Keep 1 backup per calendar week for the last `job.keep_weekly` weeks seen_weeks = set() for bf in backup_files: week_key = f"{bf.created_at.year}-W{bf.created_at.isocalendar()[1]:02d}" age_weeks = (now - bf.created_at).total_seconds() / (86400.0 * 7) if age_weeks <= job.keep_weekly: if week_key not in seen_weeks: seen_weeks.add(week_key) protected_file_ids.add(bf.id) # 3. Monthly retention: Keep 1 backup per calendar month for the last `job.keep_monthly` months seen_months = set() for bf in backup_files: month_key = bf.created_at.strftime("%Y-%m") # Approximate age in months (30 days per month) age_months = (now - bf.created_at).total_seconds() / (86400.0 * 30.4375) if age_months <= job.keep_monthly: if month_key not in seen_months: seen_months.add(month_key) protected_file_ids.add(bf.id) # Identify files to delete files_to_delete = [bf for bf in backup_files if bf.id not in protected_file_ids] pruned_count = 0 reclaimed_bytes = 0 for bf in files_to_delete: try: # Delete from physical storage await storage_provider.delete_backup_file(bf.relative_path) bf.is_active = False pruned_count += 1 reclaimed_bytes += bf.file_size except Exception as ex: # Log failure but continue processing other files await log_event( db=db, event_type="RETENTION_ERROR", message=f"Failed to delete pruned backup file {bf.filename}: {str(ex)}", severity="WARNING", client_id=job.client_id, job_id=job.id ) if pruned_count > 0: # Update client storage used counter client_res = await db.execute(select(Client).where(Client.id == job.client_id)) client = client_res.scalar_one_or_none() if client: client.storage_used_bytes = max(0, client.storage_used_bytes - reclaimed_bytes) await db.commit() await log_event( db=db, event_type="RETENTION_APPLIED", message=f"Retention policy applied for job '{job.name}': {pruned_count} obsolete backups removed, {reclaimed_bytes / (1024*1024):.2f} MB reclaimed.", severity="INFO", client_id=job.client_id, job_id=job.id ) return pruned_count