Files
FabledCurator/backend/app/tasks/scan.py
T
bvandeusen a85880f965 fix(import): split archive imports into their own task + budget; archive-aware recovery sweeps
Operator-flagged 2026-05-28: import_media_file on target 1645019 hit
SoftTimeLimitExceeded at exactly 5.0 min. Their diagnosis was correct —
the timeout covered the WHOLE archive, not per object. Importer._import_archive
(importer.py:409) runs the full per-member pipeline (sha256 + pHash +
dedup query + copy + provenance) for EVERY media member inline, all
under import_media_file's single 300s soft limit. A single media file
is sub-second; a multi-hundred-member archive blows the budget. They
shared one task name and one timeout.

**Split archive into its own task**

- New `import_archive_file` task: same body as import_media_file
  (dispatch is by file-kind inside Importer.import_one) but
  soft=30min / hard=35min. Shared `_run_import_task` helper holds the
  flip-to-processing + resilience-contract wrapper; both tasks call it.
- New `enqueue_import(task_id, task_type)` router — single source of
  truth for media-vs-archive dispatch. Used by all three enqueue sites:
  scan_directory, /api/import/retry-failed, recover_interrupted_tasks.
- scan_directory now sets ImportTask.task_type = "archive" when
  is_archive(entry) (the model field already existed, anticipating
  this; scan was hardcoding "media").
- import_archive_file routes to the existing 'import' queue via the
  task_routes `import_file.*` wildcard — no worker config change.

**Archive-aware recovery sweeps**

Both sweeps would otherwise preempt a legitimately-running archive:

- recover_interrupted_tasks (ImportTask 'processing' sweep): now
  task-type-aware. Media stays at STUCK_THRESHOLD_MINUTES (5); archives
  get ARCHIVE_STUCK_THRESHOLD_MINUTES (40 = 5-min buffer past the
  35-min hard limit). Single UPDATE with an OR predicate over the two
  (task_type, cutoff) pairs; requeue routes via enqueue_import.
- recover_stalled_task_runs (TaskRun 'running' sweep): now supports
  per-task-name overrides (TASK_STUCK_THRESHOLD_MINUTES) layered above
  the per-queue overrides added for ml. import_archive_file gets 40 min
  while the 'import' queue stays at the 5-min default for single-file
  imports. Precedence: task_name → queue → default, each pass excluding
  rows claimed by a higher-precedence pass so every row is touched once.

**Tests**

- test_import_archive_file_registered
- test_recover_stalled_task_runs_archive_task_uses_longer_threshold —
  pins that a 10-min archive task-run survives, a 50-min one is flagged,
  and a same-queue 10-min media import is flagged at the default.
- _make_task_run gains queue= + task_name= params.

After deploy: archive imports get a 30-min budget and aren't preempted
by either sweep; single-file imports keep their tight 5-min detection.
2026-05-27 22:45:11 -04:00

196 lines
7.2 KiB
Python

