- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
98 lines
3.7 KiB
Go
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, ¬used, &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)
|
|
}
|
|
}
|
|
}
|