Files
healthlog/internal/store/catalog_internal_test.go
T
av 03edf1087d Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
2026-08-02 19:23:59 +03:00

98 lines
3.7 KiB
Go

package store
import (
"context"
"path/filepath"
"strings"
"testing"
)
// План выполнения — единственный оракул требования «каталог не читает
// содержимого объектов». `bucket` объявлена WITHOUT ROWID, то есть строка
// целиком, вместе со сжатым payload, живёт в дереве первичного ключа: обход по
// нему тащил бы страницы содержимого. Проверяется, что обе выборки идут по
// ПОКРЫВАЮЩЕМУ индексу — по нему таблица не открывается вовсе.
func TestПланЗапросовКаталогаИдётПоИндексу(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
cases := []struct {
name string
sql string
args []any
}{
{"разрезы метрик", layerRangesQuery, nil},
{"общие часы двух слоёв", commonHoursQuery,
[]any{"step_count", "hour", "step_count", "minute", "2026-08-02T00:00:00Z", 48}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
plan := explain(t, st, c.sql, c.args...)
t.Logf("план: %s", plan)
if !strings.Contains(plan, "COVERING INDEX bucket_catalog") {
t.Errorf("выборка не идёт по покрывающему индексу:\n%s", plan)
}
// Обращение к самой таблице выглядит в плане как SCAN/SEARCH bucket
// без слова INDEX — именно оно и тащило бы страницы содержимого.
for _, line := range strings.Split(plan, "\n") {
if strings.Contains(line, "bucket") && !strings.Contains(line, "INDEX") {
t.Errorf("строка плана читает таблицу, а не индекс: %q", line)
}
}
})
}
}
func explain(t *testing.T, st *Store, query string, args ...any) string {
t.Helper()
rows, err := st.db.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
if err != nil {
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
t.Fatalf("разбор плана: %v", err)
}
out = append(out, detail)
}
if err := rows.Err(); err != nil {
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
}
if len(out) == 0 {
t.Fatal("план пуст — проверять нечего")
}
return strings.Join(out, "\n")
}
// Свойство, ради которого выборка объектов окна вынесена отдельной функцией:
// сколько бы часов ни было в окне, запрос ОДИН. Чтение по объекту за раз давало
// бы под сотню обращений на метрику и столько же снимков витрины.
func TestВыборкаОкнаОстаётсяОднимЗапросом(t *testing.T) {
t.Parallel()
for _, hours := range []int{1, 2, 48, 200} {
q := hourPairsQuery(hours)
if n := strings.Count(q, ";"); n != 0 {
t.Errorf("часов %d: в запросе %d разделителей — это уже не один запрос", hours, n)
}
// Три подстановки на метрику и слои плюс по одной на час.
if got, want := strings.Count(q, "?"), hours+3; got != want {
t.Errorf("часов %d: подстановок %d, ждали %d", hours, got, want)
}
}
}