"""scan tasks:
* ``scan_directory`` — walks the configured import path, creates an
ImportBatch, enumerates files into ImportTasks, and enqueues
import_media_file for each.
* ``tick_due_sources`` (FC-3d) — periodic Beat-fired scan that fires
``download_source.delay()`` for sources due for a check.
"""
import asyncio
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery
from ..config import get_config
from ..models import DownloadEvent, ImportBatch, ImportSettings, ImportTask
from ..services.archive_extractor import is_archive
from ..services.scheduler_service import select_due_sources
from ._sync_engine import sync_session_factory as _sync_session_factory
def _iter_import_files(import_root: Path):
"""Every regular file except sidecar .json, dotfiles, and .partial
temp files. The Importer dispatches by kind (media / archive /
other) — scan no longer filters to media (FC-2d-iii)."""
for entry in import_root.rglob("*"):
if not entry.is_file():
continue
if entry.name.startswith("."):
continue
if entry.suffix.lower() in (".json", ".partial"):
continue
yield entry
@celery.task(name="backend.app.tasks.scan.scan_directory", bind=True)
def scan_directory(self, triggered_by: str = "manual",
mode: str = "quick") -> int:
"""Walks the import root and creates ImportTasks. `mode` is 'quick'
or 'deep'; deep also enqueues a library-wide phash backfill and (via
Importer.deep) re-derives on already-imported files. Returns the
batch id."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
import_root = Path(settings.import_scan_path)
batch = ImportBatch(
triggered_by=triggered_by,
source_path=str(import_root),
scan_mode=mode,
status="running",
)
session.add(batch)
session.flush()
batch_id = batch.id
# Skip-set behavior splits by mode (operator-flagged 2026-05-25):
#
# quick: any non-failed prior ImportTask (active OR finished) is
# skipped — quick scan only does new-file enqueue, so re-touching
# already-imported files is wasted work.
#
# deep: ONLY currently-in-flight tasks (pending/queued/processing)
# are skipped. Completed and skipped tasks ARE re-queued because
# deep scan exists precisely to re-touch already-imported files
# (refresh sidecar metadata, fill NULL phash, fill NULL artist
# via Importer._deep_rederive). Matches IR's deep-scan behavior.
active_statuses = ["pending", "queued", "processing"]
if mode == "deep":
skip_statuses = active_statuses
else:
skip_statuses = active_statuses + ["complete", "skipped"]
non_failed_existing = set(session.execute(
select(ImportTask.source_path).where(
ImportTask.status.in_(skip_statuses),
)
).scalars().all())
# Walk and enumerate.
files_seen = 0
files_skipped_existing = 0
for entry in _iter_import_files(import_root):
entry_str = str(entry)
if entry_str in non_failed_existing:
files_skipped_existing += 1
continue
try:
size = entry.stat().st_size
except OSError:
size = None
task = ImportTask(
batch_id=batch_id,
source_path=entry_str,
# Archives route to import_archive_file (larger time
# budget) — they run the per-member pipeline inline.
task_type="archive" if is_archive(entry) else "media",
status="pending",
size_bytes=size,
)
session.add(task)
files_seen += 1
batch.total_files = files_seen
# If the walk enqueued nothing (every file was already on a
# non-failed ImportTask from a prior scan), there's no
# import_media_file message that would ever flip this batch to
# 'complete' — finalize it now so the active-batch query in
# /api/system/stats doesn't get stuck reporting all-zero
# counters from an empty scan.
if files_seen == 0:
batch.status = "complete"
batch.finished_at = datetime.now(UTC)
session.commit()
# Now enqueue each pending task on the right Celery task
# (media vs archive) via the shared router.
from .import_file import enqueue_import
for task in session.execute(
select(ImportTask).where(ImportTask.batch_id == batch_id)
).scalars():
task.status = "queued"
session.add(task)
enqueue_import(task.id, task.task_type)
session.commit()
if mode == "deep":
from .maintenance import backfill_phash
backfill_phash.delay()
return batch_id
# --- FC-3d: periodic source-check tick ------------------------------------
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
async def _tick_due_sources_async() -> dict:
factory, engine = _async_session_factory()
try:
async with factory() as session:
due = await select_due_sources(session)
if not due:
return {
"due": 0, "queued": 0, "skipped_in_flight": 0,
"tick_at": datetime.now(UTC).isoformat(),
}
due_ids = [s.id for s in due]
in_flight_rows = (await session.execute(
select(DownloadEvent.source_id).where(
DownloadEvent.source_id.in_(due_ids),
DownloadEvent.status.in_(["pending", "running"]),
)
)).scalars().all()
in_flight = set(in_flight_rows)
# Local import: keep top of module light, avoid circular import
# at celery task discovery time.
from .download import download_source
queued = 0
for s in due:
if s.id in in_flight:
continue
session.add(DownloadEvent(source_id=s.id, status="pending"))
await session.commit()
download_source.delay(s.id)
queued += 1
return {
"due": len(due),
"queued": queued,
"skipped_in_flight": len(in_flight),
"tick_at": datetime.now(UTC).isoformat(),
}
finally:
await engine.dispose()
@celery.task(name="backend.app.tasks.scan.tick_due_sources", bind=True)
def tick_due_sources(self) -> dict:
"""FC-3d: scan for sources due for a periodic check and fire
``download_source.delay()`` for each."""
return asyncio.run(_tick_due_sources_async())