Files
FabledCurator/backend/app/services/scheduler_service.py
T
bvandeusenandClaude Opus 5 6b19012bb6
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-web (push) Successful in 1m17s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m24s
Build images / promote (push) Skipped
feat: the empty front door is the install's first screen (milestone 387 step B4)
The front door is now a feed, and a blank feed implies things should be
here in a way a blank masonry does not. On a fresh install this is the
first screen anyone sees — including someone who is not the operator,
which is what milestone 328 is making possible.

Tells the two empties apart, which is the point. "No sources yet" gets
the on-ramp; "sources configured, nothing landed yet" gets told that
the first check takes a while and pointed at Downloads. Telling someone
to add a source when they already have three and are mid-backfill reads
as the app not knowing its own state.

Needs total_sources on schedule-status to distinguish them —
deliberately not auto_sources, which counts only what is on a schedule,
so a source with auto_check off would have read as "nothing
configured". Both exact-shape assertions updated in THIS change rather
than after CI caught them, which is the lesson from B3's red push.

An absent status falls back to the on-ramp on purpose: it is merely
redundant to an established operator, whereas "see what's running"
shown to someone with nothing configured is a dead end.

A filtered miss is deliberately NOT the onboarding case — the operator
has posts, they just narrowed past them. Showing a fresh-install
on-ramp there would tell someone with a full library to go set it up.

This is where logo.svg lands, as the operator asked. It earns its place
on a first-run screen and not on a populated feed, and gives the
on-ramp something to compose around instead of prose plus two buttons.
Large: the mark stops reading below ~48px, which is why the 22px nav
slot has a different one. Pinned by test so a later tidy-up cannot
quietly shrink it to a glyph.

Also extracts mountWithStore into the shared test support module.
Writing the second spec created exactly the copy-paste that open issue
3109 tracks for the backend row factories, so it is consolidated now
rather than at copy three, and recorded as snippet 3829 with the two
traps it does NOT solve — named slots rendering nothing, and
components that fetch on mount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-09 23:10:06 -04:00

258 lines
10 KiB
Python

