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
+57
View File
@@ -4,6 +4,7 @@ import (
"errors"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/ident"
)
@@ -56,3 +57,59 @@ func TestParseRejectsGarbage(t *testing.T) {
}
}
}
// Время из ULID нужно телу, лежащему в архиве без учётной записи: другого
// источника метки приёма у него нет.
func TestTimeOfВUTCИСекундах(t *testing.T) {
t.Parallel()
id := ident.NewID()
at, err := ident.TimeOf(id)
if err != nil {
t.Fatalf("TimeOf(%q): %v", id, err)
}
// UTC, а не локальная зона: ulid.Time собирает время через time.Unix, то
// есть в зоне машины, и метка подобранного тела сравнивалась бы с меткой из
// БД по-разному на разных машинах.
if at.Location() != time.UTC {
t.Errorf("зона %v, ожидался UTC", at.Location())
}
// Секундная точность — та же, что у store.Now: метка уезжает в колонку
// фиксированной ширины, где лексикографический порядок равен хронологии.
if at.Nanosecond() != 0 {
t.Errorf("метка %v несёт доли секунды", at)
}
if d := time.Since(at); d < 0 || d > time.Minute {
t.Errorf("метка %v далека от настоящего времени (%v)", at, d)
}
}
// Монотонность ULID — то, на чём стоит порядок проигрывания журнала для
// подобранных тел.
func TestTimeOfСохраняетПорядок(t *testing.T) {
t.Parallel()
first, err := ident.TimeOf(ident.NewID())
if err != nil {
t.Fatalf("TimeOf: %v", err)
}
time.Sleep(1100 * time.Millisecond)
second, err := ident.TimeOf(ident.NewID())
if err != nil {
t.Fatalf("TimeOf: %v", err)
}
if !second.After(first) {
t.Errorf("порядок меток не сохранился: %v, затем %v", first, second)
}
}
func TestTimeOfОтвергаетНеИдентификатор(t *testing.T) {
t.Parallel()
for _, s := range []string{"", "не-ulid", "01kyzbb5zy4cbkbc0agb6ad07", "readme.txt"} {
if _, err := ident.TimeOf(s); !errors.Is(err, ident.ErrInvalid) {
t.Errorf("TimeOf(%q) дал %v, ожидался ErrInvalid", s, err)
}
}
}