a85880f965
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.
262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""import_media_file task: imports one file via the Importer service and
|
|
updates the ImportTask state machine + ImportBatch counters atomically.
|
|
|
|
Resilience contract (2026-05-24, operator-mandated): once a row has been
|
|
flipped to 'processing' inside this task, EVERY exit path MUST flip it
|
|
to a terminal state (complete / skipped / failed) or rely on Celery's
|
|
autoretry to attempt the work again. No exit path is allowed to leave
|
|
the row stuck in 'processing'. The `recover_interrupted_tasks`
|
|
maintenance sweep is the safety net (5 min threshold) for the case
|
|
where even the failure-marking commit can't be written.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from celery.exceptions import SoftTimeLimitExceeded
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
|
|
|
from ..celery_app import celery
|
|
from ..models import ImportBatch, ImportSettings, ImportTask
|
|
from ..services.importer import Importer
|
|
from ..services.thumbnailer import Thumbnailer
|
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
|
|
|
IMAGES_ROOT = Path("/images")
|
|
|
|
|
|
def _map_result_to_status(result):
|
|
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
|
'superseded' = the kept row's file/ML changed → complete + re-derive.
|
|
'attached' = a non-art file preserved → complete, no ML/thumb.
|
|
'refreshed' = deep scan refreshed sidecar/phash on an existing row →
|
|
complete, no ML/thumb re-derive (file/pixels unchanged)."""
|
|
if result.status in ("imported", "superseded"):
|
|
return ("complete", True)
|
|
if result.status in ("attached", "refreshed"):
|
|
return ("complete", False)
|
|
if result.status == "skipped":
|
|
return ("skipped", False)
|
|
return ("failed", False)
|
|
|
|
|
|
def _mark_failed(session, task, error_msg: str) -> None:
|
|
"""Best-effort flip of a 'processing' row to 'failed' + batch counter
|
|
increment. Wrapped in its own try because if the DB is what just
|
|
broke, this commit will also fail — that's why the maintenance sweep
|
|
exists as a backstop."""
|
|
try:
|
|
task.status = "failed"
|
|
task.error = error_msg
|
|
task.finished_at = datetime.now(UTC)
|
|
session.add(task)
|
|
session.execute(
|
|
update(ImportBatch)
|
|
.where(ImportBatch.id == task.batch_id)
|
|
.values(failed=ImportBatch.failed + 1)
|
|
)
|
|
session.commit()
|
|
except Exception: # noqa: BLE001 — best-effort, sweep catches the rest
|
|
try:
|
|
session.rollback()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def _run_import_task(import_task_id: int) -> dict:
|
|
"""Shared body for import_media_file + import_archive_file. The two
|
|
tasks differ ONLY in their Celery time limits (a single media file
|
|
is sub-second; an archive runs the full per-member pipeline inline
|
|
for every member and can take many minutes). Both flip the row to
|
|
'processing', dispatch to `_do_import`, and honor the
|
|
flip-to-terminal resilience contract.
|
|
"""
|
|
SessionLocal = _sync_session_factory()
|
|
with SessionLocal() as session:
|
|
task = session.get(ImportTask, import_task_id)
|
|
if task is None:
|
|
return {"status": "missing", "task_id": import_task_id}
|
|
|
|
task.status = "processing"
|
|
task.started_at = datetime.now(UTC)
|
|
session.add(task)
|
|
session.commit()
|
|
|
|
try:
|
|
return _do_import(session, task, import_task_id)
|
|
except SoftTimeLimitExceeded:
|
|
_mark_failed(session, task, "soft_time_limit exceeded")
|
|
raise
|
|
except (OperationalError, DBAPIError, OSError):
|
|
# Retryable per the decorator; do NOT mark failed (let
|
|
# autoretry have a clean go at it). If autoretry exhausts,
|
|
# the row stays 'processing' and the maintenance sweep
|
|
# flips it.
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — pipeline crash, mark + re-raise
|
|
_mark_failed(session, task, f"{type(exc).__name__}: {exc}")
|
|
raise
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.import_file.import_media_file",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
|
retry_backoff=5,
|
|
retry_backoff_max=60,
|
|
retry_jitter=True,
|
|
max_retries=3,
|
|
soft_time_limit=300,
|
|
time_limit=360,
|
|
)
|
|
def import_media_file(self, import_task_id: int) -> dict:
|
|
"""Import ONE media file (or non-media → PostAttachment). Sub-second
|
|
for the common case; the tight 5-min soft limit keeps a genuinely
|
|
stuck single-file import detectable fast.
|
|
|
|
Decorator notes:
|
|
- autoretry_for: transient DB / filesystem errors retry with
|
|
exponential backoff (5s base, jitter, max 3 attempts). On final
|
|
give-up the task raises and acks_late=True (set globally on the
|
|
Celery app) does NOT redeliver — the recovery sweep catches the
|
|
row instead.
|
|
- soft_time_limit (300s) raises SoftTimeLimitExceeded in-process
|
|
so the task can mark its row failed before being killed.
|
|
- time_limit (360s) is the hard SIGKILL cap.
|
|
"""
|
|
return _run_import_task(import_task_id)
|
|
|
|
|
|
@celery.task(
|
|
name="backend.app.tasks.import_file.import_archive_file",
|
|
bind=True,
|
|
autoretry_for=(OperationalError, DBAPIError, OSError),
|
|
retry_backoff=5,
|
|
retry_backoff_max=60,
|
|
retry_jitter=True,
|
|
max_retries=3,
|
|
# Archives run the full per-member pipeline (sha256 + pHash + dedup
|
|
# query + copy + provenance) for EVERY media member inline, under a
|
|
# single task budget. A multi-hundred-member archive blows the
|
|
# 5-min media limit. soft=30min / hard=35min sizes for a large
|
|
# archive. Operator-flagged 2026-05-28 (target 1645019 hit the old
|
|
# shared 300s soft limit). The recovery sweep gives this task its
|
|
# own 40-min threshold via maintenance.TASK_STUCK_THRESHOLD_MINUTES
|
|
# so it isn't preempted while legitimately grinding through members.
|
|
soft_time_limit=1800,
|
|
time_limit=2100,
|
|
)
|
|
def import_archive_file(self, import_task_id: int) -> dict:
|
|
"""Import an archive: extract + run the per-member media pipeline for
|
|
every member inline, then preserve the archive as a PostAttachment.
|
|
Same body as import_media_file (dispatch is by file kind inside
|
|
Importer.import_one); split out purely for the larger time budget."""
|
|
return _run_import_task(import_task_id)
|
|
|
|
|
|
def enqueue_import(task_id: int, task_type: str) -> None:
|
|
"""Route an ImportTask to the right Celery task by its task_type.
|
|
Single source of truth for the media-vs-archive dispatch so the
|
|
scan, retry, and recovery-requeue paths stay in sync."""
|
|
if task_type == "archive":
|
|
import_archive_file.delay(task_id)
|
|
else:
|
|
import_media_file.delay(task_id)
|
|
|
|
|
|
def _do_import(session, task, import_task_id: int) -> dict:
|
|
"""Actual work, called from inside the resilience wrapper."""
|
|
settings = session.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
import_root = Path(settings.import_scan_path)
|
|
batch = session.get(ImportBatch, task.batch_id)
|
|
deep = bool(batch and batch.scan_mode == "deep")
|
|
importer = Importer(
|
|
session=session,
|
|
images_root=IMAGES_ROOT,
|
|
import_root=import_root,
|
|
thumbnailer=Thumbnailer(images_root=IMAGES_ROOT),
|
|
settings=settings,
|
|
deep=deep,
|
|
)
|
|
|
|
result = importer.import_one(Path(task.source_path))
|
|
|
|
if result.status in ("imported", "superseded"):
|
|
task.status = "complete"
|
|
task.result_image_id = result.image_id
|
|
counter_col_name = "imported"
|
|
counter_col = ImportBatch.imported
|
|
elif result.status == "refreshed":
|
|
# Deep-scan rederive: existing row got phash/artist/sidecar
|
|
# refreshed. Task is complete (no further work), but counted in
|
|
# `refreshed` not `imported` so the UI can surface the actual
|
|
# work done. operator-flagged 2026-05-25.
|
|
task.status = "complete"
|
|
task.result_image_id = result.image_id
|
|
counter_col_name = "refreshed"
|
|
counter_col = ImportBatch.refreshed
|
|
elif result.status == "attached":
|
|
task.status = "complete"
|
|
counter_col_name = "attachments"
|
|
counter_col = ImportBatch.attachments
|
|
elif result.status == "skipped":
|
|
task.status = "skipped"
|
|
task.error = (
|
|
f"{result.skip_reason.value}: {result.error}"
|
|
if result.skip_reason
|
|
else result.error
|
|
)
|
|
task.result_image_id = result.image_id
|
|
counter_col_name = "skipped"
|
|
counter_col = ImportBatch.skipped
|
|
else:
|
|
task.status = "failed"
|
|
task.error = result.error
|
|
counter_col_name = "failed"
|
|
counter_col = ImportBatch.failed
|
|
|
|
task.finished_at = datetime.now(UTC)
|
|
session.execute(
|
|
update(ImportBatch)
|
|
.where(ImportBatch.id == task.batch_id)
|
|
.values({counter_col_name: counter_col + 1})
|
|
)
|
|
session.add(task)
|
|
session.commit()
|
|
|
|
# Enqueue thumbnail + ML for newly imported AND superseded images
|
|
# (a superseded row has cleared ML + no thumbnail).
|
|
if result.status in ("imported", "superseded"):
|
|
from .ml import tag_and_embed
|
|
from .thumbnail import generate_thumbnail
|
|
|
|
ids = list(result.member_image_ids)
|
|
if result.image_id is not None and result.image_id not in ids:
|
|
ids.append(result.image_id)
|
|
for img_id in ids:
|
|
generate_thumbnail.delay(img_id)
|
|
tag_and_embed.delay(img_id)
|
|
|
|
# If this was the last task in the batch, mark the batch complete.
|
|
remaining = session.execute(
|
|
select(ImportTask.id)
|
|
.where(ImportTask.batch_id == task.batch_id)
|
|
.where(ImportTask.status.in_(["pending", "queued", "processing"]))
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if remaining is None:
|
|
session.execute(
|
|
update(ImportBatch)
|
|
.where(ImportBatch.id == task.batch_id)
|
|
.values(
|
|
status="complete",
|
|
finished_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
return {"task_id": import_task_id, "status": task.status}
|