e30f50e6fe
Plugs the FC long-running-entity discipline gaps the 2026-06-02 audit flagged: every entity that can get stuck now has recovery + retention + timeout, and the long-runners no longer collide with the FC-3i sweep. Recovery sweeps (every 5 min): - recover_stalled_backup_runs — flips BackupRun stuck in running/restoring past 7h (covers the 6.5h images-backup hard limit) to error. prune_backups docstring corrected — the FC-3i TaskRun sweep never touched BackupRun rows. - recover_stalled_library_audit_runs — flips LibraryAuditRun stuck past 135 min (10-min buffer above scan_library_for_rule's 2h5m hard limit) to error. Previously a SIGKILL'd row blocked all future audits until manual DB surgery. - recover_stalled_import_batches — finalizes ImportBatch rows stuck running >2h whose child tasks are all terminal (orphan case where the orchestrator crashed before the closing UPDATE). Uses the same EXISTS predicate /api/system/stats already had. Retention (daily): - prune_library_audit_runs — 30-day window. Audit rows carry matched_ids JSONB blobs that can hold tens of thousands of ids. - prune_import_batches — 30-day window. Cascades to ImportTask via the model relationship. time_limits on five long-runners that previously had none (the audit's headline finding — every one of these collided with the recover_stalled_task_runs 5-min default and could be marked 'error' mid-flight): - scan_directory: 60m soft / 70m hard - verify_integrity: 60m / 70m - backfill_phash: 30m / 35m - apply_allowlist_tags: 30m / 35m - recompute_centroids: 30m / 35m QUEUE_STUCK_THRESHOLD_MINUTES now covers maintenance (75) and scan (75) — above the longest task on each — with per-task overrides for the outliers (backup_images_task 420, restore_images_task 420, scan_library_for_rule 130). start_audit_run guard is now age-aware: a 'running' row older than the audit hard limit doesn't block a new run (the sweep will catch it within 5 min). Previously a SIGKILL'd row blocked forever. /api/import/status now uses the same EXISTS predicate /api/system/stats does, so the two endpoints no longer disagree on the active-batch question. DownloadEvent.started_at resets on pending→running so a freshly- promoted event from a busy queue isn't measured against its original enqueue time (was racing recover_stalled_download_events on heavy-queue days).
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""FC-3h: backup/restore Celery tasks.
|
|
|
|
All tasks live on the maintenance queue (per celery_app.task_routes).
|
|
task_run lifecycle tracking is automatic via FC-3i signals — these
|
|
tasks just record the operator-facing artifact metadata into
|
|
BackupRun.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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, ImportSettings
|
|
from ..services import backup_service
|
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
|
|
|
log = logging.getLogger(__name__)
|
|
IMAGES_ROOT = Path("/images")
|
|
|
|
|
|
def _mark_failed(session, row: BackupRun, exc: BaseException) -> None:
|
|
"""Flip a BackupRun row from running/restoring to error with a
|
|
truncated error message and finished_at. Caller already holds the
|
|
session open."""
|
|
row.status = "error"
|
|
row.error = f"{type(exc).__name__}: {exc}"[:2000]
|
|
row.finished_at = datetime.now(UTC)
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.backup.backup_db_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=10, retry_backoff_max=120, max_retries=2,
|
|
soft_time_limit=600, time_limit=720,
|
|
)
|
|
def backup_db_task(self, *, tag: str | None = None,
|
|
triggered_by: str = "manual") -> dict:
|
|
"""Create one DB backup. Returns {'backup_run_id': N}."""
|
|
SessionLocal = _sync_session_factory()
|
|
cfg = get_config()
|
|
now = datetime.now(UTC)
|
|
with SessionLocal() as session:
|
|
row = BackupRun(
|
|
kind="db", status="running", tag=tag,
|
|
triggered_by=triggered_by, started_at=now, manifest={},
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
run_id = row.id
|
|
|
|
try:
|
|
result = backup_service.backup_db(
|
|
db_url=cfg.database_url_sync, images_root=IMAGES_ROOT,
|
|
tag=tag, triggered_by=triggered_by,
|
|
)
|
|
except (SoftTimeLimitExceeded, Exception) as exc:
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, run_id)
|
|
if row is not None:
|
|
_mark_failed(session, row, exc)
|
|
raise
|
|
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, run_id)
|
|
row.status = "ok"
|
|
row.finished_at = datetime.now(UTC)
|
|
row.sql_path = result["sql_path"]
|
|
row.size_bytes = result["size_bytes"]
|
|
row.manifest = {
|
|
"manifest_path": result["manifest_path"],
|
|
"ts": result["ts"],
|
|
}
|
|
session.commit()
|
|
return {"backup_run_id": run_id}
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.backup.backup_images_task",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError),
|
|
retry_backoff=30, retry_backoff_max=300, max_retries=1,
|
|
soft_time_limit=21600, time_limit=23400,
|
|
)
|
|
def backup_images_task(self, *, tag: str | None = None,
|
|
triggered_by: str = "manual") -> dict:
|
|
"""Create one images backup. Same shape as backup_db_task; uses
|
|
tar_path instead of sql_path."""
|
|
SessionLocal = _sync_session_factory()
|
|
now = datetime.now(UTC)
|
|
with SessionLocal() as session:
|
|
row = BackupRun(
|
|
kind="images", status="running", tag=tag,
|
|
triggered_by=triggered_by, started_at=now, manifest={},
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
run_id = row.id
|
|
|
|
try:
|
|
result = backup_service.backup_images(
|
|
images_root=IMAGES_ROOT,
|
|
tag=tag, triggered_by=triggered_by,
|
|
)
|
|
except (SoftTimeLimitExceeded, Exception) as exc:
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, run_id)
|
|
if row is not None:
|
|
_mark_failed(session, row, exc)
|
|
raise
|
|
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, run_id)
|
|
row.status = "ok"
|
|
row.finished_at = datetime.now(UTC)
|
|
row.tar_path = result["tar_path"]
|
|
row.size_bytes = result["size_bytes"]
|
|
row.manifest = {
|
|
"manifest_path": result["manifest_path"],
|
|
"ts": result["ts"],
|
|
}
|
|
session.commit()
|
|
return {"backup_run_id": run_id}
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.backup.restore_db_task",
|
|
bind=True,
|
|
max_retries=0, # NEVER auto-retry a half-applied restore.
|
|
soft_time_limit=1200, time_limit=1800,
|
|
)
|
|
def restore_db_task(self, *, source_backup_run_id: int) -> dict:
|
|
"""Restore from a previous DB backup. Inserts a NEW BackupRun row
|
|
(kind='db', status='restoring') linked to the source via
|
|
restored_from_id; flips to 'restored' on success or 'error' on
|
|
failure. Operator sees the restore as a row in the dashboard."""
|
|
SessionLocal = _sync_session_factory()
|
|
cfg = get_config()
|
|
now = datetime.now(UTC)
|
|
with SessionLocal() as session:
|
|
src = session.get(BackupRun, source_backup_run_id)
|
|
if src is None or src.kind != "db" or not src.sql_path:
|
|
raise ValueError(
|
|
f"BackupRun id={source_backup_run_id} is not a valid DB backup"
|
|
)
|
|
marker = BackupRun(
|
|
kind="db", status="restoring",
|
|
triggered_by="restore", started_at=now,
|
|
restored_from_id=src.id,
|
|
manifest={"source_sql_path": src.sql_path},
|
|
)
|
|
session.add(marker)
|
|
session.commit()
|
|
session.refresh(marker)
|
|
marker_id = marker.id
|
|
sql_path = src.sql_path
|
|
|
|
try:
|
|
backup_service.restore_db(
|
|
db_url=cfg.database_url_sync, sql_path=Path(sql_path),
|
|
)
|
|
except (SoftTimeLimitExceeded, Exception) as exc:
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, marker_id)
|
|
if row is not None:
|
|
_mark_failed(session, row, exc)
|
|
raise
|
|
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, marker_id)
|
|
row.status = "restored"
|
|
row.finished_at = datetime.now(UTC)
|
|
session.commit()
|
|
return {"backup_run_id": marker_id}
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.backup.restore_images_task",
|
|
bind=True, max_retries=0,
|
|
soft_time_limit=21600, time_limit=23400,
|
|
)
|
|
def restore_images_task(self, *, source_backup_run_id: int) -> dict:
|
|
"""Mirrors restore_db_task; uses backup_service.restore_images."""
|
|
SessionLocal = _sync_session_factory()
|
|
now = datetime.now(UTC)
|
|
with SessionLocal() as session:
|
|
src = session.get(BackupRun, source_backup_run_id)
|
|
if src is None or src.kind != "images" or not src.tar_path:
|
|
raise ValueError(
|
|
f"BackupRun id={source_backup_run_id} is not a valid images backup"
|
|
)
|
|
marker = BackupRun(
|
|
kind="images", status="restoring",
|
|
triggered_by="restore", started_at=now,
|
|
restored_from_id=src.id,
|
|
manifest={"source_tar_path": src.tar_path},
|
|
)
|
|
session.add(marker)
|
|
session.commit()
|
|
session.refresh(marker)
|
|
marker_id = marker.id
|
|
tar_path = src.tar_path
|
|
|
|
try:
|
|
backup_service.restore_images(
|
|
images_root=IMAGES_ROOT, tar_path=Path(tar_path),
|
|
)
|
|
except (SoftTimeLimitExceeded, Exception) as exc:
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, marker_id)
|
|
if row is not None:
|
|
_mark_failed(session, row, exc)
|
|
raise
|
|
|
|
with SessionLocal() as session:
|
|
row = session.get(BackupRun, marker_id)
|
|
row.status = "restored"
|
|
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 — the
|
|
recover_stalled_backup_runs sweep flips truly-stuck ones to
|
|
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
|
|
handled those, but TaskRun cleanup never touched BackupRun rows.
|
|
Audit 2026-06-02 added the dedicated sweep.)
|
|
"""
|
|
SessionLocal = _sync_session_factory()
|
|
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
|
|
with SessionLocal() as session:
|
|
s = ImportSettings.load_sync(session)
|
|
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 = ImportSettings.load_sync(session)
|
|
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}
|