CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m17s
Build images / smoke-web (push) Skipped
CI / integration (push) Failing after 2m8s
Build images / build-ml (push) Successful in 2m18s
Build images / promote (push) Skipped
The step phase A was building toward. A1 made the gated count true, A2 made it a durable state, A3 made it visible in Subscriptions — but Subscriptions is where you go once you already suspect something. This is the line that reaches someone who wasn't looking. A thin grey strip above the feed, front door only: last check, sources failing, sources you can't see. Only the actionable items take a colour, and nothing renders at zero — a permanent "0 failing" trains you to skip the line, which would hide the real number when it appears. Two predicates, defined once. The ribbon counts and the surfaces it links to have to agree on what "failing" and "no access" MEAN, or the ribbon says 3 and the card shows 4. They live in db_helpers, which exists for exactly this reason (its docstring: divergent copies are how the race bugs crept in). Not in source_service, because scheduler_service needs them too and source_service already imports scheduler_service — the other direction is a cycle. Counting deliberately spans all ENABLED sources rather than the auto_check subset scheduler_status already walks: a source erroring on a manual-only artist is still erroring. Disabled sources count for nothing, which is what makes issue 1285 the real escape hatch for a sub you stopped paying for. Extends the existing schedule-status endpoint rather than adding a parallel aggregate — the store already fetches it. Two scalar COUNTs. The status filter is now URL-addressable, which it had to be for the ribbon's links to land anywhere: a count that drops you on an unfiltered list makes the reader redo the filtering the ribbon just did. Mirrors how artistFilter already reads from route.query. Front-door-only via a route prop, not a route.name check, so the view doesn't need to know what it's mounted as and the router states the intent in one place. Inside Browse's Posts tab you're looking FOR something and the hub is one click away. The fetch is swallowed on mount by design (rule 164): this is an aside, and the feed must render whether or not the status call succeeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""FC-3d: scheduler_service unit tests.
|
|
|
|
Covers compute_effective_interval (override/artist/default precedence,
|
|
backoff exponent at failure counts 0/1/3/6/10, floor and ceiling clamp)
|
|
and select_due_sources (auto_check off, never-checked, enabled=False,
|
|
due/not-due boundary).
|
|
"""
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from backend.app.models import Artist, ImportSettings, Source
|
|
from backend.app.services.scheduler_service import (
|
|
compute_effective_interval,
|
|
scheduler_status,
|
|
select_due_sources,
|
|
)
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
# --- compute_effective_interval (pure, no DB) -----------------------------
|
|
|
|
|
|
def _src(override=None, failures=0):
|
|
return Source(
|
|
artist_id=1, platform="patreon", url="https://x", enabled=True,
|
|
check_interval_override=override, consecutive_failures=failures,
|
|
)
|
|
|
|
|
|
def _art(interval=None, auto=True):
|
|
return Artist(name="A", slug="a", auto_check=auto, check_interval_seconds=interval)
|
|
|
|
|
|
def _settings(default=28800):
|
|
return ImportSettings(id=1, download_schedule_default_seconds=default)
|
|
|
|
|
|
def test_interval_uses_source_override_when_set():
|
|
assert compute_effective_interval(_src(override=600), _art(interval=3600), _settings()) == 600
|
|
|
|
|
|
def test_interval_falls_back_to_artist_interval():
|
|
assert compute_effective_interval(_src(), _art(interval=3600), _settings()) == 3600
|
|
|
|
|
|
def test_interval_falls_back_to_global_default():
|
|
assert compute_effective_interval(_src(), _art(), _settings(default=900)) == 900
|
|
|
|
|
|
def test_interval_backoff_zero_failures_is_identity():
|
|
assert compute_effective_interval(_src(override=600), _art(), _settings()) == 600
|
|
|
|
|
|
def test_interval_backoff_one_failure_doubles():
|
|
assert compute_effective_interval(_src(override=600, failures=1), _art(), _settings()) == 1200
|
|
|
|
|
|
def test_interval_backoff_three_failures_x8():
|
|
assert compute_effective_interval(_src(override=600, failures=3), _art(), _settings()) == 4800
|
|
|
|
|
|
def test_interval_backoff_caps_at_exponent_six():
|
|
# 600 * 2^6 = 38400; 2^7 would be 76800 but exponent caps at 6.
|
|
assert compute_effective_interval(_src(override=600, failures=6), _art(), _settings()) == 38400
|
|
assert compute_effective_interval(_src(override=600, failures=10), _art(), _settings()) == 38400
|
|
|
|
|
|
def test_interval_floor_at_60_seconds():
|
|
# base 30 with 0 failures would be 30s; floor lifts to 60.
|
|
assert compute_effective_interval(_src(override=30), _art(), _settings()) == 60
|
|
|
|
|
|
def test_interval_ceiling_at_86400_seconds():
|
|
# base 28800 * 2^6 = 1843200; ceiling caps at 86400.
|
|
assert compute_effective_interval(_src(failures=6), _art(), _settings(default=28800)) == 86400
|
|
|
|
|
|
# --- select_due_sources (real session) ------------------------------------
|
|
|
|
|
|
async def _seed_artist(db, *, auto=True, interval=None, name="A"):
|
|
a = Artist(name=name, slug=name.lower(), auto_check=auto, check_interval_seconds=interval)
|
|
db.add(a)
|
|
await db.flush()
|
|
return a
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_skips_disabled_sources(db):
|
|
artist = await _seed_artist(db, name="sel-disabled")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://sel-disabled",
|
|
enabled=False, consecutive_failures=0,
|
|
))
|
|
await db.commit()
|
|
due = await select_due_sources(db)
|
|
assert all(s.url != "https://sel-disabled" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_skips_artist_auto_check_false(db):
|
|
artist = await _seed_artist(db, auto=False, name="sel-noauto")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://sel-noauto",
|
|
enabled=True, consecutive_failures=0,
|
|
))
|
|
await db.commit()
|
|
due = await select_due_sources(db)
|
|
assert all(s.url != "https://sel-noauto" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_includes_never_checked(db):
|
|
artist = await _seed_artist(db, name="sel-never")
|
|
src = Source(
|
|
artist_id=artist.id, platform="patreon", url="https://sel-never",
|
|
enabled=True, consecutive_failures=0,
|
|
)
|
|
db.add(src)
|
|
await db.commit()
|
|
due = await select_due_sources(db)
|
|
assert any(s.url == "https://sel-never" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_excludes_not_yet_due(db):
|
|
artist = await _seed_artist(db, interval=3600, name="sel-recent")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://sel-recent",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=60),
|
|
))
|
|
await db.commit()
|
|
due = await select_due_sources(db)
|
|
assert all(s.url != "https://sel-recent" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_includes_past_due(db):
|
|
artist = await _seed_artist(db, interval=60, name="sel-past")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://sel-past",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=3600),
|
|
))
|
|
await db.commit()
|
|
due = await select_due_sources(db)
|
|
assert any(s.url == "https://sel-past" for s in due)
|
|
|
|
|
|
# --- platform-rate-limit cooldown -----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_skips_sources_on_platform_in_cooldown(db):
|
|
"""After set_platform_cooldown('patreon'), select_due_sources excludes
|
|
every patreon source — even ones that would otherwise be past-due."""
|
|
from backend.app.services.scheduler_service import set_platform_cooldown
|
|
|
|
artist = await _seed_artist(db, interval=60, name="cooldown-p")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://cd-p",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=3600),
|
|
))
|
|
await db.commit()
|
|
|
|
# Sanity-pre: due before the cooldown is set.
|
|
due = await select_due_sources(db)
|
|
assert any(s.url == "https://cd-p" for s in due)
|
|
|
|
await set_platform_cooldown(db, "patreon")
|
|
await db.commit()
|
|
|
|
due = await select_due_sources(db)
|
|
assert all(s.url != "https://cd-p" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cooldown_is_per_platform_other_platforms_unaffected(db):
|
|
"""A cooldown on patreon doesn't block subscribestar sources from
|
|
being due — cooldowns are scoped to the platform that hit the limit."""
|
|
from backend.app.services.scheduler_service import set_platform_cooldown
|
|
|
|
artist = await _seed_artist(db, interval=60, name="cooldown-mixed")
|
|
db.add_all([
|
|
Source(
|
|
artist_id=artist.id, platform="patreon", url="https://mix-p",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=3600),
|
|
),
|
|
Source(
|
|
artist_id=artist.id, platform="subscribestar", url="https://mix-ss",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=3600),
|
|
),
|
|
])
|
|
await db.commit()
|
|
|
|
await set_platform_cooldown(db, "patreon")
|
|
await db.commit()
|
|
|
|
due = await select_due_sources(db)
|
|
urls = {s.url for s in due}
|
|
assert "https://mix-ss" in urls
|
|
assert "https://mix-p" not in urls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_ignores_expired_cooldown(db):
|
|
"""An expired cooldown row doesn't filter — routing treats expired
|
|
same as not-set. The stale row stays around until a future sweep
|
|
prunes it."""
|
|
from backend.app.models import AppSetting
|
|
from backend.app.services.scheduler_service import (
|
|
PLATFORM_COOLDOWN_KEY_PREFIX,
|
|
)
|
|
|
|
artist = await _seed_artist(db, interval=60, name="cooldown-expired")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://cd-exp",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=datetime.now(UTC) - timedelta(seconds=3600),
|
|
))
|
|
db.add(AppSetting(
|
|
key=f"{PLATFORM_COOLDOWN_KEY_PREFIX}patreon",
|
|
value=(datetime.now(UTC) - timedelta(seconds=10)).isoformat(),
|
|
))
|
|
await db.commit()
|
|
|
|
due = await select_due_sources(db)
|
|
assert any(s.url == "https://cd-exp" for s in due)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_orders_most_overdue_first_then_id(db):
|
|
"""Within the due set, sources are ordered by last_checked_at ASC NULLS
|
|
FIRST. Combined with Celery FIFO on the download queue, the most-overdue
|
|
source in each tick reaches a worker first — preventing the 'a freshly-
|
|
rerun source keeps cutting in line ahead of one that's still waiting'
|
|
starvation pattern when queue throughput is below the tick population."""
|
|
artist = await _seed_artist(db, interval=60, name="order-test")
|
|
now = datetime.now(UTC)
|
|
# Newest check — least overdue (but still past its 60s interval).
|
|
s_new = Source(
|
|
artist_id=artist.id, platform="patreon", url="https://order-new",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=now - timedelta(minutes=2),
|
|
)
|
|
# Oldest check — most overdue among checked sources.
|
|
s_old = Source(
|
|
artist_id=artist.id, platform="patreon", url="https://order-old",
|
|
enabled=True, consecutive_failures=0,
|
|
last_checked_at=now - timedelta(hours=4),
|
|
)
|
|
# Never checked — wins the ordering (NULLS FIRST).
|
|
s_never = Source(
|
|
artist_id=artist.id, platform="patreon", url="https://order-never",
|
|
enabled=True, consecutive_failures=0,
|
|
)
|
|
db.add_all([s_new, s_old, s_never])
|
|
await db.commit()
|
|
|
|
due = await select_due_sources(db)
|
|
# Filter to just our test seeds (concurrent tests may add others).
|
|
urls = [s.url for s in due if s.url.startswith("https://order-")]
|
|
assert urls == [
|
|
"https://order-never",
|
|
"https://order-old",
|
|
"https://order-new",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_platform_cooldown_upserts(db):
|
|
"""Calling set_platform_cooldown twice on the same platform updates
|
|
the row in place rather than inserting a duplicate (AppSetting's PK
|
|
is `key`)."""
|
|
from sqlalchemy import select as sa_select
|
|
|
|
from backend.app.models import AppSetting
|
|
from backend.app.services.scheduler_service import (
|
|
PLATFORM_COOLDOWN_KEY_PREFIX,
|
|
set_platform_cooldown,
|
|
)
|
|
|
|
await set_platform_cooldown(db, "patreon", seconds=60)
|
|
await db.commit()
|
|
await set_platform_cooldown(db, "patreon", seconds=600)
|
|
await db.commit()
|
|
|
|
rows = (await db.execute(
|
|
sa_select(AppSetting).where(
|
|
AppSetting.key == f"{PLATFORM_COOLDOWN_KEY_PREFIX}patreon"
|
|
)
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
|
|
|
|
# --- scheduler_status ingestion counts (#387 B3) --------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_counts_failing_and_no_access_separately(db):
|
|
"""The front-door ribbon's two numbers must not bleed into each other.
|
|
|
|
A tier-limited source has consecutive_failures == 0 by construction (its
|
|
run succeeded), so counting failures by `last_error IS NOT NULL` or by the
|
|
presence of an error_type would report it as broken — which is the exact
|
|
claim phase A exists to stop the app making.
|
|
"""
|
|
artist = await _seed_artist(db, name="cnt")
|
|
db.add_all([
|
|
Source(artist_id=artist.id, platform="patreon", url="https://cnt-broken",
|
|
enabled=True, consecutive_failures=3, error_type="auth_error"),
|
|
Source(artist_id=artist.id, platform="patreon", url="https://cnt-gated",
|
|
enabled=True, consecutive_failures=0, error_type="tier_limited"),
|
|
Source(artist_id=artist.id, platform="patreon", url="https://cnt-fine",
|
|
enabled=True, consecutive_failures=0),
|
|
])
|
|
await db.commit()
|
|
|
|
status = await scheduler_status(db)
|
|
assert status["failing_sources"] == 1
|
|
assert status["no_access_sources"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_counts_ignore_disabled_sources(db):
|
|
"""Disabling is the operator's way of parking a sub they stopped paying for
|
|
(#1285 clears its state on the way). A parked source must not keep a number
|
|
lit on the front door."""
|
|
artist = await _seed_artist(db, name="cnt-off")
|
|
db.add_all([
|
|
Source(artist_id=artist.id, platform="patreon", url="https://cnt-off-broken",
|
|
enabled=False, consecutive_failures=5),
|
|
Source(artist_id=artist.id, platform="patreon", url="https://cnt-off-gated",
|
|
enabled=False, error_type="tier_limited"),
|
|
])
|
|
await db.commit()
|
|
|
|
status = await scheduler_status(db)
|
|
assert status["failing_sources"] == 0
|
|
assert status["no_access_sources"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_counts_are_not_scoped_to_auto_check(db):
|
|
"""A source erroring on a manual-only artist is still erroring. The counts
|
|
deliberately span all enabled sources, unlike `auto_sources` above which
|
|
only describes what is on a schedule."""
|
|
artist = await _seed_artist(db, auto=False, name="cnt-manual")
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon", url="https://cnt-manual",
|
|
enabled=True, consecutive_failures=2,
|
|
))
|
|
await db.commit()
|
|
|
|
status = await scheduler_status(db)
|
|
assert status["failing_sources"] == 1
|
|
assert status["auto_sources"] == 0
|