reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package replay_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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): такие
|
||||
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
|
||||
// срежет тела, которые для stateOfMind единственный источник.
|
||||
if first.Partial == 0 {
|
||||
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
|
||||
}
|
||||
|
||||
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
//
|
||||
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
|
||||
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
|
||||
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
|
||||
// заведомо сломанном правиле.
|
||||
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.Fingerprint != first.Fingerprint {
|
||||
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
|
||||
first.Fingerprint, second.Fingerprint)
|
||||
}
|
||||
|
||||
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
|
||||
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
|
||||
// живёт — он производен от архива, которого нет ни на одной другой машине.
|
||||
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
||||
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
|
||||
|
||||
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
||||
// `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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user