"""Pulse scheduler tests — cadence, kill switch, mode gating, persistence.""" from __future__ import annotations from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import create_engine from psyc import db from psyc.lines import pulse @pytest.fixture def fresh_db(tmp_path, monkeypatch): """A temp SQLite + a swapped-in registry of fast, deterministic runners. Real runners would talk to scout / classify / proof / etc — none of which we want to exercise from a pulse-only test. We replace _REGISTRY in-place so tick() drives the scheduler logic against trivial counters. """ test_db = tmp_path / "pulse.db" test_engine = create_engine(f"sqlite:///{test_db}", future=True) db._metadata.create_all(test_engine, checkfirst=True) monkeypatch.setattr(db, "_engine", test_engine) monkeypatch.setattr(db, "DB_PATH", test_db) counters = {name: 0 for name in pulse._REGISTRY} def _make(name: str): def runner() -> str: counters[name] += 1 return f"{name}: tick {counters[name]}" return runner fake_registry = {name: _make(name) for name in pulse._REGISTRY} monkeypatch.setattr(pulse, "_REGISTRY", fake_registry) return counters def _set_last_fired(name: str, when: datetime, cadence: int) -> None: """Force a pipeline's last_fired + next_fire to a known instant.""" for p in pulse.state(): if p.name == name: p.last_fired = when p.next_fire = when + timedelta(seconds=cadence) db.upsert_pulse_pipeline(pulse._pipeline_to_row(p)) return raise AssertionError(f"unknown pipeline {name}") def test_defaults_seeded_on_first_state(fresh_db): pipelines = pulse.state() names = {p.name for p in pipelines} assert names == {"fetch", "classify", "prove", "reindex", "respond", "peer-pull", "vouch-refresh"} # Spot-check a couple of defaults from the spec. by_name = {p.name: p for p in pipelines} assert by_name["fetch"].mode == pulse.PulseMode.AUTO_EXECUTE assert by_name["fetch"].cadence_seconds == 900 assert by_name["peer-pull"].enabled is False assert by_name["respond"].mode == pulse.PulseMode.AUTO_PROPOSE def test_state_persists_across_reads(fresh_db): pulse.set_cadence("fetch", 42) pulse.set_mode("classify", pulse.PulseMode.MANUAL) pulse.set_enabled("reindex", False) by_name = {p.name: p for p in pulse.state()} assert by_name["fetch"].cadence_seconds == 42 assert by_name["classify"].mode == pulse.PulseMode.MANUAL assert by_name["reindex"].enabled is False def test_tick_skips_not_due_pipelines(fresh_db): # Push every pipeline well into the future — nothing should fire. now = datetime.now(timezone.utc) for p in pulse.state(): _set_last_fired(p.name, now, p.cadence_seconds) results = pulse.tick() outcomes = {name: outcome for name, outcome, _ in results} assert all(o == "skipped" for o in outcomes.values()), outcomes assert all(c == 0 for c in fresh_db.values()), fresh_db def test_tick_fires_due_auto_pipelines(fresh_db): # Force "fetch" to be due (last fired far in the past) and re-tick. past = datetime.now(timezone.utc) - timedelta(hours=24) for p in pulse.state(): _set_last_fired(p.name, past, p.cadence_seconds) results = pulse.tick() by_name = {name: (outcome, result) for name, outcome, result in results} # auto-execute + auto-propose modes should fire; manual should skip. assert by_name["fetch"][0] == "ok" assert by_name["classify"][0] == "ok" assert by_name["prove"][0] == "ok" assert by_name["reindex"][0] == "ok" assert by_name["respond"][0] == "ok" # Manual + disabled pipelines stay skipped. assert by_name["peer-pull"][0] == "skipped" assert by_name["vouch-refresh"][0] == "skipped" def test_tick_respects_manual_mode(fresh_db): past = datetime.now(timezone.utc) - timedelta(hours=24) _set_last_fired("fetch", past, 900) pulse.set_mode("fetch", pulse.PulseMode.MANUAL) results = pulse.tick() by_name = {n: o for n, o, _ in results} assert by_name["fetch"] == "skipped" assert fresh_db["fetch"] == 0 def test_tick_respects_disabled(fresh_db): past = datetime.now(timezone.utc) - timedelta(hours=24) _set_last_fired("fetch", past, 900) pulse.set_enabled("fetch", False) results = pulse.tick() by_name = {n: o for n, o, _ in results} assert by_name["fetch"] == "skipped" assert fresh_db["fetch"] == 0 def test_kill_switch_halts_everything(fresh_db): past = datetime.now(timezone.utc) - timedelta(hours=24) for p in pulse.state(): _set_last_fired(p.name, past, p.cadence_seconds) pulse.set_kill_switch(True) results = pulse.tick() assert all(o == "skipped" for _, o, _ in results) assert all(c == 0 for c in fresh_db.values()) # And killswitch state itself round-trips. assert pulse.kill_switch_state() is True pulse.set_kill_switch(False) assert pulse.kill_switch_state() is False def test_tick_updates_last_fired_and_next_fire(fresh_db): past = datetime.now(timezone.utc) - timedelta(hours=24) _set_last_fired("fetch", past, 900) before = datetime.now(timezone.utc) pulse.tick() p = {x.name: x for x in pulse.state()}["fetch"] assert p.last_fired is not None assert p.last_fired >= before - timedelta(seconds=2) assert p.next_fire is not None assert p.next_fire > p.last_fired delta = (p.next_fire - p.last_fired).total_seconds() # 900s cadence, allow a 2s rounding window. assert 898 <= delta <= 902 assert p.last_outcome == "ok" assert "fetch: tick" in p.last_result def test_run_now_bypasses_cadence(fresh_db): # Pipeline isn't due — run_now must still fire. future = datetime.now(timezone.utc) + timedelta(hours=1) for p in pulse.state(): p.last_fired = datetime.now(timezone.utc) p.next_fire = future db.upsert_pulse_pipeline(pulse._pipeline_to_row(p)) outcome, result = pulse.run_now("fetch") assert outcome == "ok" assert "fetch: tick" in result assert fresh_db["fetch"] == 1 def test_run_now_respects_kill_switch(fresh_db): pulse.set_kill_switch(True) outcome, result = pulse.run_now("fetch") assert outcome == "skipped" assert "kill switch" in result assert fresh_db["fetch"] == 0 def test_run_now_even_when_manual(fresh_db): """Manual mode blocks tick() but NOT run_now — that's the whole point of manual.""" pulse.set_mode("peer-pull", pulse.PulseMode.MANUAL) pulse.set_enabled("peer-pull", True) outcome, _ = pulse.run_now("peer-pull") assert outcome == "ok" assert fresh_db["peer-pull"] == 1 def test_runner_exception_recorded_as_err(fresh_db, monkeypatch): def boom() -> str: raise RuntimeError("nope") # Inject a failing runner just for "fetch". bad_registry = dict(pulse._REGISTRY) bad_registry["fetch"] = boom monkeypatch.setattr(pulse, "_REGISTRY", bad_registry) past = datetime.now(timezone.utc) - timedelta(hours=24) _set_last_fired("fetch", past, 900) results = pulse.tick() by_name = {n: (o, r) for n, o, r in results} assert by_name["fetch"][0] == "err" assert "nope" in by_name["fetch"][1] # And the failure is persisted, with next_fire still pushed forward so we # don't busy-retry a broken runner every tick. p = {x.name: x for x in pulse.state()}["fetch"] assert p.last_outcome == "err" assert p.next_fire is not None and p.next_fire > p.last_fired def test_unknown_pipeline_raises(fresh_db): with pytest.raises(ValueError): pulse.set_mode("does-not-exist", pulse.PulseMode.MANUAL) with pytest.raises(ValueError): pulse.set_cadence("does-not-exist", 60) with pytest.raises(ValueError): pulse.run_now("does-not-exist") def test_invalid_cadence_raises(fresh_db): with pytest.raises(ValueError): pulse.set_cadence("fetch", 0) with pytest.raises(ValueError): pulse.set_cadence("fetch", -5)