Netpulse_SasS/server/internal/grpcapi/sla_close.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

100 lines
5 KiB
Go
Raw Permalink 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 grpcapi
import (
"context"
"time"
)
// slaCloseLockKey — окреме advisory-блокування під закриття періодів SLA.
//
// Своє, а не спільне з прибиральником даних: обидва такти тікають
// незалежно, і спільний ключ означав би, що довге закриття кварталу на
// п'ятистах хостах заодно відкладає звільнення місця на диску.
const slaCloseLockKey = 0x6e70_736c // "npsl"
// SLACloseInterval — як часто дивитись, чи не пора закривати період.
//
// Година. Найкоротший період — доба, а закривати його можна не раніше
// ніж через SLASettleLag після кінця; частіше шукати нема чого. Рідше —
// означало б, що звіт за минулий місяць стає остаточним «десь протягом
// дня», а саме першого числа по нього й приходять.
const SLACloseInterval = time.Hour
// ЧОМУ ЦЕ ФОНОВИЙ ТАКТ, А НЕ РОЗРАХУНОК ПРИ ВІДКРИТТІ СТОРІНКИ
//
// Спокуса зробити «закриємо, коли вперше відкриють» велика: коду менше,
// такту немає. І вона тихо руйнує всю задачу.
//
// Період, який ніхто не відкрив вчасно, чекати не вміє — дані під ним
// зникають за строками зберігання. Квартал, уперше відкритий через
// півроку, закрився б назавжди по тому, що від нього лишилось: кілька
// останніх тижнів замість трьох місяців, з мовчазними 100% на решті.
// Тобто найгірший звіт вийшов би саме там, де його найдовше не дивились,
// — а не дивляться найдовше рівно на ті мережі, де все спокійно.
//
// Тому закриття не залежить від уваги людини взагалі.
//
// Живе в колекторі, а не в REST-процесі, з тих самих міркувань, що й
// прибиральник даних: поруч із тим, хто ці дані створює, і подалі від
// шляху запитів людини. Кілька екземплярів колектора безпечні — тік бере
// advisory-блокування, тож працює рівно один.
// CloseSLAPeriods закриває періоди, які вже можна закрити.
func (s *Service) CloseSLAPeriods(ctx context.Context) {
t := time.NewTicker(SLACloseInterval)
defer t.Stop()
s.log.Info("закриття періодів SLA запущено", "інтервал", SLACloseInterval)
// Перший прохід одразу: після простою сервера є що добрати, і
// чекати на нього годину означає віддати цю годину даним, які тим
// часом можуть зникнути за строком зберігання.
s.slaCloseTick(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.slaCloseTick(ctx)
}
}
}
func (s *Service) slaCloseTick(ctx context.Context) {
conn, err := s.store.Pool().Acquire(ctx)
if err != nil {
s.log.Error("закриття SLA: з'єднання", "err", err)
return
}
defer conn.Release()
var got bool
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
int64(slaCloseLockKey)).Scan(&got); err != nil {
s.log.Error("закриття SLA: блокування", "err", err)
return
}
if !got {
// Закриває інший інстанс — штатний стан.
return
}
defer func() {
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, int64(slaCloseLockKey))
}()
closed, err := s.store.CloseDueSLAPeriods(ctx)
if err != nil {
// Помилка не скасовує вже закритого: кожен період закривається
// своєю транзакцією, і наступний тік продовжить з того ж місця.
s.log.Error("закриття SLA: прохід", "err", err, акритообою", len(closed))
}
for _, c := range closed {
// Кожен закритий період — окремим рядком, а не лічильником.
// Число «закрито 7» не відповідає на єдине питання, яке про
// закриття взагалі ставлять: «чому в цьому звіті таке число».
s.log.Info("період SLA закрито",
"ціль", c.Name, "період", c.Period, "кабінет", c.TenantID)
}
}