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:
+13
-14
@@ -122,26 +122,25 @@ async def delete_source(source_id: int):
|
||||
|
||||
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
||||
async def set_backfill(source_id: int):
|
||||
"""Plan #544: arm a source for backfill mode for the next N download
|
||||
runs. Body: `{"runs": int}` (1..10, default 3). Returns the updated
|
||||
source dict. While backfill_runs_remaining > 0, downloads use
|
||||
gallery-dl's full-walk config (skip: True + 30-min timeout) instead
|
||||
of the catch-up default (skip: "exit:20" + 14.5-min timeout)."""
|
||||
"""Plan #693: start or stop a run-until-done backfill. Body:
|
||||
`{"action": "start" | "stop"}` (default "start"). 'start' walks the full
|
||||
post history in time-boxed chunks until it reaches the bottom (then the
|
||||
source shows 'complete'); 'stop' cancels back to tick mode. Returns the
|
||||
updated source dict (incl. backfill_state / backfill_chunks)."""
|
||||
payload = await request.get_json(silent=True) or {}
|
||||
runs = payload.get("runs", 3)
|
||||
try:
|
||||
runs = int(runs)
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_runs", detail="runs must be an integer")
|
||||
action = payload.get("action", "start")
|
||||
if action not in ("start", "stop"):
|
||||
return _bad("invalid_action", detail="action must be 'start' or 'stop'")
|
||||
async with get_session() as session:
|
||||
try:
|
||||
record = await SourceService(session).set_backfill_runs(
|
||||
source_id, runs,
|
||||
svc = SourceService(session)
|
||||
record = (
|
||||
await svc.start_backfill(source_id)
|
||||
if action == "start"
|
||||
else await svc.stop_backfill(source_id)
|
||||
)
|
||||
except LookupError:
|
||||
return _bad("not_found", status=404)
|
||||
except ValueError as exc:
|
||||
return _bad("invalid_runs", detail=str(exc))
|
||||
return jsonify(record.to_dict())
|
||||
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ from sqlalchemy.orm import joinedload
|
||||
from ..models import Artist, DownloadEvent, Source
|
||||
from .credential_service import CredentialService
|
||||
from .gallery_dl import (
|
||||
BACKFILL_CHUNK_SECONDS,
|
||||
BACKFILL_SKIP_VALUE,
|
||||
BACKFILL_TIMEOUT_SECONDS,
|
||||
TICK_SKIP_VALUE,
|
||||
ErrorType,
|
||||
GalleryDLService,
|
||||
@@ -108,23 +108,20 @@ class DownloadService:
|
||||
self.sync_session.close()
|
||||
|
||||
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
||||
# alembic 0031 / plan #544: derive skip_value + timeout from the
|
||||
# source's backfill_runs_remaining counter. When > 0, walk the full
|
||||
# post history (skip: True + 1170s); when 0, exit gallery-dl after
|
||||
# 20 contiguous archived items (skip: "exit:20" + the default
|
||||
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
||||
# Backfill mode (plan #693): the source's `_backfill_state == "running"`
|
||||
# selects a time-boxed deep-walk chunk — skip: True (walk full history)
|
||||
# + the BACKFILL_CHUNK_SECONDS budget, resuming from the cursor
|
||||
# checkpoint (plan #689). State is the single source of truth; it stays
|
||||
# "running" across ticks until the walk reaches the bottom (phase 3
|
||||
# flips it to "complete"). Otherwise tick mode: exit gallery-dl after
|
||||
# 20 contiguous archived items (skip: "exit:20" + the default 870s).
|
||||
# Operator drives this via POST /api/sources/{id}/backfill {action}.
|
||||
overrides = ctx["config_overrides"] or {}
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
# Cursor-paged backfill (plan #689): a pending checkpoint keeps the
|
||||
# deep-walk in backfill mode across ticks until it reaches the
|
||||
# bottom, even after the operator's run budget hits 0 — so one large
|
||||
# creator (Anduo) finishes without re-triggering. The phase-3
|
||||
# stuck-guard bounds it. patreon-only: it's the sole platform with a
|
||||
# resumable gallery-dl cursor.
|
||||
pending_cursor = overrides.get("_backfill_cursor")
|
||||
if backfill_remaining > 0 or pending_cursor:
|
||||
in_backfill = overrides.get("_backfill_state") == "running"
|
||||
if in_backfill:
|
||||
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
||||
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
||||
source_config.timeout = BACKFILL_CHUNK_SECONDS
|
||||
pending_cursor = overrides.get("_backfill_cursor")
|
||||
if ctx["platform"] == "patreon" and pending_cursor:
|
||||
source_config.resume_cursor = pending_cursor
|
||||
else:
|
||||
@@ -171,6 +168,24 @@ class DownloadService:
|
||||
skip_value=skip_value,
|
||||
)
|
||||
|
||||
# A backfill chunk that hit its time-box but made forward progress is
|
||||
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as
|
||||
# "ok/progress" (PARTIAL maps to status "ok"), not a red error, and the
|
||||
# next chunk just resumes from the new cursor (plan #693). A chunk that
|
||||
# timed out with NO progress stays TIMEOUT and feeds phase 3's
|
||||
# stall-guard. Leave RATE_LIMITED alone so the platform-cooldown fires.
|
||||
if in_backfill and dl_result.error_type == ErrorType.TIMEOUT:
|
||||
new_cursor = parse_last_cursor(dl_result.stdout, dl_result.stderr)
|
||||
advanced = bool(
|
||||
(new_cursor and new_cursor != overrides.get("_backfill_cursor"))
|
||||
or dl_result.files_downloaded > 0
|
||||
)
|
||||
if advanced:
|
||||
dl_result.error_type = ErrorType.PARTIAL
|
||||
dl_result.error_message = (
|
||||
f"Backfill chunk: {dl_result.files_downloaded} file(s) — continuing"
|
||||
)
|
||||
|
||||
return await self._phase3_persist(
|
||||
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
|
||||
)
|
||||
@@ -411,35 +426,38 @@ class DownloadService:
|
||||
return event_id
|
||||
|
||||
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
|
||||
"""Cursor-paged backfill state machine (plan #689).
|
||||
"""Backfill state machine (plan #693, building on the cursor of #689).
|
||||
|
||||
A backfill is "in progress" while backfill_runs_remaining > 0 OR a
|
||||
checkpoint cursor is stored. Completion is a CLEAN rc=0 finish —
|
||||
gallery-dl with skip:True exits 0 only after exhausting the
|
||||
newest→oldest walk; a run cut short by the subprocess budget returns
|
||||
success=False / return_code=-1 (the TimeoutExpired path), so rc=0 is
|
||||
an unambiguous "reached the bottom" signal regardless of how many
|
||||
files this run fetched. (This replaces plan #544's files==0 predicate,
|
||||
whose audit-2026-06-02 VALIDATION_FAILED guard still holds: those exit
|
||||
non-zero or carry an error_type, so they are not "completed".)
|
||||
A backfill runs in time-boxed chunks while
|
||||
`config_overrides["_backfill_state"] == "running"`. Each chunk:
|
||||
- COMPLETES the walk → clean rc=0 (gallery-dl with skip:True exits 0
|
||||
only after exhausting the newest→oldest walk; a chunk cut short by
|
||||
its time-box returns success=False / rc<0 via TimeoutExpired). On
|
||||
completion: state="complete", clear the cursor, return to tick mode.
|
||||
- made PROGRESS (cursor advanced and/or files written) → stay
|
||||
"running", checkpoint the new cursor, bump the chunk counter, and
|
||||
spend one of the safety-cap chunks (backfill_runs_remaining). If the
|
||||
cap is exhausted without finishing → state="stalled".
|
||||
- made NO progress → increment the stall counter; two strikes →
|
||||
state="stalled", clear the cursor (a wedged walk can't loop).
|
||||
|
||||
For patreon, each non-completing run checkpoints gallery-dl's
|
||||
last-emitted pagination cursor — even a timed-out run, since its
|
||||
partial stdout/stderr still carries it — so the next run RESUMES
|
||||
instead of re-walking from the top. A stuck-guard clears the cursor
|
||||
after two consecutive runs that fail to advance it, so a wedged walk
|
||||
can't re-strand forever (cf. download soft-limit ladder).
|
||||
State is the single source of truth; the cursor lives only inside a
|
||||
running backfill. config_overrides is reassigned (not mutated in place)
|
||||
for SQLAlchemy JSON change detection.
|
||||
"""
|
||||
overrides = ctx["config_overrides"] or {}
|
||||
if overrides.get("_backfill_state") != "running":
|
||||
return # not backfilling — tick mode, nothing to do
|
||||
|
||||
old_cursor = overrides.get("_backfill_cursor")
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
if backfill_remaining <= 0 and not old_cursor:
|
||||
return # tick mode — counter untouched
|
||||
cap_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
|
||||
src = (await self.async_session.execute(
|
||||
select(Source).where(Source.id == ctx["source_id"])
|
||||
)).scalar_one()
|
||||
new_overrides = dict(src.config_overrides or {})
|
||||
chunks = int(new_overrides.get("_backfill_chunks", 0)) + 1
|
||||
new_overrides["_backfill_chunks"] = chunks
|
||||
|
||||
completed = (
|
||||
dl_result.success
|
||||
@@ -447,33 +465,43 @@ class DownloadService:
|
||||
and dl_result.return_code == 0
|
||||
)
|
||||
if completed:
|
||||
new_overrides["_backfill_state"] = "complete"
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
src.config_overrides = new_overrides
|
||||
src.backfill_runs_remaining = 0
|
||||
return
|
||||
|
||||
# Non-completing run. Patreon checkpoints + resumes via cursor;
|
||||
# other platforms have no resumable cursor, so just decrement.
|
||||
if ctx["platform"] == "patreon":
|
||||
new_cursor = parse_last_cursor(dl_result.stdout, dl_result.stderr)
|
||||
if new_cursor and new_cursor != old_cursor:
|
||||
# Did not finish. Patreon checkpoints + resumes via cursor; other
|
||||
# platforms have no resumable cursor (every chunk re-walks from the
|
||||
# top), so they advance only by the download archive growing.
|
||||
new_cursor = (
|
||||
parse_last_cursor(dl_result.stdout, dl_result.stderr)
|
||||
if ctx["platform"] == "patreon" else None
|
||||
)
|
||||
advanced = bool(
|
||||
(new_cursor and new_cursor != old_cursor)
|
||||
or dl_result.files_downloaded > 0
|
||||
)
|
||||
if advanced:
|
||||
if new_cursor:
|
||||
new_overrides["_backfill_cursor"] = new_cursor
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
cap_remaining = max(0, cap_remaining - 1)
|
||||
src.backfill_runs_remaining = cap_remaining
|
||||
if cap_remaining == 0:
|
||||
# Safety cap hit before reaching the bottom — pause, don't loop.
|
||||
new_overrides["_backfill_state"] = "stalled"
|
||||
else:
|
||||
stalls = int(new_overrides.get("_backfill_cursor_stalls", 0)) + 1
|
||||
if stalls >= 2:
|
||||
new_overrides["_backfill_state"] = "stalled"
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
else:
|
||||
stalls = int(new_overrides.get("_backfill_cursor_stalls", 0)) + 1
|
||||
if stalls >= 2:
|
||||
# Wedged: give up, drop back to tick mode. The surfaced
|
||||
# error_type on the event tells the operator why.
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
src.config_overrides = new_overrides
|
||||
src.backfill_runs_remaining = 0
|
||||
return
|
||||
new_overrides["_backfill_cursor_stalls"] = stalls
|
||||
src.config_overrides = new_overrides
|
||||
|
||||
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
||||
src.config_overrides = new_overrides
|
||||
|
||||
async def _update_source_health(
|
||||
self, *, source_id: int, status: str, error_message: str | None,
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, Source
|
||||
from ..utils.slug import slugify
|
||||
from .source_service import NEW_SOURCE_BACKFILL_RUNS
|
||||
from .source_service import BACKFILL_MAX_CHUNKS
|
||||
|
||||
|
||||
class UnknownPlatformError(Exception):
|
||||
@@ -205,16 +205,17 @@ class ExtensionService:
|
||||
return existing, False
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
# New subscription sources arm a few backfill runs so the
|
||||
# first ticks walk the full history (otherwise gallery-dl's
|
||||
# exit:20 short-circuits before the archive is built).
|
||||
# Mirrors SourceService.create — without it, Firefox quick-
|
||||
# add on a creator with >20 unsynced posts would surface
|
||||
# as "check failed" with no diagnosis. Audit 2026-06-02.
|
||||
# New subscription sources arm run-until-done backfill (plan #693)
|
||||
# so the first ticks walk the full history (otherwise gallery-dl's
|
||||
# exit:20 short-circuits before the archive is built). Mirrors
|
||||
# SourceService.create — without it, Firefox quick-add on a creator
|
||||
# with >20 unsynced posts would surface as "check failed" with no
|
||||
# diagnosis. Audit 2026-06-02.
|
||||
src = Source(
|
||||
artist_id=artist_id, platform=platform,
|
||||
url=url, enabled=True,
|
||||
backfill_runs_remaining=NEW_SOURCE_BACKFILL_RUNS,
|
||||
config_overrides={"_backfill_state": "running"},
|
||||
backfill_runs_remaining=BACKFILL_MAX_CHUNKS,
|
||||
)
|
||||
self.session.add(src)
|
||||
await self.session.flush()
|
||||
|
||||
@@ -56,25 +56,24 @@ class ErrorType(StrEnum):
|
||||
# 20 contiguous HEADs is still negligible.
|
||||
TICK_SKIP_VALUE = "exit:20"
|
||||
|
||||
# Backfill mode (operator-triggered deep scan): walk the full history.
|
||||
# Source.backfill_runs_remaining > 0 selects this mode; the longer
|
||||
# timeout below absorbs creators with thousands of posts.
|
||||
# Backfill mode (operator-triggered deep scan): walk the full history,
|
||||
# one TIME-BOXED CHUNK per run (plan #693). config_overrides["_backfill_state"]
|
||||
# == "running" selects this mode; the cursor checkpoint (plan #689) lets each
|
||||
# chunk resume where the last stopped, so the walk advances across chunks
|
||||
# until gallery-dl exits cleanly (= reached the bottom).
|
||||
#
|
||||
# Sits below download_source's Celery soft_time_limit
|
||||
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py) with ~180s of
|
||||
# headroom for phase-3 persist. subprocess.run MUST raise TimeoutExpired
|
||||
# before Celery raises SoftTimeLimitExceeded — that exception path
|
||||
# captures partial stdout/stderr and finalizes the event; the soft-limit
|
||||
# path (until the 2026-06-03 fix) did not. Audit history: 1800 guaranteed
|
||||
# SIGKILL against the old hard limit (Knuxy #38275); 1170 was then sized
|
||||
# "30s shy of the hard limit (1200)" but still EXCEEDED the soft limit
|
||||
# (900), so SoftTimeLimitExceeded preempted TimeoutExpired and every
|
||||
# backfill stranded empty (Anduo #39912). Raising the Celery soft/hard
|
||||
# limits to 1350/1500 (tasks/download.py) is what made 1170 safe.
|
||||
# backfill_runs_remaining=3 still gives ~58 minutes of cumulative walk
|
||||
# across three runs for prolific creators.
|
||||
# The chunk budget is deliberately FAR below download_source's Celery
|
||||
# soft_time_limit (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py), not
|
||||
# "just under" it. Hitting this budget is the NORMAL chunk boundary, not a
|
||||
# failure: subprocess.run raises TimeoutExpired (which captures partial
|
||||
# stdout/stderr + the last emitted cursor), and download_service reclassifies
|
||||
# a chunk that made progress as PARTIAL (status "ok"), not an error. The huge
|
||||
# headroom means a stuck file or a slow chunk can never let Celery's
|
||||
# SoftTimeLimitExceeded preempt TimeoutExpired (the failure mode behind Knuxy
|
||||
# #38275 / Anduo #39912/#40411). Earlier we ran one ~1170s run-to-the-wall
|
||||
# per arming; that died as a timeout error every time on large catalogs.
|
||||
BACKFILL_SKIP_VALUE = True
|
||||
BACKFILL_TIMEOUT_SECONDS = 1170
|
||||
BACKFILL_CHUNK_SECONDS = 600
|
||||
|
||||
|
||||
# Sits well below download_source's Celery soft_time_limit
|
||||
@@ -281,7 +280,12 @@ class GalleryDLService:
|
||||
"skip": True,
|
||||
"sleep": self._rate_limit,
|
||||
"sleep-request": max(0.5, self._rate_limit / 4),
|
||||
"retries": 3,
|
||||
# 2 (not 3) retries — a stuck CDN host shouldn't burn a whole
|
||||
# backfill chunk on one file. Anduo #40838 spent ~600s on a
|
||||
# single image (4 extractor HEAD retries × 30s + 4 downloader
|
||||
# GET retries × 120s); halving retries + the downloader
|
||||
# timeout below caps a wedged file at ~1-2 min instead.
|
||||
"retries": 2,
|
||||
"timeout": 30.0,
|
||||
"verify": True,
|
||||
"postprocessors": [
|
||||
@@ -296,8 +300,12 @@ class GalleryDLService:
|
||||
"downloader": {
|
||||
"part": True,
|
||||
"part-directory": str(self._config_dir / "temp"),
|
||||
"retries": 3,
|
||||
"timeout": 120.0,
|
||||
# See the extractor retries note above (Anduo #40838): 2
|
||||
# retries + a 60s read-timeout (was 120) so a stalled
|
||||
# connection fails fast. 60s is a per-read timeout, not a
|
||||
# transfer cap — large GIFs that keep streaming are unaffected.
|
||||
"retries": 2,
|
||||
"timeout": 60.0,
|
||||
# Forward Patreon as Referer/Origin to yt-dlp when it
|
||||
# fetches video manifests. Operator-flagged 2026-06-01
|
||||
# (DaferQ patreon): video posts hosted on Mux carry a JWT
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
||||
# SoftTimeLimitExceeded in-process, whereas the HARD limit SIGKILLs the
|
||||
# worker (no chance to finalize). Both gallery-dl subprocess budgets
|
||||
# (gallery_dl.py: _DEFAULT_GDL_TIMEOUT_SECONDS=870 tick,
|
||||
# BACKFILL_TIMEOUT_SECONDS=1170 backfill) MUST sit below the soft limit
|
||||
# BACKFILL_CHUNK_SECONDS=600 per backfill chunk, plan #693) MUST sit below the soft limit
|
||||
# so subprocess.run raises its own TimeoutExpired first — that path
|
||||
# captures partial stdout/stderr and finalizes the DownloadEvent. soft is
|
||||
# max-subprocess (1170) + ~180s phase-3 persist headroom; hard is soft +
|
||||
|
||||
Reference in New Issue
Block a user