diff --git a/HISTORY.md b/HISTORY.md index d336535..ee558bb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7612,3 +7612,68 @@ NULL і валить запис. Правило без власних канал міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести (`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене. + +--- + +## 2026-08-28 — Прив'язка Telegram: сторінка була, дороги до неї не було + +Власник натиснув кнопку під сповіщенням у Telegram і отримав «Ваш Telegram +не прив'язано до NetPulse. Профіль → Telegram → отримати код». Відповідь +правильна по суті. Виходу з неї не існувало. + +**Що показала база:** нуль прив'язок і нуль згенерованих кодів за весь +час. Механізмом не скористався ніхто, включно з власником. + +**Чому.** Сторінка профілю з карткою прив'язки існує й працює. Пункту +меню в неї не було; єдиний вхід — власне ім'я користувача в правому +верхньому куті. А малювалось воно за умовою «є ім'я або пошта», і `/me` +віддавало лише пошту — порожню в облікового запису власника, який +заводить установник і який входить ЗА ІМЕНЕМ. Тобто в типовій інсталяції +вхід у профіль не малювався взагалі, і сторінка була досяжна лише +введенням адреси вручну. + +Тип `Me` у фронтенді при цьому вже містив `username`. Сервер його просто +не слав — знову «обіцянка без виконання», тільки на межі шарів. + +**Виправлено чотири речі, кожна закриває свій бік:** + +* `/me` віддає `username`. Одне поле, без якого решта марна. +* Вхід у профіль малюється ЗАВЖДИ для людини (не для машинного токена), + з іконкою й підписом «Мій профіль» у підказці; без імені й пошти пише + просто «Профіль» замість того, щоб зникнути. +* Пункт меню «Обліковий запис → Мій профіль». Це свідомий виняток із + власного правила файлу («група з одного пункту додала б заголовок, + який нічого не групує»): усі інші групи — про систему, ця — про того, + хто на неї дивиться, і саме через різницю в роді профіль нікуди не + вписався й лишився без пункту. `perm` у пунктів меню став + необов'язковим: вигадувати право для власного профілю означало б + брехати про модель доступу заради типу. +* Текст бота називає те, що людина побачить на екрані, а не назву + сторінки в коді. + +Плюс сторінка каналів більше не мовчить: біля telegram-каналу видно, чи +прив'язаний Telegram у того, хто дивиться, і що саме не працюватиме без +цього. Не червоним — це незавершене налаштування, а не поломка. + +### Оренда сходинки: токен замість строку (0073) + +Остання знахідка рецензії ескалацій. `TakeDueEscalations` ставила в рядок +лише СТРОК оренди (2 хв), а партія з сотні сходинок із мережевими +таймаутами легко його переростає. Оренда спливала, другий інстанс брав ту +саму сходинку з тим самим `step_idx` і доставляв її — а перший доставляв +теж. Два дзвінки о третій ночі й два рядки «надіслано». + +Тепер у рядку лежить `lease_token`, і `ApplyEscalation` пише лише за його +збігу. Довша оренда цього не вирішила б: довший строк — це пересунута +межа, і він псує протилежний випадок (процес, що впав одразу після +взяття). При розбіжності не відбувається НІЧОГО: запис не проходить, +доставки немає (стан пишеться перед нею), оренду того, хто зараз працює, +не зривають. Сходинка лишається належною, і її доводить до розв'язку +власник токена — або, якщо і він зник, наступний такт після спливання +строку. + +Свідомо НЕ вимагається, щоб оренда була ще й живою: інакше кожна повільна +партія втрачала б усі свої сходинки, і драбина повзла б зі швидкістю +однієї сходинки за такт. + +Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**. diff --git a/server/internal/alerting/escalation.go b/server/internal/alerting/escalation.go index 6e2d8e3..ed50cd2 100644 --- a/server/internal/alerting/escalation.go +++ b/server/internal/alerting/escalation.go @@ -44,6 +44,13 @@ const escalationBatch = 100 // чергу через FOR UPDATE SKIP LOCKED, а оренда на рядку // (EscalationLease) — другий рубіж на випадок, коли рядок усе-таки взяли // двоє. +// +// Партія при цьому МОЖЕ пережити власну оренду, і це не аварія, а +// нормальний вечір: escalationBatch сходинок, кожна з мережевим +// таймаутом на канал, легко переростають дві хвилини. Рятує від +// подвоєння не строк, а токен оренди (0073): ApplyEscalation відмовить +// сходинці, чию оренду вже перебрали, і надсилання до неї не дійде — бо +// стан пишеться ПЕРЕД доставкою, а не після. func (e *Engine) escalate(ctx context.Context) { due, err := e.st.TakeDueEscalations(ctx, escalationBatch) if err != nil { @@ -88,10 +95,16 @@ func (e *Engine) escalate(ctx context.Context) { continue } if !applied { - // Драбину зупинили, поки сходинка чекала своєї черги — - // найчастіше людина натиснула «Прийняти». Це не помилка, це - // той випадок, заради якого кнопка й існує. - e.log.Info("сходинку скасовано: драбину вже зупинено", + // Три причини, і всі — не помилка. + // + // Драбину зупинили, поки сходинка чекала своєї черги + // (найчастіше людина натиснула «Прийняти») — це той випадок, + // заради якого кнопка й існує. Алерт погас іншим шляхом — + // дзвонити нема про що. Або оренду перебрали: партія + // затягнулась довше за EscalationLease, і сходинку вже веде + // інше взяття (токен у 0073). У всіх трьох сходинка НЕ + // доставляється — саме тому запис іде перед надсиланням. + e.log.Info("сходинку скасовано: драбину зупинено, алерт погас або оренду перебрано", "алерт", snap.AlertID, "сходинка", d.StepIdx+1) continue } diff --git a/server/internal/alerting/telegram_bot.go b/server/internal/alerting/telegram_bot.go index 61e6bf7..ed3b8fa 100644 --- a/server/internal/alerting/telegram_bot.go +++ b/server/internal/alerting/telegram_bot.go @@ -354,8 +354,16 @@ func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuer acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID) if errors.Is(err, store.ErrTelegramNotLinked) { + // Текст називає те, що людина побачить на екрані, а не назву + // сторінки в коді. Попередній варіант відсилав до «Профіль → + // Telegram», і саме так це вперше й не спрацювало: пункту + // «Профіль» у меню не було, посилання в шапці не малювалось + // (власник входить іменем, а не поштою, а /me імені не + // віддавало), і людина, яка сумлінно виконала інструкцію, + // доходила до висновку, що зламалось. answer = "Ваш Telegram не прив'язано до NetPulse.\n" + - "Профіль → Telegram → отримати код, потім надішліть боту /link КОД" + "У NetPulse: меню → Мій профіль → картка «Telegram» → " + + "«Отримати код», далі надішліть цьому боту: /link КОД" return } if err != nil { diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 8bf0440..1d3f45f 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -240,6 +240,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal) if p.IsUser() { resp["user_id"] = p.UserID resp["email"] = p.Email + // Ім'я входу віддається обов'язково, і це не дрібниця. + // + // Обліковий запис власника, який заводить установник, входить за + // ІМЕНЕМ (admin), а не за поштою — тобто email у нього порожній. + // Інтерфейс показував посилання на профіль лише за наявності + // імені або пошти, тож у типовій інсталяції воно не малювалось + // узагалі, і сторінка профілю ставала недосяжною: ні з меню, ні + // з шапки. Разом із нею — прив'язка Telegram, без якої кнопки + // під сповіщеннями відповідають «ваш Telegram не прив'язано» і + // не дають виходу. + resp["username"] = p.Username } else { resp["token_id"] = p.TokenID resp["token_name"] = p.TokenName diff --git a/server/internal/store/alerts_escalation.go b/server/internal/store/alerts_escalation.go index eb342b9..36fab62 100644 --- a/server/internal/store/alerts_escalation.go +++ b/server/internal/store/alerts_escalation.go @@ -375,6 +375,15 @@ type EscalationSnapshot struct { PassStart time.Time Deadline time.Time + // Токен оренди, під якою цю сходинку взято (0073). + // + // Не інформація, а перепустка: ApplyEscalation запише рішення лише + // тоді, коли в рядку лежить рівно цей токен. Знімок, чия оренда + // встигла спливти й дістатись іншому взяттю, стає недійсним — і + // саме тому обробка партії, довша за EscalationLease, більше не + // коштує другого дзвінка о третій ночі. + LeaseToken string + Steps []EscalationStep RepeatAfterMin int MaxRepeats int @@ -620,6 +629,14 @@ func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision { // сходинки — це той самий свідомий вибір, що вже зроблено для черги // подієвих алертів у 0058: «спробували» не дорівнює «доставили», і // краще не надіслати, ніж надіслати вдруге о третій ночі. +// +// Строк лишається коротким навмисно, хоч партія буває довшою за нього. +// Подовжити його «щоб вистачало» неможливо: сотня сходинок, кожна з +// власним мережевим таймаутом, переросте будь-яке число, а довга +// оренда робить гірше тому єдиному випадку, заради якого вона й +// існує, — процесу, що впав одразу після взяття. Розрив «партія довша +// за оренду» закриває не строк, а токен оренди (0073): володіння +// звіряється при записі, тож перебрана сходинка вже не подвоюється. const EscalationLease = 2 * time.Minute // TakeDueEscalations забирає сходинки, час яких настав. @@ -627,6 +644,13 @@ const EscalationLease = 2 * time.Minute // Наскрізно по всіх кабінетах і робочим пулом — так само, як черга // подієвих алертів: движок один на інсталяцію й крутиться під // advisory-блокуванням. +// +// Разом зі строком оренди рядок отримує ТОКЕН цього взяття +// (core.new_id() у SET обчислюється для кожного рядка окремо). Строк +// каже лише «зайнято до», і після його спливання рядок дістається +// іншому взяттю — а перше про це не дізнається й піде доставляти. Токен +// перетворює строк на володіння: ApplyEscalation запише рішення лише +// під тим токеном, що лежить у рядку зараз. func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) { if limit <= 0 || limit > 500 { limit = 100 @@ -642,13 +666,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation FOR UPDATE SKIP LOCKED ), taken AS ( UPDATE alr.alert_escalations e - SET leased_until = now() + $2::interval + 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.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), @@ -675,7 +702,7 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation 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.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, @@ -728,6 +755,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation // його й воскресив зупинену драбину — розбудив би саме того, хто // щойно сказав «я цим займаюсь». // +// оренда все ще НАША (lease_token, 0073) — третя умова, і вона про +// інше: не про алерт, а про право писати. Партія буває довшою за +// EscalationLease, і тоді рядок дістається наступному взяттю ще до +// того, як руки дійшли до нашої сходинки. Без звірки токена обидва +// доставили б ту саму сходинку, і в журналі стояло б два «надіслано» +// на одну — тобто саме той подвійний дзвінок о третій ночі, від якого +// весь цей порядок і побудовано. Токен звіряється завжди, а не лише +// для EscFire: запис зупинки чи відкладання від чужого імені так само +// зсунув би next_at під ногами того, хто зараз працює. +// // алерт усе ще 'firing' (лише для сходинки, що має спрацювати) — // решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи // видалення правила (resolveRuleAlerts), відновлення метрики @@ -740,7 +777,10 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation // // Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку // знову, 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 { @@ -757,17 +797,23 @@ func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d 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) + next, fired, d.Outcome, nullUUID(snap.LeaseToken)) if err != nil { return err } @@ -862,7 +908,7 @@ func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason, _, err := tx.Exec(ctx, ` WITH stopped AS ( UPDATE alr.alert_escalations - SET next_at = NULL, leased_until = NULL, + 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 diff --git a/server/internal/store/alerts_escalation_db_test.go b/server/internal/store/alerts_escalation_db_test.go index 6cfa124..a350aa4 100644 --- a/server/internal/store/alerts_escalation_db_test.go +++ b/server/internal/store/alerts_escalation_db_test.go @@ -351,6 +351,190 @@ func TestEscalationAgainstDB(t *testing.T) { t.Error("сходинка не повернулась у чергу — драбину нікому зупинити") } + // --- Оренда: токен, а не строк ------------------------------------ + // + // Розрив, якого не закривала сама лише leased_until. Партія — до + // сотні сходинок, кожна доставка з власним мережевим таймаутом, а + // оренда — дві хвилини: партія переростає її на першому ж підвислому + // каналі. Тоді рядок дістається наступному взяттю ще ДО того, як + // руки дійшли до нашої сходинки, і без звірки володіння обидва + // доставили б ту саму сходинку — два дзвінки о третій ночі й два + // рядки «надіслано» на одну сходинку. + // + // Нижче відтворено рівно цей порядок і перевірено три речі, кожна з + // яких могла б піти не так окремо. + + // (1) Оренда спливла, але НІХТО не перебрав — запис має пройти. + // + // Це не дрібниця й не піддавки: якби токен вимагав ще й живого + // строку, кожна повільна партія втрачала б усі свої сходинки, і + // драбина повзла б зі швидкістю одного такту на сходинку. Токен + // доводить володіння, а володіння не зникає від того, що годинник + // пробив, поки на рядок ніхто інший не претендує. + a5 := newAlert(ruleID + ":dev:" + deviceID + ":5") + if err := st.ArmEscalation(ctx, tenantID, a5, policyID, false, policy, started); err != nil { + t.Fatal(err) + } + if _, err := st.pool.Exec(ctx, + `UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`, + a5); err != nil { + t.Fatal(err) + } + + // takeOne бере чергу й дістає з неї знімок потрібного алерту. + takeOne := func(alertID string) EscalationSnapshot { + t.Helper() + batch, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatalf("взяття черги: %v", err) + } + for _, x := range batch { + if x.AlertID == alertID { + return x + } + } + t.Fatalf("сходинка алерту %s не потрапила в чергу", alertID) + return EscalationSnapshot{} + } + + // expireLease старить оренду, НЕ чіпаючи токена: саме так виглядає + // рядок, коли процес ще йде по партії, а строк уже вийшов. + expireLease := func(alertID string) { + t.Helper() + if _, err := st.pool.Exec(ctx, + `UPDATE alr.alert_escalations SET leased_until = now() - interval '1 second' + WHERE alert_id = $1`, alertID); err != nil { + t.Fatal(err) + } + } + + // ladder читає стан драбини так, як його бачить база. + ladder := func(alertID string) (stepIdx int, token string, nextAt *time.Time) { + t.Helper() + if err := st.pool.QueryRow(ctx, ` + SELECT step_idx, COALESCE(lease_token::text,''), next_at + FROM alr.alert_escalations WHERE alert_id = $1 + `, alertID).Scan(&stepIdx, &token, &nextAt); err != nil { + t.Fatal(err) + } + return stepIdx, token, nextAt + } + + sLate := takeOne(a5) + if sLate.LeaseToken == "" { + t.Fatal("взяття не поставило токен оренди — звіряти буде нічого") + } + expireLease(a5) + dLate := PlanEscalation(sLate, time.Now()) + if dLate.Action != EscFire { + t.Fatalf("сходинка мала спрацювати: %v/%s", dLate.Action, dLate.Outcome) + } + okLate, err := st.ApplyEscalation(ctx, sLate, dLate) + if err != nil { + t.Fatal(err) + } + if !okLate { + t.Error("запис не пройшов, хоча оренду ніхто не перебирав — " + + "повільна партія втрачатиме всі свої сходинки") + } + + // (2) Оренду ПЕРЕБРАЛИ — запізнілий запис не має пройти. + a6 := newAlert(ruleID + ":dev:" + deviceID + ":6") + if err := st.ArmEscalation(ctx, tenantID, a6, policyID, false, policy, started); err != nil { + t.Fatal(err) + } + if _, err := st.pool.Exec(ctx, + `UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`, + a6); err != nil { + t.Fatal(err) + } + + first := takeOne(a6) // перший інстанс: узяв і пішов доставляти партію + expireLease(a6) // партія затягнулась довше за оренду + second := takeOne(a6) // другий інстанс перебрав ту саму сходинку + + if first.LeaseToken == second.LeaseToken { + t.Fatal("два взяття отримали однаковий токен — перевірка нічого не доводить") + } + if first.StepIdx != second.StepIdx { + t.Fatalf("другому взяттю дісталась інша сходинка (%d проти %d) — "+ + "відтворено не той розрив", second.StepIdx, first.StepIdx) + } + + dFirst := PlanEscalation(first, time.Now()) + if dFirst.Action != EscFire { + t.Fatalf("сходинка мала спрацювати: %v/%s", dFirst.Action, dFirst.Outcome) + } + okFirst, err := st.ApplyEscalation(ctx, first, dFirst) + if err != nil { + t.Fatal(err) + } + if okFirst { + t.Error("запис пройшов під перебраною орендою — сходинка піде в канали вдруге") + } + + // Відмова має бути БЕЗ наслідків: чужий запис не зриває ані оренду + // того, хто зараз працює, ані стан драбини. Інакше запізнілий + // інстанс, нічого не доставивши, псував би роботу тому, хто доставить. + stepAfter, tokenAfter, nextAfter := ladder(a6) + if stepAfter != first.StepIdx { + t.Errorf("невдалий запис усе-таки просунув драбину: step_idx = %d", stepAfter) + } + if tokenAfter != second.LeaseToken { + t.Errorf("невдалий запис зірвав оренду того, хто працює: токен %q", tokenAfter) + } + if nextAfter == nil { + t.Error("невдалий запис зняв час наступної сходинки — драбина осиротіла") + } + + // (3) І драбина не застрягла. + // + // Найгірший розклад: другий інстанс, який перебрав сходинку, теж не + // дійшов до запису (впав, викотили нову версію). Тоді сходинка не + // доставлена НІКИМ, і єдине, що має її врятувати, — спливання + // оренди. Перевіряємо, що наступний такт бере її з тим самим + // step_idx і доводить до розв'язку. + expireLease(a6) + third := takeOne(a6) + if third.LeaseToken == second.LeaseToken || third.LeaseToken == first.LeaseToken { + t.Error("нове взяття не оновило токен — оренда перестала бути власністю") + } + if third.StepIdx != first.StepIdx { + t.Errorf("сходинку загублено: наступний такт узяв step_idx = %d замість %d", + third.StepIdx, first.StepIdx) + } + dThird := PlanEscalation(third, time.Now()) + if dThird.Action != EscFire { + t.Fatalf("сходинка мала спрацювати: %v/%s", dThird.Action, dThird.Outcome) + } + okThird, err := st.ApplyEscalation(ctx, third, dThird) + if err != nil { + t.Fatal(err) + } + if !okThird { + t.Fatal("наступний такт не зміг записати рішення — драбина застрягла назавжди") + } + stepDone, tokenDone, nextDone := ladder(a6) + if stepDone != first.StepIdx+1 { + t.Errorf("драбина не просунулась: step_idx = %d", stepDone) + } + if tokenDone != "" { + t.Errorf("токен не знято разом з орендою: %q", tokenDone) + } + if nextDone == nil { + t.Error("у живої драбини не лишилось часу наступної сходинки") + } + + // І найпізніший з усіх: перший інстанс, який усе ще тримає свій + // протухлий знімок, після цього теж нічого не запише. + okStale, err := st.ApplyEscalation(ctx, first, dFirst) + if err != nil { + t.Fatal(err) + } + if okStale { + t.Error("протухлий знімок записався після просування драбини — сходинка подвоїлась") + } + // --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ---- a2 := newAlert(ruleID + ":dev:" + deviceID + ":2") if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil { diff --git a/server/migrations/0073_escalation_lease_token.sql b/server/migrations/0073_escalation_lease_token.sql new file mode 100644 index 0000000..fad976b --- /dev/null +++ b/server/migrations/0073_escalation_lease_token.sql @@ -0,0 +1,93 @@ +-- ===================================================================== +-- NetPulse :: 0073_escalation_lease_token.sql +-- Оренда сходинки ескалації отримує токен: хто взяв, той і записує. +-- +-- ЩО БУЛО НЕ ТАК +-- +-- 0066 дала оренді рядка (leased_until) рівно половину роботи. Вона +-- чесно закриває вікно «взяли — ще не записали»: доки оренда не +-- спливла, другий інстанс сходинку не побачить. Але оренда — це строк, +-- а не володіння, і про друге вона не знає нічого. +-- +-- Строк тут дві хвилини (store.EscalationLease), а партія — до сотні +-- сходинок (alerting.escalationBatch), і кожна доставка в ній має +-- ВЛАСНИЙ мережевий таймаут: Telegram, SMTP, вебхук у чужий контур. +-- Тобто «партія довша за оренду» — це не рідкісний збіг, а звичайний +-- вечір, коли один канал підвис. +-- +-- Далі рахунок простий. Оренда спливає, поки перший інстанс ще йде по +-- партії. Другий (або наступний такт того самого процесу) бачить рядок +-- вільним, бере ту саму сходинку — з тим самим step_idx, бо перший її +-- ще не просунув, — і доставляє. Через хвилину доходить черга до неї в +-- першого, і він доставляє її вдруге. О третій ночі це два дзвінки, а в +-- журналі — два рядки «надіслано», тобто доказ перетворюється на +-- виправдання: за ним не видно, що система подзвонила двічі за одну +-- сходинку. +-- +-- ApplyEscalation при цьому мав ДВІ перевірки стану (stopped_at IS NULL +-- і «алерт усе ще firing»), і обидві потрібні — але обидві про АЛЕРТ. +-- Жодна не про те, чи оренда все ще наша. Звірити було нічим: у рядку +-- лежав лише строк, і строк першого інстансу нічим не відрізнявся від +-- строку другого. +-- +-- ЧОМУ ТОКЕН, А НЕ ДОВША ОРЕНДА +-- +-- Спокуса очевидна: зробити оренду двадцять хвилин, і партія завжди +-- вкладеться. Це не рішення, а пересунута межа. Скільки б не було +-- поставлено, знайдеться партія довша: сотня сходинок по три канали, +-- кожен зі своїм таймаутом, — це арифметика, а не патологія. Крім +-- того, довга оренда псує протилежний випадок: процес, що впав одразу +-- після взяття, тримає сходинку заручницею рівно на цей строк, а +-- сходинка ескалації — це той, кого не розбудили. +-- +-- Токен знімає обидва: строк лишається коротким (впав — сходинку +-- швидко перебирають), а подвоєння закриває звірка володіння, яка від +-- строку не залежить взагалі. Це той самий fencing-токен, що й у +-- розподілених блокуваннях: власність доводиться не тим, що годинник ще +-- не пробив, а тим, що номер збігся. +-- +-- ЧОМУ ТОКЕН НА РЯДОК, А НЕ НА ПАРТІЮ +-- +-- Один токен на всю партію теж закрив би подвоєння, і коду було б на +-- рядок менше. Але тоді два різні прогони, що поділили одну партію +-- (SKIP LOCKED саме для цього й стоїть), розрізнялись би лише +-- випадковістю збігу, а рядок у базі не казав би, ЯКЕ взяття його +-- тримає. core.new_id() у SET обчислюється для кожного рядка окремо — +-- ціна нульова, а стовпець стає самодостатнім: за ним видно конкретне +-- взяття конкретної сходинки. +-- +-- ЩО СТАНЕТЬСЯ, КОЛИ ТОКЕН НЕ ЗБІГСЯ +-- +-- Нічого не станеться — і це головна вимога до цього стовпця. UPDATE +-- не чіпає ЖОДНОГО рядка: ні next_at, ні step_idx, ні leased_until +-- (інакше запізнілий інстанс зривав би оренду того, хто зараз працює). +-- Движок бачить applied = false, у мережу нічого не йде, і сходинка +-- лишається належною — тим самим step_idx, з тим самим next_at у +-- минулому. Її доводить до розв'язку той, чий токен у рядку; якщо й він +-- не дійшов — оренда спливає за дві хвилини, і наступний такт бере +-- сходинку заново. Застрягти назавжди нема від чого: жодна гілка не +-- знімає next_at і не подовжує оренду безкінечно. +-- +-- СУМІСНІСТЬ +-- +-- Стовпець NULL для всіх наявних рядків, і це правильний стан: NULL +-- означає «зараз ніхто не тримає». Живі оренди на момент накату +-- (щонайбільше дві хвилини) отримають токен при наступному взятті. +-- Вимоги NOT NULL немає навмисно — вільний рядок мусить уміти сказати +-- «нічий», а не носити токен-привид від позаминулого взяття. +-- ===================================================================== + +ALTER TABLE alr.alert_escalations + ADD COLUMN IF NOT EXISTS lease_token uuid; + +COMMENT ON COLUMN alr.alert_escalations.lease_token IS + 'Токен поточної оренди (fencing). Ставиться разом із leased_until при ' + 'взятті сходинки, звіряється при записі рішення й знімається разом з ' + 'орендою. NULL — сходинку зараз ніхто не тримає. Потрібен тому, що ' + 'обробка партії буває довшою за оренду: без звірки володіння запізнілий ' + 'інстанс доставив би сходинку, яку вже доставив той, хто перебрав її ' + 'після спливання оренди.'; + +COMMENT ON COLUMN alr.alert_escalations.leased_until IS + 'До якого моменту сходинка вважається взятою в роботу. Строк, а не ' + 'володіння: володіння доводить lease_token.'; diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index f2e37bf..f6c7477 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -12,7 +12,14 @@ interface NavItem { to: string label: string icon: string - perm: Permission + /** + * Право, без якого пункт не показується. + * + * Необов'язкове: є сторінки, які належать кожному, хто увійшов + * (власний профіль), і вигадувати для них право означало б брехати + * про модель доступу заради типу. + */ + perm?: Permission /** Показувати лічильник алертів на цьому пункті. */ badge?: boolean } @@ -190,6 +197,27 @@ const navGroups: NavGroup[] = [ { to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' }, ], }, + { + // Окрема група з одного пункту — і це свідомий виняток із правила, + // записаного вище («група з одного пункту додала б заголовок, + // який нічого не групує»). + // + // Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того, + // хто на неї дивиться. Заголовок тут групує саме це — і саме через + // цю різницю в роді профіль нікуди не вписався й лишився без пункту + // взагалі — рівно той випадок, про який написано вище: + // «сторінка, про існування якої знає лише той, хто її писав». + // + // Ціна була не косметична: тут живе прив'язка Telegram, без якої + // кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано». + // За весь час цим не скористався ніхто — ні однієї прив'язки й жодного + // згенерованого коду, включно з власником. + // + // Без perm: профіль є в кожного, хто увійшов. + id: 'account', + title: 'Обліковий запис', + items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }], + }, ] // --------------------------------------------------------------------- @@ -392,7 +420,8 @@ export function AppShell() { // Група без жодного доступного пункту зникає цілком: заголовок над // порожнечею обіцяє розділ, якого для цієї ролі не існує. const groups = navGroups - .map((g) => ({ ...g, items: g.items.filter((i) => session.can(i.perm)) })) + // Пункт без perm бачать усі, хто увійшов. + .map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) })) .filter((g) => g.items.length > 0) const hasAny = groups.some((g) => g.items.length > 0) @@ -454,15 +483,26 @@ export function AppShell() { )} - {(me?.username || me?.email) && ( + {/* + Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим + підписати кнопку. + + Раніше умова вимагала імені або пошти, а /me віддавало лише + пошту — порожню в облікового запису власника, який заводить + установник і який входить за іменем. Тобто в типовій + інсталяції єдиний шлях у профіль не малювався взагалі, і + сторінка була досяжна лише введенням адреси вручну. + */} + {me?.userID && (