Files
healthlog/cmd/healthlog/reindex_rebuild_test.go
T
av 5ae0c5ff81 reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
2026-08-02 09:07:46 +03:00

271 lines
9.3 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// setup собирает рабочее окружение команды: архив с телами и рабочую базу,
// наполненную живым приёмом.
func setup(t *testing.T, bodies int) *config.Config {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{}
cfg.Storage.DBPath = filepath.Join(dir, "healthlog.db")
cfg.Storage.ArchiveDir = filepath.Join(dir, "raw")
cfg.Ingest.MaxBodyMB = 64
cfg.Log.Level = "error"
cfg.Log.Format = "json"
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
t.Fatalf("база: %v", err)
}
defer func() { _ = st.Close() }()
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "minute.json"))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
f := fold.New(arch, st, 64<<20, slog.New(slog.DiscardHandler))
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
for i := range bodies {
id := ident.NewID()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
AutomationID: "auto-1", Bytes: int64(len(body)), SHA256: "-",
RawPath: rawPath, ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
_, _ = f.Fold(context.Background(), id)
}
return cfg
}
func fingerprintOf(t *testing.T, path string) string {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("база %s: %v", path, err)
}
defer func() { _ = st.Close() }()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// Пересборка собирает витрину рядом и рабочую базу не трогает: очистка рабочей
// необратима и наступила бы ДО того, как известно, удалась ли пересборка.
func TestПересборкаНеТрогаетРабочуюБазу(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Folded != 3 {
t.Errorf("свёрнуто %d, ожидалось 3", rep.replay.Folded)
}
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась")
}
if rep.sourcePrint != rep.replay.Fingerprint {
t.Errorf("отпечатки разошлись при неизменном разборе:\n %s\n %s",
rep.sourcePrint, rep.replay.Fingerprint)
}
// Результат появился под именем назначения, промежуточного файла не
// осталось.
if _, err := os.Stat(tgt.final); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
assertGone(t, tgt.partial)
}
// Прерванная пересборка не оставляет файла назначения: полусобранная база
// выглядит как обычная, и человек подменит её по напечатанной процедуре.
func TestПрерваннаяПересборкаНеОставляетФайлаНазначения(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rep, err := rebuild(ctx, cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if !rep.replay.Canceled {
t.Error("отмена не отмечена в отчёте")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась при отменённой пересборке")
}
}
// Пустой архив — отказ команды, а не идеальная сходимость двух пустых витрин.
func TestПустойАрхивЭтоОтказКоманды(t *testing.T) {
t.Parallel()
cfg := setup(t, 0)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Bodies != 0 {
t.Fatalf("тел %d, ожидался пустой архив", rep.replay.Bodies)
}
// Отпечатки при этом совпадают — обе витрины пусты. Именно поэтому пустой
// журнал не может быть успехом.
if rep.sourcePrint != rep.replay.Fingerprint {
t.Error("две пустые витрины дали разные отпечатки — проверка потеряла смысл")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
}
func assertGone(t *testing.T, path string) {
t.Helper()
for _, s := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(path + s); err == nil {
t.Errorf("остался файл %s", path+s)
}
}
}
// Затребованная перезапись даёт ту же витрину, что и сборка в отсутствующий
// файл: сборка всегда начинается с пустой витрины, а не дописывается в чужое
// содержимое — иначе в результате осталось бы наследие прежнего разбора.
func TestПерезаписьДаётТуЖеВитрину(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
log := slog.New(slog.DiscardHandler)
first, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
fresh, err := rebuild(context.Background(), cfg, first, log, io.Discard)
if err != nil {
t.Fatalf("первая пересборка: %v", err)
}
// Поверх уже существующего результата, с явно затребованной перезаписью.
again, err := resolveTarget(cfg.Storage.DBPath, first.final, true)
if err != nil {
t.Fatalf("файл назначения (--force): %v", err)
}
over, err := rebuild(context.Background(), cfg, again, log, io.Discard)
if err != nil {
t.Fatalf("пересборка с перезаписью: %v", err)
}
if over.replay.Fingerprint != fresh.replay.Fingerprint {
t.Errorf("перезапись дала другую витрину:\n с нуля %s\n поверх %s",
fresh.replay.Fingerprint, over.replay.Fingerprint)
}
if over.replay.Buckets != fresh.replay.Buckets {
t.Errorf("объектов %d против %d — сборка дописалась в старое содержимое",
over.replay.Buckets, fresh.replay.Buckets)
}
}
// Исход команды целиком: пустой архив даёт ненулевой код, а не «успех»
// с идеально совпавшими пустыми отпечатками.
func TestИсходКомандыНаПустомАрхиве(t *testing.T) {
cfg := setup(t, 0)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
err := runReindex([]string{"--config", cfgPath})
if !errors.Is(err, errNothingReplayed) {
t.Errorf("пустой архив дал %v, ожидался отказ «проигрывать нечего»", err)
}
}
// И обратное: непустой журнал доводится до конца и завершается успехом.
func TestИсходКомандыНаНепустомАрхиве(t *testing.T) {
cfg := setup(t, 2)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
if err := runReindex([]string{"--config", cfgPath}); err != nil {
t.Errorf("непустой журнал дал отказ: %v", err)
}
if _, err := os.Stat(cfg.Storage.DBPath + rebuildSuffix); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
}
func writeConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()
body := fmt.Sprintf(`[storage]
db_path = %q
archive_dir = %q
[ingest]
max_body_mb = %d
[log]
level = "error"
format = "json"
`, cfg.Storage.DBPath, cfg.Storage.ArchiveDir, cfg.Ingest.MaxBodyMB)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
}