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
+110
View File
@@ -0,0 +1,110 @@
package store_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Пересборка читает рабочую базу, пока в неё может писать сервис. Обычное
// открытие накатывает миграции безусловно, а миграции меняют и данные — та, что
// ввела частичный разбор, переписала parse_status у всех строк. Значит утилите
// нужен путь чтения, который базу не трогает.
func TestOpenForReadНеПишетВБазу(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
seed(t, path)
ro, err := store.OpenForRead(path)
if err != nil {
t.Fatalf("OpenForRead: %v", err)
}
defer func() { _ = ro.Close() }()
ctx := context.Background()
got, err := ro.ListDeliveries(ctx)
if err != nil {
t.Fatalf("ListDeliveries: %v", err)
}
if len(got) != 2 {
t.Fatalf("доставок %d, ожидалось 2", len(got))
}
// Порядок журнала — (received_at, id), тот же, в котором проигрывает
// пересборка.
if got[0].ID != "01hzzzzzzzzzzzzzzzzzzzzzz1" || got[1].ID != "01hzzzzzzzzzzzzzzzzzzzzzz0" {
t.Errorf("порядок %q, %q — не по времени приёма", got[0].ID, got[1].ID)
}
// Заголовки переносятся дословно: восстановить их неоткуда, в архиве их нет.
if got[0].Headers != `{"x-test":["1"]}` {
t.Errorf("заголовки %q не дошли дословно", got[0].Headers)
}
if err := ro.CreateDelivery(ctx, store.Delivery{
ID: "01hzzzzzzzzzzzzzzzzzzzzzz2", ReceivedAt: store.Now(),
Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending,
}); err == nil {
t.Error("запись в базу, открытую на чтение, удалась")
}
}
// Расхождение версии схемы — отказ, а не повод мигрировать: иначе свежий бинарь
// молча меняет схему под работающим старым сервисом.
func TestOpenForReadОтвергаетЧужуюВерсиюСхемы(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
seed(t, path)
// Откатываем учёт миграций мимо store: имитируем базу, к которой бинарь
// новее.
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
_, err = db.Exec(`DELETE FROM goose_db_version
WHERE version_id = (SELECT max(version_id) FROM goose_db_version)`)
if err != nil {
t.Fatalf("откат версии: %v", err)
}
_ = db.Close()
if _, err := store.OpenForRead(path); !errors.Is(err, store.ErrSchemaMismatch) {
t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err)
}
}
func seed(t *testing.T, path string) {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = st.Close() }()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Второй идентификатор меньше первого, а приехал он раньше: так проверяется,
// что порядок берётся из времени приёма, а не из имени.
rows := []store.Delivery{
{ID: "01hzzzzzzzzzzzzzzzzzzzzzz1", ReceivedAt: base},
{ID: "01hzzzzzzzzzzzzzzzzzzzzzz0", ReceivedAt: base.Add(time.Second)},
}
for _, d := range rows {
d.Bytes = 1
d.SHA256 = "-"
d.RawPath = d.ID + ".json.gz"
d.ParseStatus = store.ParsePending
d.Headers = `{"x-test":["1"]}`
if err := st.CreateDelivery(context.Background(), d); err != nil {
t.Fatalf("CreateDelivery: %v", err)
}
}
}