Files
healthlog/internal/replay/archive_test.go
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

399 lines
22 KiB
Go
Raw 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 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/healthkit"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"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)
// Удержания печатаются, а не утверждаются: число производно от размера
// корпуса, а прогон живого архива в гейт не входит — константа покраснела бы
// молча (docs/review.md, 2026-08-02). Утверждается инвариант: удержание
// возможно только там, где сохранённая точка строго полнее, то есть их не
// может быть больше, чем столкновений вообще.
t.Logf("удержано точек правилом полноты %d, содержание унесено пришедшей %d",
first.PointsHeld, first.PointsErased)
// Посылка равенства «пересборка = приём» называется рядом с ним: отказавшие
// доставки живой путь в витрину не записал, а пересборка запишет, и
// расхождение отпечатков было бы отказом не правила, а посылки.
t.Logf("посылка сходимости: отказавших доставок %d",
first.FailedLayer+first.FailedMalformed+first.FailedOther)
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/research/apple-health.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)
measureCategories(t, dst, second)
}
// measureCategories проверяет реестр категориальных значений на живом корпусе.
//
// Здесь он проверяется единственным способом, каким это вообще возможно: против
// строк, которые телефон действительно присылал. Заголовков в архиве нет, то
// есть локаль у всех доставок пуста — прогон заодно доказывает, что вывод кода
// не зависит от уцелевшей учётной строки.
//
// Утверждаются СВОЙСТВА, а не числа: число строк реестра и число строк без кода
// растут вместе с потоком, а прогон живого архива в гейт не входит — константа
// покраснела бы молча (docs/review.md, 2026-08-02). Измеренное печатается.
func measureCategories(t *testing.T, dst *store.Store, second replay.Report) {
t.Helper()
values, err := dst.CategoryValues(context.Background())
if err != nil {
t.Fatalf("реестр категориальных значений: %v", err)
}
if len(values) == 0 {
t.Fatal("реестр пуст — категориальные значения не извлекаются вовсе")
}
if int64(len(values)) != second.Categories {
t.Errorf("повторное проигрывание изменило число строк реестра: %d → %d",
len(values), second.Categories)
}
var withCode, withoutCode int
for _, v := range values {
if v.Code == "" {
withoutCode++
continue
}
withCode++
// Всякий выведенный код обязан быть каноническим: обе формы имени,
// которые Apple дала одному значению, сходятся в одну (находка 43).
// Координаты — метрика и поле; сам код не печатается: он константа
// бинаря, но напечатанный рядом с живым архивом сообщает, что эта фаза
// у человека была.
if healthkit.Canonical(v.Code) != v.Code {
t.Errorf("%s/%s: выведенный код не канонический", v.Metric, v.Field)
}
// Провенанс — доставка журнала, а не пустое место.
if v.FirstDeliveryID == "" || v.FirstSeen.IsZero() {
t.Errorf("%s/%s: провенанс не заполнен", v.Metric, v.Field)
}
}
// Фазы сна — единственное, что словарь покрывает, и покрывать он их обязан:
// ради этой сверки задача и существует. Строки берутся из ЖИВОГО архива, а
// не из литерала теста.
var sleepValues, sleepCoded int
for _, v := range values {
if v.Metric != "sleep_analysis" || v.Field != "value" {
continue
}
sleepValues++
if v.Code != "" {
sleepCoded++
}
}
if sleepValues == 0 {
t.Fatal("фаз сна в реестре нет — проверять нечего")
}
if sleepCoded != sleepValues {
// Печатается ЧИСЛО, а не перечень. Перечень был бы удобнее, и ровно
// поэтому его тут быть не может: прогон идёт против рабочего архива, а
// фаза сна — значение точки. `CLAUDE.md`, «Запреты»: ничего из `./data`
// не попадает ни в логи выше `DEBUG`, ни в вывод агента. Ветка
// срабатывает как раз тогда, когда телефон принёс НОВУЮ фазу, то есть
// именно тогда, когда соблазн напечатать её сильнее всего.
//
// Адрес, по которому строки смотрят, отпечатывать не нужно: они лежат в
// пересобранной базе, `SELECT value FROM category_value WHERE code = ''`.
t.Errorf("фаз сна без кода %d из %d — словарь неполон; строки смотреть в реестре пересобранной базы",
sleepValues-sleepCoded, sleepValues)
}
// Числа печатаются: они растут с корпусом и утверждению не подлежат.
// Значения при этом НЕ печатаются — это данные о здоровье.
t.Logf("реестр: строк %d, с кодом %d, без кода %d; фаз сна %d, все с кодом",
len(values), withCode, withoutCode, sleepValues)
}
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
// живого архива, — единственное место, где правило проверяется на настоящем
// потоке, а не на фикстурах.
//
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
// живого архива в гейт не входит, так что константа, производная от размера
// корпуса, покраснела бы молча (docs/review.md, 2026-08-02). Измеренные
// числа печатаются.
func measureStyles(t *testing.T, st *store.Store) {
t.Helper()
started := time.Now()
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
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
}
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}