reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
@@ -22,6 +23,25 @@ func NewID() string {
|
||||
return strings.ToLower(ulid.Make().String())
|
||||
}
|
||||
|
||||
// TimeOf возвращает время создания идентификатора: UTC, секундная точность —
|
||||
// ровно та форма, в которой время хранится (см. store.Now).
|
||||
//
|
||||
// Нужен пересборке витрины: у тела, лежащего в архиве без учётной записи,
|
||||
// другого источника метки приёма нет. Дата каталога архива не годится — она
|
||||
// задаёт сутки, а порядок проигрывания нужен внутри суток.
|
||||
//
|
||||
// `.UTC()` здесь обязателен и не для красоты: ulid.Time собирает время через
|
||||
// time.Unix, то есть в локальной зоне машины, а Truncate работает с абсолютной
|
||||
// длительностью и зону не нормализует. Без приведения метка подобранного тела
|
||||
// сравнивалась бы с меткой из БД по-разному на разных машинах.
|
||||
func TimeOf(s string) (time.Time, error) {
|
||||
id, err := ulid.ParseStrict(strings.ToUpper(strings.TrimSpace(s)))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%w: %q", ErrInvalid, s)
|
||||
}
|
||||
return ulid.Time(id.Time()).UTC().Truncate(time.Second), nil
|
||||
}
|
||||
|
||||
// Parse валидирует внешний идентификатор и приводит его к каноническому виду.
|
||||
// Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк
|
||||
// в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user