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.
This commit is contained in:
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
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
|
||||
|
||||
@@ -96,7 +97,9 @@ def scan_directory(self, triggered_by: str = "manual",
|
||||
task = ImportTask(
|
||||
batch_id=batch_id,
|
||||
source_path=entry_str,
|
||||
task_type="media",
|
||||
# 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,
|
||||
)
|
||||
@@ -115,15 +118,16 @@ def scan_directory(self, triggered_by: str = "manual",
|
||||
batch.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
|
||||
# Now enqueue import_media_file for each pending task.
|
||||
# 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)
|
||||
from .import_file import import_media_file
|
||||
|
||||
import_media_file.delay(task.id)
|
||||
enqueue_import(task.id, task.task_type)
|
||||
session.commit()
|
||||
|
||||
if mode == "deep":
|
||||
|
||||
Reference in New Issue
Block a user