feat: Scheduler, Laufprotokoll, Aufräumen und Gesundheitsprüfung
APScheduler-Jobs je Regel mit Startversatz und Jitter, Protokoll in runs/run_items mit Regelzählern, Aufräumen nach Aufbewahrungsfrist, Health-Auswertung für /healthz und Verdrahtung des Dienststarts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f45cb56aa0
commit
f008c216be
@@ -0,0 +1,395 @@
|
||||
"""Protokollierung, Regelzähler, Benachrichtigung, Aufräumen, Health (Fallstrick 9)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from netmover.db import session_scope
|
||||
from netmover.health import check_health
|
||||
from netmover.models import (
|
||||
FileFailure,
|
||||
ItemStatus,
|
||||
Mount,
|
||||
MountState,
|
||||
Rule,
|
||||
Run,
|
||||
RunItem,
|
||||
RunStatus,
|
||||
RunTrigger,
|
||||
utcnow,
|
||||
)
|
||||
from netmover.mover.engine import ItemResult, RunResult
|
||||
from netmover.mover.runner import RuleRunner
|
||||
from netmover.notify import Notification, Notifier, render
|
||||
from netmover.runs import RunService, cleanup
|
||||
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
|
||||
from tests.conftest import MountFactory
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.results: list[RunResult] = []
|
||||
|
||||
def run(self, rule_id: int, *, dry_run: bool | None = None) -> RunResult:
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rule_id(make_mount: MountFactory, session_factory: sessionmaker[Session]) -> int:
|
||||
mid = make_mount()
|
||||
with session_scope(session_factory) as s:
|
||||
r = Rule(name="scans", src_mount_id=mid, dst_path="/x", interval_seconds=30)
|
||||
s.add(r)
|
||||
s.flush()
|
||||
return r.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sent() -> list[tuple[str, str, str]]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier(session_factory: sessionmaker[Session], sent: list[tuple[str, str, str]]) -> Notifier:
|
||||
return Notifier(session_factory, sender=lambda *a: sent.append(a), background=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_runner() -> FakeRunner:
|
||||
return FakeRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(
|
||||
session_factory: sessionmaker[Session], fake_runner: FakeRunner, notifier: Notifier
|
||||
) -> RunService:
|
||||
return RunService(session_factory, fake_runner, notifier) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _rule(sf: sessionmaker[Session], rid: int) -> Rule:
|
||||
with sf() as s:
|
||||
r = s.get(Rule, rid)
|
||||
assert r is not None
|
||||
s.expunge(r)
|
||||
return r
|
||||
|
||||
|
||||
def _enable_webhook(sf: sessionmaker[Session], mode: NotifyMode, after: int = 3) -> None:
|
||||
save_settings(
|
||||
sf, AppSettings(webhook_url="http://hook.invalid/x", notify_mode=mode, notify_after=after)
|
||||
)
|
||||
|
||||
|
||||
def ok() -> RunResult:
|
||||
return RunResult(
|
||||
RunStatus.OK, seen=1, moved=1, items=[ItemResult("a.pdf", ItemStatus.MOVED, "/x/a.pdf", 3)]
|
||||
)
|
||||
|
||||
|
||||
def err() -> RunResult:
|
||||
return RunResult(RunStatus.ERROR, message="„scans“ ist nicht eingehängt")
|
||||
|
||||
|
||||
def partial() -> RunResult:
|
||||
return RunResult(
|
||||
RunStatus.PARTIAL,
|
||||
seen=2,
|
||||
moved=1,
|
||||
failed=1,
|
||||
items=[ItemResult("b.pdf", ItemStatus.FAILED, None, 1, "kaputt (Versuch 1)")],
|
||||
)
|
||||
|
||||
|
||||
def skipped() -> RunResult:
|
||||
return RunResult(RunStatus.SKIPPED, message="läuft noch")
|
||||
|
||||
|
||||
def test_run_and_items_are_recorded(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
fake_runner.results.append(ok())
|
||||
run_id = service.execute(rule_id)
|
||||
with session_factory() as s:
|
||||
run = s.get(Run, run_id)
|
||||
assert run is not None
|
||||
assert (run.status, run.seen, run.moved, run.trigger) == (
|
||||
RunStatus.OK,
|
||||
1,
|
||||
1,
|
||||
RunTrigger.SCHEDULE,
|
||||
)
|
||||
assert run.finished_at is not None and run.finished_at >= run.started_at
|
||||
items = s.scalars(select(RunItem)).all()
|
||||
assert [(i.src, i.dst, i.status) for i in items] == [
|
||||
("a.pdf", "/x/a.pdf", ItemStatus.MOVED)
|
||||
]
|
||||
r = _rule(session_factory, rule_id)
|
||||
assert r.last_success_at is not None and r.consecutive_failures == 0
|
||||
|
||||
|
||||
def test_counters(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
fake_runner.results += [err(), partial(), skipped()]
|
||||
service.execute(rule_id)
|
||||
r = _rule(session_factory, rule_id)
|
||||
assert r.consecutive_failures == 1 and r.last_success_at is None and r.last_run_at is not None
|
||||
service.execute(rule_id)
|
||||
r = _rule(session_factory, rule_id)
|
||||
assert r.consecutive_failures == 2 and r.last_success_at is not None # partial: Werkzeug lebt
|
||||
last_run = r.last_run_at
|
||||
service.execute(rule_id) # übersprungen: protokolliert, Zähler unverändert
|
||||
r = _rule(session_factory, rule_id)
|
||||
assert r.consecutive_failures == 2 and r.last_run_at == last_run
|
||||
with session_factory() as s:
|
||||
assert [x.status for x in s.scalars(select(Run).order_by(Run.id))] == [
|
||||
RunStatus.ERROR,
|
||||
RunStatus.PARTIAL,
|
||||
RunStatus.SKIPPED,
|
||||
]
|
||||
|
||||
|
||||
def test_manual_dry_run_does_not_touch_counters(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
fake_runner.results.append(RunResult(RunStatus.ERROR, dry_run=True, message="x"))
|
||||
service.execute(rule_id, RunTrigger.MANUAL, dry_run=True)
|
||||
r = _rule(session_factory, rule_id)
|
||||
assert r.consecutive_failures == 0 and r.last_run_at is None
|
||||
|
||||
|
||||
def test_deleted_rule_is_not_recorded(service: RunService, fake_runner: FakeRunner) -> None:
|
||||
fake_runner.results.append(err())
|
||||
assert service.execute(4711) is None
|
||||
|
||||
|
||||
def test_items_are_capped(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("netmover.runs.MAX_ITEMS_PER_RUN", 3)
|
||||
fake_runner.results.append(
|
||||
RunResult(RunStatus.OK, items=[ItemResult(f"{i}.pdf", ItemStatus.MOVED) for i in range(5)])
|
||||
)
|
||||
run_id = service.execute(rule_id)
|
||||
with session_factory() as s:
|
||||
assert len(s.scalars(select(RunItem)).all()) == 3
|
||||
run = s.get(Run, run_id)
|
||||
assert run is not None and "2 weitere" in (run.message or "")
|
||||
|
||||
|
||||
# -- Benachrichtigung -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notify_every_failure(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
sent: list[tuple[str, str, str]],
|
||||
) -> None:
|
||||
_enable_webhook(session_factory, NotifyMode.EVERY)
|
||||
fake_runner.results += [err(), err(), ok()]
|
||||
for _ in range(3):
|
||||
service.execute(rule_id)
|
||||
bodies = [json.loads(b) for _, _, b in sent]
|
||||
assert [b["event"] for b in bodies] == ["rule.failed", "rule.failed", "rule.recovered"]
|
||||
assert bodies[0]["message"] == "„scans“ ist nicht eingehängt"
|
||||
assert "2× in Folge" in bodies[1]["title"]
|
||||
|
||||
|
||||
def test_notify_after_n_only_once_at_threshold(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
sent: list[tuple[str, str, str]],
|
||||
) -> None:
|
||||
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
|
||||
fake_runner.results += [err(), err(), err(), err(), partial(), ok()]
|
||||
events = []
|
||||
for _ in range(6):
|
||||
service.execute(rule_id)
|
||||
events.append(len(sent))
|
||||
assert events == [0, 0, 1, 1, 1, 2]
|
||||
assert json.loads(sent[-1][2])["event"] == "rule.recovered"
|
||||
|
||||
|
||||
def test_no_recovery_message_below_threshold(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
sent: list[tuple[str, str, str]],
|
||||
) -> None:
|
||||
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
|
||||
fake_runner.results += [err(), ok()]
|
||||
service.execute(rule_id)
|
||||
service.execute(rule_id)
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_notify_off_by_default(
|
||||
service: RunService, fake_runner: FakeRunner, rule_id: int, sent: list[tuple[str, str, str]]
|
||||
) -> None:
|
||||
fake_runner.results.append(err())
|
||||
service.execute(rule_id)
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_render_json_escapes_values() -> None:
|
||||
n = Notification("rule.failed", 'Titel mit "Anführung"', "Zeile 1\nZeile 2 \\ $x", "r")
|
||||
body = render('{"t": "$title", "m": "$message", "x": "$unbekannt"}', "application/json", n)
|
||||
data = json.loads(body)
|
||||
assert data == {"t": 'Titel mit "Anführung"', "m": "Zeile 1\nZeile 2 \\ $x", "x": "$unbekannt"}
|
||||
assert render("$message", "text/plain", n) == "Zeile 1\nZeile 2 \\ $x"
|
||||
|
||||
|
||||
def test_sender_failure_is_swallowed(session_factory: sessionmaker[Session]) -> None:
|
||||
def broken(*a: object) -> None:
|
||||
raise ConnectionError("weg")
|
||||
|
||||
_enable_webhook(session_factory, NotifyMode.EVERY)
|
||||
Notifier(session_factory, sender=broken, background=False).send(Notification("x", "t", "m"))
|
||||
|
||||
|
||||
def test_settings_roundtrip(session_factory: sessionmaker[Session]) -> None:
|
||||
assert load_settings(session_factory) == AppSettings()
|
||||
save_settings(session_factory, AppSettings(retention_days=7, notify_mode=NotifyMode.EVERY))
|
||||
loaded = load_settings(session_factory)
|
||||
assert loaded.retention_days == 7 and loaded.notify_mode is NotifyMode.EVERY
|
||||
|
||||
|
||||
# -- Aufräumen ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cleanup_removes_old_runs_with_items(
|
||||
service: RunService,
|
||||
fake_runner: FakeRunner,
|
||||
rule_id: int,
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
fake_runner.results += [ok(), ok()]
|
||||
old = service.execute(rule_id)
|
||||
new = service.execute(rule_id)
|
||||
with session_scope(session_factory) as s:
|
||||
run = s.get(Run, old)
|
||||
assert run is not None
|
||||
run.started_at = utcnow() - timedelta(days=31)
|
||||
s.add(
|
||||
FileFailure(
|
||||
rule_id=rule_id,
|
||||
src_relpath="x",
|
||||
size=1,
|
||||
mtime_ns=1,
|
||||
attempts=1,
|
||||
last_attempt_at=utcnow() - timedelta(days=40),
|
||||
)
|
||||
)
|
||||
stats = cleanup(session_factory)
|
||||
assert stats == {"runs": 1, "file_failures": 1}
|
||||
with session_factory() as s:
|
||||
assert [r.id for r in s.scalars(select(Run))] == [new]
|
||||
assert len(s.scalars(select(RunItem)).all()) == 1
|
||||
|
||||
|
||||
# -- Health (Fallstrick 9) -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _health(sf: sessionmaker[Session], started_ago: float = 3600, **kw: object): # type: ignore[no-untyped-def]
|
||||
now = utcnow()
|
||||
args: dict[str, object] = {
|
||||
"scheduler_running": True,
|
||||
"last_heartbeat": now,
|
||||
"service_started": now - timedelta(seconds=started_ago),
|
||||
"now": now,
|
||||
}
|
||||
args.update(kw)
|
||||
return check_health(sf, **args) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _age_rule(sf: sessionmaker[Session], rid: int, seconds: float | None) -> None:
|
||||
with session_scope(sf) as s:
|
||||
r = s.get(Rule, rid)
|
||||
assert r is not None
|
||||
r.created_at = utcnow() - timedelta(days=1)
|
||||
r.last_success_at = None if seconds is None else utcnow() - timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def test_health_ok(session_factory: sessionmaker[Session], rule_id: int) -> None:
|
||||
_age_rule(session_factory, rule_id, 60) # 2× Intervall
|
||||
h = _health(session_factory)
|
||||
assert h.ok, h.problems
|
||||
|
||||
|
||||
def test_health_stale_rule_after_three_intervals(
|
||||
session_factory: sessionmaker[Session], rule_id: int
|
||||
) -> None:
|
||||
_age_rule(session_factory, rule_id, 91)
|
||||
h = _health(session_factory)
|
||||
assert not h.ok and h.stale_rules == {rule_id}
|
||||
assert "kein erfolgreicher Lauf" in h.problems[0]
|
||||
|
||||
|
||||
def test_health_never_succeeded_but_service_just_started(
|
||||
session_factory: sessionmaker[Session], rule_id: int
|
||||
) -> None:
|
||||
_age_rule(session_factory, rule_id, None)
|
||||
assert _health(session_factory, started_ago=30).ok
|
||||
assert not _health(session_factory, started_ago=120).ok
|
||||
|
||||
|
||||
def test_health_disabled_rule_is_ignored(
|
||||
session_factory: sessionmaker[Session], rule_id: int
|
||||
) -> None:
|
||||
_age_rule(session_factory, rule_id, None)
|
||||
with session_scope(session_factory) as s:
|
||||
r = s.get(Rule, rule_id)
|
||||
assert r is not None
|
||||
r.enabled = False
|
||||
assert _health(session_factory).ok
|
||||
|
||||
|
||||
def test_health_mount_error_only_for_enabled_mounts(
|
||||
session_factory: sessionmaker[Session], make_mount: MountFactory
|
||||
) -> None:
|
||||
mid = make_mount("b", share="b", enabled=False)
|
||||
with session_scope(session_factory) as s:
|
||||
m = s.get(Mount, mid)
|
||||
assert m is not None
|
||||
m.state = MountState.ERROR
|
||||
assert _health(session_factory).ok
|
||||
with session_scope(session_factory) as s:
|
||||
m = s.get(Mount, mid)
|
||||
assert m is not None
|
||||
m.enabled = True
|
||||
h = _health(session_factory)
|
||||
assert not h.ok and "Fehlerzustand" in h.problems[0]
|
||||
|
||||
|
||||
def test_health_scheduler(session_factory: sessionmaker[Session]) -> None:
|
||||
assert not _health(session_factory, scheduler_running=False).ok
|
||||
assert not _health(session_factory, last_heartbeat=utcnow() - timedelta(minutes=5)).ok
|
||||
|
||||
|
||||
def test_runner_type_is_compatible(session_factory: sessionmaker[Session], tmp_path: Path) -> None:
|
||||
# FakeRunner oben muss dieselbe Schnittstelle bieten wie RuleRunner.run
|
||||
assert RuleRunner.run.__code__.co_kwonlyargcount == 1
|
||||
Reference in New Issue
Block a user