П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
487 lines
23 KiB
Go
487 lines
23 KiB
Go
package store
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"time"
|
||
)
|
||
|
||
// Розрахунок доступності за період: арифметика без бази.
|
||
//
|
||
// ЧОМУ ЦЕ ОКРЕМИЙ ФАЙЛ БЕЗ ЖОДНОГО ЗАПИТУ
|
||
//
|
||
// Усе, що робить звіт SLA спірним, — це не SQL, а домовленості: що таке
|
||
// «недоступний», куди подіти вікно обслуговування, як рахувати хост,
|
||
// заведений посеред кварталу. Перевіряти домовленості на живій базі
|
||
// означає перевіряти їх утричі дорожче й на порядок рідше — тобто не
|
||
// перевіряти. Тому весь спір винесено в чисті функції, а база лишається
|
||
// тим, чим має бути: постачальником рядків.
|
||
//
|
||
// ЩО ТАКЕ «НЕДОСТУПНИЙ» — ЧОТИРИ РІШЕННЯ, ЯКІ ТУТ ПРИЙНЯТО
|
||
//
|
||
// 1. Час, про який немає ЖОДНОГО виміру, не є ані доступністю, ані
|
||
// недоступністю. Він рахується окремо (UnknownSec) і не додається
|
||
// нікуди. Це головне рішення файлу.
|
||
//
|
||
// Обидві альтернативи гірші, і кожна по-своєму. Порахувати мовчання
|
||
// як «працювало» означає, що місяць із мертвим зондом дає 100% — а
|
||
// саме такий звіт і показують, не перевіряючи, бо він гарний.
|
||
// Порахувати мовчання як «лежало» означає, що годинна перерва в
|
||
// роботі САМОГО МОНІТОРИНГУ стає аварією в мережі клієнта, за яку
|
||
// хтось платить.
|
||
//
|
||
// Тому третій варіант: сказати вголос, скільки саме ми знаємо.
|
||
// CoveragePct — це частка періоду, підперта виміром, і поки вона
|
||
// нижча за поріг цілі, вердикт не виноситься взагалі: період
|
||
// позначається як «недостатньо даних». Зелений висновок із діри в
|
||
// даних тут неможливий за побудовою.
|
||
//
|
||
// 2. Вікно обслуговування з exclude_from_sla зупиняє годинник. Не
|
||
// «зараховується як доступність» — саме зупиняє: ці секунди виходять
|
||
// і з чисельника, і зі знаменника. Різниця видна на межі: двогодинні
|
||
// роботи, зараховані як uptime, ПОКРАЩИЛИ б місяць, у якому була
|
||
// аварія. Робота, про яку домовились заздалегідь, не має права
|
||
// покращувати звіт — лише не псувати його.
|
||
//
|
||
// 3. Зонд offline — це випадок 1, і він вирішується сам. Вимірів немає,
|
||
// годинних відер немає, час падає в UnknownSec. Окремої обробки не
|
||
// потрібно, і саме тому джерелом узято ts.icmp_1h, а не історію
|
||
// станів: історія станів пише рядок лише при ЗМІНІ, тож мовчання
|
||
// зонда вона зображує як незмінне «up».
|
||
//
|
||
// 4. Хост, вимкнений людиною (inv.devices.enabled = false), теж падає в
|
||
// UnknownSec — і це компроміс, який треба назвати вголос. Правильно
|
||
// було б зупинити годинник, як на обслуговуванні. Зробити цього не
|
||
// можна: історії поля enabled у продукті немає, тобто відповіді на
|
||
// питання «коли саме його вимкнули» не існує ніде. Вигадати її
|
||
// означало б підмішати здогад у документ для аудитора. Тому вимкнений
|
||
// хост псує ПОКРИТТЯ, а не доступність, і це видно в звіті числом.
|
||
|
||
// SLAPeriodKind — типи періодів, які вміє різати цей файл.
|
||
//
|
||
// Місяць і квартал — те, що просять на аудиті. Доба й тиждень коштували
|
||
// рівно один рядок у switch і потрібні для іншого: на них видно, що
|
||
// розрахунок узагалі працює, не чекаючи місяця.
|
||
const (
|
||
SLAPeriodDaily = "daily"
|
||
SLAPeriodWeekly = "weekly"
|
||
SLAPeriodMonthly = "monthly"
|
||
SLAPeriodQuarterly = "quarterly"
|
||
)
|
||
|
||
// slaBucketSec — довжина одного відра ts.icmp_1h.
|
||
//
|
||
// Година, і це стеля точності всього звіту: аварія на п'ять хвилин
|
||
// всередині години видна як частка втрачених спроб, а не як п'ять
|
||
// хвилин. Для «99.9% за квартал» (це 2 години 12 хвилин допустимого
|
||
// простою) такої точності досить; для «99.99%» — уже ні, і про це
|
||
// сказано людині на самій сторінці.
|
||
const slaBucketSec = 3600
|
||
|
||
// SLASpan — напіввідкритий відрізок часу [From, To).
|
||
//
|
||
// Напіввідкритий навмисно: сусідні періоди мають стикатися без щілини й
|
||
// без нахлесту, інакше секунда опівночі 1 липня потрапила б і в другий
|
||
// квартал, і в третій.
|
||
type SLASpan struct {
|
||
From time.Time
|
||
To time.Time
|
||
}
|
||
|
||
// SLAHourBucket — один рядок ts.icmp_1h, зведений до того, що потрібно
|
||
// доступності.
|
||
//
|
||
// Samples — скільки спроб зробили за годину, Down — скільки з них не
|
||
// відповіли. Саме ця пара й відрізняє «не відповідав» від «не питали»:
|
||
// відра, якого немає, немає, а не «нуль втрат».
|
||
type SLAHourBucket struct {
|
||
Start time.Time
|
||
Samples int64
|
||
Down int64
|
||
}
|
||
|
||
// SLAInput — усе, що потрібно, щоб порахувати один хост за один період.
|
||
type SLAInput struct {
|
||
// Межі періоду в абсолютному часі. Ріже їх SLAPeriodBounds у поясі
|
||
// цілі; далі пояс не потрібен — уся арифметика йде в секундах
|
||
// абсолютного часу. Наслідок, який варто розуміти: доба переходу на
|
||
// літній час має 23 або 25 годин, і місяць із нею довший чи коротший
|
||
// сам собою, без жодної окремої гілки.
|
||
From time.Time
|
||
To time.Time
|
||
|
||
// Коли хост з'явився і, якщо вже архівований, коли зник. Період
|
||
// звужується до їх перетину: хост, заведений 20 травня, не має
|
||
// «недоступності» за 1–19 травня — його не було.
|
||
CreatedAt time.Time
|
||
DeletedAt *time.Time
|
||
|
||
// Вікна обслуговування, які вже відібрано селектором. Можуть
|
||
// перетинатися — тут вони зводяться в непересічні.
|
||
Maintenance []SLASpan
|
||
|
||
// Годинні відра, що потрапляють у період. Порядок не важливий.
|
||
Buckets []SLAHourBucket
|
||
|
||
// Проти чого міряємо. Обидва числа беруться з цілі й кладуться в
|
||
// закритий період знімком: ціль живе далі й може змінитись.
|
||
TargetPct float64
|
||
MinCoveragePct float64
|
||
}
|
||
|
||
// SLAOutcome — розкладений період.
|
||
//
|
||
// Чотири часові величини взаємно виключні й у сумі дають ClockSec рівно.
|
||
// Це не педантизм: звіт, у якому частини не сходяться з цілим, читач
|
||
// перевіряє один раз, не сходить — і далі не вірить жодному числу.
|
||
type SLAOutcome struct {
|
||
ClockSec int64 `json:"clock_sec"`
|
||
MaintenanceSec int64 `json:"maintenance_sec"`
|
||
UpSec int64 `json:"up_sec"`
|
||
DownSec int64 `json:"downtime_sec"`
|
||
UnknownSec int64 `json:"unknown_sec"`
|
||
|
||
// UptimePct — з ВИМІРЯНОГО часу, а не з періоду. Знаменник —
|
||
// UpSec+DownSec; мовчання в нього не входить (див. рішення 1).
|
||
UptimePct float64 `json:"uptime_pct"`
|
||
// CoveragePct — яку частку годинника ми взагалі бачили.
|
||
CoveragePct float64 `json:"coverage_pct"`
|
||
|
||
// Incidents — скільки окремих смуг недоступності. Точність — година:
|
||
// це кількість непересічних серій годин, у яких була хоч одна
|
||
// невдала спроба. Дві аварії всередині однієї години рахуються як
|
||
// одна; годину без даних посеред аварії серія переживає, бо
|
||
// відсутність виміру не є доказом відновлення.
|
||
Incidents int `json:"incidents"`
|
||
|
||
// Insufficient — даних замало для вердикту. Тоді Breached завжди
|
||
// false, і це НЕ означає «виконано»: означає «не знаємо».
|
||
Insufficient bool `json:"insufficient"`
|
||
Breached bool `json:"breached"`
|
||
}
|
||
|
||
// ComputeSLA розкладає період одного хоста.
|
||
func ComputeSLA(in SLAInput) SLAOutcome {
|
||
var out SLAOutcome
|
||
|
||
// 1. Годинник: період, звужений життям хоста.
|
||
live, ok := slaClip(SLASpan{From: in.From, To: in.To}, slaLifetime(in))
|
||
if !ok {
|
||
// Хоста в цьому періоді не існувало. Нулі скрізь, вердикту
|
||
// немає. Показати тут «0% доступності» означало б винайти
|
||
// аварію з нічого.
|
||
out.Insufficient = true
|
||
return out
|
||
}
|
||
clock := slaSeconds(live)
|
||
out.ClockSec = int64(math.Round(clock))
|
||
|
||
// 2. Обслуговування: об'єднуємо перед відніманням.
|
||
//
|
||
// Об'єднання — не оптимізація. Два вікна 02:00–04:00 і 03:00–05:00
|
||
// дають три години зупиненого годинника, а сума тривалостей дала б
|
||
// чотири — і доступність вийшла б кращою, ніж є, тихо й на кожному
|
||
// звіті.
|
||
maint := slaMerge(slaClipAll(in.Maintenance, live))
|
||
billable := slaSubtract(live, maint)
|
||
billableF := slaSecondsAll(billable)
|
||
|
||
// 3. Відра, накладені на оплачуваний час.
|
||
var upF, downF, observedF float64
|
||
buckets := append([]SLAHourBucket(nil), in.Buckets...)
|
||
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Start.Before(buckets[j].Start) })
|
||
|
||
for _, b := range buckets {
|
||
if b.Samples <= 0 {
|
||
// Відро без жодної спроби — це те саме мовчання, лише
|
||
// записане рядком. Рахувати його як «втрат не було» —
|
||
// найтихіший спосіб зіпсувати звіт.
|
||
continue
|
||
}
|
||
ov := slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable)
|
||
if ov <= 0 {
|
||
continue
|
||
}
|
||
observedF += ov
|
||
// Втрати всередині години розподіляються рівномірно. Це
|
||
// припущення, і воно єдине можливе: у відрі немає позначки, коли
|
||
// саме впало. Похибка обмежена годиною й не накопичується —
|
||
// сума часток дає ту саму суму спроб.
|
||
d := ov * float64(b.Down) / float64(b.Samples)
|
||
downF += d
|
||
upF += ov - d
|
||
}
|
||
if observedF > billableF {
|
||
// Може статись на межі округлення чи якщо відро частково
|
||
// накрило вікно обслуговування. Виміряного часу не буває більше
|
||
// за оплачуваний.
|
||
observedF = billableF
|
||
}
|
||
|
||
// 4. Округлення так, щоб частини сходились із цілим.
|
||
//
|
||
// Спершу дві виміряні величини, далі мовчання добирається різницею,
|
||
// а обслуговування — залишком годинника. Порядок саме такий, бо
|
||
// підганяти під ціле треба найменш точну величину, а не ту, на яку
|
||
// дивиться клієнт.
|
||
out.UpSec = int64(math.Round(upF))
|
||
out.DownSec = int64(math.Round(downF))
|
||
billableSec := int64(math.Round(billableF))
|
||
if out.UpSec+out.DownSec > billableSec {
|
||
out.DownSec = billableSec - out.UpSec
|
||
if out.DownSec < 0 {
|
||
out.DownSec = 0
|
||
out.UpSec = billableSec
|
||
}
|
||
}
|
||
out.UnknownSec = billableSec - out.UpSec - out.DownSec
|
||
if out.UnknownSec < 0 {
|
||
out.UnknownSec = 0
|
||
}
|
||
out.MaintenanceSec = out.ClockSec - out.UpSec - out.DownSec - out.UnknownSec
|
||
if out.MaintenanceSec < 0 {
|
||
out.MaintenanceSec = 0
|
||
}
|
||
|
||
// 5. Висновки.
|
||
measured := out.UpSec + out.DownSec
|
||
if measured > 0 {
|
||
out.UptimePct = slaRound3(100 * float64(out.UpSec) / float64(measured))
|
||
}
|
||
if billableSec > 0 {
|
||
out.CoveragePct = slaRound3(100 * float64(measured) / float64(billableSec))
|
||
}
|
||
out.Incidents = slaIncidents(buckets, billable)
|
||
|
||
// Вердикт виноситься лише при достатньому покритті. Три способи
|
||
// його не мати: хоста не було, весь період — обслуговування, або
|
||
// вимірів менше за поріг цілі. У всіх трьох випадках єдина чесна
|
||
// відповідь — «не знаємо», і вона НЕ дорівнює «виконано».
|
||
out.Insufficient = billableSec == 0 || measured == 0 ||
|
||
out.CoveragePct < in.MinCoveragePct
|
||
if !out.Insufficient {
|
||
out.Breached = out.UptimePct < in.TargetPct
|
||
}
|
||
return out
|
||
}
|
||
|
||
// slaIncidents рахує смуги недоступності.
|
||
//
|
||
// Смуга починається на годині з невдалими спробами й закінчується на
|
||
// першій ПОВНІСТЮ вдалій годині. Година без даних смугу не закриває:
|
||
// відсутність виміру — не доказ того, що хост піднявся, і закривати нею
|
||
// аварію означало б розбивати одну добову аварію на десяток «інцидентів»
|
||
// щоразу, коли мовчав зонд.
|
||
func slaIncidents(buckets []SLAHourBucket, billable []SLASpan) int {
|
||
n := 0
|
||
inRun := false
|
||
for _, b := range buckets {
|
||
if b.Samples <= 0 {
|
||
continue
|
||
}
|
||
if slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable) <= 0 {
|
||
continue
|
||
}
|
||
if b.Down > 0 {
|
||
if !inRun {
|
||
n++
|
||
inRun = true
|
||
}
|
||
continue
|
||
}
|
||
inRun = false
|
||
}
|
||
return n
|
||
}
|
||
|
||
// slaLifetime — відрізок, у якому хост існував.
|
||
func slaLifetime(in SLAInput) SLASpan {
|
||
s := SLASpan{From: in.CreatedAt, To: in.To}
|
||
if in.DeletedAt != nil && in.DeletedAt.Before(in.To) {
|
||
s.To = *in.DeletedAt
|
||
}
|
||
// Хост без дати створення (теоретично неможливо, практично —
|
||
// відновлення з чужого дампа) не має звужувати період: краще
|
||
// порахувати весь, ніж мовчки нічого.
|
||
if s.From.IsZero() {
|
||
s.From = in.From
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Арифметика відрізків
|
||
// ---------------------------------------------------------------------
|
||
|
||
func slaSeconds(s SLASpan) float64 {
|
||
d := s.To.Sub(s.From).Seconds()
|
||
if d < 0 {
|
||
return 0
|
||
}
|
||
return d
|
||
}
|
||
|
||
func slaSecondsAll(list []SLASpan) float64 {
|
||
var t float64
|
||
for _, s := range list {
|
||
t += slaSeconds(s)
|
||
}
|
||
return t
|
||
}
|
||
|
||
// slaClip — перетин двох відрізків. ok=false, якщо перетину немає.
|
||
func slaClip(a, b SLASpan) (SLASpan, bool) {
|
||
out := SLASpan{From: a.From, To: a.To}
|
||
if b.From.After(out.From) {
|
||
out.From = b.From
|
||
}
|
||
if b.To.Before(out.To) {
|
||
out.To = b.To
|
||
}
|
||
if !out.From.Before(out.To) {
|
||
return SLASpan{}, false
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
func slaClipAll(list []SLASpan, within SLASpan) []SLASpan {
|
||
out := make([]SLASpan, 0, len(list))
|
||
for _, s := range list {
|
||
if c, ok := slaClip(s, within); ok {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// slaMerge зводить перекриття в непересічні відрізки.
|
||
func slaMerge(list []SLASpan) []SLASpan {
|
||
if len(list) == 0 {
|
||
return nil
|
||
}
|
||
cp := append([]SLASpan(nil), list...)
|
||
sort.Slice(cp, func(i, j int) bool { return cp[i].From.Before(cp[j].From) })
|
||
|
||
out := []SLASpan{cp[0]}
|
||
for _, s := range cp[1:] {
|
||
last := &out[len(out)-1]
|
||
// Стик (s.From == last.To) теж об'єднуємо: два суміжні вікна —
|
||
// одна перерва, і показувати їх окремо немає кому.
|
||
if !s.From.After(last.To) {
|
||
if s.To.After(last.To) {
|
||
last.To = s.To
|
||
}
|
||
continue
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// slaSubtract віднімає непересічні cut від base.
|
||
func slaSubtract(base SLASpan, cut []SLASpan) []SLASpan {
|
||
out := []SLASpan{base}
|
||
for _, c := range cut {
|
||
next := make([]SLASpan, 0, len(out)+1)
|
||
for _, s := range out {
|
||
ovl, ok := slaClip(s, c)
|
||
if !ok {
|
||
next = append(next, s)
|
||
continue
|
||
}
|
||
if s.From.Before(ovl.From) {
|
||
next = append(next, SLASpan{From: s.From, To: ovl.From})
|
||
}
|
||
if ovl.To.Before(s.To) {
|
||
next = append(next, SLASpan{From: ovl.To, To: s.To})
|
||
}
|
||
}
|
||
out = next
|
||
}
|
||
return out
|
||
}
|
||
|
||
func slaOverlapAll(s SLASpan, list []SLASpan) float64 {
|
||
var t float64
|
||
for _, b := range list {
|
||
if c, ok := slaClip(s, b); ok {
|
||
t += slaSeconds(c)
|
||
}
|
||
}
|
||
return t
|
||
}
|
||
|
||
func slaRound3(v float64) float64 {
|
||
return math.Round(v*1000) / 1000
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Календарні межі
|
||
// ---------------------------------------------------------------------
|
||
|
||
// SLAPeriodBounds — межі періоду, у який потрапляє anchor.
|
||
//
|
||
// Ріже в поясі loc, а не в UTC. «Квартал» для клієнта в Києві — це три
|
||
// календарні місяці за київським календарем; зсунуті на дві-три години
|
||
// межі здаються дрібницею рівно доти, доки в цих годинах не станеться
|
||
// аварія, яка потрапить не в той квартал.
|
||
func SLAPeriodBounds(kind string, anchor time.Time, loc *time.Location) (SLASpan, error) {
|
||
if loc == nil {
|
||
loc = time.UTC
|
||
}
|
||
t := anchor.In(loc)
|
||
y, m, d := t.Date()
|
||
midnight := func(yy int, mm time.Month, dd int) time.Time {
|
||
return time.Date(yy, mm, dd, 0, 0, 0, 0, loc)
|
||
}
|
||
|
||
switch kind {
|
||
case SLAPeriodDaily:
|
||
f := midnight(y, m, d)
|
||
return SLASpan{From: f, To: f.AddDate(0, 0, 1)}, nil
|
||
case SLAPeriodWeekly:
|
||
// Понеділок — перший день тижня. time.Weekday рахує від неділі,
|
||
// тож зсув, а не віднімання: інакше кожна неділя опинялась би в
|
||
// наступному тижні.
|
||
shift := (int(t.Weekday()) + 6) % 7
|
||
f := midnight(y, m, d).AddDate(0, 0, -shift)
|
||
return SLASpan{From: f, To: f.AddDate(0, 0, 7)}, nil
|
||
case SLAPeriodMonthly:
|
||
f := midnight(y, m, 1)
|
||
return SLASpan{From: f, To: f.AddDate(0, 1, 0)}, nil
|
||
case SLAPeriodQuarterly:
|
||
f := midnight(y, time.Month((int(m)-1)/3*3+1), 1)
|
||
return SLASpan{From: f, To: f.AddDate(0, 3, 0)}, nil
|
||
}
|
||
return SLASpan{}, fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, kind)
|
||
}
|
||
|
||
// SLAPrevPeriod — межі періоду, що передує тому, у якому now.
|
||
//
|
||
// Потрібне рівно для закриття: закривати можна лише те, що вже
|
||
// скінчилось.
|
||
func SLAPrevPeriod(kind string, now time.Time, loc *time.Location) (SLASpan, error) {
|
||
cur, err := SLAPeriodBounds(kind, now, loc)
|
||
if err != nil {
|
||
return SLASpan{}, err
|
||
}
|
||
// Відступ усередину попереднього періоду, а не віднімання довжини:
|
||
// довжина місяця й кварталу різна, а секунда перед початком поточного
|
||
// гарантовано належить попередньому.
|
||
return SLAPeriodBounds(kind, cur.From.Add(-time.Second), loc)
|
||
}
|
||
|
||
// SLASettleLag — скільки чекати після кінця періоду перед закриттям.
|
||
//
|
||
// Шість годин. Причина в 0005: ts.icmp_1h рахується з ts.icmp_5m
|
||
// політикою з end_offset 1 година й розкладом раз на годину, а сама
|
||
// ts.icmp_5m — з відставанням у 5 хвилин. Тобто останні години періоду
|
||
// матеріалізуються не раніше ніж за дві години після його кінця.
|
||
//
|
||
// Шість замість двох — це запас на зупинений на ніч сервер і на
|
||
// відкладені фонові задачі TimescaleDB. Ціна запасу — звіт за минулий
|
||
// місяць стає остаточним об 06:00 першого числа, а не опівночі. Ціна
|
||
// його відсутності — закритий назавжди період із недорахованим хвостом,
|
||
// і виправити його можна лише руками.
|
||
const SLASettleLag = 6 * time.Hour
|