Три вади, знайдені рецензією, яких щасливий шлях показати не міг: * outcome='sent' писався до доставки; помилка читання каналів клала в кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі слідом «надіслано». Канали тепер читаються до просування стану, журнал пишеться після доставки, з правдою. * UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред партії не рятувало людину від дзвінка. * час брався раз на партію. Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер домислював enabled на оновленні, channel_ids сходинок не звірялись із каналами кабінету (зокрема чужого). І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
761 lines
37 KiB
Go
761 lines
37 KiB
Go
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
|
||
}
|