Files
onever_drive/backend/app/services/retention_service.py
T

111 lines
4.2 KiB
Python

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