fc3h: prune_backups (daily retention) + backup_db_nightly (hourly tick, settings-gated)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,12 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..config import get_config
|
||||
from ..models import BackupRun
|
||||
from ..models import BackupRun, ImportSettings
|
||||
from ..services import backup_service
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
@@ -220,3 +221,72 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict:
|
||||
row.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return {"backup_run_id": marker_id}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.prune_backups",
|
||||
soft_time_limit=300, time_limit=600,
|
||||
)
|
||||
def prune_backups() -> dict:
|
||||
"""Daily Beat. Per-kind retention from ImportSettings.
|
||||
|
||||
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
|
||||
Tagged rows (tag IS NOT NULL) are never pruned.
|
||||
Status='running' / 'restoring' rows are never pruned (recovery
|
||||
sweep from FC-3i handles those via task_run).
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
|
||||
with SessionLocal() as session:
|
||||
s = session.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
for kind, keep in (
|
||||
("db", s.backup_db_keep_last_n),
|
||||
("images", s.backup_images_keep_last_n),
|
||||
):
|
||||
candidates = session.execute(
|
||||
select(BackupRun)
|
||||
.where(BackupRun.kind == kind)
|
||||
.where(BackupRun.tag.is_(None))
|
||||
.where(BackupRun.status.in_(["ok", "error"]))
|
||||
.order_by(BackupRun.started_at.desc())
|
||||
.offset(keep)
|
||||
).scalars().all()
|
||||
for row in candidates:
|
||||
result = backup_service.unlink_artifact_files(
|
||||
sql_path=row.sql_path,
|
||||
tar_path=row.tar_path,
|
||||
manifest_path=(row.manifest or {}).get("manifest_path"),
|
||||
)
|
||||
counts["files_unlinked"] += sum(
|
||||
1 for v in result.values() if v
|
||||
)
|
||||
session.delete(row)
|
||||
counts[f"{kind}_deleted"] += 1
|
||||
session.commit()
|
||||
return counts
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.backup_db_nightly",
|
||||
soft_time_limit=60, time_limit=120,
|
||||
)
|
||||
def backup_db_nightly() -> dict:
|
||||
"""Hourly tick. Dispatches a real backup ONLY if the configured
|
||||
UTC hour matches and the nightly setting is enabled. Returns
|
||||
either {'skipped': '<reason>'} or {'dispatched': '<task_id>'}."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
s = session.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
nightly_enabled = s.backup_db_nightly_enabled
|
||||
configured_hour = s.backup_db_nightly_hour_utc
|
||||
if not nightly_enabled:
|
||||
return {"skipped": "nightly disabled"}
|
||||
now_hour = datetime.now(UTC).hour
|
||||
if now_hour != configured_hour:
|
||||
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
|
||||
res = backup_db_task.delay(triggered_by="nightly")
|
||||
return {"dispatched": res.id}
|
||||
|
||||
Reference in New Issue
Block a user