П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
100 lines
5 KiB
Go
100 lines
5 KiB
Go
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)
|
||
}
|
||
}
|