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
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user