"""Periodic maintenance: recover stuck import tasks, garbage-collect old finished tasks.""" from datetime import UTC, datetime, timedelta from sqlalchemy import create_engine, delete, select, update from sqlalchemy.orm import sessionmaker from ..celery_app import celery from ..config import get_config from ..models import ImportTask STUCK_THRESHOLD_MINUTES = 30 OLD_TASK_DAYS = 7 def _sync_session_factory(): cfg = get_config() engine = create_engine(cfg.database_url_sync, future=True, pool_pre_ping=True) return sessionmaker(engine, expire_on_commit=False) @celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks") def recover_interrupted_tasks() -> int: """Find ImportTask rows stuck in 'processing' for >30 min and re-queue them. Why 30 min: large videos can legitimately take many minutes to import; 30 is a safe gate that catches actual crashes (which leave the row stuck forever) without resetting slow-but-still-running jobs. """ SessionLocal = _sync_session_factory() cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES) with SessionLocal() as session: stuck_ids = session.execute( select(ImportTask.id) .where(ImportTask.status == "processing") .where(ImportTask.started_at < cutoff) ).scalars().all() if not stuck_ids: return 0 session.execute( update(ImportTask) .where(ImportTask.id.in_(stuck_ids)) .values(status="queued", started_at=None, error="recovered from stuck state") ) session.commit() from .import_file import import_media_file for tid in stuck_ids: import_media_file.delay(tid) return len(stuck_ids) @celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks") def cleanup_old_tasks() -> int: """Delete completed/skipped/failed ImportTask rows older than 7 days. Why 7 days: long enough to debug an issue an operator only notices days later; short enough that the task table stays a useful operational view rather than an archive. Matches IR's default. """ SessionLocal = _sync_session_factory() cutoff = datetime.now(UTC) - timedelta(days=OLD_TASK_DAYS) with SessionLocal() as session: result = session.execute( delete(ImportTask) .where(ImportTask.status.in_(["complete", "skipped", "failed"])) .where(ImportTask.finished_at < cutoff) ) session.commit() return result.rowcount or 0