feat(download): smarter backfill — time-boxed chunks, run-until-done (backend)
Plan #693. Large-catalog backfill (Anduo) no longer sprints to the timeout wall and dies as an error each run. Builds on the cursor checkpoint (#689). - Time-boxed chunks: BACKFILL_TIMEOUT_SECONDS(1170)→BACKFILL_CHUNK_SECONDS(600), far under the 1350 soft limit. Hitting it = normal chunk boundary (the TimeoutExpired path already captures partial output + the cursor), not a near-wall death. - Run-until-done state machine driven by config_overrides[_backfill_state] (running/complete/stalled). A running backfill auto-continues in chunks across ticks until gallery-dl exits cleanly (rc=0 = reached the bottom → 'complete'); a safety-cap (BACKFILL_MAX_CHUNKS=200) + the #689 stall-guard pause a pathological walk as 'stalled'. Replaces the N-runs counter (backfill_runs_remaining repurposed as the cap countdown). - Progress, not error: a chunk that timed out but advanced (cursor moved and/or files written) is reclassified TIMEOUT→PARTIAL (status 'ok'). - Retry storm tamed: gallery-dl retries 3→2, downloader timeout 120→60s, so one stuck CDN file fails in ~1-2 min not ~10 (Anduo #40838). - API: POST /sources/{id}/backfill now takes {action: start|stop}; service start_backfill/stop_backfill; new enabled sources auto-arm run-until-done; source dict exposes backfill_state + backfill_chunks. Frontend (Start/Stop control + state badge) lands in the next push. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,9 @@ class SourceRecord:
|
||||
consecutive_failures: int
|
||||
next_check_at: str | None
|
||||
backfill_runs_remaining: int
|
||||
# plan #693: derived from config_overrides for the UI badge.
|
||||
backfill_state: str | None # "running" | "complete" | "stalled" | None (idle)
|
||||
backfill_chunks: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -82,6 +85,8 @@ class SourceRecord:
|
||||
"consecutive_failures": self.consecutive_failures,
|
||||
"next_check_at": self.next_check_at,
|
||||
"backfill_runs_remaining": self.backfill_runs_remaining,
|
||||
"backfill_state": self.backfill_state,
|
||||
"backfill_chunks": self.backfill_chunks,
|
||||
}
|
||||
|
||||
|
||||
@@ -89,10 +94,13 @@ class SourceRecord:
|
||||
|
||||
_EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"}
|
||||
|
||||
# Plan #544 follow-up: newly created enabled sources pre-arm backfill so
|
||||
# their first N polls walk gallery-dl's full post history with the longer
|
||||
# timeout (matches the manual "Deep scan" button's default).
|
||||
NEW_SOURCE_BACKFILL_RUNS = 3
|
||||
# Plan #693: backfill safety cap. "Start backfill" (and a newly created
|
||||
# enabled source) arms a run-until-done walk; this caps how many time-boxed
|
||||
# chunks it may spend before pausing as "stalled", so a pathological walk that
|
||||
# never reaches the bottom can't run forever. Generous on purpose — at
|
||||
# BACKFILL_CHUNK_SECONDS (600s) per chunk this is ~33h of cumulative walk, far
|
||||
# beyond any real catalog; the cursor stall-guard is the real terminator.
|
||||
BACKFILL_MAX_CHUNKS = 200
|
||||
|
||||
|
||||
class SourceService:
|
||||
@@ -135,6 +143,7 @@ class SourceService:
|
||||
self, source: Source, artist: Artist, settings: ImportSettings,
|
||||
) -> SourceRecord:
|
||||
nxt = compute_next_check_at(source, artist, settings)
|
||||
co = source.config_overrides or {}
|
||||
return SourceRecord(
|
||||
id=source.id,
|
||||
artist_id=source.artist_id,
|
||||
@@ -151,6 +160,8 @@ class SourceService:
|
||||
consecutive_failures=source.consecutive_failures or 0,
|
||||
next_check_at=nxt.isoformat() if nxt else None,
|
||||
backfill_runs_remaining=source.backfill_runs_remaining or 0,
|
||||
backfill_state=co.get("_backfill_state"),
|
||||
backfill_chunks=int(co.get("_backfill_chunks", 0)),
|
||||
)
|
||||
|
||||
async def _row_to_record(self, source: Source) -> SourceRecord:
|
||||
@@ -212,16 +223,17 @@ class SourceService:
|
||||
select(func.count(Source.id)).where(Source.artist_id == artist_id)
|
||||
)).scalar_one()
|
||||
|
||||
# Plan #544 follow-up: a freshly added subscription has no archive
|
||||
# yet, so the first few polls would walk the full post history in
|
||||
# tick mode and trip exit:20 after ~20 contiguous archive hits —
|
||||
# except there are none yet, so tick mode would walk forever and
|
||||
# blow the wall-clock cap. Pre-arm backfill so the initial syncs
|
||||
# use the longer timeout + skip:True walk. Tick mode resumes once
|
||||
# the budget is spent or the queue drains.
|
||||
# Disabled sources (incl. sidecar synthetics, url='sidecar:...')
|
||||
# are never polled, so leave their counter at 0.
|
||||
backfill_runs = NEW_SOURCE_BACKFILL_RUNS if enabled else 0
|
||||
# Plan #693: a freshly added subscription has no archive yet, so it
|
||||
# should walk its full post history once. Arm run-until-done backfill
|
||||
# (state="running" + the chunk cap); the time-boxed chunks march to the
|
||||
# bottom across ticks, then flip to "complete" and tick mode takes over.
|
||||
# Disabled sources (incl. sidecar synthetics, url='sidecar:...') are
|
||||
# never polled, so leave them idle.
|
||||
if enabled:
|
||||
config_overrides = {**(config_overrides or {}), "_backfill_state": "running"}
|
||||
backfill_runs = BACKFILL_MAX_CHUNKS
|
||||
else:
|
||||
backfill_runs = 0
|
||||
source = Source(
|
||||
artist_id=artist_id, platform=platform, url=url,
|
||||
enabled=enabled, config_overrides=config_overrides,
|
||||
@@ -286,22 +298,41 @@ class SourceService:
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
async def set_backfill_runs(
|
||||
self, source_id: int, runs: int,
|
||||
) -> SourceRecord:
|
||||
"""Plan #544: arm a source for backfill mode. The next `runs`
|
||||
download runs will use gallery-dl's full-walk config (skip: True
|
||||
+ 30-min timeout) instead of the catch-up default. Runs must be
|
||||
1..10 — bigger is rejected to keep the operator from accidentally
|
||||
setting a runaway budget."""
|
||||
if not isinstance(runs, int) or runs < 1 or runs > 10:
|
||||
raise ValueError("runs must be an integer in [1, 10]")
|
||||
async def start_backfill(self, source_id: int) -> SourceRecord:
|
||||
"""Plan #693: arm a run-until-done backfill. Sets state="running" and
|
||||
the chunk cap; download runs then walk the full post history in
|
||||
time-boxed chunks (skip:True + BACKFILL_CHUNK_SECONDS), resuming from
|
||||
the cursor each chunk, until gallery-dl reaches the bottom (→ state
|
||||
"complete") or the cap/stall-guard pauses it (→ "stalled"). Clears any
|
||||
prior cursor/chunk/stall state so a re-start walks fresh from the top."""
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(Source.id == source_id)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError(f"source id={source_id} not found")
|
||||
source.backfill_runs_remaining = runs
|
||||
co = dict(source.config_overrides or {})
|
||||
co["_backfill_state"] = "running"
|
||||
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks"):
|
||||
co.pop(k, None)
|
||||
source.config_overrides = co
|
||||
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
async def stop_backfill(self, source_id: int) -> SourceRecord:
|
||||
"""Plan #693: cancel an in-progress backfill — back to idle/tick mode.
|
||||
Clears the running state + cursor/chunk/stall bookkeeping."""
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(Source.id == source_id)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError(f"source id={source_id} not found")
|
||||
co = dict(source.config_overrides or {})
|
||||
for k in ("_backfill_state", "_backfill_cursor", "_backfill_cursor_stalls",
|
||||
"_backfill_chunks"):
|
||||
co.pop(k, None)
|
||||
source.config_overrides = co
|
||||
source.backfill_runs_remaining = 0
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user