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
+79
View File
@@ -10,11 +10,18 @@ import (
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// bodyExt — расширение файла тела. Всё, что ему не соответствует, телом не
// является: остаток `*.json.gz.tmp` от прерванной записи, чужой файл, каталог.
const bodyExt = ".json.gz"
// Archive — каталог сырых тел, разложенных по дате приёма.
type Archive struct {
root string
@@ -28,6 +35,23 @@ func New(root string) (*Archive, error) {
return &Archive{root: root}, nil
}
// Existing открывает уже существующий архив и каталога не создаёт.
//
// Отличие от New не косметическое: приёму каталог создать надо, а пересборке —
// нельзя. Созданный на лету пустой каталог превращает запуск не из той
// директории в успешный прогон по пустому журналу, а пустая витрина совпадает
// по отпечатку с пустой витриной, то есть выглядит идеальной сходимостью.
func Existing(root string) (*Archive, error) {
fi, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("open archive dir %q: %w", root, err)
}
if !fi.IsDir() {
return nil, fmt.Errorf("archive dir %q: не каталог", root)
}
return &Archive{root: root}, nil
}
// Root возвращает корневой каталог архива.
func (a *Archive) Root() string { return a.root }
@@ -60,6 +84,61 @@ func (a *Archive) Write(id string, at time.Time, body []byte) (string, error) {
return rel, nil
}
// Listing — что нашлось в архиве.
type Listing struct {
// Bodies — относительные пути тел, отсортированные лексикографически.
// Порядок здесь только для воспроизводимости перечисления: журнал
// упорядочивает не он, а время приёма.
Bodies []string
// Skipped — файлы, телом не являющиеся. Считаются, а не выбрасываются:
// молчаливый пропуск означал бы «тело есть, а в отчёте его нет».
Skipped []string
}
// List перечисляет тела архива.
//
// Ошибку чтения каталога отдаёт наружу, а не превращает в пустой список, и
// каталога не создаёт. Различие принципиально для пересборки: нечитаемый или
// отсутствующий каталог означает «неизвестно, есть ли тела», а не «тел нет», —
// а пустой журнал даёт пустую витрину, чей отпечаток совпадает с отпечатком
// любой другой пустой витрины, то есть выглядит идеальной сходимостью.
func (a *Archive) List() (Listing, error) {
var out Listing
err := filepath.WalkDir(a.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Отказ чтения каталога прекращает обход целиком: пропустить его
// значило бы молча потерять сутки журнала.
return fmt.Errorf("walk archive %q: %w", path, err)
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(a.root, path)
if err != nil {
return fmt.Errorf("relative path %q: %w", path, err)
}
if !strings.HasSuffix(d.Name(), bodyExt) {
out.Skipped = append(out.Skipped, rel)
return nil
}
out.Bodies = append(out.Bodies, rel)
return nil
})
if err != nil {
return Listing{}, err //nolint:wrapcheck // ошибка уже обёрнута внутри обхода
}
sort.Strings(out.Bodies)
sort.Strings(out.Skipped)
return out, nil
}
// BodyID возвращает идентификатор доставки по относительному пути тела.
func BodyID(rel string) string {
return strings.TrimSuffix(filepath.Base(rel), bodyExt)
}
// Open открывает сохранённое тело для чтения (распакованным). Нужен для
// пересборки витрины из архива.
func (a *Archive) Open(rel string) (io.ReadCloser, error) {