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"` // Чи відкладати сходинки на час тихої години правила. // // false (типово) — драбина пробивається: тиха година стосується // ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб дійти, коли // перше проґавили. true — «ці люди зараз не на зміні», і будити їх // драбиною так само неправильно, як першим сповіщенням. // // Вибір не технічний і залежить від того, чи є в кабінету нічна // зміна, — тому він тут, а не в нашому коді. Disaster пробивається // за будь-якого значення. RespectQuietHours bool `json:"respect_quiet_hours"` // Скільки правил уже посилаються на цю політику — щоб видалення не // було мовчазним вимкненням ескалації на десятку тригерів. 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, p.respect_quiet_hours, (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.RespectQuietHours, &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, respect_quiet_hours) VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7) RETURNING id::text `, tenantID, p.Name, nullString(p.Description), string(steps), repeat, p.MaxRepeats, p.RespectQuietHours).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, respect_quiet_hours = $8, updated_at = now() WHERE tenant_id = $1 AND id = $2 `, tenantID, id, p.Name, nullString(p.Description), string(steps), repeat, p.MaxRepeats, p.RespectQuietHours) 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, firstNotified time.Time) error { if len(p.Steps) == 0 { return nil } // Відлік — від першого сповіщення, а не від початку алерту: див. // armEscalation. Для алерту, з якого щойно зняли заглушення, це // різниця між «драбина попереду» і «драбина протухла ще у вікні // обслуговування й висиплеться одним залпом». passStart := firstNotified 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 }) } // quietHoldsStep — чи тримає тиха година цю сходинку. // // Окремою функцією, бо це рішення про чийсь сон, і в ньому три умови, // кожна з яких має право на власний тест: драбина мусить сама зважати на // тиху годину, розклад мусить існувати й попадати, а disaster мусить // проходити попри все. func quietHoldsStep(s EscalationSnapshot, now time.Time) bool { if !s.RespectQuietHours || s.Schedule == nil { return false } if SeverityRank(s.Alert.Severity) >= SeverityRank("disaster") { return false } return s.Schedule.IsQuiet(now) } // 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 // Токен оренди, під якою цю сходинку взято (0073). // // Не інформація, а перепустка: ApplyEscalation запише рішення лише // тоді, коли в рядку лежить рівно цей токен. Знімок, чия оренда // встигла спливти й дістатись іншому взяттю, стає недійсним — і // саме тому обробка партії, довша за EscalationLease, більше не // коштує другого дзвінка о третій ночі. LeaseToken string Steps []EscalationStep RepeatAfterMin int MaxRepeats int // Тиха година правила, за яким піднято алерт, і чи зважає на неї ця // драбина. Читаються разом зі сходинкою, бо рішення «дзвонити чи // відкласти» ухвалюється в момент сходинки, а не в момент взведення: // розклад могли переписати за ті години, що драбина йшла. RespectQuietHours bool Schedule *RouteSchedule // Стан алерту на момент читання. Порожньо — алерту вже немає. 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) } if quietHoldsStep(s, now) { // Кабінет сказав, що вночі його драбина мовчить: тиха година для // нього означає «цих людей зараз немає», а не «не турбуйте // дрібницями». Сходинка відкладається так само, як під // заглушенням, і з тієї ж причини — «не зараз» не є «не треба». // // Перевіряємо ту саму тиху годину, що глушила перше сповіщення // (розклад правила), інакше одне слово в двох місцях означало б // різне. І так само, як там, disaster проходить: сенс чергування // в тому, щоб найважче підняли. next := now.Add(EscalationRecheck) if !next.Before(s.Deadline) { return stop("deadline", "тиха година пережила стелю життя драбини") } return EscalationDecision{ Action: EscDefer, Outcome: "quiet_hours", Detail: "тиха година правила — сходинку відкладено", StepIdx: s.StepIdx, RepeatIdx: s.RepeatIdx, NextStepIdx: s.StepIdx, NextRepeatIdx: s.RepeatIdx, NextPassStart: s.PassStart, NextAt: &next, } } 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: «спробували» не дорівнює «доставили», і // краще не надіслати, ніж надіслати вдруге о третій ночі. // // Строк лишається коротким навмисно, хоч партія буває довшою за нього. // Подовжити його «щоб вистачало» неможливо: сотня сходинок, кожна з // власним мережевим таймаутом, переросте будь-яке число, а довга // оренда робить гірше тому єдиному випадку, заради якого вона й // існує, — процесу, що впав одразу після взяття. Розрив «партія довша // за оренду» закриває не строк, а токен оренди (0073): володіння // звіряється при записі, тож перебрана сходинка вже не подвоюється. const EscalationLease = 2 * time.Minute // TakeDueEscalations забирає сходинки, час яких настав. // // Наскрізно по всіх кабінетах і робочим пулом — так само, як черга // подієвих алертів: движок один на інсталяцію й крутиться під // advisory-блокуванням. // // Разом зі строком оренди рядок отримує ТОКЕН цього взяття // (core.new_id() у SET обчислюється для кожного рядка окремо). Строк // каже лише «зайнято до», і після його спливання рядок дістається // іншому взяттю — а перше про це не дізнається й піде доставляти. Токен // перетворює строк на володіння: ApplyEscalation запише рішення лише // під тим токеном, що лежить у рядку зараз. 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, lease_token = core.new_id() 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, e.lease_token ) 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, t.lease_token::text, COALESCE(p.name,''), COALESCE(p.steps::text,'[]'), COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0), COALESCE(p.respect_quiet_hours,false), COALESCE(rl.notify_schedule::text,''), 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 LEFT JOIN alr.rules rl ON rl.id = a.rule_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, sched string if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent, &s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, &s.LeaseToken, &s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats, &s.RespectQuietHours, &sched, &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 sched != "" { var sc RouteSchedule if json.Unmarshal([]byte(sched), &sc) == nil { s.Schedule = &sc } } 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 — людина натиснула «Прийняти» (або закрила алерт // руками): ці шляхи ставлять stopped_at самі. Без умови UPDATE зняв би // його й воскресив зупинену драбину — розбудив би саме того, хто // щойно сказав «я цим займаюсь». // // оренда все ще НАША (lease_token, 0073) — третя умова, і вона про // інше: не про алерт, а про право писати. Партія буває довшою за // EscalationLease, і тоді рядок дістається наступному взяттю ще до // того, як руки дійшли до нашої сходинки. Без звірки токена обидва // доставили б ту саму сходинку, і в журналі стояло б два «надіслано» // на одну — тобто саме той подвійний дзвінок о третій ночі, від якого // весь цей порядок і побудовано. Токен звіряється завжди, а не лише // для EscFire: запис зупинки чи відкладання від чужого імені так само // зсунув би next_at під ногами того, хто зараз працює. // // алерт усе ще 'firing' (лише для сходинки, що має спрацювати) — // решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи // видалення правила (resolveRuleAlerts), відновлення метрики // (ResolveMissing), гасіння простроченого подієвого. Вони переводять // алерт у 'resolved', і драбина зупиниться на НАСТУПНІЙ сходинці — а // та, що вже в партії, без цієї умови подзвонить за погашеним // алертом. Умова стоїть тут, а не в кожному з тих шляхів, саме тому // що їх багато й з часом побільшає: перевіряти стан у момент дії // надійніше, ніж пам'ятати про драбину в кожному новому місці. // // Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку // знову, PlanEscalation побачить 'resolved' і зупинить драбину штатно — // тобто вона не застрягне. Те саме стосується й розбіжного токена: // сходинка лишається належною (next_at не зрушено), її доводить до // розв'язку той, чий токен зараз у рядку, а якщо не дійде й він — // оренда спливе за EscalationLease, і сходинку переберуть заново. 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, lease_token = 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 -- Знімок без токена (він міг лишитись від процесу, що -- пережив накат 0073) до запису не допускається: NULL = NULL -- хибне, тож рядків не буде. Це правильний бік помилки — -- сходинку переберуть, а не подвоять. AND lease_token = $8::uuid AND (NOT $6 OR EXISTS ( SELECT 1 FROM alr.alerts a WHERE a.id = alr.alert_escalations.alert_id AND a.state = 'firing')) `, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart, next, fired, d.Outcome, nullUUID(snap.LeaseToken)) 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 { // Відкладання пишеться ОДИН раз на сходинку, а не щоперевірки. // // Відкладена сходинка (заглушення або тиха година) переглядається // кожні EscalationRecheck, тобто близько 288 разів на добу, а драбина // може чекати тижнями (стеля життя — до ~21 доби). Без цієї умови // одна ніч під тихою годиною лишала б сотні однакових рядків, і // журнал, у який заходять із питанням «чому мене не розбудили», // ставав би нечитабельним рівно тоді, коли він потрібен. Один рядок // каже те саме: цю сходинку відкладено, і ось чому. once := outcome == "suppressed" || outcome == "quiet_hours" 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) SELECT $1, $2, $3, $4, $5, $6, $7 WHERE NOT $8 OR NOT EXISTS ( SELECT 1 FROM alr.escalation_steps WHERE alert_id = $2 AND step_idx = $4 AND repeat_idx = $5 AND outcome = $6) `, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID), d.StepIdx, d.RepeatIdx, outcome, nullString(detail), once) 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, lease_token = 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 }