"""FC-3d: pure logic for deciding which sources are due for a check.
The Celery tick task wraps `select_due_sources` and fires
`download_source.delay()` per result; this module knows nothing about
Celery, gallery-dl, or any side effect.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import AppSetting, Artist, ImportSettings, Source
from .db_helpers import failing_sources_clause, no_access_sources_clause
MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400
MAX_BACKOFF_EXPONENT = 6
# AppSetting key stamped every time the Beat tick fires (see scan.py). The
# tick runs every 60s; the UI flags the scheduler as stalled if the last
# stamp is older than a few minutes.
SCHEDULER_LAST_TICK_KEY = "scheduler_last_tick_at"
# AppSetting key prefix for per-platform rate-limit cooldowns. When a
# download surfaces ErrorType.RATE_LIMITED, every other source on the same
# platform is deferred for PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS so the next
# scan tick doesn't fire a burst of due same-platform sources back into the
# same limit. Per-source consecutive_failures backoff still applies on top
# of this — but this is PREVENTIVE (kills the same-tick burst from N due
# sources hammering the platform at once), while consecutive_failures is
# REACTIVE (slows the offender down over many cycles). Operator-confirmed
# 2026-05-30.
PLATFORM_COOLDOWN_KEY_PREFIX = "platform_cooldown:"
PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS = 900 # 15 min
def compute_effective_interval(
source: Source, artist: Artist, settings: ImportSettings,
) -> int:
"""Return the seconds between scheduled checks for one source.
Precedence: source.check_interval_override > artist.check_interval_seconds
> settings.download_schedule_default_seconds. Multiplied by
2 ** min(consecutive_failures, MAX_BACKOFF_EXPONENT) and clamped to
[MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS].
"""
base = (
source.check_interval_override
or artist.check_interval_seconds
or settings.download_schedule_default_seconds
)
exponent = min(max(0, source.consecutive_failures or 0), MAX_BACKOFF_EXPONENT)
factor = 2 ** exponent
raw = base * factor
return max(MIN_INTERVAL_SECONDS, min(MAX_INTERVAL_SECONDS, raw))
async def set_platform_cooldown(
session: AsyncSession, platform: str,
seconds: int = PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS,
) -> None:
"""Stamp a cooldown expiry on the given platform so select_due_sources
skips every source on that platform until it expires.
Called when a download surfaces ErrorType.RATE_LIMITED so the other
sources on the same platform don't all retry into the same rate limit.
Caller is responsible for committing the session.
Uses INSERT...ON CONFLICT DO UPDATE so two concurrent workers hitting
the same platform's rate limit don't race: a SELECT-then-INSERT pattern
would let the loser's whole transaction (including the source-health
update + event finalize) roll back on a unique-violation, stranding
that event. Atomic upsert avoids that.
"""
now = datetime.now(UTC)
expires_at = (now + timedelta(seconds=seconds)).isoformat()
key = f"{PLATFORM_COOLDOWN_KEY_PREFIX}{platform}"
stmt = pg_insert(AppSetting.__table__).values(
key=key, value=expires_at, updated_at=now,
).on_conflict_do_update(
index_elements=["key"],
set_={"value": expires_at, "updated_at": now},
)
await session.execute(stmt)
async def active_platform_cooldowns(session: AsyncSession) -> dict[str, datetime]:
"""Return {platform: expires_at} for platforms whose cooldown is still
in the future. Expired rows are ignored (a future maintenance sweep can
delete them; they don't affect routing decisions on their own).
Exposed beyond scheduler_service so the manual check endpoint
(`/api/sources/<id>/check`) can defer bulk retries that would bowl
into the same rate limit the cooldown is preventing.
"""
rows = (await session.execute(
select(AppSetting.key, AppSetting.value)
.where(AppSetting.key.startswith(PLATFORM_COOLDOWN_KEY_PREFIX))
)).all()
if not rows:
return {}
now = datetime.now(UTC)
active: dict[str, datetime] = {}
for key, value in rows:
try:
expires_at = datetime.fromisoformat(value)
except (ValueError, TypeError):
continue
if expires_at > now:
active[key[len(PLATFORM_COOLDOWN_KEY_PREFIX):]] = expires_at
return active
async def select_due_sources(session: AsyncSession) -> list[Source]:
"""Sources where (enabled, artist.auto_check) and now >= last_checked_at + effective_interval.
Never-checked sources (last_checked_at IS NULL) are always due. Sources
whose platform is currently in a rate-limit cooldown are excluded — the
cooldown is the preventive half of the burst-prevention pair (per-source
consecutive_failures backoff handles the offending source itself).
Ordering: last_checked_at ASC NULLS FIRST, then id. Never-checked
sources go first, then the longest-since-checked, so the most overdue
sources hit Celery's FIFO download queue first. Anti-starvation: if
queue throughput ever falls below the tick rate, a freshly-rerun source
can't keep cutting in line ahead of one that hasn't been checked at all.
Operator-confirmed 2026-05-30.
"""
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
.order_by(Source.last_checked_at.asc().nulls_first(), Source.id)
)).scalars().all()
cooldowns = await active_platform_cooldowns(session)
settings = await ImportSettings.load(session)
now = datetime.now(UTC)
due: list[Source] = []
for s in rows:
if s.platform in cooldowns:
continue
interval = compute_effective_interval(s, s.artist, settings)
if s.last_checked_at is None:
due.append(s)
continue
elapsed = (now - s.last_checked_at).total_seconds()
if elapsed >= interval:
due.append(s)
return due
def compute_next_check_at(
source: Source, artist: Artist, settings: ImportSettings,
) -> datetime | None:
"""Return the projected datetime of the next check, or None if never checked."""
if source.last_checked_at is None:
return None
interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval)
async def record_tick(session: AsyncSession) -> None:
"""Stamp the current time on the SCHEDULER_LAST_TICK_KEY AppSetting.
Called once per Beat tick so the UI can prove the scheduler is alive.
Commits its own write so the stamp survives even if the rest of the
tick errors out.
"""
now_iso = datetime.now(UTC).isoformat()
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
if row is None:
session.add(AppSetting(key=SCHEDULER_LAST_TICK_KEY, value=now_iso))
else:
row.value = now_iso
await session.commit()
async def scheduler_status(session: AsyncSession) -> dict:
"""Summarise scheduler health for the dashboard.
Returns last_tick_at (when Beat last fired), next_due_at (earliest
upcoming scheduled check across enabled auto-check sources), due_now
(how many are due right now), and auto_sources (total under schedule).
"""
last_tick_at = (await session.execute(
select(AppSetting.value).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
)).scalars().all()
settings = await ImportSettings.load(session)
now = datetime.now(UTC)
due_now = 0
next_due_at: datetime | None = None
for s in rows:
if s.last_checked_at is None:
due_now += 1
continue
nca = compute_next_check_at(s, s.artist, settings)
if nca is None or nca <= now:
due_now += 1
elif next_due_at is None or nca < next_due_at:
next_due_at = nca
cooldowns = await active_platform_cooldowns(session)
# Ingestion health for the front-door ribbon (#387 B3). Counted over ENABLED
# sources rather than the auto_check subset walked above: a source that is
# erroring or paywalled is worth surfacing whether or not a schedule happens
# to poll it. Two scalar COUNTs, not a second pass over `rows`.
#
# Both predicates are the shared ones, so the ribbon and the surfaces it
# links to cannot disagree about what they are counting.
failing_sources = (await session.execute(
select(func.count()).select_from(Source)
.where(Source.enabled.is_(True), failing_sources_clause())
)).scalar_one()
no_access_sources = (await session.execute(
select(func.count()).select_from(Source)
.where(Source.enabled.is_(True), no_access_sources_clause())
)).scalar_one()
# #387 B4: lets the front door tell "nothing configured yet" (a fresh
# install — show the on-ramp) apart from "configured, still fetching" (a
# first run in progress — show what's running). Telling someone to add a
# source when they already have three and are mid-backfill is worse than
# saying nothing. Deliberately NOT auto_sources, which counts only what is
# on a schedule: a source with auto_check off still means "configured".
total_sources = (await session.execute(
select(func.count()).select_from(Source).where(Source.enabled.is_(True))
)).scalar_one()
return {
"last_tick_at": last_tick_at,
"next_due_at": next_due_at.isoformat() if next_due_at else None,
"due_now": due_now,
"auto_sources": len(rows),
"failing_sources": failing_sources,
"no_access_sources": no_access_sources,
"total_sources": total_sources,
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
}