feat(hands,jobs): job-run watchdog looks a week back and alarms on jobs that stopped running
This commit is contained in:
1 parent
426b4055f4
commit
91bd50a66d
6 files changed
+620
-71
No files matched your search
@@ -38,9 +38,10 @@ jobs = [
|
||||
# ради чего заведён; чтение 25 событий стоит копейки.
|
||||
Job("отметка", beat, cron="12 17 * * *", critical=False),
|
||||
Job("сторож", watch, cron="42 */6 * * *", critical=False),
|
||||
# Сторож собственного журнала прогонов. Час - это компромисс: окно счёта
|
||||
# сутки, и джоб, упавший три раза подряд, лежит уже заметно дольше часа,
|
||||
# так что чаще смысла нет, а реже - тревога стареет на свой же период.
|
||||
# Сторож собственного журнала прогонов. Час - это компромисс: джоб,
|
||||
# упавший три раза подряд, лежит уже заметно дольше часа, так что чаще
|
||||
# смысла нет, а реже - тревога стареет на свой же период. Этот же час -
|
||||
# разрешение давности: молчание короче двух часов он не различает.
|
||||
# ":27" выбрана пустой: на ":00" ротация и куратор, на ":40" логи, на
|
||||
# ":42" сторож соседа, а собственный прогон этого джоба не должен ждать
|
||||
# очереди за чужим спавном.
|
||||
|
||||
+75
-22
@@ -4,6 +4,10 @@
|
||||
(`run.conversations.db.session()` и модель `JobRunRecord`): своей строки
|
||||
подключения у джоба нет, и второй правды о том, где журнал, тоже.
|
||||
|
||||
Кроны и зона, в которой они читаются, берутся у самого планировщика
|
||||
(`run.scheduler.jobs`, `run.scheduler.tz`): давность меряется по тому
|
||||
расписанию, что реально зарегистрировано, а не по копии списка.
|
||||
|
||||
Устроен как сторож соседа (`beaver_agent/jobs/heartbeat.py`): состояние
|
||||
файлом в `мета/бобер/.состояние/`, тревога - спавном джоба к триажу, и
|
||||
запись «сказали» ставится только после того, как тревога ушла.
|
||||
@@ -16,8 +20,9 @@ from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver_gateway.storage.models import JobRunRecord
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from beaver_agent.hands.heartbeat import BOOT
|
||||
from beaver_agent.hands.runs import (
|
||||
STREAK,
|
||||
WINDOW,
|
||||
@@ -28,6 +33,7 @@ from beaver_agent.hands.runs import (
|
||||
clear_text,
|
||||
empty_text,
|
||||
look,
|
||||
quiet_text,
|
||||
)
|
||||
from beaver_agent.vault import STATE_DIR
|
||||
|
||||
@@ -42,13 +48,14 @@ STATE_FILE = STATE_DIR / "прогоны.json"
|
||||
|
||||
TITLE = "прогоны"
|
||||
|
||||
LIMIT = 5000
|
||||
"""Потолок строк за окно. Сутки нормальной работы - это сотни строк; упереться
|
||||
в потолок можно только при шторме, и тогда серии считаются по новейшим, а
|
||||
недостающий хвост честно виден как `capped` в тексте тревоги."""
|
||||
LIMIT = 20000
|
||||
"""Потолок строк за окно. Неделя нормальной работы - это около 1700 строк
|
||||
(`вайбграм` раз в 10 минут даёт 1008 из них); упереться в потолок можно
|
||||
только при шторме, и тогда серии считаются по новейшим, а недостающий хвост
|
||||
честно виден как `capped` в тексте тревоги."""
|
||||
|
||||
BRIEF = (
|
||||
"Сторож собственных прогонов (таблица `job_runs`, окно сутки) нашёл "
|
||||
"Сторож собственных прогонов (таблица `job_runs`, окно неделя) нашёл "
|
||||
"лежащие джобы: у каждого {streak} и больше падений подряд, считая от "
|
||||
"последнего прогона.\n"
|
||||
"{body}\n"
|
||||
@@ -60,6 +67,20 @@ BRIEF = (
|
||||
"«не срочно»: повтори один раз с тем же текстом."
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -95,20 +116,35 @@ async def recent(run: JobRun, *, since: datetime) -> list[Run]:
|
||||
)
|
||||
async with run.conversations.db.session() as session:
|
||||
rows = (await session.exec(stmt)).all()
|
||||
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,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
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:
|
||||
@@ -116,13 +152,24 @@ 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)
|
||||
report = look(state, rows, now=now, me=run.job.name)
|
||||
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()
|
||||
@@ -136,10 +183,16 @@ async def _tell(run: JobRun, report: Report, state: Runs, *, now: datetime) -> N
|
||||
await _spawn(run, BRIEF.format(body=alarm_text(report), streak=STREAK))
|
||||
for tail in report.alarms:
|
||||
state.alerted[tail.job] = now
|
||||
if report.clears:
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user