fix: a stopped source is not a failing one, and cannot be deep-scanned (4279)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
Ebi77 sat in the "1 source is failing" banner for six days with no action available, reading `stranded by recovery sweep (no terminal status after time_limit)`. Four things lined up: 1. The membership sweep did its job — saw `former_patron`, disabled the source, cleared its failure state. Clean at 02:50. 2. Twenty minutes later a deep scan was armed on it. `/backfill` had a credential pre-flight but NO `enabled` guard, while `/check` has carried one all along. The two trigger endpoints disagreed, and the ungated one is the one that arms the long walk. 3. Without a membership the walk cannot finish, never reaches a terminal status, and the recovery sweep strands it with consecutive_failures = 1. 4. Nothing could clear that. A disabled source is never scheduled, so no successful run resets the count; `SourceService.update` clears only on an explicit disable and it was already disabled; and the banner's Retry routes to `/check`, which refuses a disabled source. The card offered a button structurally incapable of acting on the only source it was showing. `failing_sources_clause()` now means "enabled AND erroring". That also settles a disagreement its two callers already had: the scheduler's count paired it with `enabled.is_(True)` and `SourceService.list(failing=True)` did not, so one counted Ebi77 and the other did not — exactly the drift the note above that function warns about, which is why the test belongs IN the predicate rather than beside it. The scheduler's now-duplicate clause is dropped so one place decides. `/backfill` gains the guard for start/recover/recapture. `stop` stays open on a disabled source, or arming becomes a one-way door. Migration 0101 clears failure state on sources that are already disabled — the predicate fixes what the surfaces report, not what the rows carry, and the rows are why the operator had no way out (lesson #4202). It matches what `update` already does on an explicit disable, so rows disabled by any other path come into line. Enabled sources are untouched: a real failure on a live source must keep showing, which the second new test pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -274,6 +274,47 @@ async def test_backfill_endpoint_start_and_stop(client, artist, db):
|
||||
assert (await stopped.get_json())["backfill_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_refuses_a_disabled_source(client, artist, db):
|
||||
"""#4279: a source FC deliberately stopped must not be armable for a deep
|
||||
walk. Arming one is how Ebi77 got a failure nobody could clear — the walk
|
||||
cannot complete without access, the recovery sweep strands it, and a
|
||||
disabled source is never scheduled again to reset the count."""
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-stopped", enabled=False,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
|
||||
for action in ("start", "recover", "recapture"):
|
||||
resp = await client.post(
|
||||
f"/api/sources/{src.id}/backfill", json={"action": action},
|
||||
)
|
||||
assert resp.status_code == 400, action
|
||||
assert (await resp.get_json())["error"] == "source_disabled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_stop_still_works_on_a_disabled_source(client, artist, db):
|
||||
"""Only the ARMING actions are gated. Cancelling a walk on a source that
|
||||
was disabled mid-backfill must stay available, or the arm becomes a
|
||||
one-way door."""
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-stopping", enabled=False,
|
||||
config_overrides={"_backfill_state": "running"},
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/sources/{src.id}/backfill", json={"action": "stop"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["backfill_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_defaults_to_start(client, artist, db):
|
||||
src = Source(
|
||||
|
||||
Reference in New Issue
Block a user