200 lines
9.5 KiB
Python
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)
|