- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
271 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|