Files
joulenap/backend/tests/test_scheduler.py
T
Catubba 8074043a7f fix: backend robustness + config secret-file permissions
- BE-B3: open SQLite with WAL + busy_timeout + foreign_keys via a connect
  listener, so a running cycle's frequent commits don't risk "database is
  locked" against dashboard polling, and the CASCADE FKs actually enforce.
- BE-S2: write config.yaml owner-only (0600) — it holds API tokens, the
  session key and notification secrets, so it must not be world-readable on
  the host/bind mount (matches the SSH key). Best-effort; re-asserted on save.
- BE-B5: hold the single-run lock across a manual power-off (JobService
  .exclusive()) so a scheduled cycle can't start in the check-then-act gap
  and get its PBS shut down mid-backup.
- BE-B6: if the worker thread fails to start, fail the run and release the
  single-run lock instead of leaking it (which would 409 every later run).
- BE-B7: re-arm the daily history-prune job on rearm so a runtime timezone
  change moves it into the new zone instead of the boot-time zone.
2026-07-10 23:01:28 +02:00

322 lines
10 KiB
Python

"""Scheduler arming / re-arming. The backup job is the only cron job; GC has none."""
from __future__ import annotations
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from apscheduler.triggers.cron import CronTrigger
from app.config import Config
from app.core.scheduler import (
BACKUP_JOB_ID,
PRUNE_JOB_ID,
VERIFY_JOB_ID,
Scheduler,
_build_trigger,
_translate_dow,
resolve_timezone,
)
def _config(enabled: bool = True, schedule: str = "0 4 * * *") -> Config:
cfg = Config()
cfg.backup.enabled = enabled
cfg.backup.schedule = schedule
return cfg
def test_arms_backup_job_when_enabled():
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config())
job = sched.backup_job
assert job is not None and job.id == BACKUP_JOB_ID
assert isinstance(job.trigger, CronTrigger)
def test_no_job_when_disabled():
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config(enabled=False))
assert sched.backup_job is None
def test_only_one_job_armed_no_separate_gc_job():
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config())
assert len(sched._scheduler.get_jobs()) == 1
def test_rearm_replaces_existing_job():
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config(schedule="0 4 * * *"))
sched.rearm(_config(schedule="30 5 * * *"))
jobs = sched._scheduler.get_jobs()
assert len(jobs) == 1
assert jobs[0].id == BACKUP_JOB_ID
def test_rearm_to_disabled_clears_job():
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config())
sched.rearm(_config(enabled=False))
assert sched.backup_job is None
def test_next_run_time_computed_when_running():
sched = Scheduler(lambda _trigger: None)
sched.start()
try:
sched.rearm(_config())
assert sched.next_run_time is not None
finally:
sched.shutdown()
def test_fire_backup_passes_scheduled_trigger():
from app.db.models import RunTrigger
seen: list[RunTrigger] = []
sched = Scheduler(lambda trigger: seen.append(trigger))
sched._fire_backup()
assert seen == [RunTrigger.SCHEDULED]
def test_fire_backup_swallows_errors():
def boom(_trigger):
raise RuntimeError("already running")
sched = Scheduler(boom)
# Must not propagate — a raising fire would kill the scheduler thread.
sched._fire_backup()
# --- history-prune housekeeping job -------------------------------------------
def test_arm_prune_adds_daily_job():
sched = Scheduler(lambda _trigger: None, run_prune=lambda: None)
sched.arm_prune()
assert sched.prune_job is not None and sched.prune_job.id == PRUNE_JOB_ID
def test_arm_prune_noop_without_callback():
sched = Scheduler(lambda _trigger: None) # no prune callback
sched.arm_prune()
assert sched.prune_job is None
def test_rearm_preserves_prune_job():
# Re-arming (or disabling) the backup job must not drop the prune housekeeping job.
sched = Scheduler(lambda _trigger: None, run_prune=lambda: None)
sched.arm_prune()
sched.rearm(_config())
sched.rearm(_config(enabled=False))
assert sched.prune_job is not None
assert {j.id for j in sched._scheduler.get_jobs()} == {PRUNE_JOB_ID}
def test_rearm_updates_prune_job_timezone():
# A runtime timezone change (via rearm) must move the prune job into the new zone, not
# leave it firing in the boot-time zone (BE-B7). Started so replace_existing dedups the
# prune job (as it does in production, where rearm always runs on a started scheduler).
sched = Scheduler(lambda _trigger: None, run_prune=lambda: None, timezone="UTC")
sched.start()
try:
sched.arm_prune()
assert sched.prune_job is not None
assert str(sched.prune_job.trigger.timezone) == "UTC"
cfg = _config()
cfg.app.timezone = "Europe/Rome"
sched.rearm(cfg)
assert sched.prune_job is not None
assert str(sched.prune_job.trigger.timezone) == "Europe/Rome"
finally:
sched.shutdown()
def test_fire_prune_invokes_callback():
calls: list[int] = []
sched = Scheduler(lambda _trigger: None, run_prune=lambda: calls.append(1))
sched._fire_prune()
assert calls == [1]
def test_fire_prune_swallows_errors():
def boom():
raise RuntimeError("db locked")
sched = Scheduler(lambda _trigger: None, run_prune=boom)
sched._fire_prune() # must not propagate
# --- scheduled verify job -----------------------------------------------------
def _verify_config(enabled: bool = True, schedule: str = "0 3 1 * *") -> Config:
cfg = _config()
cfg.maintenance.verify.enabled = enabled
cfg.maintenance.verify.schedule = schedule
return cfg
def test_arms_verify_job_when_enabled():
sched = Scheduler(lambda _t: None, run_verify=lambda _t: None)
sched.rearm(_verify_config())
assert sched.verify_job is not None and sched.verify_job.id == VERIFY_JOB_ID
def test_no_verify_job_when_disabled():
sched = Scheduler(lambda _t: None, run_verify=lambda _t: None)
sched.rearm(_verify_config(enabled=False))
assert sched.verify_job is None
def test_no_verify_job_without_callback():
sched = Scheduler(lambda _t: None) # no run_verify
sched.rearm(_verify_config())
assert sched.verify_job is None
def test_rearm_to_disabled_clears_verify_job():
sched = Scheduler(lambda _t: None, run_verify=lambda _t: None)
sched.rearm(_verify_config())
sched.rearm(_verify_config(enabled=False))
assert sched.verify_job is None
def test_invalid_backup_schedule_does_not_crash_rearm():
# A hand-edited/invalid backup schedule must not raise out of rearm (BE-B1) — otherwise
# a bad string on disk bricks every startup. It's skipped, leaving nothing armed.
sched = Scheduler(lambda _trigger: None)
sched.rearm(_config(schedule="0 4 * *")) # 4 fields, unparseable
assert sched.backup_job is None
def test_legacy_verify_schedule_does_not_crash_or_arm():
# The old config used schedule "monthly" (not cron). Arming must not raise, just skip.
sched = Scheduler(lambda _t: None, run_verify=lambda _t: None)
sched.rearm(_verify_config(schedule="monthly"))
assert sched.verify_job is None
def test_fire_verify_passes_scheduled_trigger():
from app.db.models import RunTrigger
seen: list[RunTrigger] = []
sched = Scheduler(lambda _t: None, run_verify=lambda trigger: seen.append(trigger))
sched._fire_verify()
assert seen == [RunTrigger.SCHEDULED]
def test_fire_verify_swallows_errors():
def boom(_trigger):
raise RuntimeError("already running")
sched = Scheduler(lambda _t: None, run_verify=boom)
sched._fire_verify() # must not propagate
def test_fire_backup_logs_already_running_as_info(caplog):
import logging
from app.jobs import AlreadyRunningError
def already_running(_trigger):
raise AlreadyRunningError("in progress")
sched = Scheduler(already_running)
with caplog.at_level(logging.INFO, logger="joulenap.scheduler"):
sched._fire_backup() # must not raise
assert any(
r.levelno == logging.INFO and "already in progress" in r.getMessage()
for r in caplog.records
)
assert not any(r.levelno >= logging.ERROR for r in caplog.records)
# --- day-of-week mapping (the cron-vs-APScheduler numbering bug) --------------
def test_translate_dow_maps_cron_numbers_to_apscheduler_names():
# cron 1..6 = Mon..Sat; "Sundays off" must not include sun.
assert _translate_dow("1,2,3,4,5,6") == "mon,tue,wed,thu,fri,sat"
assert _translate_dow("0") == "sun"
assert _translate_dow("7") == "sun"
assert _translate_dow("*") == "*"
def test_build_trigger_excludes_sunday_when_off():
# "Sundays off" cron from the UI. Verify the next fire after a Saturday is Monday,
# i.e. Sunday is skipped (the bug let it fire on Sunday).
trigger = _build_trigger("0 4 * * 1,2,3,4,5,6", UTC)
sat = datetime(2026, 6, 27, 5, 0, tzinfo=UTC) # Saturday 05:00, after 04:00 fire
nxt = trigger.get_next_fire_time(None, sat)
assert nxt.weekday() == 0 # Monday (not Sunday=6)
# --- timezone (the container-defaults-to-UTC footgun) -------------------------
def test_resolve_timezone_explicit_name():
assert str(resolve_timezone("Europe/Rome")) == "Europe/Rome"
def test_resolve_timezone_utc_string_needs_no_tzdata():
# "UTC" short-circuits to a fixed-offset tz so it resolves even without the IANA db.
assert resolve_timezone("UTC") is UTC
def test_resolve_timezone_name_takes_precedence_over_env(monkeypatch):
monkeypatch.setenv("TZ", "America/New_York")
assert str(resolve_timezone("Europe/Rome")) == "Europe/Rome"
def test_resolve_timezone_falls_back_to_tz_env(monkeypatch):
monkeypatch.setenv("TZ", "America/New_York")
assert str(resolve_timezone("")) == "America/New_York"
assert str(resolve_timezone(None)) == "America/New_York"
def test_resolve_timezone_defaults_to_utc(monkeypatch):
monkeypatch.delenv("TZ", raising=False)
assert resolve_timezone("") is UTC
def test_resolve_timezone_invalid_falls_back_to_utc(monkeypatch):
monkeypatch.delenv("TZ", raising=False)
assert resolve_timezone("Not/AZone") is UTC
def test_backup_trigger_carries_configured_timezone():
sched = Scheduler(lambda _t: None)
cfg = _config(schedule="0 2 * * *")
cfg.app.timezone = "Europe/Rome"
sched.rearm(cfg)
assert str(sched.backup_job.trigger.timezone) == "Europe/Rome"
def test_rearm_applies_changed_app_timezone():
# The scheduler starts in UTC; saving a new app.timezone calls rearm(), which must
# re-arm the backup job in the new zone rather than keep UTC until a restart.
sched = Scheduler(lambda _t: None, timezone="UTC")
cfg = _config(schedule="0 22 * * *")
cfg.app.timezone = "Europe/Rome"
sched.rearm(cfg)
assert str(sched.backup_job.trigger.timezone) == "Europe/Rome"
def test_schedule_fires_at_configured_local_time():
# The whole point: "0 2" in Europe/Rome must mean 02:00 *Rome local* (00:00 UTC in
# summer, UTC+2) — not 02:00 UTC as a bare container scheduler would do.
sched = Scheduler(lambda _t: None)
cfg = _config(schedule="0 2 * * *")
cfg.app.timezone = "Europe/Rome"
sched.rearm(cfg)
ref = datetime(2026, 7, 1, 12, 0, tzinfo=ZoneInfo("Europe/Rome"))
nxt = sched.backup_job.trigger.get_next_fire_time(None, ref)
assert nxt.hour == 2 # 02:00 Rome local
assert nxt.astimezone(UTC).hour == 0 # == 00:00 UTC