Files
siftlode/backend/tests/test_scheduler.py
T
peter d13483a593 feat(config): job intervals live in sysconfig, every setting says when it applies, the unread SponsorBlock default goes
An interval override was a row of its own table beside the one store every other admin knob uses; it
is now the `job_interval_<job>` sysconfig key (migration 0110 copies them over and drops the table).
The Scheduler page stays its one editor: the Configuration page and the setup wizard neither list nor
write those keys, since a write there would skip the reschedule.

Each ConfigSpec now states `applies` (required, no default). Every key is `live`, held by a scan that
finds no read at import time; a `restart` key is refused until the page can say so. A second scan
requires a reader for every key on the Configuration page — `sponsorblock_default` had none since it
was added, so it leaves the registry, the translations and the store.
2026-10-18 02:23:15 +02:00

465 lines
19 KiB
Python

"""The scheduler's run path, DB-lane: the single-run guard, and every run as a durable `job_runs` row
— its outcome as data, what it spent, the lines it logged — that outlives the process.
_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
import logging
import time
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig
from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent
from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
def _release():
scheduler._record(_JOB, running=False)
def test_claim_is_exclusive_until_released():
_release()
# First caller claims it.
assert scheduler._claim_running(_JOB) is True
# A second caller is refused while the first still holds it (the TOCTOU the fix closes).
assert scheduler._claim_running(_JOB) is False
assert scheduler._claim_running(_JOB) is False
# _job's finally releases the claim...
_release()
# ...and now it can be claimed again.
assert scheduler._claim_running(_JOB) is True
_release()
# ---- the result as data --------------------------------------------------------------------------
def _snapshot_job(db, job_id):
return next(j for j in scheduler.scheduler_snapshot(db)["jobs"] if j["id"] == job_id)
def _runs(db, job=None) -> list[JobRun]:
db.expire_all()
q = select(JobRun).order_by(JobRun.id)
if job is not None:
q = q.where(JobRun.job == job)
return list(db.execute(q).scalars())
def _manual(actor_id, name, fn):
token = scheduler._manual_actor.set(actor_id)
try:
scheduler._job(name, fn)
finally:
scheduler._manual_actor.reset(token)
def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
"""The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3,
failed=0", a Python repr for a nested phase). The result rides along as data, so the client names
it; the one-liner stays as the fallback."""
admin = make_user("sched@example.com", role="admin")
_manual(admin.id, "rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
_manual(admin.id, "enrich", lambda _db: 5) # enrich returns a bare count
rss = _snapshot_job(db, "rss_poll")
assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1}
assert rss["last_result"] == "new=3, failed=0, fallback=1"
assert _snapshot_job(db, "enrich")["result"] == {"enriched": 5}
notices = db.execute(
select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id)
).scalars()
assert [n.data["result"] for n in notices] == [
{"new": 3, "failed": 0, "fallback": 1},
{"enriched": 5},
]
def test_a_failed_run_drops_the_previous_result(db):
scheduler._job("rss_poll", lambda _db: {"new": 1})
scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom")))
rss = _snapshot_job(db, "rss_poll")
assert rss["status"] == "error"
assert rss["last_error"] == "boom"
assert rss["result"] is None
def test_a_failure_the_job_names_rides_along_as_data(db, make_user):
"""A failed RSS poll reached the Hungarian UI as the server's English sentence. A failure the job
names (`progress.JobFailed`) is stored as data beside that sentence — on the run's row, the
dashboard and the manual-run notice — so the client says it in the reader's language."""
from app import progress
def outage(_db):
raise progress.JobFailed("rss_all_failed", "RSS poll failed", total=2, cause="transport")
admin = make_user("failure@example.com", role="admin")
_manual(admin.id, "rss_poll", outage)
named = {"failure": "rss_all_failed", "total": 2, "cause": "transport"}
rss = _snapshot_job(db, "rss_poll")
assert rss["last_error"] == "RSS poll failed"
assert rss["result"] == named
notice = db.execute(select(Notification).where(Notification.user_id == admin.id)).scalar_one()
assert notice.data["result"] == named
(run,) = _runs(db, "rss_poll")
assert (run.status, run.error, run.failure) == ("error", "RSS poll failed", named)
# ---- the durable row -----------------------------------------------------------------------------
def test_every_run_is_a_row_with_who_started_it_and_none_in_the_audit_log(db, make_user):
"""A no-op scheduled poll was not written anywhere (the audit rule kept only changes), so "did
it run at 03:00?" had no answer. Every run is a row now; the audit log keeps admin actions only."""
admin = make_user("runs@example.com", role="admin")
scheduler._job("rss_poll", lambda _db: {"new": 0})
_manual(admin.id, "rss_poll", lambda _db: {"new": 2})
scheduled, manual = _runs(db, "rss_poll")
assert (scheduled.trigger, scheduled.actor_id, scheduled.status) == ("scheduled", None, "ok")
assert scheduled.result == {"new": 0}
assert (manual.trigger, manual.actor_id, manual.summary) == ("manual", admin.id, "new=2")
assert manual.finished_at is not None and manual.finished_at >= manual.started_at
assert db.execute(select(AuditLog)).scalars().all() == []
def test_the_last_run_survives_a_restart(db):
"""The dashboard's "last run" lived in the process's memory: a deploy blanked every job."""
scheduler._job("plex_sync", lambda _db: {"items": 4})
with scheduler._activity_lock:
scheduler._activity.clear() # what a new process starts with
plex = _snapshot_job(db, "plex_sync")
assert (plex["status"], plex["result"], plex["last_trigger"]) == (
"ok",
{"items": 4},
"scheduled",
)
assert plex["last_finished"] is not None
def test_a_row_shows_its_last_three_runs_newest_first_an_interrupted_one_included(db):
"""The row said only how the LAST run went; an interrupted run read as "no run recorded"."""
oldest = jobruns.start("shorts", RunTrigger.SCHEDULED, None)
jobruns.interrupt_leftovers()
scheduler._job("shorts", lambda _db: {"probed": 1})
scheduler._job("shorts", lambda _db: {"probed": 2})
scheduler._job("shorts", lambda _db: 1 / 0)
scheduler._job("rss_poll", lambda _db: {"new": 0}) # another job's run stays on its own row
recent = _snapshot_job(db, "shorts")["recent"]
assert [r["status"] for r in recent] == ["error", "ok", "ok"]
assert oldest not in [r["id"] for r in recent] # the fourth-newest is not shown
assert recent[0]["trigger"] == "scheduled" and recent[0]["finished_at"] is not None
assert [r["status"] for r in _snapshot_job(db, "rss_poll")["recent"]] == ["ok"]
jobruns.start("shorts", RunTrigger.SCHEDULED, None)
jobruns.interrupt_leftovers()
shorts = _snapshot_job(db, "shorts")
assert shorts["status"] == "interrupted"
assert [r["status"] for r in shorts["recent"]] == ["interrupted", "error", "ok"]
def test_a_paused_sync_is_a_skipped_run(db):
state.set_sync_paused(db, True)
db.commit()
scheduler._job("rss_poll", lambda _db: pytest.fail("a paused run must not do its work"))
(run,) = _runs(db, "rss_poll")
assert (run.status, run.result) == ("skipped", {"skipped": "sync paused"})
def test_a_run_left_running_by_a_stopped_process_is_interrupted_and_stays_so(db):
"""A deploy mid-run left nothing behind before; now the row would say `running` forever. The
next startup closes it, and the stale thread's own finish cannot rewrite it."""
run_id = jobruns.start("backfill", RunTrigger.SCHEDULED, None)
assert jobruns.interrupt_leftovers() == 1
(run,) = _runs(db, "backfill")
assert run.status == "interrupted" and run.finished_at is not None
jobruns.finish(run_id, RunStatus.OK, summary="late")
(run,) = _runs(db, "backfill")
assert (run.status, run.summary) == ("interrupted", None)
def test_a_run_knows_what_it_spent_and_a_request_spend_is_nobodys_run(db):
def spends(_db):
quota.record_usage(5)
quota.record_usage(3)
return {"enriched": 2}
scheduler._job("enrich", spends)
quota.record_usage(11) # a request's own spend, outside any run
(run,) = _runs(db, "enrich")
assert run.quota_units == 8
events = db.execute(select(QuotaEvent.units, QuotaEvent.job_run_id)).all()
assert sorted(events) == [(3, run.id), (5, run.id), (11, None)]
assert _snapshot_job(db, "enrich")["quota_units"] == 8
class _Collect(logging.Handler):
def __init__(self):
super().__init__()
self.addFilter(logs.ContextFilter())
self.records: list[logging.LogRecord] = []
def emit(self, record):
self.records.append(record)
def test_a_runs_log_lines_carry_its_id(db):
"""So a run's row can open exactly the lines it logged (the log table's `run_id`)."""
probe = logging.getLogger("siftlode.test.run")
handler = _Collect()
probe.addHandler(handler)
probe.setLevel(logging.INFO)
try:
scheduler._job("autotag", lambda _db: probe.info("tagging") or {"tagged": 1})
probe.info("after the run")
finally:
probe.removeHandler(handler)
(run,) = _runs(db, "autotag")
inside, after = (logs.record_fields(r).get("run_id") for r in handler.records)
assert (inside, after) == (run.id, None)
# …and it reaches the stored row, whose columns are the model's.
assert logsink.to_row(logs.record_fields(handler.records[0]))["run_id"] == run.id
def test_retention_reaps_finished_runs_only(db):
old = datetime.now(timezone.utc) - timedelta(days=40)
db.add_all(
[
JobRun(
job="rss_poll",
trigger="scheduled",
status="ok",
started_at=old,
finished_at=old,
),
JobRun(job="backfill", trigger="scheduled", status="running", started_at=old),
JobRun(
job="rss_poll",
trigger="scheduled",
status="ok",
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
),
]
)
db.commit()
assert jobruns.gc(db, 0) == 0 # 0 = keep forever
assert jobruns.gc(db, 30) == 1
db.commit()
assert sorted((r.job, r.status) for r in _runs(db)) == [
("backfill", "running"),
("rss_poll", "ok"),
]
# ---- "run now" through the scheduler --------------------------------------------------------------
@pytest.fixture
def live_scheduler(db, monkeypatch):
"""This process's scheduler, started as the API starts it (on the migrated schema `db` brings),
with interval jobs off."""
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", False)
scheduler.start_scheduler()
try:
yield scheduler._scheduler
finally:
scheduler.shutdown_scheduler()
def _wait_finished(db, job, timeout=10.0) -> JobRun:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
runs = [r for r in _runs(db, job) if r.status != "running"]
if runs:
return runs[-1]
time.sleep(0.05)
raise AssertionError(f"{job} did not finish")
def test_run_now_goes_through_the_scheduler_even_with_interval_jobs_off(
db, make_user, live_scheduler, monkeypatch
):
admin = make_user("now@example.com", role="admin")
monkeypatch.setitem(
scheduler.JOB_FUNCS, "shorts", lambda: scheduler._job("shorts", lambda _db: {"probed": 3})
)
assert live_scheduler.get_jobs() == [] # SCHEDULER_ENABLED=false: no interval job
live_scheduler.pause() # hold the one-shot so the second click finds it pending
assert scheduler.trigger_job("shorts", admin.id) == "started"
assert scheduler.trigger_job("shorts", admin.id) == "already_running"
assert [j.id for j in live_scheduler.get_jobs()] == ["shorts@now"]
live_scheduler.resume()
run = _wait_finished(db, "shorts")
assert (run.trigger, run.actor_id, run.result) == ("manual", admin.id, {"probed": 3})
def test_an_ad_hoc_run_is_the_users_run_of_its_target_with_its_spend_and_failure(
db, make_user, live_scheduler
):
"""The one-channel deep backfill ran as a BackgroundTask nothing recorded: its spend counted as
nobody's run and a failure lived only in the log."""
user = make_user("adhoc@example.com")
def work():
quota.record_usage(3)
return {"videos_added": 50}
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, work, actor_id=user.id, target="UC_a")
run = _wait_finished(db, "channel_backfill")
assert (run.trigger, run.actor_id, run.target) == ("ad_hoc", user.id, "UC_a")
assert (run.status, run.result, run.quota_units) == ("ok", {"videos_added": 50}, 3)
def fail():
raise RuntimeError("quota exceeded")
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, fail, actor_id=user.id, target="UC_b")
deadline = time.monotonic() + 10
while time.monotonic() < deadline and len(_finished(db, "channel_backfill")) < 2:
time.sleep(0.05)
failed = next(r for r in _runs(db, "channel_backfill") if r.target == "UC_b")
assert (failed.status, failed.error) == ("error", "quota exceeded")
def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, make_user):
user = make_user("shown@example.com")
db.add(Channel(id="UC_shown", title="BBC Earth"))
db.commit()
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_none")
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 69}, user.id, "UC_shown")
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
assert (row["id"], row["running"], row["target"], row["target_title"]) == (
"channel_backfill",
False,
"UC_shown",
"BBC Earth",
)
assert (row["status"], row["result"], row["last_trigger"]) == (
"ok",
{"videos_added": 69},
"ad_hoc",
)
assert len(row["recent"]) == 2
assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"])
jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_shown")
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is True
def _finished(db, job) -> list[JobRun]:
return [r for r in _runs(db, job) if r.status != "running"]
def test_an_ad_hoc_job_outside_the_named_list_is_refused(live_scheduler):
"""The page names every ad-hoc job from AD_HOC_JOBS; one outside it would show as a bare id."""
with pytest.raises(KeyError):
scheduler.run_ad_hoc("unnamed_job", dict, actor_id=1, target="x")
assert live_scheduler.get_jobs() == []
def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run(
db, make_user, live_scheduler
):
user = make_user("twice@example.com")
live_scheduler.pause() # hold the one-shots so the second request finds the first pending
for target in ("UC_x", "UC_x", "UC_y"):
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, dict, actor_id=user.id, target=target)
assert sorted(j.id for j in live_scheduler.get_jobs()) == [
"channel_backfill@UC_x",
"channel_backfill@UC_y",
]
live_scheduler.remove_all_jobs() # never run: a run finishing after the test would land in the next
def test_a_manual_run_leaves_no_actor_behind_on_its_thread(db, make_user):
"""The scheduler's pool threads are reused: an actor a manual run left set made the next
scheduled run on that thread an admin's "manual" run, with a notice in their inbox."""
admin = make_user("pool@example.com", role="admin")
seen = []
scheduler._as_actor(lambda: seen.append(scheduler._manual_actor.get()), admin.id)()
assert seen == [admin.id]
assert scheduler._manual_actor.get() is None # this same thread's next task runs as nobody
def test_run_now_refuses_a_job_already_running(live_scheduler):
assert scheduler._claim_running("maintenance") is True
try:
assert scheduler.trigger_job("maintenance") == "already_running"
finally:
scheduler._record("maintenance", running=False)
def test_startup_closes_what_the_previous_process_left_running(db, monkeypatch):
jobruns.start("plex_sync", RunTrigger.MANUAL, None)
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", False)
scheduler.start_scheduler()
scheduler.shutdown_scheduler()
(run,) = _runs(db, "plex_sync")
assert run.status == "interrupted"
def test_every_job_has_an_interval_and_every_interval_a_job():
assert set(scheduler.JOB_FUNCS) == set(scheduler.JOB_INTERVALS)
def test_a_stored_interval_is_what_the_job_starts_with(db, monkeypatch):
sysconfig.set_value(db, "job_interval_rss_poll", 25)
db.commit()
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", True)
scheduler.start_scheduler()
try:
sched = scheduler._scheduler
assert sched is not None
assert sched.get_job("rss_poll").trigger.interval == timedelta(minutes=25)
default = scheduler.JOB_INTERVALS["enrich"]
assert sched.get_job("enrich").trigger.interval == timedelta(minutes=default)
finally:
scheduler.shutdown_scheduler()
def test_an_interval_set_on_the_scheduler_page_is_stored_and_reschedules_the_job(
db, make_user, as_user, live_scheduler
):
live_scheduler.add_job(lambda: None, "interval", minutes=10, id="backfill")
admin = as_user(make_user(role="admin"))
r = admin.patch("/api/admin/scheduler/jobs/backfill", json={"interval_minutes": 45})
assert r.status_code == 200, r.text
assert live_scheduler.get_job("backfill").trigger.interval == timedelta(minutes=45)
db.expire_all()
assert sysconfig.get_int(db, "job_interval_backfill") == 45
assert scheduler.load_intervals(db)["backfill"] == 45
# The Configuration page neither lists an interval nor writes one past the reschedule.
assert (
admin.patch("/api/admin/config/job_interval_backfill", json={"value": 5}).status_code == 404
)
assert admin.delete("/api/admin/config/job_interval_backfill").status_code == 404
assert "scheduler" not in admin.get("/api/admin/config").json()["groups"]
db.expire_all()
assert sysconfig.get_int(db, "job_interval_backfill") == 45
def test_the_setup_wizard_does_not_write_an_interval(db):
from fastapi import HTTPException
from app.routes.setup import setup_config
with pytest.raises(HTTPException) as refused:
setup_config({"job_interval_rss_poll": 5}, db)
assert refused.value.status_code == 400
assert setup_config({"quota_daily_budget": 5000}, db)["saved"] == ["quota_daily_budget"]