Netpulse_SasS/server/internal/store/storage_usage.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

631 lines
31 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 store
import (
"context"
"fmt"
"math"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
// Скільки місця займають дані й на скільки його ще вистачить.
//
// НАВІЩО ЦЕ ОКРЕМА СТОРІНКА
//
// Диск закінчується мовчки. Postgres не попереджає й не сповільнюється —
// він працює нормально рівно до останнього вільного байта, а потім
// перестає приймати записи взагалі. Тобто перша ознака проблеми і є
// сама аварія, і настає вона одночасно для всього продукту.
//
// Єдине, що рятує, — побачити нахил заздалегідь. «120 МБ» не означає
// нічого; «плюс 40 МБ за добу, вільного на 12 діб» означає все. Тому
// сторінка показує не розмір, а ШВИДКІСТЬ і те, що з неї випливає.
//
// ЗВІДКИ ЦИФРИ
//
// З системних представлень Postgres і TimescaleDB, а не з формул за
// кількістю рядків. Розмір рядка залежить від стиснення, TOAST і
// роздутості після видалень; будь-яка формула розійшлася б із диском у
// той самий день, коли на неї почнуть покладатись, — і розійшлася б
// саме в бік «усе гаразд».
//
// Приріст рахується з ряду спостережень (core.storage_samples), а не з
// різниці «зараз проти нуля»: інсталяція, яку підняли з дампа, інакше
// показала б добовий приріст, що дорівнює всій базі.
//
// ЧОГО ТУТ ЧЕСНО НЕМАЄ
//
// Вільного місця на томі. Postgres не має функції, яка це повертає, а
// процес застосунку живе в іншому контейнері й може стояти взагалі на
// іншій машині — тобто statfs теж не відповідь. Ємність вводить людина,
// а поки не ввела, сторінка показує розмір і приріст, але не показує
// дати переповнення. Вигадана дата гірша за її відсутність: на такі
// дати дивляться саме тоді, коли перевіряти вже пізно.
//
// ДОПИСАНО В 0067
//
// «statfs не відповідь» лишається правдою за замовчуванням і перестає
// нею бути, щойно том бази змонтовано в контейнер прибиральника. Тоді
// вільне місце міряється, а не вводиться, і різниця не косметична:
// введене число застаріває мовчки — том розширили, поруч поклали дамп,
// журнал попереднього запису роздувся від застряглого слота реплікації.
// Виміряне бачить усе це, бо міряє ТОМ, а не суму відомих нам таблиць.
//
// Шлях перевіряється перед тим, як йому повірити: том, менший за саму
// базу, — це не том бази, а кореневий розділ контейнера, у який
// показали помилково. Такий шлях відхиляється з поясненням, а не
// мовчки: помилка тут дає не «немає числа», а «є число, і воно
// втішне».
// StorageEntry — один рядок таблиці розмірів.
type StorageEntry struct {
Kind string `json:"kind"`
Label string `json:"label"`
Group string `json:"group"`
Relation string `json:"relation"`
Bytes int64 `json:"bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
Chunks int `json:"chunks"`
// KeepDays — чинний строк. nil означає «не видаляється»; саме такі
// рядки й треба показувати першими, коли місце закінчується.
KeepDays *int `json:"keep_days"`
// Managed — чи керується строк сторінкою строків. Для ncm.configs і
// core.secrets ні: у них своя політика (0037), і робити вигляд, що
// їх крутять тут, означало б обіцяти неіснуючу ручку.
Managed bool `json:"managed"`
// PerDay — приріст за добу в байтах. Може бути від'ємним: щойно
// увімкнений строк спершу все прибирає, і від'ємний приріст тут —
// правильна відповідь, а не помилка вимірювання.
PerDay float64 `json:"per_day"`
}
// StorageOverview — уся сторінка одним об'єктом.
type StorageOverview struct {
DatabaseBytes int64 `json:"database_bytes"`
// DiskBytes — ємність тому ЗІ СЛІВ ЛЮДИНИ. 0 означає «не сказали».
// Лишається окремим полем від виміряної: форма ємності показує саме
// введене число, і підставити в неї виміряне означало б, що людина
// зберігає назад те, чого не вводила.
DiskBytes int64 `json:"disk_bytes"`
WarnPct int `json:"warn_pct"`
// DiskTotalBytes — ємність, на яку система спирається насправді:
// виміряна, якщо змогли, інакше введена. 0 — не знаємо нізвідки.
DiskTotalBytes int64 `json:"disk_total_bytes"`
// FreeBytes — скільки місця лишилось. nil — не знаємо, і це не те
// саме, що нуль: нуль означає «місця немає».
FreeBytes *int64 `json:"free_bytes,omitempty"`
// FreeSource — звідки взялось попереднє число: "measured" (statfs
// тому) або "declared" (ємність від людини мінус розмір баз).
// Різниця в довірі: виміряне враховує все, що лежить на тому поруч,
// оголошене — лише те, про що ми знаємо самі.
FreeSource string `json:"free_source,omitempty"`
// DataPath — шлях, за яким міряли, і PathError — чому не виміряли.
// Друге показується людині дослівно: мовчазна відмова від
// вимірювання невідрізнима від «шлях не задавали».
DataPath string `json:"data_path,omitempty"`
PathError string `json:"path_error,omitempty"`
// ClusterBytes — усі бази кластера разом. Том ділять і вони: на
// томі, крім netpulse, лежать template0/1 і postgres, а на спільному
// сервері може лежати чужа база того ж кластера.
ClusterBytes int64 `json:"cluster_bytes"`
// WalBytes — скільки важить журнал попереднього запису. Не наші
// дані й ми ними не керуємо, але місце вони їдять із того ж тому —
// і саме вони пояснюють «база не росте, а вільного меншає».
// 0 означає й «порожній», і «не дали подивитись»: pg_ls_waldir
// вимагає членства в pg_monitor.
WalBytes int64 `json:"wal_bytes,omitempty"`
// MaxWalBytes — скільки Postgres має право написати в журнал між
// контрольними точками. Це підлога вільного місця, нижче якої
// відсотки перестають щось означати.
MaxWalBytes int64 `json:"max_wal_bytes,omitempty"`
// PerDayBytes — приріст усієї бази за добу.
PerDayBytes float64 `json:"per_day_bytes"`
// ObservedDays — на скільки діб спостережень спирається приріст.
// Без цього числа «плюс 40 МБ на добу» невідрізнимо від здогадки за
// двома точками, знятими з різницею в годину.
ObservedDays float64 `json:"observed_days"`
// DaysLeft — на скільки діб вистачить за поточної швидкості.
// nil — або ємності не сказали, або база не росте.
DaysLeft *float64 `json:"days_left,omitempty"`
SampledAt *time.Time `json:"sampled_at,omitempty"`
Entries []StorageEntry `json:"entries"`
// Alert — стан попередження про вичерпання місця: як налаштоване й
// що сказало б зараз. Їде тим самим запитом, що й розміри, бо це
// відповідь на те саме питання: сторінка, яка показує «вистачить на
// 9 діб» і мовчить про те, попередить хтось про це чи ні, лишає
// людину рівно там, де вона була.
Alert StorageAlertState `json:"alert"`
}
// storageExtras — таблиці, які на сторінці показують, але строком не
// керують.
//
// Перелік короткий і кожен рядок у ньому — відповідь на питання, яке
// інакше поставили б до підсумку: «а чому сума рядків менша за розмір
// бази». Найважливіший тут core.secrets: у ньому лежать зашифровані
// тіла конфігів, і саме там мегабайти, яких у ncm.configs не видно —
// у ній самі лише метадані версій.
var storageExtras = []struct {
Relation string
Label string
Group string
Note string
}{
{
Relation: "core.secrets", Label: "Тіла конфігів і секрети", Group: "Конфігурації",
Note: "Зашифровані тіла версій конфігів, доступи й токени. Основна вага архіву конфігів — тут, а не в ncm.configs.",
},
{
Relation: "ncm.configs", Label: "Версії конфігів (метадані)", Group: "Конфігурації",
Note: "Строк керується політикою збору конфігів: «останні N версій АБО молодші за M днів», із захистом останньої версії хоста.",
},
{
Relation: "ts.series", Label: "Довідник рядів метрик", Group: "Метрики",
Note: "Рядок на кожну пару «хост + метрика». Не росте з часом — росте з кількістю хостів і шаблонів.",
},
{
Relation: "alr.alerts", Label: "Активні алерти", Group: "Алерти",
Note: "Закриті переїжджають в історію фоном, тому таблиця лишається маленькою незалежно від віку інсталяції.",
},
{
Relation: "core.event_outbox", Label: "Черга подій", Group: "Службове",
Note: "Доставлені події прибираються окремим тактом; сюди дивляться, коли підозрюють, що вони перестали доставлятись.",
},
{
Relation: "topo.map_revisions", Label: "Історія правок мап", Group: "Службове",
Note: "Кожна ревізія — повний знімок полотна. Обмежена стелею кількості на мапу, а не віком.",
},
}
// StorageUsage збирає всю сторінку.
func (s *Store) StorageUsage(ctx context.Context) (*StorageOverview, error) {
out := &StorageOverview{WarnPct: 80}
var dataPath *string
if err := s.bg.QueryRow(ctx, `
SELECT pg_database_size(current_database())::bigint,
COALESCE((SELECT disk_bytes FROM core.storage_config), 0),
COALESCE((SELECT warn_pct FROM core.storage_config), 80),
(SELECT data_path FROM core.storage_config),
COALESCE((SELECT alert_enabled FROM core.storage_config), true),
COALESCE((SELECT alert_days_warn FROM core.storage_config), 21),
COALESCE((SELECT alert_days_crit FROM core.storage_config), 4)
`).Scan(&out.DatabaseBytes, &out.DiskBytes, &out.WarnPct, &dataPath,
&out.Alert.Enabled, &out.Alert.DaysWarn, &out.Alert.DaysCrit); err != nil {
return nil, err
}
if dataPath != nil {
out.DataPath = *dataPath
}
rows, err := s.RetentionSettings(ctx)
if err != nil {
return nil, err
}
sizes, err := s.relationSizes(ctx)
if err != nil {
return nil, err
}
growth, sampledAt, observed := s.storageGrowth(ctx)
out.SampledAt = sampledAt
out.ObservedDays = observed
for _, r := range rows {
out.Entries = append(out.Entries, StorageEntry{
Kind: r.Kind, Label: r.Label, Group: r.Group, Relation: r.Relation,
Bytes: r.Bytes, CompressedBytes: r.CompressedBytes, Chunks: r.Chunks,
KeepDays: r.KeepDays, Managed: true,
PerDay: growth[r.Kind],
})
}
for _, e := range storageExtras {
sz := sizes[e.Relation]
out.Entries = append(out.Entries, StorageEntry{
Kind: "@" + e.Relation, Label: e.Label, Group: e.Group, Relation: e.Relation,
Bytes: sz.total, Managed: false,
PerDay: growth["@"+e.Relation],
})
}
sort.SliceStable(out.Entries, func(i, j int) bool {
return out.Entries[i].Bytes > out.Entries[j].Bytes
})
out.PerDayBytes = growth["@database"]
s.fillVolume(ctx, out)
// Запас рахується лише тоді, коли обидва доданки справжні. Нуль у
// знаменнику дав би нескінченність, а невідома ємність — цифру,
// узяту зі стелі; обидва варіанти на цій сторінці однаково шкідливі.
//
// Від'ємний приріст сюди теж не проходить, і це не та сама умова:
// «база зменшилась» дало б від'ємний запас, тобто дату переповнення
// в минулому.
out.DaysLeft = storageDaysLeft(out.FreeBytes, out.PerDayBytes)
// Вирок попередження рахується з тих самих чисел, що вже лежать в
// out, і жодного власного запиту не робить. Так сторінка й алерт не
// можуть розійтись: розходження між ними неможливо пояснити нікому.
verdict := storageVerdict(out, out.Alert.StorageAlertConfig)
verdict.FiringSince = s.storageAlertSince(ctx)
out.Alert = verdict
return out, nil
}
// storageDaysLeft — на скільки діб вистачить вільного місця.
//
// Окремою функцією з однією рядком тіла, бо саме цей рядок і є місце,
// де прогноз бреше найдорожче. Три способи збрехати, і всі три
// закриваються тут:
//
// знаменник нуль — база не росте. Ділення дало б +Inf, а +Inf на
// шляху до JSON не серіалізується взагалі: список алертів віддав би
// 500 замість алерту. Правильна відповідь — «немає числа».
//
// знаменник від'ємний — базу почистили або видалили хост. Ділення
// дало б від'ємний запас, тобто дату переповнення в минулому.
//
// чисельника немає — ємності тому не знає ніхто. Підставити сюди
// розмір бази замість вільного місця означало б порахувати запас із
// самого себе.
//
// nil тут — робоча відповідь, а не помилка. Показувати її треба словами
// («база не росте», «спостережень ще немає»), і саме тому вона одна на
// всі три випадки: розрізняє їх той, хто показує, а не той, хто ділить.
func storageDaysLeft(free *int64, perDay float64) *float64 {
if free == nil || math.IsNaN(perDay) || perDay <= 0 {
return nil
}
d := float64(*free) / perDay
return &d
}
// fillVolume з'ясовує, скільки на томі всього й скільки лишилось.
//
// Три джерела, і між ними саме такий порядок:
//
// statfs тому — правда про ТОМ. Він один бачить усе, що лежить поруч
// із базою: журнал попереднього запису, дампи, чужі бази того ж
// кластера. Доступний лише тоді, коли том змонтовано в цей
// контейнер, тобто за замовчуванням недоступний.
//
// ємність від людини мінус розмір баз — шлях 0064. Слабший
// свідомо: він рахує вільним усе, чого не бачить, а не бачить він
// саме те, що переповнює том найчастіше.
//
// нічого — теж відповідь, і чесна. Тоді сторінка не показує запасу,
// а попередження каже про себе, що йому нема на що спертись.
//
// Помилки не піднімаються нагору навмисно: сторінка розмірів має
// відкриватись і тоді, коли шлях указано криво, а pg_ls_waldir заборонено
// роллю. Кожна невдача коштує одного числа, а не всієї сторінки — той
// самий принцип, що й у relationSizes зі статистикою стиснення.
func (s *Store) fillVolume(ctx context.Context, out *StorageOverview) {
// Розмір усіх баз кластера. Постає замість розміру однієї бази скрізь,
// де йдеться про ТОМ: template0/1 і postgres лежать на ньому ж, а на
// спільному сервері там може лежати й чужа база.
out.ClusterBytes = out.DatabaseBytes
var cluster int64
if err := s.bg.QueryRow(ctx, `
SELECT COALESCE(sum(pg_database_size(oid)), 0)::bigint FROM pg_database
`).Scan(&cluster); err == nil && cluster > 0 {
out.ClusterBytes = cluster
}
// Журнал попереднього запису. Права може не бути (потрібне членство
// в pg_monitor) — тоді просто немає числа.
_ = s.bg.QueryRow(ctx, `
SELECT COALESCE(sum(size), 0)::bigint FROM pg_ls_waldir()
`).Scan(&out.WalBytes)
// Скільки журналу Postgres має право написати між контрольними
// точками. Звичайний параметр, не привілейований; читається як
// текст ('1GB') і переводиться в байти самою базою.
_ = s.bg.QueryRow(ctx, `
SELECT pg_size_bytes(current_setting('max_wal_size'))
`).Scan(&out.MaxWalBytes)
if out.DataPath != "" {
free, total, err := diskFree(out.DataPath)
switch {
case err != nil:
out.PathError = err.Error()
case total < out.ClusterBytes:
// Том, менший за бази, які на ньому нібито лежать, — це не
// том бази. Найчастіше показали на кореневий розділ
// контейнера збірника. Прийняти таке число означало б
// показати запас, більший за справжній, — тобто помилитись
// у той єдиний бік, у який тут не можна.
out.PathError = fmt.Sprintf(
"том за шляхом %s має %s — це менше за самі бази (%s), тобто це не том бази",
out.DataPath, humanBytes(total), humanBytes(out.ClusterBytes))
default:
out.DiskTotalBytes, out.FreeSource = total, "measured"
f := free
out.FreeBytes = &f
return
}
}
if out.DiskBytes > 0 {
out.DiskTotalBytes, out.FreeSource = out.DiskBytes, "declared"
f := out.DiskBytes - out.ClusterBytes
if f < 0 {
// Бази більші за оголошену ємність. Число застаріло — том
// розширили, а сказати забули. Нуль тут не брехня, а
// найобережніше з можливих прочитань.
f = 0
}
out.FreeBytes = &f
}
}
// SetStorageConfig зберігає ємність тому й поріг попередження.
func (s *Store) SetStorageConfig(ctx context.Context, diskBytes int64, warnPct int, userID string) error {
if diskBytes < 0 {
return ErrInvalid
}
if warnPct < 50 || warnPct > 99 {
return ErrInvalid
}
_, err := s.bg.Exec(ctx, `
INSERT INTO core.storage_config (id, disk_bytes, warn_pct, updated_at, updated_by)
VALUES (true, $1, $2, now(), $3)
ON CONFLICT (id) DO UPDATE SET
disk_bytes = EXCLUDED.disk_bytes,
warn_pct = EXCLUDED.warn_pct,
updated_at = now(),
updated_by = EXCLUDED.updated_by
`, diskBytes, warnPct, nullUUID(userID))
return err
}
// storageSampleKeep — скільки тримати самі спостереження.
//
// Рік із запасом. Рядок важить десятки байтів, а їх заводиться близько
// тридцяти на добу — тобто вся історія спостережень за рік менша за
// одну секунду роботи збору лічильників. Прибирати її агресивніше
// означало б економити на тому, чим міряють економію.
const storageSampleKeep = 400
// SampleStorage знімає поточні розміри в ряд спостережень.
//
// Раз на такт, а не на кожен перегляд сторінки: приріст має бути
// властивістю системи, а не того, як часто на неї дивляться. Знімок
// однією транзакцією — інакше рядки одного зняття мали б різні мітки
// часу, і різниця між ними читалась би як приріст.
func (s *Store) SampleStorage(ctx context.Context) error {
rows, err := s.RetentionSettings(ctx)
if err != nil {
return err
}
sizes, err := s.relationSizes(ctx)
if err != nil {
return err
}
type sample struct {
kind string
bytes int64
}
samples := make([]sample, 0, len(rows)+len(storageExtras)+1)
for _, r := range rows {
samples = append(samples, sample{r.Kind, r.Bytes})
}
for _, e := range storageExtras {
samples = append(samples, sample{"@" + e.Relation, sizes[e.Relation].total})
}
return pgx.BeginFunc(ctx, s.bg, func(tx pgx.Tx) error {
var dbBytes int64
if err := tx.QueryRow(ctx,
`SELECT pg_database_size(current_database())::bigint`).Scan(&dbBytes); err != nil {
return err
}
samples = append(samples, sample{"@database", dbBytes})
// Мітка одна на весь знімок: now() у транзакції не змінюється,
// і саме на це спирається розрахунок приросту.
for _, sm := range samples {
if _, err := tx.Exec(ctx, `
INSERT INTO core.storage_samples (captured_at, kind, bytes)
VALUES (date_trunc('hour', now()), $1, $2)
ON CONFLICT (captured_at, kind) DO UPDATE SET bytes = EXCLUDED.bytes
`, sm.kind, sm.bytes); err != nil {
return err
}
}
_, err := tx.Exec(ctx, `
DELETE FROM core.storage_samples
WHERE captured_at < now() - make_interval(days => $1::int)
`, storageSampleKeep)
return err
})
}
// storageGrowthWindow — на якому вікні рахувати приріст.
//
// Тридцять діб. Коротше вікно ловить випадковий сплеск (одна балакуча
// коробка залила syslog за вихідні) і перетворює його на прогноз
// переповнення на понеділок. Довше — розмиває реальну зміну: після
// підключення нової дільниці нахил змінюється назавжди, і місяця
// достатньо, щоб це побачити.
const storageGrowthWindow = 30
// storageGrowth рахує приріст за добу для кожного виду даних.
//
// Проста різниця між найранішим і найпізнішим спостереженням у вікні, а
// не регресія. Регресія тут виглядала б науковіше й відповідала б на
// інше питання: вона згладила б сходинку від увімкненого строку саме
// тоді, коли цю сходинку й треба побачити. Різниця країв чесно каже
// «за останній місяць стало на стільки більше».
func (s *Store) storageGrowth(ctx context.Context) (map[string]float64, *time.Time, float64) {
out := map[string]float64{}
rows, err := s.bg.Query(ctx, `
SELECT kind,
(array_agg(bytes ORDER BY captured_at))[1],
(array_agg(bytes ORDER BY captured_at DESC))[1],
min(captured_at), max(captured_at)
FROM core.storage_samples
WHERE captured_at > now() - make_interval(days => $1::int)
GROUP BY kind
`, storageGrowthWindow)
if err != nil {
return out, nil, 0
}
defer rows.Close()
var (
latest *time.Time
observed float64
)
for rows.Next() {
var (
kind string
first, last int64
from, to time.Time
)
if err := rows.Scan(&kind, &first, &last, &from, &to); err != nil {
return out, latest, observed
}
span := to.Sub(from).Hours() / 24
if span >= 0.5 {
out[kind] = float64(last-first) / span
}
if kind == "@database" {
t := to
latest = &t
observed = math.Round(span*10) / 10
}
}
return out, latest, observed
}
// ---------------------------------------------------------------------
// Розміри відношень
// ---------------------------------------------------------------------
// relationSize — розмір одного відношення.
type relationSize struct {
total int64
compressed int64
chunks int
}
// relationSizes читає розміри всього, що показує сторінка.
//
// Трьома запитами, а не одним, і це не про акуратність, а про стійкість:
// hypertable_compression_stats — найкрихкіша з трьох функцій (вона
// мінялась між випусками TimescaleDB і має вилетіти в 2.18 разом із
// перейменуванням на columnstore). Її збій має коштувати одного стовпця
// на сторінці, а не всієї сторінки.
func (s *Store) relationSizes(ctx context.Context) (map[string]relationSize, error) {
out := map[string]relationSize{}
rows, err := s.bg.Query(ctx, `
SELECT r.relation,
COALESCE((SELECT sum(total_bytes) FROM hypertable_detailed_size(
core.retention_hypertable(r.relation)::regclass)), 0)::bigint,
COALESCE((SELECT count(*) FROM timescaledb_information.chunks c
WHERE c.hypertable_schema =
split_part(core.retention_hypertable(r.relation), '.', 1)
AND c.hypertable_name =
split_part(core.retention_hypertable(r.relation), '.', 2)), 0)::int
FROM core.retention_settings r
WHERE r.mechanism = 'timescale'
`)
if err != nil {
return nil, err
}
for rows.Next() {
var rel string
var sz relationSize
if err := rows.Scan(&rel, &sz.total, &sz.chunks); err != nil {
rows.Close()
return nil, err
}
out[rel] = sz
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Скільки з цього вже стиснуто. Помилку ковтаємо свідомо: див.
// коментар вище.
if crows, err := s.bg.Query(ctx, `
SELECT r.relation,
COALESCE((SELECT sum(after_compression_total_bytes)
FROM hypertable_compression_stats(
core.retention_hypertable(r.relation)::regclass)), 0)::bigint
FROM core.retention_settings r
WHERE r.mechanism = 'timescale'
`); err == nil {
for crows.Next() {
var rel string
var b int64
if err := crows.Scan(&rel, &b); err != nil {
break
}
sz := out[rel]
sz.compressed = b
out[rel] = sz
}
crows.Close()
}
// Звичайні таблиці: керовані, ті, що зникають каскадом разом із
// ними, і довідкові. Без ncm.command_targets «прогони команд важать
// 2 МБ» було б неправдою на два порядки — транскрипти лежать саме
// в цілях прогону.
plain := []string{}
for _, k := range retentionCatalog {
plain = append(plain, k.SizeAlso...)
}
for _, e := range storageExtras {
plain = append(plain, e.Relation)
}
prows, err := s.bg.Query(ctx, `
SELECT relation, pg_total_relation_size(relation::regclass)::bigint
FROM (
SELECT relation FROM core.retention_settings WHERE mechanism = 'batch'
UNION
SELECT unnest($1::text[])
) q(relation)
WHERE to_regclass(relation) IS NOT NULL
`, plain)
if err != nil {
return nil, err
}
defer prows.Close()
for prows.Next() {
var rel string
var total int64
if err := prows.Scan(&rel, &total); err != nil {
return nil, err
}
out[rel] = relationSize{total: total}
}
return out, prows.Err()
}