- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
294 lines
15 KiB
Go
294 lines
15 KiB
Go
package replay_test
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"flag"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
|
"git.vakhrushev.me/av/healthlog/internal/hae"
|
|
"git.vakhrushev.me/av/healthlog/internal/ident"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
// archiveDir включает прогон сходимости на живом архиве.
|
|
//
|
|
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
|
|
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
|
|
// должен висеть на каждом `task gate`. Запускается командой
|
|
// `task verify:archive`.
|
|
var archiveDir = flag.String("healthlog.archive", "",
|
|
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
|
|
|
|
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
|
|
// разбора, обязан пройти через код без потерь и без расхождений — и повторное
|
|
// проигрывание журнала обязано дать то же состояние.
|
|
//
|
|
// Прогон идёт через ту же операцию, которой пересобирает витрину
|
|
// `healthlog reindex`. Собственный обход и собственный порядок здесь были
|
|
// раньше и были ошибкой: они образовывали второй проигрыватель журнала, чьи
|
|
// правила разошлись с настоящим, — а зеленел бы при этом он.
|
|
func TestReplayЖивогоАрхива(t *testing.T) {
|
|
if *archiveDir == "" {
|
|
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
|
|
}
|
|
bodies := collectBodies(t, *archiveDir)
|
|
if len(bodies) == 0 {
|
|
t.Skipf("живого архива нет в %s — прогон пропущен", *archiveDir)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
arch := openArchive(t, filepath.Join(dir, "raw"))
|
|
src := openStore(t, filepath.Join(dir, "live.db"))
|
|
ctx := context.Background()
|
|
|
|
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
|
|
// путь чтения был ровно тот, каким пойдёт пересборка.
|
|
//
|
|
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
|
|
// Поэтому автоматизация у всех одна: так проверяется в том числе
|
|
// наследование слоя по цепочке доставок. Метка приёма берётся из ULID,
|
|
// то есть хронология журнала настоящая.
|
|
for _, path := range bodies {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("чтение %s: %v", path, err)
|
|
}
|
|
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
|
|
at, err := ident.TimeOf(id)
|
|
if err != nil {
|
|
t.Fatalf("время из ULID %s: %v", id, err)
|
|
}
|
|
writeBody(t, arch, src, item{id: id, at: at, automationID: "auto"}, gunzip(t, body))
|
|
}
|
|
|
|
first, dst := run(t, ctx, arch, src, filepath.Join(dir, "first.db"))
|
|
|
|
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
|
|
// полей: их не было ни разу на всём корпусе. Число печатается, а не
|
|
// проверяется: появление такого набора — событие для разбора, а не отказ
|
|
// сходимости.
|
|
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
|
|
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
|
|
first.FailedOther, first.Incomparable)
|
|
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
|
|
first.Partial, first.Adopted, first.Orphans)
|
|
|
|
if first.Folded == 0 {
|
|
t.Fatal("ни одна доставка не свернулась")
|
|
}
|
|
|
|
// Половина живого потока не несёт metrics вовсе (находка 50): это
|
|
// тренировки и состояние разума. С тех пор как обе секции покрыты,
|
|
// частично разобранных доставок в архиве нет — и вместо них проверяется то,
|
|
// ради чего покрытие делалось: сущности доехали до витрины.
|
|
//
|
|
// Проверяется свойство, а не число: корпус растёт с каждой доставкой, а
|
|
// прогон живого архива в гейт не входит, так что протухшая константа
|
|
// покраснела бы молча.
|
|
if first.Workouts == 0 {
|
|
t.Error("тренировок в витрине нет — секция workouts не разбирается")
|
|
}
|
|
if first.Records == 0 {
|
|
t.Error("записей в витрине нет — секция stateOfMind не разбирается")
|
|
}
|
|
// Удержанные версии сущностей печатаются рядом с их числом. Число — это
|
|
// «сколько лежит», а удержания — «сколько правило слияния не пустило», и
|
|
// второе отпечатком не проверяется по построению: живой приём и пересборка
|
|
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
|
|
// версии. Печатается, а не утверждается: удержание — событие для разбора,
|
|
// а не отказ сходимости.
|
|
t.Logf("тренировок %d, записей %d, удержано версий сущностей %d",
|
|
first.Workouts, first.Records, first.EntitiesHeld)
|
|
|
|
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
|
// детерминирована, и пересборка даёт то же, что живой приём.
|
|
//
|
|
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
|
|
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
|
|
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
|
|
// заведомо сломанном правиле.
|
|
second, _ := run(t, ctx, arch, src, filepath.Join(dir, "second.db"))
|
|
if second.Buckets != first.Buckets {
|
|
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
|
|
}
|
|
if second.Workouts != first.Workouts || second.Records != first.Records {
|
|
t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d",
|
|
first.Workouts, first.Records, second.Workouts, second.Records)
|
|
}
|
|
if second.Fingerprint != first.Fingerprint {
|
|
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
|
|
first.Fingerprint, second.Fingerprint)
|
|
}
|
|
|
|
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
|
|
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
|
|
// живёт — он производен от архива, которого нет ни на одной другой машине.
|
|
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
|
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
|
|
|
|
measureStyles(t, dst)
|
|
|
|
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
|
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
|
|
//
|
|
// Проверяется само свойство, а не измеренное когда-то число. Прежняя
|
|
// редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела
|
|
// молча, когда архив дорос до 116: константа, производная от корпуса,
|
|
// протухает с каждой новой доставкой, а прогон живого архива в гейт не
|
|
// входит, так что краснота никому не видна.
|
|
coords, labels := countSleepKeys(t, dst)
|
|
if coords == 0 {
|
|
t.Fatal("записей сна в витрине нет — проверять нечего")
|
|
}
|
|
if coords <= labels {
|
|
t.Errorf("координат сна %d при %d различных метках: ключ схлопнул записи до метки",
|
|
coords, labels)
|
|
}
|
|
t.Logf("координат сна %d, различных меток %d", coords, labels)
|
|
}
|
|
|
|
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
|
|
// живого архива, — единственное место, где правило проверяется на настоящем
|
|
// потоке, а не на фикстурах.
|
|
//
|
|
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
|
|
// живого архива в гейт не входит, так что константа, производная от размера
|
|
// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные
|
|
// числа печатаются.
|
|
func measureStyles(t *testing.T, st *store.Store) {
|
|
t.Helper()
|
|
|
|
started := time.Now()
|
|
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("каталог: %v", err)
|
|
}
|
|
elapsed := time.Since(started)
|
|
|
|
byStyle := map[catalog.Style]int{}
|
|
for _, m := range metrics {
|
|
byStyle[m.Aggregation.Style]++
|
|
|
|
// Главное свойство правила: свидетельства единогласны. Противоречие —
|
|
// событие для разбора, а не отказ прогона, поэтому оно печатается с
|
|
// координатами и валит тест: пока его нет, посылка «род измерим» верна.
|
|
if m.Aggregation.Conflicting > 0 {
|
|
t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись",
|
|
m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing)
|
|
}
|
|
|
|
// Род измеряется только сверкой минутного слоя с часовым. Метрика с
|
|
// объявленным родом обязана иметь оба слоя: иначе он выведен из
|
|
// нижнего, а нижний слой HAE — посекундная развёртка, и его сумма
|
|
// завышена.
|
|
if m.Aggregation.Style == catalog.Unknown {
|
|
continue
|
|
}
|
|
var hasMinute, hasHour bool
|
|
for _, l := range m.Layers {
|
|
hasMinute = hasMinute || l.Layer == string(hae.LayerMinute)
|
|
hasHour = hasHour || l.Layer == string(hae.LayerHour)
|
|
}
|
|
if !hasMinute || !hasHour {
|
|
t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers)
|
|
}
|
|
}
|
|
|
|
// Роды разошлись: правило различает накопительные и мгновенные, а не
|
|
// сваливает всё в одну кучу и не отвечает `unknown` на весь корпус.
|
|
if byStyle[catalog.Cumulative] == 0 {
|
|
t.Error("накопительных метрик не нашлось — правило не различает роды")
|
|
}
|
|
if byStyle[catalog.Instant] == 0 {
|
|
t.Error("мгновенных метрик не нашлось — правило не различает роды")
|
|
}
|
|
|
|
t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d",
|
|
len(metrics), elapsed.Round(time.Millisecond),
|
|
byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown])
|
|
for _, m := range metrics {
|
|
t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d",
|
|
m.Metric, m.Aggregation.Style, m.Aggregation.Hours,
|
|
m.Aggregation.Compared, m.Aggregation.Agreeing)
|
|
}
|
|
}
|
|
|
|
// countSleepKeys возвращает число различных координат записей сна и число
|
|
// различных меток начала. Разница между ними и есть то, что теряет ключ по
|
|
// метке.
|
|
//
|
|
// Каталог разрезов — отдельная задача, поэтому здесь перебор по известным
|
|
// слоям, а не запрос к нему.
|
|
func countSleepKeys(t *testing.T, st *store.Store) (coords, labels int) {
|
|
t.Helper()
|
|
|
|
type key struct{ start, end int64 }
|
|
ctx := context.Background()
|
|
seenCoord := map[key]struct{}{}
|
|
seenLabel := map[int64]struct{}{}
|
|
|
|
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
|
|
hours, err := st.BucketHours(ctx, "sleep_analysis", layer)
|
|
if err != nil {
|
|
t.Fatalf("часы объектов: %v", err)
|
|
}
|
|
for _, h := range hours {
|
|
b, err := st.Bucket(ctx, "sleep_analysis", layer, h)
|
|
if err != nil {
|
|
t.Fatalf("чтение объекта: %v", err)
|
|
}
|
|
for _, p := range b.Points {
|
|
seenCoord[key{p.Start.UnixNano(), p.End.UnixNano()}] = struct{}{}
|
|
seenLabel[p.Start.UnixNano()] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return len(seenCoord), len(seenLabel)
|
|
}
|
|
|
|
func collectBodies(t *testing.T, root string) []string {
|
|
t.Helper()
|
|
|
|
var out []string
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil //nolint:nilerr // архива может не быть — это не отказ теста
|
|
}
|
|
if !info.IsDir() && filepath.Ext(path) == ".gz" {
|
|
out = append(out, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func gunzip(t *testing.T, body []byte) []byte {
|
|
t.Helper()
|
|
|
|
gz, err := gzip.NewReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("распаковка: %v", err)
|
|
}
|
|
defer func() { _ = gz.Close() }()
|
|
|
|
out, err := io.ReadAll(gz)
|
|
if err != nil {
|
|
t.Fatalf("чтение: %v", err)
|
|
}
|
|
return out
|
|
}
|