fix: phase 3 stops importing at a deadline and leaves the rest for the next chunk
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Failing after 2m34s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped

A Discord recapture chunk on #banana-land was killed at the 1350s soft limit
twice (events 92354 on 2026-09-28 and 92623 on 2026-09-29). The walk's
budget (CHUNK_TOTAL_SECONDS, #4407) assumed 2.5s per import; measured on
those chunks, a real import of the source's doodles took about 5s, so ~290
imports the walk had planned to fit took about twice the time reserved.

- PHASE3_DEADLINE_SECONDS (1200s from task start): on native platforms
  phase 3 starts no import past it. The files left stay on disk with no
  record and are not marked seen; the next chunk resumes on the page that
  holds the first of them and imports them through the existing "on disk
  but never imported" path. A walk that reached its end becomes PARTIAL so
  the backfill is not marked complete while files wait.
- DownloadResult.written_cursors records each written file's page cursor;
  mark_seen_after_import takes skip_paths.
- PHASE3_IMPORT_SECONDS 2.5 -> 5.0, the slow end of what was measured, so
  the walk rarely leans on the deadline.

Task #4493.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 09:53:57 -05:00
co-authored by Claude Opus 5.5
parent 3c3045190d
commit 3f5224800e
6 changed files with 197 additions and 12 deletions
+62 -3
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -39,7 +40,7 @@ from .gallery_dl import (
walk_completed,
)
from .importer import Importer
from .ingest_core import DEFAULT_REVISIT_DAYS
from .ingest_core import DEFAULT_REVISIT_DAYS, PHASE3_DEADLINE_SECONDS
from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown
@@ -82,6 +83,7 @@ class DownloadService:
async def download_source(self, source_id: int) -> int:
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
started = time.monotonic()
setup = await self._phase1_setup(source_id)
if setup["status"] in ("skipped", "in_flight"):
return setup["event_id"]
@@ -174,6 +176,7 @@ class DownloadService:
return await self._phase3_persist(
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
started=started,
)
async def _run_download(
@@ -268,6 +271,8 @@ class DownloadService:
ctx: dict,
dl_result,
resolved_campaign_id: str | None,
*,
started: float | None = None,
) -> int:
# Cache Patreon campaign id on the source.
if resolved_campaign_id:
@@ -303,7 +308,23 @@ class DownloadService:
loop = asyncio.get_running_loop()
for path_str in dl_result.written_paths or []:
# Phase 3 shares the task's soft limit with the walk, and an import's
# cost is only estimated there. Native platforms stop starting imports
# at the deadline: what is left stays on disk with no record, which the
# next chunk's walk imports. gallery-dl platforms have no such pickup,
# so they import everything.
deadline = (
started + PHASE3_DEADLINE_SECONDS
if started is not None and uses_native_ingester(ctx["platform"])
else None
)
written_paths = list(dl_result.written_paths or [])
deferred_from: int | None = None
for idx, path_str in enumerate(written_paths):
if deadline is not None and time.monotonic() >= deadline:
deferred_from = idx
break
if path_str in (dl_result.quarantined_paths or []):
continue
path = Path(path_str)
@@ -427,8 +448,16 @@ class DownloadService:
# has been through the importer. Had the run died before here they stay
# unmarked, and the next walk imports them from disk (ingest_core).
mark_seen = getattr(dl_result, "mark_seen_after_import", None)
deferred = written_paths[deferred_from:] if deferred_from is not None else []
if mark_seen is not None:
await loop.run_in_executor(None, mark_seen)
if deferred:
await loop.run_in_executor(
None, lambda: mark_seen(skip_paths=frozenset(deferred)),
)
else:
await loop.run_in_executor(None, mark_seen)
if deferred:
self._defer_unimported(dl_result, deferred_from, len(deferred))
# #830 recapture: backfill source_filehash on EXISTING on-disk images so
# their post-body inline <img src=CDN> remaps to the local copy. A
@@ -530,6 +559,33 @@ class DownloadService:
await self.async_session.commit()
return event_id
@staticmethod
def _defer_unimported(dl_result, first: int, count: int) -> None:
"""Turn this chunk into a chunk boundary at the first file phase 3 left.
The next chunk resumes on the page that file came from, so its walk
meets every left file again, on disk with no record, and imports it. A
walk that reached its end has not finished while files wait, so a clean
result becomes PARTIAL; a real failure keeps its own error.
"""
cursors = getattr(dl_result, "written_cursors", None) or []
if first < len(cursors):
dl_result.cursor = cursors[first]
dl_result.phase3_imported_any = first > 0
msg = (
f"Imports stopped at the chunk deadline: {count} file(s) left "
"for the next chunk"
)
dl_result.stdout = f"{dl_result.stdout}\n{msg}" if dl_result.stdout else msg
log.info("%s", msg)
if dl_result.error_type in (None, ErrorType.PARTIAL, ErrorType.TIMEOUT) or (
is_informational(dl_result.error_type)
):
dl_result.success = False
dl_result.return_code = -1
dl_result.error_type = ErrorType.PARTIAL
dl_result.error_message = msg
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
"""Backfill state machine (plan #693, building on the cursor of #689).
@@ -597,9 +653,12 @@ class DownloadService:
new_cursor = (
dl_result.cursor if uses_native_ingester(ctx["platform"]) else None
)
# Imports phase 3 finished before its deadline are progress even when
# the cursor went back to the page holding the files it left.
advanced = bool(
(new_cursor and new_cursor != old_cursor)
or dl_result.files_downloaded > 0
or getattr(dl_result, "phase3_imported_any", False)
)
if advanced:
if new_cursor: