Netpulse_SasS/server/internal/store/alerts_escalation.go
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

921 lines
48 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"`
// Чи відкладати сходинки на час тихої години правила.
//
// 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
}