reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -90,3 +91,77 @@ func newArchive(t *testing.T) *archive.Archive {
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Перечисление тел — вход пересборки. Всё, что телом не является, обязано быть
|
||||
// посчитано, а не выброшено молча: тело есть, а в отчёте его нет.
|
||||
func TestListРазводитТелаИПрочиеФайлы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
a, err := archive.New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
for _, id := range []string{"b", "a"} {
|
||||
if _, err := a.Write(id, at, []byte(`{"data":{}}`)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
day := filepath.Join(root, "2026", "08", "01")
|
||||
for _, name := range []string{"a.json.gz.tmp", "readme.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("подготовка файла: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := a.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
filepath.Join("2026", "08", "01", "a.json.gz"),
|
||||
filepath.Join("2026", "08", "01", "b.json.gz"),
|
||||
}
|
||||
if !reflect.DeepEqual(got.Bodies, want) {
|
||||
t.Errorf("тела %v, ожидались %v", got.Bodies, want)
|
||||
}
|
||||
if len(got.Skipped) != 2 {
|
||||
t.Errorf("пропущено %v, ожидалось два файла", got.Skipped)
|
||||
}
|
||||
if id := archive.BodyID(got.Bodies[0]); id != "a" {
|
||||
t.Errorf("BodyID = %q, ожидался %q", id, "a")
|
||||
}
|
||||
}
|
||||
|
||||
// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел нет»:
|
||||
// пустой журнал даёт пустую витрину, которая по отпечатку совпадает с любой
|
||||
// другой пустой витриной и выглядит идеальной сходимостью.
|
||||
func TestОтсутствующийКаталогНеЯвляетсяПустымАрхивом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "нет-такого")
|
||||
if _, err := archive.Existing(missing); err == nil {
|
||||
t.Error("Existing создал или принял отсутствующий каталог")
|
||||
}
|
||||
if _, err := os.Stat(missing); err == nil {
|
||||
t.Error("Existing создал каталог — пересборке этого делать нельзя")
|
||||
}
|
||||
|
||||
// А приёму каталог создать надо: это и есть разница между конструкторами.
|
||||
if _, err := archive.New(missing); err != nil {
|
||||
t.Errorf("New: %v", err)
|
||||
}
|
||||
a, err := archive.Existing(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("Existing после New: %v", err)
|
||||
}
|
||||
list, err := a.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list.Bodies) != 0 {
|
||||
t.Errorf("в пустом архиве нашлись тела: %v", list.Bodies)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user