reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -312,6 +312,17 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
|
||||
}
|
||||
}
|
||||
|
||||
// ReadBody читает тело из архива с той же границей размера, что и свёртка.
|
||||
//
|
||||
// Экспортировано ради пересборки: ей нужно прочесть тело, у которого ещё нет
|
||||
// учётной записи, чтобы посчитать размер и хеш. Своей копией чтения это делать
|
||||
// нельзя — граница обязана быть общей, иначе тело, принятое приёмом со `200`,
|
||||
// начнёт вечно отказывать на каждой пересборке, и договорённость «предел тот
|
||||
// же» ничем не проверяется.
|
||||
func (s *Service) ReadBody(rawPath string) ([]byte, error) {
|
||||
return s.readBody(rawPath)
|
||||
}
|
||||
|
||||
func (s *Service) readBody(rawPath string) ([]byte, error) {
|
||||
r, err := s.arch.Open(rawPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// archiveDir включает прогон сходимости на живом архиве.
|
||||
//
|
||||
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
|
||||
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
|
||||
// должен висеть на каждом `task gate`. Запускается командой
|
||||
// `task verify:archive`.
|
||||
var archiveDir = flag.String("healthlog.archive", "",
|
||||
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
|
||||
|
||||
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
|
||||
// разбора, обязан пройти через код без потерь и без расхождений — и повторный
|
||||
// прогон журнала обязан дать то же состояние.
|
||||
func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
if *archiveDir == "" {
|
||||
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
|
||||
}
|
||||
root := *archiveDir
|
||||
bodies := collectBodies(t, root)
|
||||
if len(bodies) == 0 {
|
||||
t.Skipf("живого архива нет в %s — прогон пропущен", root)
|
||||
}
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
partial := 0
|
||||
sections := map[string]int{}
|
||||
|
||||
var folded, failed, incomparable int
|
||||
for _, path := range bodies {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение %s: %v", path, err)
|
||||
}
|
||||
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
|
||||
// путь чтения был ровно тот, каким пойдёт пересборка.
|
||||
//
|
||||
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
|
||||
// Поэтому автоматизация у всех одна: так проверяется в том числе
|
||||
// наследование слоя по цепочке доставок.
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
|
||||
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
|
||||
|
||||
res, err := f.Fold(ctx, id)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
folded++
|
||||
incomparable += res.Incomparable
|
||||
if len(res.Uncovered) > 0 {
|
||||
partial++
|
||||
for _, s := range res.Uncovered {
|
||||
sections[s]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
|
||||
// полей: их не было ни разу на всём корпусе. Число печатается, а не
|
||||
// проверяется: появление такого набора — событие для разбора, а не отказ
|
||||
// сходимости.
|
||||
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
|
||||
len(bodies), folded, failed, incomparable)
|
||||
t.Logf("частично разобрано %d, непокрытые секции: %v", partial, sections)
|
||||
|
||||
if folded == 0 {
|
||||
t.Fatal("ни одна доставка не свернулась")
|
||||
}
|
||||
|
||||
// Половина живого потока не несёт metrics вовсе (находка 50): такие
|
||||
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
|
||||
// срежет тела, которые для stateOfMind единственный источник.
|
||||
if partial == 0 {
|
||||
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
|
||||
}
|
||||
|
||||
// Повторный прогон того же журнала не меняет состояния: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
//
|
||||
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
|
||||
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
|
||||
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
|
||||
// заведомо сломанном правиле.
|
||||
before, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
fingerprintBefore, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
var refolded int
|
||||
for _, path := range bodies {
|
||||
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
|
||||
continue
|
||||
}
|
||||
refolded++
|
||||
}
|
||||
if refolded != folded {
|
||||
t.Fatalf("повторно свёрнуто %d доставок из %d — проверка идемпотентности вхолостую",
|
||||
refolded, folded)
|
||||
}
|
||||
after, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
if before != after {
|
||||
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
|
||||
}
|
||||
fingerprintAfter, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
if fingerprintBefore != fingerprintAfter {
|
||||
t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s",
|
||||
fingerprintBefore, fingerprintAfter)
|
||||
}
|
||||
|
||||
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
|
||||
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
|
||||
// живёт — он производен от архива, которого нет ни на одной другой машине.
|
||||
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
||||
t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter)
|
||||
|
||||
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
|
||||
// интервалу — 174 (docs/local-research.md, находка 47). Если координата
|
||||
// когда-нибудь схлопнется обратно до метки, здесь станет 170.
|
||||
if got := countPoints(t, st, "sleep_analysis"); got != 174 {
|
||||
t.Errorf("координат sleep_analysis %d, измерено 174: ключ схлопнул записи", got)
|
||||
}
|
||||
}
|
||||
|
||||
// countPoints считает точки метрики во всех слоях. Каталог разрезов — отдельная
|
||||
// задача, поэтому здесь перебор по известным слоям, а не запрос к нему.
|
||||
func countPoints(t *testing.T, st *store.Store, metric string) int {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
total := 0
|
||||
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
|
||||
hours, err := st.BucketHours(ctx, metric, layer)
|
||||
if err != nil {
|
||||
t.Fatalf("часы объектов: %v", err)
|
||||
}
|
||||
for _, h := range hours {
|
||||
b, err := st.Bucket(ctx, metric, layer, h)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение объекта: %v", err)
|
||||
}
|
||||
total += len(b.Points)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func collectBodies(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
|
||||
var out []string
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // архива может не быть — это не отказ теста
|
||||
}
|
||||
if !info.IsDir() && filepath.Ext(path) == ".gz" {
|
||||
out = append(out, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func gunzip(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("распаковка: %v", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
out, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user