Netpulse_SasS/server/internal/store/alerts_escalation.go
byrsapty cedf1d261d
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m50s
CI / agent (push) Successful in 1m2s
Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

* outcome='sent' писався до доставки; помилка читання каналів клала в
  кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
  слідом «надіслано». Канали тепер читаються до просування стану,
  журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
  партії не рятувало людину від дзвінка.
* час брався раз на партію.

Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).

І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:20:18 +03:00

761 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ---------------------------------------------------------------------
// Політики
// ---------------------------------------------------------------------
// EscalationStep — одна сходинка драбини.
//
// AfterMin рахується від ПОЧАТКУ алерту, а не від попередньої сходинки.
// Людина проектує чергування абсолютними числами («через 15 хвилин —
// другий інженер, через 45 — керівник»), і відносні проміжки змушували б
// перераховувати всю драбину щоразу, коли посередині додається сходинка.
type EscalationStep struct {
AfterMin int `json:"after_min"`
ChannelIDs []string `json:"channel_ids"`
}
// EscalationPolicy — драбина цілком.
type EscalationPolicy struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Steps []EscalationStep `json:"steps"`
// Через скільки хвилин після вичерпання драбини почати її спочатку.
// 0 — не повторювати.
RepeatAfterMin int `json:"repeat_after_min"`
MaxRepeats int `json:"max_repeats"`
// Скільки правил уже посилаються на цю політику — щоб видалення не
// було мовчазним вимкненням ескалації на десятку тригерів.
RuleCount int `json:"rule_count"`
}
// Стелі драбини. Живуть тут, а не в HTTP-шарі, бо ту саму перевірку
// робить і збереження політики, і CHECK у 0066: три різні числа в трьох
// місцях розходяться на першій же правці.
const (
MaxEscalationSteps = 10
MaxEscalationRepeats = 10
// Доба — стеля проміжку сходинки. Більше означає «розбудити
// завтра», а це вже не ескалація, а нагадування.
MaxEscalationAfterMin = 24 * 60
)
// ValidateEscalationSteps відмовляє в драбині, яка не робитиме того, що
// про неї думає людина.
//
// Головна перевірка тут — перша сходинка НЕ на нульовій хвилині.
// Сходинка «через 0 хвилин» пішла б одночасно зі звичайним сповіщенням
// про той самий алерт: людина отримала б два однакові повідомлення й
// вирішила, що система заїкається. Ескалація починається там, де
// закінчилось мовчання, тобто строго пізніше.
func ValidateEscalationSteps(steps []EscalationStep) error {
if len(steps) == 0 {
return fmt.Errorf("%w: драбина без жодної сходинки нікого не розбудить — "+
"додайте принаймні одну", ErrInvalid)
}
if len(steps) > MaxEscalationSteps {
return fmt.Errorf("%w: сходинок не більше %d: довша драбина — це вже розсилка "+
"на всю компанію з інтервалом", ErrInvalid, MaxEscalationSteps)
}
prev := 0
for i, s := range steps {
if s.AfterMin <= 0 {
return fmt.Errorf("%w: сходинка %d стоїть на %d хв — перша ескалація має бути "+
"пізніше за саме сповіщення, інакше вона його просто продублює",
ErrInvalid, i+1, s.AfterMin)
}
if s.AfterMin > MaxEscalationAfterMin {
return fmt.Errorf("%w: сходинка %d аж через %d хв — це вже нагадування, "+
"а не ескалація (стеля %d хв)", ErrInvalid, i+1, s.AfterMin, MaxEscalationAfterMin)
}
if i > 0 && s.AfterMin <= prev {
return fmt.Errorf("%w: сходинка %d (%d хв) не пізніша за попередню (%d хв) — "+
"драбина має вести вгору", ErrInvalid, i+1, s.AfterMin, prev)
}
if len(s.ChannelIDs) == 0 {
return fmt.Errorf("%w: сходинка %d не має жодного каналу — вона мовчатиме, "+
"а драбина рахуватиме її пройденою", ErrInvalid, i+1)
}
prev = s.AfterMin
}
return nil
}
// ValidateStepChannels відмовляє в драбині, сходинка якої посилається на
// канал, якого в цьому кабінеті немає.
//
// Перевірка окремо від ValidateEscalationSteps, бо вона єдина потребує
// бази: решта драбини перевіряється як текст, а «чи є такий канал» —
// лише запитом. Розділення дозволяє тримати першу половину чистою й
// перевіреною без Postgres.
//
// Ціна відсутності цієї перевірки — рівно та сама мовчазна обіцянка,
// заради якої й написана вся 0066. Сходинка з неіснуючим UUID
// виглядає в переліку налаштованою, движок не знаходить для неї жодного
// каналу, списує її й пише в журнал «нікуди не пішло» — але читає той
// журнал уже той, хто прийшов розбиратися вранці, а не той, кого мали
// розбудити вночі.
//
// known — ідентифікатори каналів ЦЬОГО кабінету. Саме тому перевірка
// закриває й підстановку чужого UUID: перелік читається під RLS у тій
// самій транзакції, що й запис.
func ValidateStepChannels(steps []EscalationStep, known map[string]bool) error {
for i, s := range steps {
for _, id := range s.ChannelIDs {
if known[id] {
continue
}
// Ідентифікатор у тексті лишаємо навмисно: у формі канали
// обираються галочками, тож людина, яка це побачила,
// надсилає драбину не з форми — і їй потрібно знати, який
// саме рядок не прийнято.
return fmt.Errorf("%w: сходинка %d посилається на канал %s, якого немає в цьому "+
"кабінеті — оберіть канал зі списку на сторінці «Канали»", ErrInvalid, i+1, id)
}
}
return nil
}
// tenantChannelIDs — ідентифікатори каналів кабінету, як їх бачить ця
// транзакція.
//
// Читається саме в транзакції запису, а не окремим викликом до неї:
// інакше між перевіркою й записом лишалась би щілина, в якій канал
// встигає зникнути.
func tenantChannelIDs(ctx context.Context, tx pgx.Tx, tenantID string) (map[string]bool, error) {
rows, err := tx.Query(ctx,
`SELECT id::text FROM alr.channels WHERE tenant_id = $1`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
known := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
known[id] = true
}
return known, rows.Err()
}
// ListEscalationPolicies читає політики кабінету.
func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) {
var out []EscalationPolicy
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text,
COALESCE(p.repeat_after_min, 0), p.max_repeats,
(SELECT count(*)::int FROM alr.rules r WHERE r.escalation_policy_id = p.id)
FROM alr.escalation_policies p
WHERE p.tenant_id = $1
ORDER BY p.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var p EscalationPolicy
var steps string
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps,
&p.RepeatAfterMin, &p.MaxRepeats, &p.RuleCount); err != nil {
return err
}
if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil {
return fmt.Errorf("політика %s: сходинки: %w", p.Name, err)
}
if p.Steps == nil {
p.Steps = []EscalationStep{}
}
out = append(out, p)
}
return rows.Err()
})
return out, err
}
// SaveEscalationPolicy створює або замінює політику цілком.
//
// Цілком, а не полями: форма показує повну драбину, і часткове
// оновлення дало б комбінацію сходинок, якої людина не бачила.
func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p EscalationPolicy) (string, error) {
steps, err := json.Marshal(p.Steps)
if err != nil {
return "", err
}
var repeat any
if p.RepeatAfterMin > 0 {
repeat = p.RepeatAfterMin
}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Обидві перевірки тут, а не лише в HTTP: драбина — це список
// людей, яких будять уночі, і єдине місце, де він може бути
// перевірений раз і назавжди, — це запис у базу. Перевірка,
// продубльована в кожному обробнику, розходиться на першому ж
// новому шляху запису (імпорт, шаблон, API-токен).
if err := ValidateEscalationSteps(p.Steps); err != nil {
return err
}
known, err := tenantChannelIDs(ctx, tx, tenantID)
if err != nil {
return err
}
if err := ValidateStepChannels(p.Steps, known); err != nil {
return err
}
if id == "" {
return tx.QueryRow(ctx, `
INSERT INTO alr.escalation_policies
(tenant_id, name, description, steps, repeat_after_min, max_repeats)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
RETURNING id::text
`, tenantID, p.Name, nullString(p.Description), string(steps),
repeat, p.MaxRepeats).Scan(&id)
}
ct, err := tx.Exec(ctx, `
UPDATE alr.escalation_policies
SET name = $3, description = $4, steps = $5::jsonb,
repeat_after_min = $6, max_repeats = $7, updated_at = now()
WHERE tenant_id = $1 AND id = $2
`, tenantID, id, p.Name, nullString(p.Description), string(steps),
repeat, p.MaxRepeats)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
return id, err
}
// DeleteEscalationPolicy прибирає політику.
//
// Правила, що на неї посилались, лишаються без ескалації (ON DELETE SET
// NULL у 0066), а живі драбини зупиняє движок із причиною «політику
// видалено». Мовчазного продовження за старою копією немає навмисно:
// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший
// із можливих станів.
func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// ---------------------------------------------------------------------
// Взведення драбини
// ---------------------------------------------------------------------
// EscalationGrace — запас до жорсткої стелі життя драбини.
//
// Стеля потрібна через заглушення: заглушена сходинка не витрачається,
// а відкладається (див. PlanEscalation), і без стелі відкладання ходило
// б по колу місяцями на алерті, який ніхто не закриє. Доба запасу
// означає «драбину, яку цілу добу не давали пройти, вже нема сенсу
// проходити»: за добу або аварію розібрали, або вона перестала бути
// новиною.
const EscalationGrace = 24 * time.Hour
// ArmEscalation ставить драбину на бойовий звід.
//
// ON CONFLICT DO NOTHING — і це не оптимізація, а вимога. Повторна
// доставка того самого алерту трапляється (ретрай, другий інстанс,
// перезапуск між надсиланням і записом), і кожна з них інакше
// перезапускала б драбину з нуля: алерт висів би годинами, а «наступного»
// будили б щоп'ятнадцять хвилин заново.
func (s *Store) ArmEscalation(ctx context.Context, tenantID, alertID, policyID string,
isEvent bool, p EscalationPolicy, alertStarted time.Time) error {
if len(p.Steps) == 0 {
return nil
}
passStart := alertStarted
next := passStart.Add(time.Duration(p.Steps[0].AfterMin) * time.Minute)
deadline := passStart.Add(escalationSpan(p, isEvent)).Add(EscalationGrace)
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO alr.alert_escalations
(alert_id, tenant_id, policy_id, is_event, step_idx, repeat_idx,
pass_start, next_at, deadline)
VALUES ($1, $2, $3, $4, 0, 0, $5, $6, $7)
ON CONFLICT (alert_id) DO NOTHING
`, alertID, tenantID, policyID, isEvent, passStart, next, deadline)
return err
})
}
// escalationSpan — скільки триває драбина, якщо ніхто не втручається.
func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration {
if len(p.Steps) == 0 {
return 0
}
span := time.Duration(p.Steps[len(p.Steps)-1].AfterMin) * time.Minute
if !isEvent && p.RepeatAfterMin > 0 && p.MaxRepeats > 0 {
pass := time.Duration(p.RepeatAfterMin) * time.Minute
span += time.Duration(p.MaxRepeats) * (pass + span)
}
return span
}
// ---------------------------------------------------------------------
// Рішення про одну сходинку
// ---------------------------------------------------------------------
// EscalationSnapshot — усе, що потрібно, щоб вирішити долю однієї
// сходинки. Читається з бази одним запитом і в один момент часу: стан
// алерту, прочитаний окремо від стану драбини, встиг би застаріти рівно
// між двома запитами — тобто саме тоді, коли алерт підтвердили.
type EscalationSnapshot struct {
AlertID string
TenantID string
PolicyID string
PolicyName string
IsEvent bool
StepIdx int
RepeatIdx int
PassStart time.Time
Deadline time.Time
Steps []EscalationStep
RepeatAfterMin int
MaxRepeats int
// Стан алерту на момент читання. Порожньо — алерту вже немає.
AlertState string
// Алерт у вигляді, придатному для тексту повідомлення.
Alert Alert
}
// EscalationAction — що робити з цією сходинкою.
type EscalationAction int
const (
// EscFire — доставити сходинку.
EscFire EscalationAction = iota
// EscDefer — не доставляти й не витрачати: перевірити пізніше.
EscDefer
// EscStop — драбина закінчилась, доставляти нічого.
EscStop
)
// EscalationDecision — рішення разом із новим станом драбини.
type EscalationDecision struct {
Action EscalationAction
// Код для журналу: sent | done | acked | closed | suppressed |
// deadline | no_policy.
Outcome string
Detail string
// Яку саме сходинку доставляємо (для EscFire).
StepIdx int
RepeatIdx int
ChannelIDs []string
// Новий стан. NextAt == nil означає «драбину зупинено».
NextStepIdx int
NextRepeatIdx int
NextPassStart time.Time
NextAt *time.Time
}
// EscalationRecheck — через скільки перевірити відкладену драбину.
//
// Порівняно з тіком движка це довго й навмисно: заглушений алерт не
// потребує уваги щопівхвилини, а кожна перевірка — це рядок у журналі
// сходинок.
const EscalationRecheck = 5 * time.Minute
// PlanEscalation вирішує долю однієї сходинки.
//
// Функція чиста, і це головне архітектурне рішення в усій ескалації.
// Причина проста: половина роботи ескалації — НЕ будити. «Сходинка
// спрацювала» перевіряється легко й доводить мало; «сходинка не
// спрацювала, бо алерт підтвердили / закрили / хост заглушено / вікно
// обслуговування / драбина протухла» — це п'ять різних гілок, кожна з
// яких коштує чийогось сну, і перевіряти їх треба без бази.
//
// Перевірка стану робиться ПЕРЕД КОЖНОЮ сходинкою, а не один раз на
// початку. Інакше драбина, взведена о 02:40, о 03:10 будила б людину
// через алерт, закритий о 02:45, — тобто ескалація воскрешала б мертве.
func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision {
stop := func(outcome, detail string) EscalationDecision {
return EscalationDecision{
Action: EscStop, Outcome: outcome, Detail: detail,
StepIdx: s.StepIdx, RepeatIdx: s.RepeatIdx,
NextStepIdx: s.StepIdx, NextRepeatIdx: s.RepeatIdx,
NextPassStart: s.PassStart,
}
}
switch s.AlertState {
case "", "resolved", "expired":
// Закритий алерт ескалації не потребує за визначенням. Окремо
// від 'acknowledged', бо це різні історії: тут проблеми більше
// немає, там нею зайнялись.
return stop("closed", "алерт закрито — далі будити нікого")
case "acknowledged":
// Рівно те, заради чого існує кнопка «Прийняти»: ack не гасить
// проблему, він зупиняє драбину. Без цього людина, яка вже
// дивиться на аварію, за 15 хвилин отримала б дзвінок від
// керівника про те, що вона й так чинить.
return stop("acked", "алерт підтверджено — драбину зупинено")
}
if len(s.Steps) == 0 || s.StepIdx < 0 || s.StepIdx >= len(s.Steps) {
// Політику видалили або переписали коротшою, поки драбина йшла.
// Мовчки добивати за старою копією не можна: драбини, якої вже
// немає у формі, ніхто не знайде, коли питатиме «звідки дзвінок».
return stop("no_policy", "політику ескалації видалено або скорочено")
}
if !now.Before(s.Deadline) {
return stop("deadline", "драбина протухла — стелю життя вичерпано")
}
if s.AlertState == "suppressed" {
// Вікно обслуговування й ручне заглушення зупиняють ескалацію
// так само, як звичайне сповіщення: обидва означають «не
// турбувати», і драбина не має бути винятком.
//
// Але сходинка при цьому НЕ витрачається. Різниця принципова:
// заглушення — це «не зараз», а не «проблеми немає». Списана
// сходинка означала б, що півгодинне вікно обслуговування тихо
// роззброює драбину до кінця життя алерту — тобто рівно та
// мовчазна відмова, від якої ескалація й рятує. Тому чекаємо,
// а стелю життя (Deadline) поставлено саме для того, щоб це
// чекання колись закінчилось.
next := now.Add(EscalationRecheck)
if !next.Before(s.Deadline) {
return stop("deadline", "заглушення пережило стелю життя драбини")
}
return EscalationDecision{
Action: EscDefer, Outcome: "suppressed",
Detail: "придушено (" + s.Alert.SuppressedBy + ") — сходинку відкладено",
StepIdx: s.StepIdx,
RepeatIdx: s.RepeatIdx,
NextStepIdx: s.StepIdx,
NextRepeatIdx: s.RepeatIdx,
NextPassStart: s.PassStart,
NextAt: &next,
}
}
if s.AlertState != "firing" {
// Невідомий стан. Мовчати безпечніше, ніж будити за здогадкою.
return stop("closed", "невідомий стан алерту "+s.AlertState)
}
d := EscalationDecision{
Action: EscFire,
Outcome: "sent",
StepIdx: s.StepIdx,
RepeatIdx: s.RepeatIdx,
ChannelIDs: s.Steps[s.StepIdx].ChannelIDs,
NextPassStart: s.PassStart,
}
// Є наступна сходинка в цьому проході.
if s.StepIdx+1 < len(s.Steps) {
cur := s.Steps[s.StepIdx].AfterMin
nxt := s.Steps[s.StepIdx+1].AfterMin
at := s.PassStart.Add(time.Duration(nxt) * time.Minute)
// Якщо сходинка спізнилась (процес стояв, драбина чекала кінця
// вікна обслуговування), наступна не має спрацювати негайно
// слідом: інакше після паузи вся драбина висиплеться в одну
// хвилину й розбудить одразу всіх.
gap := nxt - cur
if gap < 1 {
gap = 1
}
if floor := now.Add(time.Duration(gap) * time.Minute); at.Before(floor) {
at = floor
}
d.NextStepIdx = s.StepIdx + 1
d.NextRepeatIdx = s.RepeatIdx
d.NextAt = &at
return d
}
// Драбина вичерпана. Повтор — це ставка на те, що проблема ще
// триває, і зробити її можна лише там, де існування алерту саме по
// собі є доказом: метричний алерт живий рівно доти, доки виконується
// умова, і зникає сам, щойно вона перестала.
//
// Подієвий алерт (0058) такого доказу не дає. Рядок журналу стався
// один раз і «перестати ставатись» не може: алерт висить, поки його
// не закриє людина або строк auto_close. Повторювати за ним драбину
// означало б будити всю зміну по колу через один нічний блимок
// порту, який давно припинився. Тому подієвий алерт проходить
// драбину рівно раз — і на цьому ескалація закінчується.
if !s.IsEvent && s.RepeatAfterMin > 0 && s.RepeatIdx+1 <= s.MaxRepeats {
at := now.Add(time.Duration(s.RepeatAfterMin) * time.Minute)
if !at.Before(s.Deadline) {
d.Outcome = "done"
d.Detail = "драбину пройдено; повтор не вміщається у стелю життя"
d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx
return d
}
d.NextStepIdx = 0
d.NextRepeatIdx = s.RepeatIdx + 1
// Відлік нового проходу зсуваємо так, щоб перша сходинка
// припала рівно на «через repeat_after_min», а не на
// «repeat_after_min + after_min першої сходинки».
d.NextPassStart = at.Add(-time.Duration(s.Steps[0].AfterMin) * time.Minute)
d.NextAt = &at
return d
}
d.Outcome = "done"
if s.IsEvent {
d.Detail = "драбину пройдено; подієвий алерт не повторюється"
} else {
d.Detail = "драбину пройдено повністю"
}
d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx
return d
}
// ---------------------------------------------------------------------
// Читання й запис стану
// ---------------------------------------------------------------------
// EscalationLease — на скільки сходинка вважається взятою в роботу.
//
// Взяття, рішення й запис — три кроки, і між ними процес може впасти.
// Оренда закриває найгіршу з двох дір: доки вона не спливла, другий
// інстанс тієї самої сходинки не візьме, тож дубля не буде. Друга діра
// (падіння між записом і надсиланням) коштує однієї недоставленої
// сходинки — це той самий свідомий вибір, що вже зроблено для черги
// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і
// краще не надіслати, ніж надіслати вдруге о третій ночі.
const EscalationLease = 2 * time.Minute
// TakeDueEscalations забирає сходинки, час яких настав.
//
// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга
// подієвих алертів: движок один на інсталяцію й крутиться під
// advisory-блокуванням.
func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.bg.Query(ctx, `
WITH due AS (
SELECT alert_id FROM alr.alert_escalations
WHERE stopped_at IS NULL
AND next_at IS NOT NULL AND next_at <= now()
AND (leased_until IS NULL OR leased_until <= now())
ORDER BY next_at
LIMIT $1
FOR UPDATE SKIP LOCKED
), taken AS (
UPDATE alr.alert_escalations e
SET leased_until = now() + $2::interval
FROM due d WHERE e.alert_id = d.alert_id
RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event,
e.step_idx, e.repeat_idx, e.pass_start, e.deadline
)
SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''),
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
COALESCE(p.name,''), COALESCE(p.steps::text,'[]'),
COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0),
COALESCE(a.state::text,''), COALESCE(a.severity::text,'info'),
COALESCE(a.title,''), COALESCE(a.message,''),
COALESCE(a.device_id::text,''), COALESCE(d.name,''),
COALESCE(a.rule_id::text,''), COALESCE(a.suppressed_by,''),
COALESCE(a.started_at, t.pass_start)
FROM taken t
LEFT JOIN alr.alerts a ON a.id = t.alert_id
LEFT JOIN alr.escalation_policies p ON p.id = t.policy_id
LEFT JOIN inv.devices d ON d.id = a.device_id
ORDER BY t.pass_start
`, limit, EscalationLease.String())
if err != nil {
return nil, err
}
defer rows.Close()
var out []EscalationSnapshot
for rows.Next() {
var s EscalationSnapshot
var steps string
if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent,
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline,
&s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats,
&s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message,
&s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID,
&s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(steps), &s.Steps); err != nil {
// Політика з нечитабельними сходинками не має валити чергу:
// решта драбин у кабінеті ні в чому не винна.
s.Steps = nil
}
s.Alert.ID = s.AlertID
s.Alert.TenantID = s.TenantID
s.Alert.State = s.AlertState
out = append(out, s)
}
return out, rows.Err()
}
// ApplyEscalation просуває стан драбини й повертає, чи вдалось.
//
// Стан записується ДО надсилання — з тієї ж причини, що й у
// RecordNotification: падіння між записом і надсиланням лишає
// непройдену сходинку, а зворотний порядок лишав би драбину на місці, і
// після підйому вона надіслала б те саме вдруге. Краще не надіслати, ніж
// надіслати двічі о третій ночі.
//
// А от ЖУРНАЛ пишеться після доставки (LogEscalationStep), і це окреме
// рішення. Журнал існує рівно для відповіді на «чому мене розбудили» та
// «чому не розбудили», тож рядок «надіслано» на сходинці, якій не
// знайшлось жодного каналу, гірший за відсутній: він перетворює
// доказ на брехню. Ціна — рідкісний випадок падіння між просуванням і
// записом: сходинка лишиться без рядка. Це видно (step_idx більший за
// кількість рядків) і це чесно.
//
// stopped_at IS NULL — не оптимізація, а перевірка стану. Між тим, як
// сходинку взяли в чергу, і тим, як до неї дійшли руки, людина могла
// натиснути «Прийняти»: партія обробляється послідовно, кожна доставка
// має власний таймаут, і розрив вимірюється хвилинами. Без цієї умови
// UPDATE зняв би stopped_at і воскресив зупинену драбину — тобто
// розбудив би саме того, хто щойно сказав «я цим займаюсь».
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
var next any
if d.NextAt != nil {
next = *d.NextAt
}
fired := d.Action == EscFire
applied := false
err := s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE alr.alert_escalations
SET step_idx = $2,
repeat_idx = $3,
pass_start = $4,
next_at = $5,
leased_until = NULL,
last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END,
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
WHERE alert_id = $1
AND stopped_at IS NULL
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
next, fired, d.Outcome)
if err != nil {
return err
}
applied = tag.RowsAffected() > 0
return nil
})
return applied, err
}
// LogEscalationStep записує в журнал те, що СПРАВДІ сталося зі сходинкою.
//
// Викликається після доставки, тому outcome тут може відрізнятись від
// того, що планувалось: сходинка, чиї канали видалили, вимкнули або
// підняли їм поріг серйозності, отримує 'no_channels', а не 'sent'.
// Саме заради цієї різниці журнал і винесено з ApplyEscalation.
func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot,
d EscalationDecision, outcome, detail string) error {
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO alr.escalation_steps
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID),
d.StepIdx, d.RepeatIdx, outcome, nullString(detail))
return err
})
}
// AlertEscalation — стан драбини для картки алерту.
//
// Без цього ескалація перетворюється на невидиму магію: людину підняли
// о 03:10, а на екрані нічого не пояснює, звідки взявся дзвінок. Тут
// видно і те, скільки сходинок пройдено, і коли буде наступна, і — якщо
// драбину зупинено — чому саме.
type AlertEscalation struct {
PolicyName string `json:"policy_name"`
// Скільки сходинок цього проходу вже доставлено.
Step int `json:"step"`
// Скільки їх усього в драбині.
Total int `json:"total"`
Repeat int `json:"repeat"`
// Коли наступна. Порожньо — драбина зупинена або вичерпана.
NextAt *time.Time `json:"next_at,omitempty"`
StoppedAt *time.Time `json:"stopped_at,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
}
// PurgeEscalationLog прибирає журнал сходинок за строком.
//
// Той самий строк, що й у alr.notifications (0007). Таблиця не
// гіпертаблиця й політики ретеншену TimescaleDB не має, тож прибирає
// її прибиральник алертів.
func (s *Store) PurgeEscalationLog(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.bg.Exec(ctx,
`DELETE FROM alr.escalation_steps WHERE ts < now() - $1::interval`,
olderThan.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// stopEscalationTx зупиняє драбину в межах уже відкритої транзакції.
//
// Викликається з підтвердження й ручного закриття алерту. Це НЕ той
// механізм, який гарантує зупинку: гарантує її перевірка стану перед
// кожною сходинкою в PlanEscalation, і саме там зупиняються драбини
// алертів, закритих шляхами, до яких цей код не дотягується (гасіння
// прострочених подієвих, закриття різницею множин, вимкнення правила).
// Тут — лише щоб картка алерту сказала «ескалацію зупинено» одразу, а
// не за півхвилини, коли настане час наступної сходинки.
func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason, detail string) error {
// Одним оператором, щоб журнал не міг розійтися зі станом: окремий
// SELECT після UPDATE записав би рядок і тоді, коли драбини вже не
// було, — тобто вигадав би подію.
_, err := tx.Exec(ctx, `
WITH stopped AS (
UPDATE alr.alert_escalations
SET next_at = NULL, leased_until = NULL,
stopped_at = now(), stop_reason = $3
WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL
RETURNING policy_id, step_idx, repeat_idx
)
INSERT INTO alr.escalation_steps
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
SELECT $1, $2, policy_id, step_idx, repeat_idx, $3, $4 FROM stopped
`, tenantID, alertID, reason, nullString(detail))
return err
}