reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
+3
View File
@@ -3,6 +3,7 @@
// Подкоманды:
//
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main
@@ -27,6 +28,8 @@ func main() {
switch cmd {
case "serve":
err = runServe(args)
case "reindex":
err = runReindex(args)
case "healthcheck":
err = runHealthcheck(args)
default:
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"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/logging"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// rebuildSuffix — как зовётся собранная витрина рядом с рабочей базой.
// Соседом, а не во временном каталоге: подмена обязана быть переименованием
// внутри одной файловой системы.
const rebuildSuffix = ".rebuild"
// partialSuffix — под каким именем витрина собирается, пока не готова.
//
// Полусобранная база выглядит как обычная, и файл с именем результата человек
// подменит по напечатанной процедуре не глядя. Поэтому имя результата
// появляется последним шагом успеха, а не первым шагом работы.
const partialSuffix = ".partial"
// progressInterval — как часто печатается прогресс. Прогон на полном архиве
// идёт минутами и молчит; зависший при этом неотличим от идущего.
const progressInterval = 5 * time.Second
// errNothingReplayed — журнал пуст или не свернулось ничего.
var errNothingReplayed = errors.New("проигрывать нечего")
func runReindex(args []string) error {
fs := flag.NewFlagSet("reindex", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
out := fs.String("out", "", "куда собрать витрину (по умолчанию — рабочая база с суффиксом "+rebuildSuffix+")")
force := fs.Bool("force", false, "перезаписать существующий файл назначения")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Лог — в stderr: stdout занят отчётом человеку, и лог в том же потоке
// сделал бы отчёт неразбираемым.
log := logging.NewErr(cfg.Log.Level, cfg.Log.Format)
target, err := resolveTarget(cfg.Storage.DBPath, *out, *force)
if err != nil {
return err
}
// Отмена приходит из сигнала: команду прерывает человек, и без этого вся
// логика отмены недостижима — процесс умирал бы мимо неё.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Прогресс — в поток ошибок: stdout занят отчётом, который человек
// перенаправляет и читает глазами.
rep, err := rebuild(ctx, cfg, target, log, os.Stderr)
if err != nil {
return err
}
writeReport(os.Stdout, rep)
if rep.replay.Canceled {
return errors.New("пересборка отменена")
}
if rep.replay.Bodies == 0 || rep.replay.Folded == 0 {
// Пустая витрина совпадает по отпечатку с пустой витриной, то есть
// пустой прогон выглядит идеальной сходимостью. Успехом он быть не
// может: человек, выполнивший напечатанную процедуру, заменил бы
// накопленное пустым.
return errNothingReplayed
}
return nil
}
// target — куда собираем и как называется промежуточный файл.
type target struct {
final string
partial string
}
// resolveTarget выбирает файл назначения и проверяет, что писать в него можно.
func resolveTarget(dbPath, out string, force bool) (target, error) {
final := out
if final == "" {
final = dbPath + rebuildSuffix
}
// Тождество определяется файлом, а не строкой пути: `..`, симлинк или
// другой префикс монтирования дают ту же цель при другой строке, а ошибка
// здесь означает проигрывание журнала прямо в живую рабочую базу.
same, err := sameFile(final, dbPath)
if err != nil {
return target{}, err
}
if same {
return target{}, fmt.Errorf("файл назначения %q — это рабочая база", final)
}
if _, err := os.Stat(final); err == nil && !force {
return target{}, fmt.Errorf("файл назначения %q уже существует (--force перезапишет)", final)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return target{}, fmt.Errorf("stat %q: %w", final, err)
}
// Имя промежуточного файла уникально: фиксированное затирало бы чужой файл
// с тем же именем ДО всякой проверки, то есть мимо правила «без --force не
// перезаписываем», и обломок прошлого прогона блокировал бы следующий.
return target{final: final, partial: final + "." + ident.NewID() + partialSuffix}, nil
}
// sameFile отвечает, ведут ли два пути к одному файлу.
//
// Когда файла назначения ещё нет, сравниваются каталог-родитель и имя: сам файл
// сравнить не с чем, а совпадение каталога и имени — это и есть тождество
// будущего файла.
func sameFile(a, b string) (bool, error) {
// Совпадение очищенных путей — тождество независимо от того, существуют ли
// файлы. Без этой проверки `--out <db_path>` при отсутствующей рабочей базе
// устанавливал бы витрину прямо на её место, минуя всё правило «подмену
// делает человек при остановленном сервисе».
if filepath.Clean(a) == filepath.Clean(b) {
return true, nil
}
fa, errA := os.Stat(a)
fb, errB := os.Stat(b)
switch {
case errA == nil && errB == nil:
return os.SameFile(fa, fb), nil
case errB != nil:
// Рабочей базы нет: сравнивать не с чем, а совпадение строк уже
// исключено выше.
return false, nil
}
da, err := os.Stat(filepath.Dir(a))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(a), err)
}
db, err := os.Stat(filepath.Dir(b))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(b), err)
}
return os.SameFile(da, db) && filepath.Base(a) == filepath.Base(b), nil
}
// report — всё, что печатается человеку.
type report struct {
replay replay.Report
target string
dbPath string
sourcePrint string
sourceBuckets int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
}
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
// назначения последним шагом успеха.
func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger, progress io.Writer) (report, error) {
rep := report{target: t.final, dbPath: cfg.Storage.DBPath}
// Отмена — не отказ пересборки, а требование прекратить работу, и застать
// она может на любом шаге, включая снятие отпечатка рабочей витрины.
stopped := func(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
if ctx.Err() != nil {
rep.replay.Canceled = true
return rep, nil
}
arch, err := archive.Existing(cfg.Storage.ArchiveDir)
if err != nil {
return rep, err
}
// Рабочей базы может не быть вовсе — журнал тогда состоит из одних
// подобранных тел. Это законный вход: восстановление после её потери. Но
// заголовки доставок при этом не воскресают, они жили только в ней.
var src *store.Store
if _, err := os.Stat(cfg.Storage.DBPath); errors.Is(err, os.ErrNotExist) {
rep.sourceMissing = true
} else if err != nil {
return rep, fmt.Errorf("stat %q: %w", cfg.Storage.DBPath, err)
} else {
src, err = store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return rep, err
}
defer func() { _ = src.Close() }()
// Отпечаток рабочей витрины снимается ДО проигрывания, иначе под живым
// приёмом он всегда движется, и оракул отвечает «разошлись» независимо
// от того, разошёлся ли разбор.
if rep.sourcePrint, err = src.Fingerprint(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBefore, err = src.CountDeliveries(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
dst, err := store.Open(t.partial)
if err != nil {
return rep, err
}
rep.replay, err = replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
// `mode=replay` в логе не украшение: за один прогон через слияние
// проходит вся история, и её WARN о перезаписях иначе неотличимы от
// аномалий живого приёма в общем логе.
Fold: fold.New(arch, dst, int64(cfg.Ingest.MaxBodyMB)<<20, log.With("mode", "replay")),
Progress: progressEvery(progress, progressInterval, time.Now),
Log: log,
})
if cerr := dst.Close(); err == nil {
err = cerr
}
if err != nil {
removeDB(t.partial)
return rep, err
}
if src != nil && !rep.replay.Canceled {
if rep.sourceAfter, err = src.CountDeliveries(ctx); err != nil {
removeDB(t.partial)
return canceledOr(rep, err, stopped)
}
}
ok := !rep.replay.Canceled && rep.replay.Bodies > 0 && rep.replay.Folded > 0
if !ok {
removeDB(t.partial)
return rep, nil
}
if err := os.Rename(t.partial, t.final); err != nil {
removeDB(t.partial)
return rep, fmt.Errorf("переименование в %q: %w", t.final, err)
}
return rep, nil
}
// progressEvery печатает прогресс не чаще интервала.
//
// Живёт в команде, а не в пакете проигрывания: «куда и как часто печатать» —
// забота адресата вывода. Часы параметром, чтобы функция была проверяема, не
// завися от настоящего времени.
func progressEvery(w io.Writer, every time.Duration, now func() time.Time) func(done, total int) {
last := now()
return func(done, total int) {
if done < total && now().Sub(last) < every {
return
}
last = now()
_, _ = fmt.Fprintf(w, "проиграно %d из %d\n", done, total)
}
}
// canceledOr отличает отмену от настоящего отказа: первая не является ошибкой
// команды, вторая является.
func canceledOr(rep report, err error, stopped func(error) bool) (report, error) {
if stopped(err) {
rep.replay.Canceled = true
return rep, nil
}
return rep, err
}
// removeDB убирает файл базы вместе со спутниками журнала SQLite: оставленный
// `-wal` подцепится к следующему файлу с тем же именем.
func removeDB(path string) {
for _, s := range []string{"", "-wal", "-shm"} {
_ = os.Remove(path + s)
}
}
+270
View File
@@ -0,0 +1,270 @@
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)
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"fmt"
"io"
)
// writeReport печатает итог пересборки человеку.
//
// Отдельной функцией с io.Writer, а не печатью в os.Stdout из недр: отчёт —
// новая поверхность вывода, и единственное, что защищает её от утечки данных о
// здоровье, — тест. Тест на глобальном os.Stdout был бы тестом на глобальном
// состоянии, то есть его бы не написали.
//
// Ни значений точек, ни имён метрик, ни имён устройств здесь нет и быть не
// может: содержимое витрины входит в отчёт только отпечатком, а он берёт его
// хешем.
func writeReport(w io.Writer, r report) {
p := func(format string, args ...any) {
_, _ = fmt.Fprintf(w, format+"\n", args...)
}
p("пересборка витрины из журнала")
p(" архив: тел %d, пропущено файлов %d, повторов идентификатора %d",
r.replay.Bodies, r.replay.SkippedFiles, r.replay.Duplicates)
p(" учёт: подобрано тел без записи %d, не удалось подобрать %d, записей без тела %d",
r.replay.Adopted, r.replay.AdoptFailed, r.replay.Orphans)
p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d",
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther)
p(" слияние: частично разобрано %d, несравнимых наборов %d",
r.replay.Partial, r.replay.Incomparable)
if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
// отмене не снимались. Печатать их сравнение значило бы выдать
// неизмеренное за измеренное — в единственном оракуле задачи.
p("")
p("прогон ОТМЕНЁН: сравнение не проводилось, файл назначения не создан")
return
}
// «Часть журнала не прочитана» — отдельное состояние, и оно обязано быть
// видно рядом с вердиктом отпечатков. Пропущенный симлинк на каталог уносит
// из прогона целый месяц одной строкой в счётчике, а вердикт «СОВПАЛИ»
// выдал бы сертификат воспроизводимости прогону, который этих тел не читал.
partialJournal := r.replay.SkippedFiles > 0 || r.replay.Orphans > 0 || r.replay.Duplicates > 0
// Нештатные отказы. Невыведенный слой сюда не входит: он есть в каждом
// журнале, и предупреждать о нём значило бы отправлять человека искать
// дефект там, где его нет. А вот «содержимое не разбирается» штатным не
// является: тело один раз уже прошло проверку формы на приёме.
badFailures := r.replay.FailedOther > 0 || r.replay.FailedMalformed > 0 || r.replay.AdoptFailed > 0
if r.sourceMissing {
p(" объектов: %d", r.replay.Buckets)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
} else {
// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
switch {
case r.sourcePrint == r.replay.Fingerprint && !partialJournal:
p(" отпечатки СОВПАЛИ — состояние воспроизводимо")
case r.sourcePrint == r.replay.Fingerprint:
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
default:
p(" отпечатки РАЗОШЛИСЬ")
p(" ожидаемые причины: исправленный разбор; признак sealed не")
p(" переносится (правила его выставления ещё нет)")
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
}
}
}
if r.replay.Bodies == 0 || r.replay.Folded == 0 {
p("")
p("проигрывать было нечего: файл назначения не создан.")
p("проверьте storage.archive_dir и каталог запуска — пустая витрина")
p("совпадает по отпечатку с пустой витриной и выглядит идеальной сверкой")
return
}
if d := r.sourceAfter - r.sourceBefore; d != 0 {
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве,
// но не в собранном файле. Подмена стёрла бы их учёт вместе с
// заголовками, восстановить которые неоткуда, — поэтому процедура здесь
// не печатается вовсе.
p("")
p("за время прогона в рабочую базу приехало доставок: %d.", d)
p("подменять этим файлом НЕЛЬЗЯ: учёта новых доставок в нём нет, а вместе")
p("с ним пропали бы их заголовки. Остановите сервис и пересоберите заново.")
return
}
p("")
if partialJournal {
p("ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА: пропущено файлов %d, записей без тела %d,",
r.replay.SkippedFiles, r.replay.Orphans)
p("повторов идентификатора %d. Пересобранная витрина беднее рабочей на",
r.replay.Duplicates)
p("объекты этих доставок — и на объекты тех, кто наследовал от них слой.")
p("Проверьте каталог архива (симлинк на подкаталог обходом не читается)")
p("по DEBUG-строкам лога, прежде чем подменять базу.")
p("")
}
if badFailures {
p("отказы, которых быть не должно (%d прочих, %d по содержимому, %d при подборе) —",
r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed)
p("разберитесь по логу, прежде чем подменять базу.")
p("")
}
p("собрано в %s", r.target)
p("подмена — вручную и при ОСТАНОВЛЕННОМ сервисе: он держит файл открытым,")
p("и переименование поверх живого процесса портит базу молча.")
p("")
p(" task down")
p(" mv %s %s", r.target, r.dbPath)
p(" rm -f %s-wal %s-shm", r.dbPath, r.dbPath)
p(" task up")
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/replay"
)
// Тождество файла назначения определяется файлом, а не строкой пути: `..`,
// симлинк или другой префикс монтирования дают ту же цель при другой строке, а
// ошибка здесь означает проигрывание журнала прямо в живую рабочую базу.
func TestФайлНазначенияНеМожетБытьРабочейБазой(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
link := filepath.Join(dir, "link.db")
if err := os.Symlink(db, link); err != nil {
t.Skipf("символические ссылки недоступны: %v", err)
}
cases := map[string]string{
"тот же путь": db,
// Строкой, а не через filepath.Join: он бы почистил путь, и случай
// выродился бы в совпадение строк.
"через родителя": dir + "/sub/../healthlog.db",
"символическая ссылка": link,
}
for name, out := range cases {
if _, err := resolveTarget(db, out, false); err == nil {
t.Errorf("%s: файл назначения %q принят за отдельный файл", name, out)
}
}
}
// Существующий файл не перезаписывается молча; умолчание — сосед рабочей базы,
// чтобы подмена оставалась переименованием внутри одной файловой системы.
func TestВыборФайлаНазначения(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
tgt, err := resolveTarget(db, "", false)
if err != nil {
t.Fatalf("умолчание: %v", err)
}
if tgt.final != db+rebuildSuffix {
t.Errorf("умолчание %q, ожидался сосед рабочей базы", tgt.final)
}
if filepath.Dir(tgt.partial) != filepath.Dir(tgt.final) {
t.Errorf("промежуточный файл %q не рядом с результатом", tgt.partial)
}
busy := filepath.Join(dir, "занято.db")
if err := os.WriteFile(busy, []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
if _, err := resolveTarget(db, busy, false); err == nil {
t.Error("существующий файл назначения принят без --force")
}
if _, err := resolveTarget(db, busy, true); err != nil {
t.Errorf("--force не разрешил перезапись: %v", err)
}
if _, err := os.Stat(busy); err != nil {
t.Error("проверка аргументов уже что-то удалила — решать это должен прогон")
}
}
// Отчёт — новая поверхность вывода, и единственное, что защищает её от утечки
// данных о здоровье, это проверка. Поэтому рендер принимает io.Writer, а не
// печатает в os.Stdout из недр.
func TestОтчётНеРаскрываетДанныхОЗдоровье(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049,
Fingerprint: "aaaa", Partial: 53,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2040,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
// Ни одного слова, которым могло бы оказаться измерение, имя метрики или
// устройства: в отчёт они попадают только через отпечаток, а он берёт
// содержимое хешем.
for _, forbidden := range []string{
"heart_rate", "sleep_analysis", "active_energy", "qty",
"Apple Watch", "iPhone", "value",
} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт содержит %q", forbidden)
}
}
// Расхождение отпечатков названо, и рядом — направление: «было/стало».
// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
for _, want := range []string{"РАЗОШЛИСЬ", "было 2040, стало 2049", "task down", "mv "} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q", want)
}
}
}
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве, но не
// в собранном файле: подмена стёрла бы их учёт вместе с заголовками, которые
// не восстанавливаются ниоткуда.
func TestПриездДоставокЗаПрогонОтменяетПодмену(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBefore: 116,
sourceAfter: 119,
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана, хотя учёт новых доставок в файл не попал")
}
if !strings.Contains(out, "приехало доставок: 3") {
t.Errorf("отчёт не назвал приезд доставок: %s", out)
}
}
// Пустой журнал выглядит идеальной сходимостью: отпечаток пустой витрины
// совпадает с отпечатком пустой витрины. Успехом он быть не может, и процедуру
// подмены печатать нельзя — человек заменил бы накопленное пустым.
func TestПустойЖурналНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 0, Folded: 0, Fingerprint: "same"},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
// Отпечатки совпадают: обе витрины пусты.
sourcePrint: "same",
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана при пустом журнале")
}
if !strings.Contains(out, "нечего") {
t.Error("отчёт не говорит, что проигрывать было нечего")
}
}
// Отмена — не успех: файла назначения нет, подменять нечего.
func TestОтменённыйПрогонНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 10, Folded: 3, Canceled: true},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBefore: 116,
})
out := buf.String()
if strings.Contains(out, "mv ") {
t.Error("процедура подмены напечатана после отмены")
}
if !strings.Contains(out, "ОТМЕНЁН") {
t.Error("отмена не названа в отчёте")
}
// Ни отпечатки, ни разница доставок при отмене не снимались — печатать их
// значило бы выдать неизмеренное за измеренное.
for _, forbidden := range []string{"СОВПАЛИ", "РАЗОШЛИСЬ", "приехало доставок"} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт после отмены содержит %q — величина не измерялась", forbidden)
}
}
}
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит со словом
// «fatal» и кодом 1, а это первое, что человек наберёт у команды с тремя
// флагами.
func TestСправкаНеЯвляетсяОтказом(t *testing.T) {
if err := runReindex([]string{"-h"}); err != nil {
t.Errorf("reindex -h вернул ошибку: %v", err)
}
}
// Пропущенный файл, запись без тела или повтор означают, что часть журнала не
// прочитана. Вердикт «СОВПАЛИ — состояние воспроизводимо» тогда выдавал бы
// сертификат воспроизводимости прогону, который этих тел не читал.
func TestНепрочитаннаяЧастьЖурналаВидна(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 100, Buckets: 2049, Fingerprint: "aaaa",
// Симлинк на каталог суток уносит из прогона целый месяц одной
// строкой счётчика.
SkippedFiles: 1,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "СОВПАЛИ — состояние воспроизводимо") {
t.Error("вердикт о воспроизводимости выдан прогону, читавшему не весь журнал")
}
if !strings.Contains(out, "ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА") {
t.Errorf("отчёт не предупредил о непрочитанной части журнала:\n%s", out)
}
}
// Тело, разобранное приёмом, не может перестать разбираться: `content` — не
// штатный отказ, в отличие от невыведенного слоя.
func TestНеразобранноеСодержимоеПредупреждает(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 99, FailedMalformed: 1,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
})
if !strings.Contains(buf.String(), "которых быть не должно") {
t.Errorf("неразобранное содержимое не подняло предупреждения:\n%s", buf.String())
}
}
// Штатный отказ — невыведенный слой — предупреждения поднимать не должен:
// такие доставки есть в каждом журнале.
func TestНевыведенныйСлойНеПоднимаетТревоги(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 98, FailedLayer: 2,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "aaaa", sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "которых быть не должно") {
t.Error("штатный отказ поднял тревогу — человека послали искать несуществующий дефект")
}
if !strings.Contains(out, "task down") {
t.Error("процедура подмены не напечатана при штатном исходе")
}
}
// Рабочей базы может не быть — но и тогда файл назначения не может совпасть с
// её путём: иначе витрина устанавливается на место, минуя правило «подмену
// делает человек при остановленном сервисе».
func TestФайлНазначенияНеМожетБытьПутёмОтсутствующейБазы(t *testing.T) {
t.Parallel()
db := filepath.Join(t.TempDir(), "healthlog.db")
if _, err := resolveTarget(db, db, false); err == nil {
t.Error("путь отсутствующей рабочей базы принят как файл назначения")
}
if _, err := resolveTarget(db, db, true); err == nil {
t.Error("--force позволил собрать витрину прямо на место рабочей базы")
}
}