fix(maintenance): time-box + self-resume the archive re-extract task
reextract_archive_attachments loaded ALL PostAttachments and ran in one pass up to a 30-min soft limit, then died without re-enqueueing — a large archive backlog would only ever partially process. And a naive re-run can't advance: an already-extracted archive is still an archive on disk, so it'd re-extract the same first batch forever. Give it a real cursor + time-box + self-resume (mirrors normalize_tags_task, operator-asked 2026-06-07: reasonable timeout, then re-queue so other work keeps flowing): - service scans attachments with id > after_id in ascending order, time-boxes the chunk, and reports partial=True + resume_after_id (last scanned id). - task passes a 600s budget and re-enqueues itself from the cursor until the scan is exhausted. Routes on the maintenance_long lane. - This is independent of the maintenance_long lane isolation (already shipped) — that stops long tasks starving the quick maintenance queue; this stops the re-extract itself dying on a big backlog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ the one-and-done GS/IR migration tooling.)
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -723,7 +724,13 @@ def _reextract_archive_to_post(
|
||||
sidecar_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def reextract_archive_attachments(session: Session, *, images_root: Path) -> dict:
|
||||
def reextract_archive_attachments(
|
||||
session: Session,
|
||||
*,
|
||||
images_root: Path,
|
||||
time_budget_seconds: float | None = None,
|
||||
after_id: int = 0,
|
||||
) -> dict:
|
||||
"""Re-process existing PostAttachments that are ACTUALLY archives but were
|
||||
filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
|
||||
extension-less Patreon attachment names). For each: extract the members,
|
||||
@@ -731,6 +738,14 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
|
||||
Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's
|
||||
safe to run repeatedly. Returns a summary dict for task_run.metadata.
|
||||
|
||||
Time-boxed + resumable: scans PostAttachments in ascending id order starting
|
||||
after ``after_id``. When ``time_budget_seconds`` elapses, stops and reports
|
||||
``partial=True`` + ``resume_after_id`` (the last scanned id) so the task can
|
||||
re-enqueue itself and continue — a large archive back-catalog can't run the
|
||||
task into the Celery time limit or hog the maintenance lane. A bare re-run
|
||||
(after_id=0) would never advance because an already-extracted archive is
|
||||
still an archive on disk, so the cursor is what guarantees forward progress.
|
||||
"""
|
||||
from ..models import ImportSettings, Post, PostAttachment, Source
|
||||
from ..tasks.ml import tag_and_embed
|
||||
@@ -742,7 +757,7 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
summary = {
|
||||
"scanned": 0, "archives": 0, "members_imported": 0,
|
||||
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
|
||||
"errors": 0,
|
||||
"errors": 0, "partial": False, "resume_after_id": after_id,
|
||||
}
|
||||
settings = ImportSettings.load_sync(session)
|
||||
importer = Importer(
|
||||
@@ -751,11 +766,15 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
)
|
||||
|
||||
attachments = session.execute(
|
||||
select(PostAttachment).order_by(PostAttachment.id)
|
||||
select(PostAttachment)
|
||||
.where(PostAttachment.id > after_id)
|
||||
.order_by(PostAttachment.id)
|
||||
).scalars().all()
|
||||
enqueue_ids: list[int] = []
|
||||
start = time.monotonic()
|
||||
for att in attachments:
|
||||
summary["scanned"] += 1
|
||||
summary["resume_after_id"] = att.id
|
||||
stored = Path(att.path)
|
||||
try:
|
||||
if not stored.is_file() or not is_archive(stored):
|
||||
@@ -795,6 +814,19 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
summary["posts_touched"] += 1
|
||||
enqueue_ids.extend(ids)
|
||||
|
||||
# Time-box the chunk. resume_after_id already points at this attachment,
|
||||
# so the next run starts strictly after it. Checked after the commit so a
|
||||
# half-extracted archive never straddles the boundary.
|
||||
if (
|
||||
time_budget_seconds is not None
|
||||
and time.monotonic() - start >= time_budget_seconds
|
||||
):
|
||||
summary["partial"] = True
|
||||
break
|
||||
else:
|
||||
# Loop ran to exhaustion — nothing left to resume.
|
||||
summary["partial"] = False
|
||||
|
||||
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
||||
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||
for img_id in enqueue_ids:
|
||||
|
||||
Reference in New Issue
Block a user