Files
wordcloud/backend/service/cleanup_service.py
T

146 lines
5.3 KiB
Python

"""Deterministic retention preview and physical cleanup for jobs and products."""
from __future__ import annotations
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from pydantic import BaseModel, Field
from .metadata_store import MetadataStore
from .product_archive_store import ProductArchiveStore
from .schemas import ProductRecord
from .storage import Storage
TEMPORARY_RETENTION = timedelta(days=30)
REMINDER_AGE = timedelta(days=23)
class TemporaryJob(BaseModel):
job_id: str
created_at: datetime
size_bytes: int
class CleanupReport(BaseModel):
temporary_jobs: list[TemporaryJob] = Field(default_factory=list)
pending_products: list[ProductRecord] = Field(default_factory=list)
reclaimable_bytes: int = 0
reminder_job_ids: list[str] = Field(default_factory=list)
deleted_job_ids: list[str] = Field(default_factory=list)
deleted_product_ids: list[str] = Field(default_factory=list)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
class CleanupService:
"""Calculate candidates first and apply only those still eligible."""
def __init__(
self,
storage: Storage,
metadata_store: MetadataStore,
product_store: ProductArchiveStore,
) -> None:
self.storage = storage
self.metadata_store = metadata_store
self.product_store = product_store
def archive_protected_job_ids(self) -> set[str]:
rows = self.product_store._fetchall(
"""SELECT DISTINCT source_job_id
FROM product_wordcloud_archives
WHERE source_job_id != ''"""
)
return {str(row["source_job_id"]) for row in rows}
def _is_archive_protected(self, job_id: str) -> bool:
row = self.product_store._fetchone(
"""SELECT 1 FROM product_wordcloud_archives
WHERE source_job_id = ? LIMIT 1""",
(job_id,),
)
return row is not None
def preview(self, now: datetime) -> CleanupReport:
now_utc = _as_utc(now)
protected = self.archive_protected_job_ids()
temporary_jobs: list[TemporaryJob] = []
reminder_job_ids: list[str] = []
for job in self.metadata_store.load_jobs():
if job.status != "success" or not job.artifacts.get("db") or job.job_id in protected:
continue
age = now_utc - _as_utc(job.created_at)
if age >= TEMPORARY_RETENTION:
temporary_jobs.append(
TemporaryJob(
job_id=job.job_id,
created_at=job.created_at,
size_bytes=self.storage.job_dir_size(job.job_id),
)
)
elif age >= REMINDER_AGE:
reminder_job_ids.append(job.job_id)
temporary_jobs.sort(key=lambda item: (item.created_at, item.job_id))
reminder_job_ids.sort()
pending_products = self.product_store.due_product_cleanups(now_utc)
product_bytes = sum(
self._directory_size(self.product_store.root.parent / product.product_id)
for product in pending_products
)
return CleanupReport(
temporary_jobs=temporary_jobs,
pending_products=pending_products,
reclaimable_bytes=sum(item.size_bytes for item in temporary_jobs) + product_bytes,
reminder_job_ids=reminder_job_ids,
)
def apply(self, now: datetime) -> CleanupReport:
now_utc = _as_utc(now)
report = self.preview(now_utc)
for item in report.temporary_jobs:
# An archive can be committed after preview. Protect it at the last
# possible point before removing the original workspace.
if self._is_archive_protected(item.job_id):
continue
self.storage.remove_job_dir(item.job_id)
self.metadata_store.delete_job(item.job_id)
report.deleted_job_ids.append(item.job_id)
for candidate in report.pending_products:
product = self.product_store.claim_product_purge(candidate.product_id, now_utc)
if product is None:
continue
product_dir = self.product_store.root.parent / product.product_id
try:
self._before_product_files_delete(product.product_id)
if product_dir.exists():
shutil.rmtree(product_dir)
except Exception:
# rmtree may have removed only part of the archive. Quarantine
# the record for manual inspection instead of retrying it.
self.product_store.fail_product_purge(product.product_id)
raise
self.product_store.finalize_product_purge(product.product_id, now=now_utc)
report.deleted_product_ids.append(product.product_id)
return report
def _before_product_files_delete(self, product_id: str) -> None:
"""Interleaving seam between the durable claim and physical deletion."""
@staticmethod
def _directory_size(root: Path) -> int:
if not root.exists():
return 0
return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())