reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 позволил собрать витрину прямо на место рабочей базы")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user