package fold_test import ( "bytes" "compress/gzip" "context" "flag" "io" "os" "path/filepath" "sort" "strings" "testing" "git.vakhrushev.me/av/healthlog/internal/store" ) // archiveDir включает прогон сходимости на живом архиве. // // Флагом, а не переменной окружения и не путём по умолчанию: архив в // репозиторий не попадает (данные о здоровье), прогон занимает минуту и не // должен висеть на каждом `task gate`. Запускается командой // `task verify:archive`. var archiveDir = flag.String("healthlog.archive", "", "каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)") // Сходимость на живом архиве: тот же корпус, на котором выводились правила // разбора, обязан пройти через код без потерь и без расхождений — и повторный // прогон журнала обязан дать то же состояние. func TestReplayЖивогоАрхива(t *testing.T) { if *archiveDir == "" { t.Skip("прогон живого архива выключен: задайте -healthlog.archive") } root := *archiveDir bodies := collectBodies(t, root) if len(bodies) == 0 { t.Skipf("живого архива нет в %s — прогон пропущен", root) } f, arch, st := newFold(t) ctx := context.Background() var folded, failed, incomparable int 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") deliver(t, arch, st, id, "", "auto", gunzip(t, body)) res, err := f.Fold(ctx, id) if err != nil { failed++ continue } folded++ incomparable += res.Incomparable } // Несравнимые наборы полей — посылка, на которой стоит отказ от объединения // полей: их не было ни разу на всём корпусе. Число печатается, а не // проверяется: появление такого набора — событие для разбора, а не отказ // сходимости. t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d", len(bodies), folded, failed, incomparable) if folded == 0 { t.Fatal("ни одна доставка не свернулась") } // Повторный прогон того же журнала не меняет состояния: свёртка // детерминирована, и пересборка даёт то же, что живой приём. // // Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате // всегда лежит ровно одна точка, и правило разрешения столкновений выбирает, // какая это будет точка, а не сколько их. Счёт объектов совпал бы и при // заведомо сломанном правиле. before, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } fingerprintBefore, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } var refolded int for _, path := range bodies { if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil { continue } refolded++ } if refolded != folded { t.Fatalf("повторно свёрнуто %d доставок из %d — проверка идемпотентности вхолостую", refolded, folded) } after, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } if before != after { t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after) } fingerprintAfter, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } if fingerprintBefore != fingerprintAfter { t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s", fingerprintBefore, fingerprintAfter) } // Отпечаток печатается всегда: это единственный способ сравнить состояние с // тем, что давала прежняя редакция правила слияния. Эталон в репозитории не // живёт — он производен от архива, которого нет ни на одной другой машине. // Значений точек отпечаток не раскрывает: содержимое входит в него хешем. t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter) // Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по // интервалу — 174 (docs/local-research.md, находка 47). Если координата // когда-нибудь схлопнется обратно до метки, здесь станет 170. if got := countPoints(t, st, "sleep_analysis"); got != 174 { t.Errorf("координат sleep_analysis %d, измерено 174: ключ схлопнул записи", got) } } // countPoints считает точки метрики во всех слоях. Каталог разрезов — отдельная // задача, поэтому здесь перебор по известным слоям, а не запрос к нему. func countPoints(t *testing.T, st *store.Store, metric string) int { t.Helper() ctx := context.Background() total := 0 for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} { hours, err := st.BucketHours(ctx, metric, layer) if err != nil { t.Fatalf("часы объектов: %v", err) } for _, h := range hours { b, err := st.Bucket(ctx, metric, layer, h) if err != nil { t.Fatalf("чтение объекта: %v", err) } total += len(b.Points) } } return total } 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 }