archive_purge.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """Archive auto-purge service (#1008 follow-up).
  2. Age-based hard-delete of print archives. Unlike the library trash flow there is
  3. no soft-delete intermediate — archives are historical print records, so the
  4. "undo" window the library bin provides doesn't apply here. A user who wants to
  5. keep an archive should download or favourite it before the purge window elapses.
  6. The sweeper runs on the same 15-minute cadence as the library trash sweeper but
  7. throttles actual purge runs to once per 24h. Admins can also trigger a manual
  8. purge from the Settings UI.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import logging
  13. from datetime import datetime, timedelta, timezone
  14. from sqlalchemy import func, select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.core import database as _database
  17. from backend.app.models.archive import PrintArchive
  18. from backend.app.models.settings import Settings
  19. from backend.app.services.archive import ArchiveService
  20. logger = logging.getLogger(__name__)
  21. AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
  22. AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
  23. AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
  24. DEFAULT_AUTO_PURGE_DAYS = 365
  25. # 7-day floor mirrors the library auto-purge; anything shorter treats archives
  26. # as ephemeral which is rarely what anyone wants.
  27. MIN_AUTO_PURGE_DAYS = 7
  28. MAX_AUTO_PURGE_DAYS = 3650
  29. def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
  30. return now - timedelta(days=older_than_days)
  31. class ArchivePurgeService:
  32. """Manages archive auto-purge sweeper + admin-triggered manual purges."""
  33. def __init__(self):
  34. self._scheduler_task: asyncio.Task | None = None
  35. # Match library trash cadence — the 24h throttle keeps actual work rare.
  36. self._check_interval = 900
  37. async def start_scheduler(self):
  38. if self._scheduler_task is not None:
  39. return
  40. logger.info("Starting archive auto-purge sweeper")
  41. self._scheduler_task = asyncio.create_task(self._scheduler_loop())
  42. def stop_scheduler(self):
  43. if self._scheduler_task:
  44. self._scheduler_task.cancel()
  45. self._scheduler_task = None
  46. logger.info("Stopped archive auto-purge sweeper")
  47. async def _scheduler_loop(self):
  48. while True:
  49. try:
  50. await asyncio.sleep(self._check_interval)
  51. async with _database.async_session() as db:
  52. await self._maybe_run_auto_purge(db)
  53. except asyncio.CancelledError:
  54. break
  55. except Exception as e: # pragma: no cover - defensive
  56. logger.error("Error in archive auto-purge sweeper: %s", e)
  57. await asyncio.sleep(60)
  58. # ---- Settings -----------------------------------------------------
  59. @staticmethod
  60. async def _read_setting(db: AsyncSession, key: str) -> str | None:
  61. result = await db.execute(select(Settings.value).where(Settings.key == key))
  62. return result.scalar_one_or_none()
  63. @staticmethod
  64. async def _write_setting(db: AsyncSession, key: str, value: str) -> None:
  65. result = await db.execute(select(Settings).where(Settings.key == key))
  66. row = result.scalar_one_or_none()
  67. if row is None:
  68. db.add(Settings(key=key, value=value))
  69. else:
  70. row.value = value
  71. async def get_settings(self, db: AsyncSession) -> dict:
  72. """Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
  73. enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
  74. days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
  75. enabled = (enabled_raw or "false").lower() == "true"
  76. try:
  77. days = int(days_raw) if days_raw is not None else DEFAULT_AUTO_PURGE_DAYS
  78. except (TypeError, ValueError):
  79. days = DEFAULT_AUTO_PURGE_DAYS
  80. days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
  81. return {"enabled": enabled, "days": days}
  82. async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
  83. clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
  84. await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
  85. await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
  86. await db.commit()
  87. return {"enabled": enabled, "days": clamped_days}
  88. async def _get_last_run(self, db: AsyncSession) -> datetime | None:
  89. raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
  90. if not raw:
  91. return None
  92. try:
  93. return datetime.fromisoformat(raw.replace("Z", "+00:00"))
  94. except ValueError:
  95. return None
  96. async def _stamp_last_run(self, db: AsyncSession, when: datetime) -> None:
  97. await self._write_setting(db, AUTO_PURGE_LAST_RUN_KEY, when.isoformat())
  98. await db.commit()
  99. async def _maybe_run_auto_purge(self, db: AsyncSession) -> int:
  100. """Run the auto-purge if enabled and >=24h has elapsed since last run."""
  101. cfg = await self.get_settings(db)
  102. if not cfg["enabled"]:
  103. return 0
  104. now = datetime.now(timezone.utc)
  105. last = await self._get_last_run(db)
  106. if last is not None and (now - last) < timedelta(hours=24):
  107. return 0
  108. deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
  109. await self._stamp_last_run(db, now)
  110. if deleted:
  111. logger.info(
  112. "Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
  113. deleted,
  114. cfg["days"],
  115. )
  116. return deleted
  117. # ---- Preview / purge ---------------------------------------------
  118. async def preview_purge(
  119. self,
  120. db: AsyncSession,
  121. older_than_days: int,
  122. sample_limit: int = 5,
  123. ) -> dict:
  124. """Count + size of archives eligible for purge. Read-only."""
  125. if older_than_days < 1:
  126. return {
  127. "count": 0,
  128. "total_bytes": 0,
  129. "sample_filenames": [],
  130. "older_than_days": older_than_days,
  131. }
  132. now = datetime.now(timezone.utc)
  133. cutoff = _age_cutoff(now, older_than_days)
  134. clause = PrintArchive.created_at < cutoff
  135. count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
  136. count = int(count_result.scalar() or 0)
  137. size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
  138. total_bytes = int(size_result.scalar() or 0)
  139. sample_result = await db.execute(
  140. select(PrintArchive.filename).where(clause).order_by(PrintArchive.created_at).limit(sample_limit)
  141. )
  142. samples = [row[0] for row in sample_result.all()]
  143. return {
  144. "count": count,
  145. "total_bytes": total_bytes,
  146. "sample_filenames": samples,
  147. "older_than_days": older_than_days,
  148. }
  149. async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
  150. """Hard-delete archives older than ``older_than_days``. Returns count.
  151. Delegates to :meth:`ArchiveService.delete_archive` for every row so the
  152. on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
  153. same safety-checked path as manual deletion. Each delete runs in its
  154. own session so a commit-per-row doesn't churn the caller's session
  155. (and matches how the sweeper uses :func:`_database.async_session` in production).
  156. """
  157. if older_than_days < 1:
  158. return 0
  159. now = datetime.now(timezone.utc)
  160. cutoff = _age_cutoff(now, older_than_days)
  161. id_result = await db.execute(select(PrintArchive.id).where(PrintArchive.created_at < cutoff))
  162. ids = [row[0] for row in id_result.all()]
  163. if not ids:
  164. return 0
  165. deleted = 0
  166. for archive_id in ids:
  167. async with _database.async_session() as delete_db:
  168. service = ArchiveService(delete_db)
  169. if await service.delete_archive(archive_id):
  170. deleted += 1
  171. if deleted:
  172. logger.info(
  173. "Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
  174. deleted,
  175. older_than_days,
  176. )
  177. return deleted
  178. archive_purge_service = ArchivePurgeService()