Files

200 lines
9.5 KiB
Python

"""Сторож собственных прогонов: раз в час читает `job_runs` и будит человека.
Подключение к базе - то же самое, которым пишет сам планировщик
(`run.conversations.db.session()` и модель `JobRunRecord`): своей строки
подключения у джоба нет, и второй правды о том, где журнал, тоже.
Кроны и зона, в которой они читаются, берутся у самого планировщика
(`run.scheduler.jobs`, `run.scheduler.tz`): давность меряется по тому
расписанию, что реально зарегистрировано, а не по копии списка.
Устроен как сторож соседа (`beaver_agent/jobs/heartbeat.py`): состояние
файлом в `мета/бобер/.состояние/`, тревога - спавном джоба к триажу, и
запись «сказали» ставится только после того, как тревога ушла.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from beaver_gateway.storage.models import JobRunRecord
from sqlmodel import col, func, select
from beaver_agent.hands.heartbeat import BOOT
from beaver_agent.hands.runs import (
STREAK,
WINDOW,
Report,
Run,
Runs,
alarm_text,
clear_text,
empty_text,
look,
quiet_text,
)
from beaver_agent.vault import STATE_DIR
if TYPE_CHECKING:
from beaver_gateway.jobs.scheduler import JobRun
_log = logging.getLogger(__name__)
STATE_FILE = STATE_DIR / "прогоны.json"
"""Кому уже сказали. Без файла тревога о лежащем джобе повторится, а «отбой»
по тому, о ком говорили до потери файла, не придёт - показания не осталось."""
TITLE = "прогоны"
LIMIT = 20000
"""Потолок строк за окно. Неделя нормальной работы - это около 1700 строк
(`вайбграм` раз в 10 минут даёт 1008 из них); упереться в потолок можно
только при шторме, и тогда серии считаются по новейшим, а недостающий хвост
честно виден как `capped` в тексте тревоги."""
BRIEF = (
"Сторож собственных прогонов (таблица `job_runs`, окно неделя) нашёл "
"лежащие джобы: у каждого {streak} и больше падений подряд, считая от "
"последнего прогона.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: какой джоб лежит, сколько прогонов подряд и что в "
"ошибке). Это авария у НАС, а не у соседа: джоб пишется в журнал каждый "
"прогон, и раз он падает подряд, сам он не починится. Подробности - "
"в логах gateway. Если inject вернул ошибку - это сбой доставки, а не "
"«не срочно»: повтори один раз с тем же текстом."
)
QUIET_BRIEF = (
"Сторож собственных прогонов нашёл джобы, которые ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ: "
"у каждого есть крон, а последней записи в журнале `job_runs` больше "
"двух периодов.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: какой джоб молчит, сколько и при каком периоде). Это "
"не падение: джоб не падает, его просто никто не зовёт. Смотреть стоит "
"расписание (не снят ли джоб из `beaver_agent/jobs/__init__.py`), кроны "
"в pgqueuer и лог gateway на старте - джоб, упавший на импорте, не "
"регистрируется вовсе. Если inject вернул ошибку - это сбой доставки, "
"а не «не срочно»: повтори один раз с тем же текстом."
)
CLEAR_BRIEF = (
"Сторож собственных прогонов: то, о чём он тревожился, починилось.\n"
"{body}\n"
'Владельца НЕ будить: inject(conversation="master", urgency="normal", '
"text=одна строка «отбой»). Человек знает про тревогу, ему нужно знать, "
"что она кончилась."
)
EMPTY_BRIEF = (
"Сторож собственных прогонов не нашёл в журнале НИЧЕГО.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: что за сутки не отработал ни один джоб и куда смотреть). "
"Молчащий планировщик - это молчание всех джобов разом, включая тех, что "
"носят человеку почту."
)
async def recent(run: JobRun, *, since: datetime) -> list[Run]:
"""Прогоны всех джобов за окно, новейшие первыми.
`started_at` в базе лежит наивным UTC (так его кладёт планировщик), и
сравнение в `where` тоже наивное - как это делает gateway в своих
запросах по времени.
"""
stmt = (
select(JobRunRecord)
.where(
col(JobRunRecord.started_at) >= since.astimezone(UTC).replace(tzinfo=None)
)
.order_by(col(JobRunRecord.id).desc())
.limit(LIMIT)
)
async with run.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
return [_run(row) for row in rows]
def _run(row: JobRunRecord) -> Run:
return Run(
id=row.id or 0,
job=row.job,
status=row.status,
started_at=(
row.started_at
if row.started_at.tzinfo
else row.started_at.replace(tzinfo=UTC)
),
error=row.error,
)
async def latest(run: JobRun) -> dict[str, Run]:
"""Последний прогон каждого джоба за всю историю журнала.
Не за окно: джоб, молчащий дольше недели, иначе выглядел бы «ни разу не
запускавшимся» и уходил бы в тихий пропуск ровно тогда, когда молчит
дольше всего. Тем же запросом gateway строит свою сводку по джобам.
"""
newest = select(func.max(JobRunRecord.id)).group_by(col(JobRunRecord.job))
stmt = select(JobRunRecord).where(col(JobRunRecord.id).in_(newest))
async with run.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
return {row.job: _run(row) for row in rows}
async def watch_runs(run: JobRun) -> None:
"""Прогон сторожа: журнал → решение → тревога, «отбой» или молчание."""
now = datetime.now(UTC)
try:
rows = await recent(run, since=now - WINDOW)
last = await latest(run)
except Exception: # noqa: BLE001 - отказ запроса это отсутствие показания
# Журнал не прочитан - сказать о здоровье джобов нечего, и молчание
# базы выдавать за здоровье нельзя ровно так же, как за болезнь.
_log.exception("прогоны: журнал не прочитан, прогон без показания")
return
state = Runs.load(STATE_FILE)
crons = {job.name: job.cron for job in run.scheduler.jobs if job.cron}
report = look(
state,
rows,
now=now,
me=run.job.name,
crons=crons,
latest=last,
tz=run.scheduler.tz,
boot=BOOT,
)
_log.info("прогоны: %s", report.note)
await _tell(run, report, state, now=now)
state.save()
async def _tell(run: JobRun, report: Report, state: Runs, *, now: datetime) -> None:
if report.empty:
await _spawn(run, EMPTY_BRIEF.format(body=empty_text(report)))
state.empty_alerted = now
if report.alarms:
await _spawn(run, BRIEF.format(body=alarm_text(report), streak=STREAK))
for tail in report.alarms:
state.alerted[tail.job] = now
if report.quiet:
await _spawn(run, QUIET_BRIEF.format(body=quiet_text(report)))
for quiet in report.quiet:
state.quiet[quiet.job] = now
if report.clears or report.resumed:
await _spawn(run, CLEAR_BRIEF.format(body=clear_text(report)))
for clear in report.clears:
state.alerted.pop(clear.job, None)
for resumed in report.resumed:
state.quiet.pop(resumed.job, None)
async def _spawn(run: JobRun, text: str) -> None:
await run.spawn_job(agent="beaver-triage", title=TITLE, text=text)