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
+44 -4
View File
@@ -61,9 +61,12 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind`
половина потока), принимаются, хранятся и честно помечаются как неразобранные. половина потока), принимаются, хранятся и честно помечаются как неразобранные.
Чего ещё нет: пересборки хранилища из архива (`reindex`), каталога метрик с Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
измеренным родом агрегации и **read API** — данные наружу пока не отдаются витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
никак. План в [docs/plan.md](docs/plan.md). пересборка воспроизводима и повторный прогон ничего не меняет.
Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API**
данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
@@ -73,10 +76,47 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
``` ```
healthlog serve приём + read API + MCP healthlog serve приём + read API + MCP
healthlog import родной экспорт Apple Health (в планах) healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка хранилища из архива (в планах) healthlog reindex пересборка витрины из журнала
healthlog healthcheck проверка живости для docker HEALTHCHECK healthlog healthcheck проверка живости для docker HEALTHCHECK
``` ```
### Пересборка витрины
Разбор пишется по реальным данным и будет ошибаться. Исправленный разбор
применяется к уже разобранному пересборкой:
```
healthlog reindex --config ./config.toml
```
Команда собирает витрину в **отдельный файл** рядом с рабочей базой и печатает
два отпечатка — рабочей витрины и пересобранной. Рабочую базу она не трогает
вовсе (открывает её только на чтение и без наката миграций), поэтому запускать
её при живом сервисе безопасно — так и стоит делать, если нужно просто сверить.
**Применить** результат — другое дело. Подмена возможна только при остановленном
сервисе: он держит файл базы открытым, и переименование поверх живого процесса
портит базу молча. Сервис при этом надо остановить **до** пересборки, а не после:
доставки, приехавшие за время прогона, в собранный файл не попадут, и подмена
стёрла бы их учёт вместе с заголовками, которые не восстанавливаются ниоткуда.
Команда это проверяет и в таком случае процедуру подмены не печатает вовсе.
```
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Прогон идёт линейно по архиву: на 116 телах — около полуминуты, и время растёт
вместе с архивом. Свободного места нужно не меньше текущего размера базы:
собранный файл ложится рядом с ней, на тот же том.
Прогон, убитый жёстко (`SIGKILL`, потеря питания), оставляет рядом с базой файлы
`*.partial*` — это его недособранный результат. Штатное прерывание (`Ctrl+C`) их
убирает само; оставшиеся можно удалять руками, следующему прогону они не мешают.
## Локальный запуск ## Локальный запуск
Конфиг необязателен — без него берутся умолчания (`:8080`, `./healthlog.db`, Конфиг необязателен — без него берутся умолчания (`:8080`, `./healthlog.db`,
+1 -1
View File
@@ -39,7 +39,7 @@ tasks:
# Не входит в `task test` и `task gate` намеренно: архив в репозиторий не # Не входит в `task test` и `task gate` намеренно: архив в репозиторий не
# попадает, прогон занимает минуту, и держать его на каждом гейте значит # попадает, прогон занимает минуту, и держать его на каждом гейте значит
# платить за проверку, которая возможна только на этой машине. # платить за проверку, которая возможна только на этой машине.
- go test ./internal/fold -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1 - go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
lint: lint:
desc: Запуск golangci-lint desc: Запуск golangci-lint
+3
View File
@@ -3,6 +3,7 @@
// Подкоманды: // Подкоманды:
// //
// healthlog [serve] --config <path> принимать пакеты (по умолчанию) // healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK) // healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main package main
@@ -27,6 +28,8 @@ func main() {
switch cmd { switch cmd {
case "serve": case "serve":
err = runServe(args) err = runServe(args)
case "reindex":
err = runReindex(args)
case "healthcheck": case "healthcheck":
err = runHealthcheck(args) err = runHealthcheck(args)
default: default:
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// rebuildSuffix — как зовётся собранная витрина рядом с рабочей базой.
// Соседом, а не во временном каталоге: подмена обязана быть переименованием
// внутри одной файловой системы.
const rebuildSuffix = ".rebuild"
// partialSuffix — под каким именем витрина собирается, пока не готова.
//
// Полусобранная база выглядит как обычная, и файл с именем результата человек
// подменит по напечатанной процедуре не глядя. Поэтому имя результата
// появляется последним шагом успеха, а не первым шагом работы.
const partialSuffix = ".partial"
// progressInterval — как часто печатается прогресс. Прогон на полном архиве
// идёт минутами и молчит; зависший при этом неотличим от идущего.
const progressInterval = 5 * time.Second
// errNothingReplayed — журнал пуст или не свернулось ничего.
var errNothingReplayed = errors.New("проигрывать нечего")
func runReindex(args []string) error {
fs := flag.NewFlagSet("reindex", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
out := fs.String("out", "", "куда собрать витрину (по умолчанию — рабочая база с суффиксом "+rebuildSuffix+")")
force := fs.Bool("force", false, "перезаписать существующий файл назначения")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Лог — в stderr: stdout занят отчётом человеку, и лог в том же потоке
// сделал бы отчёт неразбираемым.
log := logging.NewErr(cfg.Log.Level, cfg.Log.Format)
target, err := resolveTarget(cfg.Storage.DBPath, *out, *force)
if err != nil {
return err
}
// Отмена приходит из сигнала: команду прерывает человек, и без этого вся
// логика отмены недостижима — процесс умирал бы мимо неё.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Прогресс — в поток ошибок: stdout занят отчётом, который человек
// перенаправляет и читает глазами.
rep, err := rebuild(ctx, cfg, target, log, os.Stderr)
if err != nil {
return err
}
writeReport(os.Stdout, rep)
if rep.replay.Canceled {
return errors.New("пересборка отменена")
}
if rep.replay.Bodies == 0 || rep.replay.Folded == 0 {
// Пустая витрина совпадает по отпечатку с пустой витриной, то есть
// пустой прогон выглядит идеальной сходимостью. Успехом он быть не
// может: человек, выполнивший напечатанную процедуру, заменил бы
// накопленное пустым.
return errNothingReplayed
}
return nil
}
// target — куда собираем и как называется промежуточный файл.
type target struct {
final string
partial string
}
// resolveTarget выбирает файл назначения и проверяет, что писать в него можно.
func resolveTarget(dbPath, out string, force bool) (target, error) {
final := out
if final == "" {
final = dbPath + rebuildSuffix
}
// Тождество определяется файлом, а не строкой пути: `..`, симлинк или
// другой префикс монтирования дают ту же цель при другой строке, а ошибка
// здесь означает проигрывание журнала прямо в живую рабочую базу.
same, err := sameFile(final, dbPath)
if err != nil {
return target{}, err
}
if same {
return target{}, fmt.Errorf("файл назначения %q — это рабочая база", final)
}
if _, err := os.Stat(final); err == nil && !force {
return target{}, fmt.Errorf("файл назначения %q уже существует (--force перезапишет)", final)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return target{}, fmt.Errorf("stat %q: %w", final, err)
}
// Имя промежуточного файла уникально: фиксированное затирало бы чужой файл
// с тем же именем ДО всякой проверки, то есть мимо правила «без --force не
// перезаписываем», и обломок прошлого прогона блокировал бы следующий.
return target{final: final, partial: final + "." + ident.NewID() + partialSuffix}, nil
}
// sameFile отвечает, ведут ли два пути к одному файлу.
//
// Когда файла назначения ещё нет, сравниваются каталог-родитель и имя: сам файл
// сравнить не с чем, а совпадение каталога и имени — это и есть тождество
// будущего файла.
func sameFile(a, b string) (bool, error) {
// Совпадение очищенных путей — тождество независимо от того, существуют ли
// файлы. Без этой проверки `--out <db_path>` при отсутствующей рабочей базе
// устанавливал бы витрину прямо на её место, минуя всё правило «подмену
// делает человек при остановленном сервисе».
if filepath.Clean(a) == filepath.Clean(b) {
return true, nil
}
fa, errA := os.Stat(a)
fb, errB := os.Stat(b)
switch {
case errA == nil && errB == nil:
return os.SameFile(fa, fb), nil
case errB != nil:
// Рабочей базы нет: сравнивать не с чем, а совпадение строк уже
// исключено выше.
return false, nil
}
da, err := os.Stat(filepath.Dir(a))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(a), err)
}
db, err := os.Stat(filepath.Dir(b))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(b), err)
}
return os.SameFile(da, db) && filepath.Base(a) == filepath.Base(b), nil
}
// report — всё, что печатается человеку.
type report struct {
replay replay.Report
target string
dbPath string
sourcePrint string
sourceBuckets int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
}
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
// назначения последним шагом успеха.
func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger, progress io.Writer) (report, error) {
rep := report{target: t.final, dbPath: cfg.Storage.DBPath}
// Отмена — не отказ пересборки, а требование прекратить работу, и застать
// она может на любом шаге, включая снятие отпечатка рабочей витрины.
stopped := func(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
if ctx.Err() != nil {
rep.replay.Canceled = true
return rep, nil
}
arch, err := archive.Existing(cfg.Storage.ArchiveDir)
if err != nil {
return rep, err
}
// Рабочей базы может не быть вовсе — журнал тогда состоит из одних
// подобранных тел. Это законный вход: восстановление после её потери. Но
// заголовки доставок при этом не воскресают, они жили только в ней.
var src *store.Store
if _, err := os.Stat(cfg.Storage.DBPath); errors.Is(err, os.ErrNotExist) {
rep.sourceMissing = true
} else if err != nil {
return rep, fmt.Errorf("stat %q: %w", cfg.Storage.DBPath, err)
} else {
src, err = store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return rep, err
}
defer func() { _ = src.Close() }()
// Отпечаток рабочей витрины снимается ДО проигрывания, иначе под живым
// приёмом он всегда движется, и оракул отвечает «разошлись» независимо
// от того, разошёлся ли разбор.
if rep.sourcePrint, err = src.Fingerprint(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBefore, err = src.CountDeliveries(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
dst, err := store.Open(t.partial)
if err != nil {
return rep, err
}
rep.replay, err = replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
// `mode=replay` в логе не украшение: за один прогон через слияние
// проходит вся история, и её WARN о перезаписях иначе неотличимы от
// аномалий живого приёма в общем логе.
Fold: fold.New(arch, dst, int64(cfg.Ingest.MaxBodyMB)<<20, log.With("mode", "replay")),
Progress: progressEvery(progress, progressInterval, time.Now),
Log: log,
})
if cerr := dst.Close(); err == nil {
err = cerr
}
if err != nil {
removeDB(t.partial)
return rep, err
}
if src != nil && !rep.replay.Canceled {
if rep.sourceAfter, err = src.CountDeliveries(ctx); err != nil {
removeDB(t.partial)
return canceledOr(rep, err, stopped)
}
}
ok := !rep.replay.Canceled && rep.replay.Bodies > 0 && rep.replay.Folded > 0
if !ok {
removeDB(t.partial)
return rep, nil
}
if err := os.Rename(t.partial, t.final); err != nil {
removeDB(t.partial)
return rep, fmt.Errorf("переименование в %q: %w", t.final, err)
}
return rep, nil
}
// progressEvery печатает прогресс не чаще интервала.
//
// Живёт в команде, а не в пакете проигрывания: «куда и как часто печатать» —
// забота адресата вывода. Часы параметром, чтобы функция была проверяема, не
// завися от настоящего времени.
func progressEvery(w io.Writer, every time.Duration, now func() time.Time) func(done, total int) {
last := now()
return func(done, total int) {
if done < total && now().Sub(last) < every {
return
}
last = now()
_, _ = fmt.Fprintf(w, "проиграно %d из %d\n", done, total)
}
}
// canceledOr отличает отмену от настоящего отказа: первая не является ошибкой
// команды, вторая является.
func canceledOr(rep report, err error, stopped func(error) bool) (report, error) {
if stopped(err) {
rep.replay.Canceled = true
return rep, nil
}
return rep, err
}
// removeDB убирает файл базы вместе со спутниками журнала SQLite: оставленный
// `-wal` подцепится к следующему файлу с тем же именем.
func removeDB(path string) {
for _, s := range []string{"", "-wal", "-shm"} {
_ = os.Remove(path + s)
}
}
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// setup собирает рабочее окружение команды: архив с телами и рабочую базу,
// наполненную живым приёмом.
func setup(t *testing.T, bodies int) *config.Config {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{}
cfg.Storage.DBPath = filepath.Join(dir, "healthlog.db")
cfg.Storage.ArchiveDir = filepath.Join(dir, "raw")
cfg.Ingest.MaxBodyMB = 64
cfg.Log.Level = "error"
cfg.Log.Format = "json"
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
t.Fatalf("база: %v", err)
}
defer func() { _ = st.Close() }()
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "minute.json"))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
f := fold.New(arch, st, 64<<20, slog.New(slog.DiscardHandler))
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
for i := range bodies {
id := ident.NewID()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
AutomationID: "auto-1", Bytes: int64(len(body)), SHA256: "-",
RawPath: rawPath, ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
_, _ = f.Fold(context.Background(), id)
}
return cfg
}
func fingerprintOf(t *testing.T, path string) string {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("база %s: %v", path, err)
}
defer func() { _ = st.Close() }()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// Пересборка собирает витрину рядом и рабочую базу не трогает: очистка рабочей
// необратима и наступила бы ДО того, как известно, удалась ли пересборка.
func TestПересборкаНеТрогаетРабочуюБазу(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Folded != 3 {
t.Errorf("свёрнуто %d, ожидалось 3", rep.replay.Folded)
}
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась")
}
if rep.sourcePrint != rep.replay.Fingerprint {
t.Errorf("отпечатки разошлись при неизменном разборе:\n %s\n %s",
rep.sourcePrint, rep.replay.Fingerprint)
}
// Результат появился под именем назначения, промежуточного файла не
// осталось.
if _, err := os.Stat(tgt.final); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
assertGone(t, tgt.partial)
}
// Прерванная пересборка не оставляет файла назначения: полусобранная база
// выглядит как обычная, и человек подменит её по напечатанной процедуре.
func TestПрерваннаяПересборкаНеОставляетФайлаНазначения(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rep, err := rebuild(ctx, cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if !rep.replay.Canceled {
t.Error("отмена не отмечена в отчёте")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась при отменённой пересборке")
}
}
// Пустой архив — отказ команды, а не идеальная сходимость двух пустых витрин.
func TestПустойАрхивЭтоОтказКоманды(t *testing.T) {
t.Parallel()
cfg := setup(t, 0)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Bodies != 0 {
t.Fatalf("тел %d, ожидался пустой архив", rep.replay.Bodies)
}
// Отпечатки при этом совпадают — обе витрины пусты. Именно поэтому пустой
// журнал не может быть успехом.
if rep.sourcePrint != rep.replay.Fingerprint {
t.Error("две пустые витрины дали разные отпечатки — проверка потеряла смысл")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
}
func assertGone(t *testing.T, path string) {
t.Helper()
for _, s := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(path + s); err == nil {
t.Errorf("остался файл %s", path+s)
}
}
}
// Затребованная перезапись даёт ту же витрину, что и сборка в отсутствующий
// файл: сборка всегда начинается с пустой витрины, а не дописывается в чужое
// содержимое — иначе в результате осталось бы наследие прежнего разбора.
func TestПерезаписьДаётТуЖеВитрину(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
log := slog.New(slog.DiscardHandler)
first, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
fresh, err := rebuild(context.Background(), cfg, first, log, io.Discard)
if err != nil {
t.Fatalf("первая пересборка: %v", err)
}
// Поверх уже существующего результата, с явно затребованной перезаписью.
again, err := resolveTarget(cfg.Storage.DBPath, first.final, true)
if err != nil {
t.Fatalf("файл назначения (--force): %v", err)
}
over, err := rebuild(context.Background(), cfg, again, log, io.Discard)
if err != nil {
t.Fatalf("пересборка с перезаписью: %v", err)
}
if over.replay.Fingerprint != fresh.replay.Fingerprint {
t.Errorf("перезапись дала другую витрину:\n с нуля %s\n поверх %s",
fresh.replay.Fingerprint, over.replay.Fingerprint)
}
if over.replay.Buckets != fresh.replay.Buckets {
t.Errorf("объектов %d против %d — сборка дописалась в старое содержимое",
over.replay.Buckets, fresh.replay.Buckets)
}
}
// Исход команды целиком: пустой архив даёт ненулевой код, а не «успех»
// с идеально совпавшими пустыми отпечатками.
func TestИсходКомандыНаПустомАрхиве(t *testing.T) {
cfg := setup(t, 0)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
err := runReindex([]string{"--config", cfgPath})
if !errors.Is(err, errNothingReplayed) {
t.Errorf("пустой архив дал %v, ожидался отказ «проигрывать нечего»", err)
}
}
// И обратное: непустой журнал доводится до конца и завершается успехом.
func TestИсходКомандыНаНепустомАрхиве(t *testing.T) {
cfg := setup(t, 2)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
if err := runReindex([]string{"--config", cfgPath}); err != nil {
t.Errorf("непустой журнал дал отказ: %v", err)
}
if _, err := os.Stat(cfg.Storage.DBPath + rebuildSuffix); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
}
func writeConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()
body := fmt.Sprintf(`[storage]
db_path = %q
archive_dir = %q
[ingest]
max_body_mb = %d
[log]
level = "error"
format = "json"
`, cfg.Storage.DBPath, cfg.Storage.ArchiveDir, cfg.Ingest.MaxBodyMB)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"fmt"
"io"
)
// writeReport печатает итог пересборки человеку.
//
// Отдельной функцией с io.Writer, а не печатью в os.Stdout из недр: отчёт —
// новая поверхность вывода, и единственное, что защищает её от утечки данных о
// здоровье, — тест. Тест на глобальном os.Stdout был бы тестом на глобальном
// состоянии, то есть его бы не написали.
//
// Ни значений точек, ни имён метрик, ни имён устройств здесь нет и быть не
// может: содержимое витрины входит в отчёт только отпечатком, а он берёт его
// хешем.
func writeReport(w io.Writer, r report) {
p := func(format string, args ...any) {
_, _ = fmt.Fprintf(w, format+"\n", args...)
}
p("пересборка витрины из журнала")
p(" архив: тел %d, пропущено файлов %d, повторов идентификатора %d",
r.replay.Bodies, r.replay.SkippedFiles, r.replay.Duplicates)
p(" учёт: подобрано тел без записи %d, не удалось подобрать %d, записей без тела %d",
r.replay.Adopted, r.replay.AdoptFailed, r.replay.Orphans)
p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d",
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther)
p(" слияние: частично разобрано %d, несравнимых наборов %d",
r.replay.Partial, r.replay.Incomparable)
if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
// отмене не снимались. Печатать их сравнение значило бы выдать
// неизмеренное за измеренное — в единственном оракуле задачи.
p("")
p("прогон ОТМЕНЁН: сравнение не проводилось, файл назначения не создан")
return
}
// «Часть журнала не прочитана» — отдельное состояние, и оно обязано быть
// видно рядом с вердиктом отпечатков. Пропущенный симлинк на каталог уносит
// из прогона целый месяц одной строкой в счётчике, а вердикт «СОВПАЛИ»
// выдал бы сертификат воспроизводимости прогону, который этих тел не читал.
partialJournal := r.replay.SkippedFiles > 0 || r.replay.Orphans > 0 || r.replay.Duplicates > 0
// Нештатные отказы. Невыведенный слой сюда не входит: он есть в каждом
// журнале, и предупреждать о нём значило бы отправлять человека искать
// дефект там, где его нет. А вот «содержимое не разбирается» штатным не
// является: тело один раз уже прошло проверку формы на приёме.
badFailures := r.replay.FailedOther > 0 || r.replay.FailedMalformed > 0 || r.replay.AdoptFailed > 0
if r.sourceMissing {
p(" объектов: %d", r.replay.Buckets)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
} else {
// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
switch {
case r.sourcePrint == r.replay.Fingerprint && !partialJournal:
p(" отпечатки СОВПАЛИ — состояние воспроизводимо")
case r.sourcePrint == r.replay.Fingerprint:
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
default:
p(" отпечатки РАЗОШЛИСЬ")
p(" ожидаемые причины: исправленный разбор; признак sealed не")
p(" переносится (правила его выставления ещё нет)")
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
}
}
}
if r.replay.Bodies == 0 || r.replay.Folded == 0 {
p("")
p("проигрывать было нечего: файл назначения не создан.")
p("проверьте storage.archive_dir и каталог запуска — пустая витрина")
p("совпадает по отпечатку с пустой витриной и выглядит идеальной сверкой")
return
}
if d := r.sourceAfter - r.sourceBefore; d != 0 {
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве,
// но не в собранном файле. Подмена стёрла бы их учёт вместе с
// заголовками, восстановить которые неоткуда, — поэтому процедура здесь
// не печатается вовсе.
p("")
p("за время прогона в рабочую базу приехало доставок: %d.", d)
p("подменять этим файлом НЕЛЬЗЯ: учёта новых доставок в нём нет, а вместе")
p("с ним пропали бы их заголовки. Остановите сервис и пересоберите заново.")
return
}
p("")
if partialJournal {
p("ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА: пропущено файлов %d, записей без тела %d,",
r.replay.SkippedFiles, r.replay.Orphans)
p("повторов идентификатора %d. Пересобранная витрина беднее рабочей на",
r.replay.Duplicates)
p("объекты этих доставок — и на объекты тех, кто наследовал от них слой.")
p("Проверьте каталог архива (симлинк на подкаталог обходом не читается)")
p("по DEBUG-строкам лога, прежде чем подменять базу.")
p("")
}
if badFailures {
p("отказы, которых быть не должно (%d прочих, %d по содержимому, %d при подборе) —",
r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed)
p("разберитесь по логу, прежде чем подменять базу.")
p("")
}
p("собрано в %s", r.target)
p("подмена — вручную и при ОСТАНОВЛЕННОМ сервисе: он держит файл открытым,")
p("и переименование поверх живого процесса портит базу молча.")
p("")
p(" task down")
p(" mv %s %s", r.target, r.dbPath)
p(" rm -f %s-wal %s-shm", r.dbPath, r.dbPath)
p(" task up")
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/replay"
)
// Тождество файла назначения определяется файлом, а не строкой пути: `..`,
// симлинк или другой префикс монтирования дают ту же цель при другой строке, а
// ошибка здесь означает проигрывание журнала прямо в живую рабочую базу.
func TestФайлНазначенияНеМожетБытьРабочейБазой(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
link := filepath.Join(dir, "link.db")
if err := os.Symlink(db, link); err != nil {
t.Skipf("символические ссылки недоступны: %v", err)
}
cases := map[string]string{
"тот же путь": db,
// Строкой, а не через filepath.Join: он бы почистил путь, и случай
// выродился бы в совпадение строк.
"через родителя": dir + "/sub/../healthlog.db",
"символическая ссылка": link,
}
for name, out := range cases {
if _, err := resolveTarget(db, out, false); err == nil {
t.Errorf("%s: файл назначения %q принят за отдельный файл", name, out)
}
}
}
// Существующий файл не перезаписывается молча; умолчание — сосед рабочей базы,
// чтобы подмена оставалась переименованием внутри одной файловой системы.
func TestВыборФайлаНазначения(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
tgt, err := resolveTarget(db, "", false)
if err != nil {
t.Fatalf("умолчание: %v", err)
}
if tgt.final != db+rebuildSuffix {
t.Errorf("умолчание %q, ожидался сосед рабочей базы", tgt.final)
}
if filepath.Dir(tgt.partial) != filepath.Dir(tgt.final) {
t.Errorf("промежуточный файл %q не рядом с результатом", tgt.partial)
}
busy := filepath.Join(dir, "занято.db")
if err := os.WriteFile(busy, []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
if _, err := resolveTarget(db, busy, false); err == nil {
t.Error("существующий файл назначения принят без --force")
}
if _, err := resolveTarget(db, busy, true); err != nil {
t.Errorf("--force не разрешил перезапись: %v", err)
}
if _, err := os.Stat(busy); err != nil {
t.Error("проверка аргументов уже что-то удалила — решать это должен прогон")
}
}
// Отчёт — новая поверхность вывода, и единственное, что защищает её от утечки
// данных о здоровье, это проверка. Поэтому рендер принимает io.Writer, а не
// печатает в os.Stdout из недр.
func TestОтчётНеРаскрываетДанныхОЗдоровье(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049,
Fingerprint: "aaaa", Partial: 53,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2040,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
// Ни одного слова, которым могло бы оказаться измерение, имя метрики или
// устройства: в отчёт они попадают только через отпечаток, а он берёт
// содержимое хешем.
for _, forbidden := range []string{
"heart_rate", "sleep_analysis", "active_energy", "qty",
"Apple Watch", "iPhone", "value",
} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт содержит %q", forbidden)
}
}
// Расхождение отпечатков названо, и рядом — направление: «было/стало».
// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
for _, want := range []string{"РАЗОШЛИСЬ", "было 2040, стало 2049", "task down", "mv "} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q", want)
}
}
}
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве, но не
// в собранном файле: подмена стёрла бы их учёт вместе с заголовками, которые
// не восстанавливаются ниоткуда.
func TestПриездДоставокЗаПрогонОтменяетПодмену(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBefore: 116,
sourceAfter: 119,
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана, хотя учёт новых доставок в файл не попал")
}
if !strings.Contains(out, "приехало доставок: 3") {
t.Errorf("отчёт не назвал приезд доставок: %s", out)
}
}
// Пустой журнал выглядит идеальной сходимостью: отпечаток пустой витрины
// совпадает с отпечатком пустой витрины. Успехом он быть не может, и процедуру
// подмены печатать нельзя — человек заменил бы накопленное пустым.
func TestПустойЖурналНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 0, Folded: 0, Fingerprint: "same"},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
// Отпечатки совпадают: обе витрины пусты.
sourcePrint: "same",
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана при пустом журнале")
}
if !strings.Contains(out, "нечего") {
t.Error("отчёт не говорит, что проигрывать было нечего")
}
}
// Отмена — не успех: файла назначения нет, подменять нечего.
func TestОтменённыйПрогонНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 10, Folded: 3, Canceled: true},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBefore: 116,
})
out := buf.String()
if strings.Contains(out, "mv ") {
t.Error("процедура подмены напечатана после отмены")
}
if !strings.Contains(out, "ОТМЕНЁН") {
t.Error("отмена не названа в отчёте")
}
// Ни отпечатки, ни разница доставок при отмене не снимались — печатать их
// значило бы выдать неизмеренное за измеренное.
for _, forbidden := range []string{"СОВПАЛИ", "РАЗОШЛИСЬ", "приехало доставок"} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт после отмены содержит %q — величина не измерялась", forbidden)
}
}
}
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит со словом
// «fatal» и кодом 1, а это первое, что человек наберёт у команды с тремя
// флагами.
func TestСправкаНеЯвляетсяОтказом(t *testing.T) {
if err := runReindex([]string{"-h"}); err != nil {
t.Errorf("reindex -h вернул ошибку: %v", err)
}
}
// Пропущенный файл, запись без тела или повтор означают, что часть журнала не
// прочитана. Вердикт «СОВПАЛИ — состояние воспроизводимо» тогда выдавал бы
// сертификат воспроизводимости прогону, который этих тел не читал.
func TestНепрочитаннаяЧастьЖурналаВидна(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 100, Buckets: 2049, Fingerprint: "aaaa",
// Симлинк на каталог суток уносит из прогона целый месяц одной
// строкой счётчика.
SkippedFiles: 1,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "СОВПАЛИ — состояние воспроизводимо") {
t.Error("вердикт о воспроизводимости выдан прогону, читавшему не весь журнал")
}
if !strings.Contains(out, "ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА") {
t.Errorf("отчёт не предупредил о непрочитанной части журнала:\n%s", out)
}
}
// Тело, разобранное приёмом, не может перестать разбираться: `content` — не
// штатный отказ, в отличие от невыведенного слоя.
func TestНеразобранноеСодержимоеПредупреждает(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 99, FailedMalformed: 1,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
})
if !strings.Contains(buf.String(), "которых быть не должно") {
t.Errorf("неразобранное содержимое не подняло предупреждения:\n%s", buf.String())
}
}
// Штатный отказ — невыведенный слой — предупреждения поднимать не должен:
// такие доставки есть в каждом журнале.
func TestНевыведенныйСлойНеПоднимаетТревоги(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 98, FailedLayer: 2,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "aaaa", sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "которых быть не должно") {
t.Error("штатный отказ поднял тревогу — человека послали искать несуществующий дефект")
}
if !strings.Contains(out, "task down") {
t.Error("процедура подмены не напечатана при штатном исходе")
}
}
// Рабочей базы может не быть — но и тогда файл назначения не может совпасть с
// её путём: иначе витрина устанавливается на место, минуя правило «подмену
// делает человек при остановленном сервисе».
func TestФайлНазначенияНеМожетБытьПутёмОтсутствующейБазы(t *testing.T) {
t.Parallel()
db := filepath.Join(t.TempDir(), "healthlog.db")
if _, err := resolveTarget(db, db, false); err == nil {
t.Error("путь отсутствующей рабочей базы принят как файл назначения")
}
if _, err := resolveTarget(db, db, true); err == nil {
t.Error("--force позволил собрать витрину прямо на место рабочей базы")
}
}
+2
View File
@@ -22,6 +22,8 @@ db_path = "/data/healthlog.db"
archive_dir = "/data/raw" archive_dir = "/data/raw"
[ingest] [ingest]
# Ретроактивен: тем же пределом пересборка читает тела из архива, см.
# config.example.toml.
max_body_mb = 64 max_body_mb = 64
[log] [log]
+5
View File
@@ -27,6 +27,11 @@ db_path = "./data/healthlog.db" # файл SQLite; каталог долж
archive_dir = "./data/raw" # корень сырого архива; создаётся при старте archive_dir = "./data/raw" # корень сырого архива; создаётся при старте
[ingest] [ingest]
# ВНИМАНИЕ: параметр РЕТРОАКТИВЕН. Тем же пределом читаются тела из архива при
# пересборке (`healthlog reindex`), поэтому понижение выбрасывает из
# пересобранной витрины все уже принятые тела крупнее нового значения — они
# начнут отказывать на каждом прогоне. Понижать только вместе с проверкой, что
# таких тел в архиве нет.
max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413 max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413
[log] [log]
+86 -3
View File
@@ -213,6 +213,8 @@ HRV); у накопительных — только `date`. Поэтому то
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | | `hae` | разбор формата HAE, канонизация, хеш содержимого |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | | `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | | `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API | | `httpapi` | приём и read API |
@@ -319,7 +321,85 @@ HRV); у накопительных — только `date`. Поэтому то
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт **`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
существует, она просто вырожденный случай с пустым снапшотом. существует, она просто вырожденный случай с пустым снапшотом. Проигрывание
живёт в `internal/replay`; `healthlog import` добавит стадию снапшота **перед**
ним, а не заведёт вторую похожую операцию.
**Журналом считается архив, а не таблица доставок.** Перечислять строки
`delivery` значило бы пересобирать витрину из витрины. Тело может лежать в
архиве без учётной записи: приём кладёт его на диск раньше строки в базе
(обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
оставляет тело без учёта — такое тело пересборка заводит заново, восстанавливая
метку приёма из ULID, а размер и хеш пересчитывая по распакованному телу.
Обратный случай — строка без тела — станет штатным вместе с ретеншеном и потому
считается, а не роняет прогон.
**Заголовков доставки в архиве нет**, и это named предел модели: они живут
только в `delivery`, поэтому пересборка читает рабочую базу, а полная потеря
базы деградирует вывод слоя навсегда. Закрывается это тем, что заголовки надо
класть в архив рядом с телом (так делает WARC) — отдельная задача беклога.
#### Пересборка идёт в отдельный файл, а подмену делает человек
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в ней
результат прежнего, неверного разбора — то есть не сделало бы того, ради чего
она существует.
Начать с пустой можно двумя способами, и выбран второй.
- **Очистить рабочую витрину и проиграть в неё же** — отвергнуто. Единственная
необратимая операция всей задачи (`DELETE FROM bucket`) выполнялась бы **до**
того, как станет известно, удалась ли пересборка; отказ на середине оставлял
бы витрину пустой наполовину в состоянии, неотличимом от нормального.
- **Собрать рядом и подменить** — взято. Это blue-green rebuild проекции,
стандартный приём event sourcing («вместо усечения существующей модели строим
новую в параллельном хранилище и переключаем чтение»); той же формы `_reindex`
с переключением алиаса в Elasticsearch и собственный `VACUUM INTO` SQLite.
Отказ становится бесплатным: рабочая база не тронута, промежуточный файл
удаляется.
- **Теневая таблица в той же базе** (`bucket_new` → переименование в
транзакции) — отвергнуто дважды. Имя `bucket` зашито литералом во весь слой
записи, то есть вариант требует параметризовать таблицей самый опасный код
проекта ради операции раз в полгода; и он не решает того, ради чего
затевался, — живой приём во время пересборки пишет в **старую** таблицу, и
при подмене его точки пропадают.
**Подмену рабочей базы делает человек, и это не лень.** Файл базы держит
открытым процесс сервиса, а переименование не касается уже открытого
дескриптора: процесс продолжит писать в отвязанный inode, читатели увидят новый
файл, данные разойдутся молча. Документация SQLite называет переименование
используемого файла прямой причиной порчи базы. Безопасная подмена требует
остановленного сервиса, а остановить его команда не может — сервисом управляет
окружение снаружи, и CLI, делающий вид, что управляет, обещал бы безопасность,
которой не обеспечивает. Поэтому команда печатает процедуру, а выполняет её
человек:
```
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Пересборка при этом **читает рабочую базу без наката миграций**: обычное
открытие мигрирует безусловно, а миграции меняют и данные (та, что ввела
частичный разбор, переписала `parse_status` у всех строк). Расхождение версии
схемы — отказ с указанием обеих, а не миграция под работающим сервисом.
**Оракул сходимости встроен в команду**: печатаются отпечаток рабочей витрины и
отпечаток пересобранной, снятые так, что первый берётся **до** проигрывания —
иначе под живым приёмом он движется, и ответ «разошлись» не значил бы ничего.
Пустой журнал при этом успехом не считается: отпечаток пустой витрины совпадает
с отпечатком пустой витрины, то есть выглядит идеальной сходимостью, а человек,
выполнивший напечатанную процедуру, заменил бы накопленное пустым.
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
`points`, `derived_layer`, `uncovered_sections`. Последнее не косметика:
доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего
разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала.
#### Что не восстанавливается, и это сказано вслух #### Что не восстанавливается, и это сказано вслух
@@ -500,8 +580,11 @@ hour метки выровнены на час heart_rate 00:00:00
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как `sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое. `day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
приёме: это ещё одна причина держать сырой архив. причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
`docs/review-journal.md`).
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
+1 -1
View File
@@ -21,7 +21,6 @@
## высокий ## высокий
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика - [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
@@ -41,6 +40,7 @@
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка - [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего - [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed - [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
## низкий ## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
-28
View File
@@ -1,28 +0,0 @@
# Пересборка хранилища из сырого архива
**Приоритет:** высокий
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
Риск в другом: без пересборки ошибка разбора становится потерей данных —
исправленный код не применится к тому, что уже разобрано неверно.
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
агрегации на полном ряду доставок надёжнее, чем на одной.
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
похожими.
Отсюда требование, которое легко упустить: **свёртка обязана быть
детерминированной.** Проигрывание должно давать то же состояние, что приём в
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
одинаково полные точки с разными значениями разрешает порядок — значит
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
приёмом, и повторный прогон ничего не меняет.
Связано: план → шаг «Разбор и хранилище», `docs/architecture.md` → «Сырой архив».
@@ -0,0 +1,40 @@
# Заголовки доставки в архиве рядом с телом
**Приоритет:** средний
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
лежит только **тело**: заголовки запроса (`automation-id`,
`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут
единственной копией — в колонке `delivery.headers`.
Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex`
читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда.
Пока база цела, это работает. Если базу потерять, весь журнал становится
«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего,
заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся
никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с
оговоркой, которой в инварианте нет.
Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его
заголовками именно потому, что тело без метаданных запроса события не
воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и
на то, что заголовки лежат рядом текстом, а не в отдельной базе.
Развилка формы (решать при взятии, не сейчас):
- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и
одна операция, но файл перестаёт быть «телом как пришло»;
- файл-спутник `<ulid>.headers.json` — тело остаётся дословным, зато на доставку
два файла и два fsync, а атомарность пары надо обеспечивать самому;
- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по
операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
же состояние, что пересборка с базой.
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`.
+9 -8
View File
@@ -15,13 +15,14 @@
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
беклоге ноль. беклоге ноль.
Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После **`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
миграции 00005 доставки числятся `pending`, а подобрать их некому: код сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`
пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но после миграции 00005, подбираются им же — но применяется результат подменой
учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки базы, а её делает человек при остановленном сервисе. Тем же кодом закрывается
нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку». половина задачи «разнести ответ и свёртку»: проигрывание журнала теперь готовая
операция.
Потом — остаток разбора: тренировки и записи со своими `id` (это половина Дальше — остаток разбора: тренировки и записи со своими `id` (это половина
потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются), потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются),
словарь категориальных значений. словарь категориальных значений.
@@ -32,8 +33,8 @@
- [x] **1. Каркас.** - [x] **1. Каркас.**
- [x] **2. Приём без разбора.****подключаем телефон по локальной сети** - [x] **2. Приём без разбора.****подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со - [~] **3. Разбор и хранилище.** Метрики и `reindex` — сделано; тренировки и
своими `id`, `reindex` и словарь категориальных значений — нет. записи со своими `id`, словарь категориальных значений — нет.
- [ ] **4. Каталог и род агрегации.** - [ ] **4. Каталог и род агрегации.**
- [ ] **5. Read API.** - [ ] **5. Read API.**
- [ ] **6. Самоописание.** - [ ] **6. Самоописание.**
+26
View File
@@ -47,3 +47,29 @@
обязан иметь границу по `received_at` разбираемой доставки. Тест сходимости обязан иметь границу по `received_at` разбираемой доставки. Тест сходимости
на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным — на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным —
именно он это поймал. именно он это поймал.
## 2026-08-02 — прогон живого архива был красным и об этом никто не знал
- **Где:** `internal/fold/replay_test.go` (перенесён в `internal/replay/archive_test.go`)
- **Симптом:** первый же запуск `task verify:archive` в задаче про пересборку
дал `координат sleep_analysis 222, измерено 174`. Проверено прогоном прежней
редакции теста на том же архиве: она даёт ровно те же 222, 2049 объектов и тот
же отпечаток — значит тест покраснел не от изменений задачи, а сам, когда
архив дорос с 94 доставок до 116.
- **Причина:** утверждение было пришпилено к **числу, производному от корпуса**
(174 координаты сна). Корпус растёт с каждой доставкой, то есть константа
протухает по расписанию телефона. Проверяемое свойство при этом другое и от
размера корпуса не зависит: ключ по интервалу не схлопывает записи до ключа
по метке (222 координаты против 218 меток).
- **Почему не поймали:** прогон живого архива намеренно не входит в `task gate`
(минута работы, данные есть только на этой машине). У проверки, которую гейт
не гоняет, краснота никому не видна — она обнаруживается только следующей
задачей, которая до неё дотянется. Ни один проход ревью прогон не запускал:
проходы читают код, а не гоняют опциональные команды.
- **Что меняем:** утверждение переписано на само свойство (координат строго
больше, чем различных меток), измеренные числа остались в `t.Logf`. Правило
общее и годится в конвенции: **в проверке на живом корпусе нельзя утверждать
число, производное от размера корпуса** — утверждать надо инвариант, а число
печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
проверяет настоящий проигрыватель журнала.
+79
View File
@@ -10,11 +10,18 @@ import (
"compress/gzip" "compress/gzip"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"time" "time"
) )
// bodyExt — расширение файла тела. Всё, что ему не соответствует, телом не
// является: остаток `*.json.gz.tmp` от прерванной записи, чужой файл, каталог.
const bodyExt = ".json.gz"
// Archive — каталог сырых тел, разложенных по дате приёма. // Archive — каталог сырых тел, разложенных по дате приёма.
type Archive struct { type Archive struct {
root string root string
@@ -28,6 +35,23 @@ func New(root string) (*Archive, error) {
return &Archive{root: root}, nil 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 возвращает корневой каталог архива. // Root возвращает корневой каталог архива.
func (a *Archive) Root() string { return a.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 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 открывает сохранённое тело для чтения (распакованным). Нужен для // Open открывает сохранённое тело для чтения (распакованным). Нужен для
// пересборки витрины из архива. // пересборки витрины из архива.
func (a *Archive) Open(rel string) (io.ReadCloser, error) { func (a *Archive) Open(rel string) (io.ReadCloser, error) {
+75
View File
@@ -4,6 +4,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -90,3 +91,77 @@ func newArchive(t *testing.T) *archive.Archive {
} }
return a 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)
}
}
+11
View File
@@ -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) { func (s *Service) readBody(rawPath string) ([]byte, error) {
r, err := s.arch.Open(rawPath) r, err := s.arch.Open(rawPath)
if err != nil { if err != nil {
-207
View File
@@ -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
}
+20
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/oklog/ulid/v2" "github.com/oklog/ulid/v2"
) )
@@ -22,6 +23,25 @@ func NewID() string {
return strings.ToLower(ulid.Make().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 валидирует внешний идентификатор и приводит его к каноническому виду. // Parse валидирует внешний идентификатор и приводит его к каноническому виду.
// Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк // Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк
// в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к // в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к
+57
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/ident" "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)
}
}
}
+8 -1
View File
@@ -90,7 +90,14 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
Bytes: int64(len(body)), Bytes: int64(len(body)),
SHA256: hex.EncodeToString(sum[:]), SHA256: hex.EncodeToString(sum[:]),
} }
receivedAt := store.Now() // Метка приёма выводится ИЗ идентификатора, а не берётся вторым обращением
// к часам. Источник обязан быть один: у тела, лежащего в архиве без учётной
// записи, метку восстанавливают из ULID, и два разных источника разошлись бы
// на границе секунды — а от порядка журнала зависит наследование слоя.
receivedAt, err := ident.TimeOf(res.DeliveryID)
if err != nil {
receivedAt = store.Now()
}
rawPath, err := s.arch.Write(res.DeliveryID, receivedAt, body) rawPath, err := s.arch.Write(res.DeliveryID, receivedAt, body)
if err != nil { if err != nil {
+23 -5
View File
@@ -2,6 +2,7 @@
package logging package logging
import ( import (
"io"
"log/slog" "log/slog"
"os" "os"
"strings" "strings"
@@ -10,21 +11,38 @@ import (
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text"). // New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
func New(level, format string) *slog.Logger { func New(level, format string) *slog.Logger {
return NewTo(os.Stdout, level, format)
}
// NewErr — тот же логгер, что New, но в stderr.
//
// Нужен командам CLI: stdout у них занят отчётом человеку, и лог в том же
// потоке сделал бы отчёт неразбираемым — а именно его человек перенаправляет в
// файл и читает глазами.
func NewErr(level, format string) *slog.Logger {
return NewTo(os.Stderr, level, format)
}
// NewTo — общая форма: приёмник вывода параметром, как у log.New и
// slog.NewTextHandler. New и NewErr — тонкие обёртки над ней; отдельные имена
// существуют ради читаемости места вызова, а не ради разного поведения.
func NewTo(w io.Writer, level, format string) *slog.Logger {
opts := &slog.HandlerOptions{Level: parseLevel(level), ReplaceAttr: utcTime} opts := &slog.HandlerOptions{Level: parseLevel(level), ReplaceAttr: utcTime}
var handler slog.Handler var handler slog.Handler
if strings.EqualFold(format, "text") { if strings.EqualFold(format, "text") {
handler = slog.NewTextHandler(os.Stdout, opts) handler = slog.NewTextHandler(w, opts)
} else { } else {
handler = slog.NewJSONHandler(os.Stdout, opts) handler = slog.NewJSONHandler(w, opts)
} }
return slog.New(handler) return slog.New(handler)
} }
// NewStderr — JSON-логгер в stderr (UTC) для фатальных ошибок старта, когда // NewStderr — JSON-логгер в stderr для фатальных ошибок старта, когда основной
// основной логгер ещё не собран (конфиг не прочитан). // логгер ещё не собран (конфиг не прочитан). Уровень и формат брать неоткуда,
// поэтому они фиксированы — этим он и отличается от NewErr.
func NewStderr() *slog.Logger { func NewStderr() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: utcTime})) return NewTo(os.Stderr, "info", "json")
} }
// utcTime приводит метку времени записи к UTC: однозначный порядок событий и // utcTime приводит метку времени записи к UTC: однозначный порядок событий и
+65
View File
@@ -0,0 +1,65 @@
package logging
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
// Уровень — это адресат, а не громкость: DEBUG предназначен разработчику при
// отладке и в штатной работе наружу не выходит.
func TestУровеньОтсекаетНижние(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := NewTo(&buf, "warn", "json")
log.Debug("отладка")
log.Info("событие")
log.Warn("может стать проблемой")
out := buf.String()
if strings.Contains(out, "отладка") || strings.Contains(out, "событие") {
t.Errorf("уровень warn пропустил записи ниже себя: %s", out)
}
if !strings.Contains(out, "может стать проблемой") {
t.Errorf("запись уровня warn потерялась: %s", out)
}
}
// Время в логах — UTC: иначе порядок событий между машинами не сравнить.
func TestВремяВUTC(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
NewTo(&buf, "info", "json").Info("событие")
var rec map[string]any
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("запись не JSON: %v (%s)", err, buf.String())
}
ts, _ := rec[slog.TimeKey].(string)
if !strings.HasSuffix(ts, "Z") {
t.Errorf("метка времени %q не в UTC", ts)
}
}
// Команды CLI пишут отчёт в stdout, поэтому их лог обязан идти в stderr —
// иначе отчёт не разобрать ни глазами, ни перенаправлением.
func TestNewErrПишетВStderrАNewВStdout(t *testing.T) {
t.Parallel()
// Прямой проверки потока здесь нет намеренно: подмена os.Stdout была бы
// мутацией глобала. Проверяем то, что от этих конструкторов зависит на
// самом деле, — что они собирают разные назначения и оба живые.
if New("info", "json") == nil || NewErr("info", "text") == nil {
t.Fatal("конструктор вернул nil")
}
var buf bytes.Buffer
NewTo(&buf, "info", "text").Info("событие", "ключ", "значение")
if !strings.Contains(buf.String(), "ключ=значение") {
t.Errorf("текстовый формат не применён: %s", buf.String())
}
}
+201
View File
@@ -0,0 +1,201 @@
package replay_test
import (
"bytes"
"compress/gzip"
"context"
"flag"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// archiveDir включает прогон сходимости на живом архиве.
//
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
// должен висеть на каждом `task gate`. Запускается командой
// `task verify:archive`.
var archiveDir = flag.String("healthlog.archive", "",
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
// разбора, обязан пройти через код без потерь и без расхождений — и повторное
// проигрывание журнала обязано дать то же состояние.
//
// Прогон идёт через ту же операцию, которой пересобирает витрину
// `healthlog reindex`. Собственный обход и собственный порядок здесь были
// раньше и были ошибкой: они образовывали второй проигрыватель журнала, чьи
// правила разошлись с настоящим, — а зеленел бы при этом он.
func TestReplayЖивогоАрхива(t *testing.T) {
if *archiveDir == "" {
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
}
bodies := collectBodies(t, *archiveDir)
if len(bodies) == 0 {
t.Skipf("живого архива нет в %s — прогон пропущен", *archiveDir)
}
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
// путь чтения был ровно тот, каким пойдёт пересборка.
//
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
// Поэтому автоматизация у всех одна: так проверяется в том числе
// наследование слоя по цепочке доставок. Метка приёма берётся из ULID,
// то есть хронология журнала настоящая.
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")
at, err := ident.TimeOf(id)
if err != nil {
t.Fatalf("время из ULID %s: %v", id, err)
}
writeBody(t, arch, src, item{id: id, at: at, automationID: "auto"}, gunzip(t, body))
}
first, dst := run(t, ctx, arch, src, filepath.Join(dir, "first.db"))
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
// полей: их не было ни разу на всём корпусе. Число печатается, а не
// проверяется: появление такого набора — событие для разбора, а не отказ
// сходимости.
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
first.FailedOther, first.Incomparable)
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
first.Partial, first.Adopted, first.Orphans)
if first.Folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
// Половина живого потока не несёт metrics вовсе (находка 50): такие
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
// срежет тела, которые для stateOfMind единственный источник.
if first.Partial == 0 {
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
}
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
second, _ := run(t, ctx, arch, src, filepath.Join(dir, "second.db"))
if second.Buckets != first.Buckets {
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
}
if second.Fingerprint != first.Fingerprint {
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
first.Fingerprint, second.Fingerprint)
}
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
// живёт — он производен от архива, которого нет ни на одной другой машине.
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
//
// Проверяется само свойство, а не измеренное когда-то число. Прежняя
// редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела
// молча, когда архив дорос до 116: константа, производная от корпуса,
// протухает с каждой новой доставкой, а прогон живого архива в гейт не
// входит, так что краснота никому не видна.
coords, labels := countSleepKeys(t, dst)
if coords == 0 {
t.Fatal("записей сна в витрине нет — проверять нечего")
}
if coords <= labels {
t.Errorf("координат сна %d при %d различных метках: ключ схлопнул записи до метки",
coords, labels)
}
t.Logf("координат сна %d, различных меток %d", coords, labels)
}
// countSleepKeys возвращает число различных координат записей сна и число
// различных меток начала. Разница между ними и есть то, что теряет ключ по
// метке.
//
// Каталог разрезов — отдельная задача, поэтому здесь перебор по известным
// слоям, а не запрос к нему.
func countSleepKeys(t *testing.T, st *store.Store) (coords, labels int) {
t.Helper()
type key struct{ start, end int64 }
ctx := context.Background()
seenCoord := map[key]struct{}{}
seenLabel := map[int64]struct{}{}
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
hours, err := st.BucketHours(ctx, "sleep_analysis", layer)
if err != nil {
t.Fatalf("часы объектов: %v", err)
}
for _, h := range hours {
b, err := st.Bucket(ctx, "sleep_analysis", layer, h)
if err != nil {
t.Fatalf("чтение объекта: %v", err)
}
for _, p := range b.Points {
seenCoord[key{p.Start.UnixNano(), p.End.UnixNano()}] = struct{}{}
seenLabel[p.Start.UnixNano()] = struct{}{}
}
}
}
return len(seenCoord), len(seenLabel)
}
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
}
+412
View File
@@ -0,0 +1,412 @@
// Package replay — проигрывание журнала доставок в витрину.
//
// Состояние healthlog есть свёртка по журналу:
// `import(снапшот экспорта) + replay(доставки по received_at)`. Здесь живёт
// вторая половина формулы; пересборка из архива — её вырожденный случай с
// пустым снапшотом, а не отдельная операция. Когда появится импорт родного
// экспорта Apple, он добавит стадию снапшота ПЕРЕД проигрыванием и переиспользует
// эту же операцию.
//
// Собственного разбора и собственного слияния пакет не имеет: он зовёт ту же
// свёртку, что и приём, по идентификатору доставки. Второй путь разбора
// разошёлся бы с первым молча — и уже расходился: прежний прогон живого архива
// сортировал тела по путям, а не по времени приёма.
package replay
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sort"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Options — что нужно проигрыванию.
type Options struct {
// Archive — журнал тел. Именно он источник состава: перечислять только
// строки учёта значило бы пересобирать витрину из витрины.
Archive *archive.Archive
// Source — рабочая база, откуда берётся учёт доставок. Открывается на
// чтение: заголовков доставки в архиве нет, а восстановить их неоткуда.
// Пустой источник допустим — тогда весь журнал состоит из подобранных тел.
Source *store.Store
// Target — база назначения, куда собирается витрина.
Target *store.Store
// Fold — свёртка над Target. Собирается вызывающим с тем же пределом
// размера тела, что и у приёма; тем же пределом пересборка читает тело при
// подборе — своей копии границы у неё нет намеренно.
Fold *fold.Service
// Progress зовётся по мере продвижения. Нужен потому, что прогон на полном
// архиве молчит минутами, и зависший неотличим от идущего.
Progress func(done, total int)
Log *slog.Logger
}
// Report — итог проигрывания. Значений точек не несёт: содержимое входит в
// отчёт только отпечатком.
type Report struct {
// Bodies — тел в архиве, признанных телами.
Bodies int
// SkippedFiles — файлов, телом не являющихся (чужое расширение, остаток
// прерванной записи, имя не разбирается как ULID).
SkippedFiles int
// Duplicates — тел, чей идентификатор уже встретился в другом каталоге.
// Проигрывается первое; второе считается, а не роняет прогон.
Duplicates int
// Adopted — тел, у которых учётной записи не было: приём успел записать
// тело и не успел строку.
Adopted int
// AdoptFailed — тел без учёта, которые не удалось прочитать, чтобы завести
// запись. Тело остаётся в архиве.
AdoptFailed int
// Orphans — строк учёта, у которых тела в архиве нет. Станет штатным, когда
// появится ретеншен архива.
Orphans int
// Folded — сколько доставок свернулось.
Folded int
// Отказы разведены по классам, потому что читаются они по-разному.
// FailedLayer — слой не выводится: штатный исход, таких доставок в журнале
// заведомо есть. FailedMalformed — содержимое не разбирается. FailedOther —
// всё прочее (тело не читается, отказ базы); только оно означает, что с
// пересборкой что-то не так. Один общий счётчик отправлял бы человека
// искать дефект там, где его нет.
FailedLayer int
FailedMalformed int
FailedOther int
// Partial — доставок, в теле которых остались непокрытые разбором секции.
// Не отклонение, а половина потока; названо потому, что именно эти тела
// ретеншену трогать нельзя.
Partial int
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей.
Incomparable int
Buckets int64
Fingerprint string
// Canceled — проигрывание прервано отменой, а не дошло до конца.
Canceled bool
}
// Run проигрывает журнал в базу назначения.
//
// Порядок строго `(received_at, id)`: слой доставки без плотных метрик
// наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки той же автоматизации, то есть является
// функцией префикса журнала. Обход каталога совпадает с хронологией только по
// датам каталогов и внутри суток не упорядочивает ничего.
//
// Проигрывание последовательное. Распараллеливать его нельзя по той же причине:
// слой зависит от префикса, а запись часового объекта — это чтение, слияние и
// запись обратно.
func Run(ctx context.Context, o Options) (Report, error) {
var rep Report
log := o.Log
if log == nil {
log = slog.New(slog.DiscardHandler)
}
log = log.With("capability", "replay")
// База назначения обязана быть пустой: проигрывание поверх накопленного
// оставило бы в витрине результат прежнего разбора — точки из объекта не
// удаляются никогда, — то есть не сделало бы того, ради чего пересборка и
// существует.
n, err := o.Target.CountDeliveries(ctx)
if err != nil {
return stopOr(rep, err)
}
if n > 0 {
return rep, fmt.Errorf("база назначения не пуста: %d доставок", n)
}
journal, orphans, rep, err := o.collect(ctx, rep, log)
if err != nil {
return stopOr(rep, err)
}
// Строка старта: прогон идёт минутами, и убитый на середине не оставлял бы
// о себе в логе ни следа — только чужие с виду записи свёртки.
log.InfoContext(ctx, "journal replay started",
"archive_dir", o.Archive.Root(), "bodies", len(journal), "orphans", len(orphans))
// Учётные записи, тела которых в архиве нет, переносятся ПЕРВЫМИ и не
// сворачиваются. Не перенести их значило бы потерять факты журнала —
// заголовки, хеш, автоматизацию — при первой же подмене базы: тела уже нет,
// и восстановить их будет нечем. В наследовании слоя они не участвуют:
// выведенного слоя у них нет.
for _, d := range orphans {
if err := o.Target.CreateDelivery(ctx, d); err != nil {
return stopOr(rep, err)
}
}
for i, d := range journal {
if ctx.Err() != nil {
rep.Canceled = true
return rep, nil
}
if err := o.Target.CreateDelivery(ctx, d); err != nil {
return stopOr(rep, err)
}
st, err := o.Fold.Fold(ctx, d.ID)
switch {
case err == nil:
rep.Folded++
case ctx.Err() != nil:
// Отмена, застигшая свёртку, — не отказ доставки: считать её отказом
// значило бы обвинить разбор в том, чего он не делал, и отправить
// человека искать дефект по логу.
rep.Canceled = true
return rep, nil
case errors.Is(err, hae.ErrLayerUnknown):
// Штатный исход, уже записанный свёрткой в лог и в parse_status:
// журнал заведомо содержит тела без плотных метрик. Останов на
// первом лишил бы пересборки все остальные.
rep.FailedLayer++
case errors.Is(err, hae.ErrMalformed):
rep.FailedMalformed++
default:
rep.FailedOther++
}
if err == nil {
// Счётчики читаются только у успешной свёртки: при ошибке поля Stats
// заполнены частично (Uncovered у отказавшего разбора всегда пуст,
// хотя в базу список записан) — и Partial молча занижался бы. А по
// нему принимается решение о ретеншене тел.
if len(st.Uncovered) > 0 {
rep.Partial++
}
rep.Incomparable += st.Incomparable
}
if o.Progress != nil {
o.Progress(i+1, len(journal))
}
}
// Отмена могла прийти на последней доставке: без этой проверки запросы ниже
// вернули бы context.Canceled как обычную ошибку, и команда завершилась бы
// одной строкой «fatal», не напечатав частичного отчёта.
if ctx.Err() != nil {
rep.Canceled = true
return rep, nil
}
rep.Buckets, err = o.Target.CountBuckets(ctx)
if err != nil {
return stopOr(rep, err)
}
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
if err != nil {
return stopOr(rep, err)
}
log.InfoContext(ctx, "journal replayed",
"bodies", rep.Bodies,
"skipped_files", rep.SkippedFiles,
"adopted", rep.Adopted,
"adopt_failed", rep.AdoptFailed,
"orphans", rep.Orphans,
"duplicates", rep.Duplicates,
"folded", rep.Folded,
"failed_layer", rep.FailedLayer,
"failed_malformed", rep.FailedMalformed,
"failed_other", rep.FailedOther,
"partial", rep.Partial,
"incomparable", rep.Incomparable,
"buckets", rep.Buckets)
return rep, nil
}
// collect собирает состав журнала и упорядочивает его.
//
// Второй возврат — учётные записи, тел которых в архиве нет. Они переносятся в
// базу назначения, но не сворачиваются.
func (o Options) collect(ctx context.Context, rep Report, log *slog.Logger) ([]store.Delivery, []store.Delivery, Report, error) {
listing, err := o.Archive.List()
if err != nil {
// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел
// нет»: пустой журнал дал бы пустую витрину, чей отпечаток совпадает с
// отпечатком любой другой пустой витрины.
return nil, nil, rep, err
}
rep.SkippedFiles = len(listing.Skipped)
for _, rel := range listing.Skipped {
// Путь в архиве — дата и ULID, содержимого тела в нём нет. Без этой
// строки счётчик пропусков в отчёте не на что раскрыть: имя файла не
// узнать иначе как обходом архива руками.
log.DebugContext(ctx, "archive file is not a body", "file", rel)
}
var known []store.Delivery
if o.Source != nil {
known, err = o.Source.ListDeliveries(ctx)
if err != nil {
return nil, nil, rep, err
}
}
byID := make(map[string]store.Delivery, len(known))
for _, d := range known {
byID[d.ID] = d
}
journal := make([]store.Delivery, 0, len(listing.Bodies))
seen := make(map[string]struct{}, len(listing.Bodies))
for _, rel := range listing.Bodies {
// Имя обязано быть КАНОНИЧЕСКИМ идентификатором, а не приводиться к
// нему: ident.Parse — функция входной границы, она обрезает пробелы и
// поднимает регистр, и `\u00a0<ulid>.json.gz` дал бы ту же координату
// журнала, что настоящее тело. Подложенный файл сортируется раньше и
// вытеснил бы настоящее — а невидимые пробелы в этих данных уже
// встречались.
id, err := ident.Parse(archive.BodyID(rel))
if err != nil || archive.BodyID(rel) != id {
rep.SkippedFiles++
log.DebugContext(ctx, "archive file is not a body", "file", rel)
continue
}
if _, dup := seen[id]; dup {
// Одно имя в двух каталогах: копия, восстановленная руками, или
// тело, переложенное не туда. Вторая запись журнала с тем же
// идентификатором сорвала бы весь прогон отказом по первичному
// ключу — то есть один посторонний файл лишал бы пересборки всё
// остальное.
rep.Duplicates++
log.WarnContext(ctx, "archive body id repeats", "file", rel, "delivery_id", id)
continue
}
rep.Bodies++
seen[id] = struct{}{}
if d, ok := byID[id]; ok {
journal = append(journal, prepare(d))
continue
}
d, err := o.adopt(id, rel)
if err != nil {
// Тело есть, а завести по нему запись не вышло: без этой строки
// счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает
// человека «разобраться по логу».
rep.AdoptFailed++
log.WarnContext(ctx, "archive body not adopted", "error", err, "file", rel, "delivery_id", id)
continue
}
rep.Adopted++
journal = append(journal, d)
}
// Учётная запись, тела которой в архиве нет. Переносится, но не
// сворачивается: не перенести значило бы стереть первой же подменой базы
// единственное свидетельство, что доставка была, — тела-то уже нет.
orphans := make([]store.Delivery, 0)
for _, d := range known {
if _, ok := seen[d.ID]; !ok {
rep.Orphans++
// Не `pending`: тела нет и не будет, а «этим разбором ещё не
// смотрели» обещало бы данные, которых не появится, и ретеншен,
// который pending не трогает никогда, берёг бы такие строки вечно.
o := prepare(d)
o.ParseStatus = store.ParseFailed
orphans = append(orphans, o)
log.DebugContext(ctx, "delivery body missing", "delivery_id", d.ID, "file", d.RawPath)
}
}
// Тотальный ключ: `received_at` хранится с секундной точностью, и доставки
// одной секунды без второго ключа шли бы в неопределённом порядке — два
// прогона одного журнала могли бы разойтись.
sort.Slice(journal, func(i, j int) bool {
a, b := journal[i], journal[j]
if !a.ReceivedAt.Equal(b.ReceivedAt) {
return a.ReceivedAt.Before(b.ReceivedAt)
}
return a.ID < b.ID
})
return journal, orphans, rep, nil
}
// stopOr отличает отмену от настоящего отказа: первая не ошибка операции, а
// требование прекратить работу, и застать она может любой шаг.
//
// Единая точка на весь пакет: разбросанные по шагам проверки означали бы, что
// каждый новый шаг обязан вспомнить правило руками, а забытый превращает
// Ctrl-C в «ошибку окружения» без частичного отчёта.
func stopOr(rep Report, err error) (Report, error) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
rep.Canceled = true
return rep, nil
}
return rep, err
}
// prepare оставляет от учётной записи ФАКТЫ ЖУРНАЛА и сбрасывает производные от
// разбора поля.
//
// `parse_status`, `points`, `derived_layer` и `uncovered_sections` — результат
// ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало вместе с доставкой. Перенести их
// значило бы сделать пересобранную витрину функцией прошлого прогона: доставка,
// чей повторный разбор отказал (штатный исход, когда слой не выводится),
// сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и
// следующая доставка той же автоматизации унаследовала бы его молча. Оба прогона
// при этом самосогласованы, поэтому проверка «повторная пересборка ничего не
// меняет» такого не ловит.
//
// Незаполненные здесь колонки получают значения по умолчанию схемы: пустой слой
// и пустой список непокрытых секций.
func prepare(d store.Delivery) store.Delivery {
return store.Delivery{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
AutomationName: d.AutomationName,
AutomationID: d.AutomationID,
Aggregation: d.Aggregation,
Period: d.Period,
SessionID: d.SessionID,
Bytes: d.Bytes,
SHA256: d.SHA256,
RawPath: d.RawPath,
Headers: d.Headers,
ParseStatus: store.ParsePending,
}
}
// adopt заводит учётную запись для тела, лежащего в архиве без неё.
//
// Такое тело — не экзотика: приём кладёт тело на диск раньше строки в базе
// (обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
// оставляет тело без учёта. Обещание подобрать его записано в пакете приёма.
//
// Восстанавливается ровно то, что выводится из самого тела и его имени.
// Заголовков в архиве нет вовсе, поэтому вывод слоя у такой доставки честно
// деградирует: наследовать не от чего и подтверждать нечем.
func (o Options) adopt(id, rel string) (store.Delivery, error) {
at, err := ident.TimeOf(id)
if err != nil {
return store.Delivery{}, err
}
body, err := o.Fold.ReadBody(rel)
if err != nil {
return store.Delivery{}, err
}
sum := sha256.Sum256(body)
return store.Delivery{
ID: id,
ReceivedAt: at,
// Размер и хеш — по РАСПАКОВАННОМУ телу, как их считает приём. Иначе в
// тех же колонках появились бы значения другой природы, и индекс по
// хешу начал бы врать на границе подобранных тел.
Bytes: int64(len(body)),
SHA256: hex.EncodeToString(sum[:]),
RawPath: rel,
ParseStatus: store.ParsePending,
}, nil
}
+627
View File
@@ -0,0 +1,627 @@
package replay_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// item — одна доставка тестового журнала.
type item struct {
id string
at time.Time
automationID string
aggregation string
fixture string
}
func fixture(t *testing.T, name string) []byte {
t.Helper()
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name))
if err != nil {
t.Fatalf("фикстура %s: %v", name, err)
}
return body
}
func openStore(t *testing.T, path string) *store.Store {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("база %s: %v", path, err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func openArchive(t *testing.T, root string) *archive.Archive {
t.Helper()
arch, err := archive.New(root)
if err != nil {
t.Fatalf("архив: %v", err)
}
return arch
}
// live воспроизводит живой приём: тело в архив, строка учёта, свёртка — в том
// порядке и тем кодом, каким это делает `internal/ingest`.
func live(t *testing.T, arch *archive.Archive, st *store.Store, items []item) {
t.Helper()
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
for _, it := range items {
writeBody(t, arch, st, it, fixture(t, it.fixture))
_, _ = f.Fold(context.Background(), it.id)
}
}
func writeBody(t *testing.T, arch *archive.Archive, st *store.Store, it item, body []byte) {
t.Helper()
rawPath, err := arch.Write(it.id, it.at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
if st == nil {
return
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: it.id,
ReceivedAt: it.at,
AutomationID: it.automationID,
Aggregation: it.aggregation,
Bytes: int64(len(body)),
SHA256: "-",
RawPath: rawPath,
Headers: `{"x-test":["1"]}`,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
}
// run проигрывает журнал в свежую базу и возвращает отчёт вместе с ней.
func run(t *testing.T, ctx context.Context, arch *archive.Archive, src *store.Store, out string) (replay.Report, *store.Store) {
t.Helper()
dst := openStore(t, out)
rep, err := replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err != nil {
t.Fatalf("проигрывание: %v", err)
}
return rep, dst
}
func fingerprint(t *testing.T, st *store.Store) string {
t.Helper()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// journal собирает журнал из фикстур с монотонными идентификаторами.
func journal(t *testing.T, fixtures ...string) []item {
t.Helper()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
out := make([]item, 0, len(fixtures))
for i, f := range fixtures {
out = append(out, item{
id: ident.NewID(),
at: base.Add(time.Duration(i+1) * time.Second),
automationID: "auto-1",
aggregation: "Default",
fixture: f,
})
}
return out
}
// Главная проверка задачи: пересборка с нуля даёт то же состояние, что
// накопленный приём, а повторный прогон ничего не меняет.
func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json", "raw.json", "mixed.json", "sparse_sleep.json")
live(t, arch, src, items)
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Bodies != len(items) {
t.Fatalf("тел в журнале %d, ожидалось %d", rep.Bodies, len(items))
}
if rep.Folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
if rep.Adopted != 0 || rep.Orphans != 0 || rep.SkippedFiles != 0 {
t.Errorf("журнал не должен был дать подобранных/сирот/пропусков: %+v", rep)
}
want := fingerprint(t, src)
if rep.Fingerprint != want {
t.Errorf("отпечаток пересобранной витрины не совпал с накопленной:\n приём %s\n пересборка %s",
want, rep.Fingerprint)
}
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
// координаты — функция множества кандидатов, а не порядка прихода.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
}
}
// Порядок проигрывания задаётся журналом, а не раскладкой файлов: доставка без
// плотных метрик наследует слой ПРЕДШЕСТВУЮЩЕЙ доставки той же автоматизации.
//
// Журнал устроен так, что порядок имён файлов ОБРАТЕН хронологии. Проигрывание
// по каталогу поставило бы доставку без плотных метрик первой — наследовать ей
// было бы не от чего, слой не вывелся бы, и точки не сохранились бы вовсе.
func TestПорядокЗадаётсяЖурналомАНеКаталогом(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
ids := []string{ident.NewID(), ident.NewID(), ident.NewID()}
sort.Strings(ids)
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Хронология обратна лексикографике имён: самый ранний файл — самая поздняя
// доставка.
items := []item{
{id: ids[2], at: base.Add(1 * time.Second), automationID: "a", aggregation: "Default", fixture: "hour.json"},
{id: ids[1], at: base.Add(2 * time.Second), automationID: "a", aggregation: "Default", fixture: "minute.json"},
{id: ids[0], at: base.Add(3 * time.Second), automationID: "a", aggregation: "Default", fixture: "sparse_sleep.json"},
}
for _, it := range items {
writeBody(t, arch, src, it, fixture(t, it.fixture))
}
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.FailedLayer+rep.FailedMalformed+rep.FailedOther != 0 {
t.Fatalf("отказов %d: доставка без плотных метрик не нашла предшественника — порядок взят из каталога",
rep.FailedLayer+rep.FailedMalformed+rep.FailedOther)
}
// Предшественник — минутная доставка, значит эпизоды сна легли в minute.
hours, err := dst.BucketHours(ctx, "sleep_analysis", "minute")
if err != nil {
t.Fatalf("часы объектов: %v", err)
}
if len(hours) == 0 {
t.Error("эпизоды сна не унаследовали слой предшествующей доставки")
}
}
// Тело без учётной записи — не экзотика: приём кладёт тело на диск раньше
// строки в базе, и отказ на вставке оставляет тело без учёта.
func TestТелоБезУчётаПодбирается(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
orphanBody := fixture(t, "minute.json")
orphan := item{id: ident.NewID(), at: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)}
// Строки учёта нет — только тело.
writeBody(t, arch, nil, orphan, orphanBody)
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Adopted != 1 {
t.Fatalf("подобрано %d тел, ожидалось 1: %+v", rep.Adopted, rep)
}
if rep.Folded != 1 {
t.Fatalf("свёрнуто %d, ожидалась 1", rep.Folded)
}
if rep.Buckets == 0 {
t.Error("точки подобранного тела не доехали до витрины")
}
// Размер и хеш считаются по РАСПАКОВАННОМУ телу — как их считает приём.
got, err := dst.DeliveryForParse(ctx, orphan.id)
if err != nil {
t.Fatalf("учёт подобранного тела: %v", err)
}
wantAt, err := ident.TimeOf(orphan.id)
if err != nil {
t.Fatalf("время из ULID: %v", err)
}
if !got.ReceivedAt.Equal(wantAt) {
t.Errorf("метка приёма %v, ожидалась из ULID %v", got.ReceivedAt, wantAt)
}
all, err := dst.ListDeliveries(ctx)
if err != nil {
t.Fatalf("учёт: %v", err)
}
if len(all) != 1 || all[0].Bytes != int64(len(orphanBody)) {
t.Errorf("размер подобранного тела %v, ожидался по распакованному %d", all, len(orphanBody))
}
}
// Учётная запись без тела станет штатной, когда появится ретеншен архива:
// тела срезаются до даты проверенного экспорта, а строки живут дольше.
func TestУчётБезТелаНеРоняетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
// Строка есть, тело исчезло.
if err := os.Remove(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")); err != nil {
t.Fatalf("удаление тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Orphans != 1 {
t.Errorf("записей без тела %d, ожидалась 1: %+v", rep.Orphans, rep)
}
if rep.Bodies != 0 {
t.Errorf("тел %d, ожидался 0", rep.Bodies)
}
}
// Файл, телом не являющийся, считается отдельно: молчаливый пропуск означал бы
// «тело есть, а в отчёте его нет».
func TestФайлНеТелоСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
day := filepath.Join(arch.Root(), "2026", "08", "01")
// Остаток прерванной записи и файл с именем, которое не идентификатор.
for _, name := range []string{items[0].id + ".json.gz.tmp", "readme.txt", "не-ulid.json.gz"} {
if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.SkippedFiles != 3 {
t.Errorf("пропущено файлов %d, ожидалось 3: %+v", rep.SkippedFiles, rep)
}
if rep.Bodies != 1 || rep.Folded != 1 {
t.Errorf("тел %d, свёрнуто %d, ожидалось 1 и 1", rep.Bodies, rep.Folded)
}
}
// Слой прошлого разбора не должен доживать до наследования: иначе витрина
// оказывается функцией предыдущего прогона, а не журнала.
func TestСлойПрошлогоРазбораНеНаследуется(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
clean, _ := run(t, ctx, arch, src, filepath.Join(dir, "clean.db"))
// Портим производное поле: как если бы прежний разбор вывел другой слой.
for _, it := range items {
err := src.FinishParse(ctx, it.id, store.ParseOutcome{
Status: store.ParseDone,
Layer: "raw",
})
if err != nil {
t.Fatalf("порча derived_layer: %v", err)
}
}
dirty, _ := run(t, ctx, arch, src, filepath.Join(dir, "dirty.db"))
if dirty.Fingerprint != clean.Fingerprint {
t.Errorf("слой прошлого разбора повлиял на пересборку:\n чистая %s\n с порчей %s",
clean.Fingerprint, dirty.Fingerprint)
}
}
// Отмена прекращает проигрывание: это требование прекратить работу, а не
// свойство доставки.
func TestОтменаПрекращаетПроигрывание(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
items := journal(t, "minute.json", "hour.json", "raw.json")
live(t, arch, src, items)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dst := openStore(t, filepath.Join(dir, "rebuild.db"))
rep, err := replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
// Отмена приходит посреди журнала — так же, как её принесёт сигнал.
Progress: func(done, _ int) {
if done == 1 {
cancel()
}
},
})
if err != nil {
t.Fatalf("проигрывание: %v", err)
}
if !rep.Canceled {
t.Error("отмена не отмечена в отчёте")
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d доставок, ожидалась 1 до отмены", rep.Folded)
}
// Отмена до начала работы — тоже отмена, а не отказ.
stopped, stop := context.WithCancel(context.Background())
stop()
early, err := replay.Run(stopped, replay.Options{
Archive: arch,
Source: src,
Target: openStore(t, filepath.Join(dir, "rebuild2.db")),
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err != nil {
t.Fatalf("проигрывание при отменённом контексте: %v", err)
}
if !early.Canceled || early.Folded != 0 {
t.Errorf("отмена до старта дала %+v", early)
}
}
// Нечитаемый или отсутствующий каталог архива — отказ, а не пустой журнал:
// пустая витрина совпадает по отпечатку с пустой витриной и выглядит идеальной
// сходимостью.
func TestОтсутствующийАрхивЭтоОтказ(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if _, err := archive.Existing(filepath.Join(dir, "нет-такого")); err == nil {
t.Fatal("отсутствующий каталог архива не дал отказа")
}
arch := openArchive(t, filepath.Join(dir, "raw"))
if err := os.RemoveAll(arch.Root()); err != nil {
t.Fatalf("удаление каталога: %v", err)
}
dst := openStore(t, filepath.Join(dir, "rebuild.db"))
_, err := replay.Run(context.Background(), replay.Options{
Archive: arch,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err == nil {
t.Error("исчезнувший каталог архива дал пустой журнал вместо отказа")
}
}
// Битое тело не срывает прогон: остальные доставки обязаны проиграться.
func TestБитоеТелоНеСрываетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
broken := filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")
if err := os.WriteFile(broken, []byte("не gzip"), 0o600); err != nil {
t.Fatalf("порча тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
// Битый gzip — «прочее», а не невыведенный слой: классы разведены именно
// затем, чтобы человек не искал дефект там, где его нет.
if rep.FailedOther != 1 {
t.Errorf("прочих отказов %d, ожидался 1: %+v", rep.FailedOther, rep)
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d, ожидалась 1 — прогон сорвался на битом теле", rep.Folded)
}
}
// Одно имя тела в двух каталогах — копия, восстановленная руками, или тело,
// переложенное не туда. Вторая запись журнала с тем же идентификатором сорвала
// бы весь прогон отказом по первичному ключу.
func TestПовторИдентификатораНеСрываетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
// Тот же файл, но под другой датой.
body, err := os.ReadFile(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz"))
if err != nil {
t.Fatalf("чтение тела: %v", err)
}
other := filepath.Join(arch.Root(), "2026", "07", "31")
if err := os.MkdirAll(other, 0o755); err != nil {
t.Fatalf("каталог: %v", err)
}
if err := os.WriteFile(filepath.Join(other, items[0].id+".json.gz"), body, 0o600); err != nil {
t.Fatalf("копия тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Duplicates != 1 {
t.Errorf("повторов %d, ожидался 1: %+v", rep.Duplicates, rep)
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d, ожидалась 1 — повтор сорвал прогон", rep.Folded)
}
}
// Доставки одной секунды упорядочиваются идентификатором: `received_at` хранится
// с секундной точностью, и без второго ключа два прогона одного журнала могли бы
// разойтись.
func TestДоставкиОднойСекундыУпорядоченыИдентификатором(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
ids := []string{ident.NewID(), ident.NewID()}
sort.Strings(ids)
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Обе доставки — одна секунда. Первая по идентификатору минутная, вторая без
// плотных метрик: если тай-брейк исчезнет, вторая может пойти первой и
// остаться без предшественника.
writeBody(t, arch, src, item{id: ids[0], at: at, automationID: "a"}, fixture(t, "minute.json"))
writeBody(t, arch, src, item{id: ids[1], at: at, automationID: "a"}, fixture(t, "sparse_sleep.json"))
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.FailedLayer != 0 {
t.Fatalf("слой не вывелся у %d доставок: порядок внутри секунды не задан", rep.FailedLayer)
}
// Повтор в другую базу обязан дать тот же отпечаток.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("порядок внутри секунды не детерминирован:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
}
}
// Учётная запись, тела которой нет, переносится в базу назначения: не перенести
// значило бы стереть первой же подменой единственное свидетельство, что
// доставка была, — тела уже нет, восстановить нечем.
func TestУчётБезТелаПереноситсяВБазуНазначения(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
if err := os.Remove(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")); err != nil {
t.Fatalf("удаление тела: %v", err)
}
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Orphans != 1 {
t.Fatalf("записей без тела %d, ожидалась 1", rep.Orphans)
}
got, err := dst.ListDeliveries(ctx)
if err != nil {
t.Fatalf("учёт: %v", err)
}
if len(got) != len(items) {
t.Fatalf("строк учёта %d, ожидалось %d — запись без тела потеряна", len(got), len(items))
}
// Заголовки переносятся дословно: в архиве их нет вовсе.
for _, d := range got {
if d.Headers != `{"x-test":["1"]}` {
t.Errorf("заголовки доставки %s не дошли дословно: %q", d.ID, d.Headers)
}
}
// Статус — `failed`, а не `pending`. Различие несущее: `pending` означает
// «этим разбором ещё не смотрели» и обещает данные, которых не появится —
// тела уже нет, — а ретеншен, который pending не трогает никогда, берёг бы
// такие строки вечно.
b, err := dst.DeliveryStatus(ctx, items[0].id)
if err != nil {
t.Fatalf("статус записи без тела: %v", err)
}
if b != store.ParseFailed {
t.Errorf("статус записи без тела %q, ожидался %q", b, store.ParseFailed)
}
}
// Имя тела обязано быть КАНОНИЧЕСКИМ идентификатором. Разбор с приведением
// (обрезка пробелов, регистр) дал бы одну координату журнала двум файлам, и
// подложенный вытеснил бы настоящий — невидимые пробелы в этих данных уже
// встречались.
func TestИмяТелаОбязаноБытьКаноническим(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
day := filepath.Join(arch.Root(), "2026", "08", "01")
body, err := os.ReadFile(filepath.Join(day, items[0].id+".json.gz"))
if err != nil {
t.Fatalf("чтение тела: %v", err)
}
// Те же 26 знаков, но с невидимым префиксом и в верхнем регистре: обе формы
// ident.Parse приводит к тому же идентификатору.
for _, name := range []string{" " + items[0].id + ".json.gz", strings.ToUpper(items[0].id) + ".json.gz"} {
if err := os.WriteFile(filepath.Join(day, name), body, 0o600); err != nil {
t.Fatalf("подложенный файл: %v", err)
}
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Bodies != 1 {
t.Errorf("тел %d, ожидалось 1: подложенное имя принято за тело (%+v)", rep.Bodies, rep)
}
if rep.SkippedFiles != 2 {
t.Errorf("пропущено %d, ожидалось 2", rep.SkippedFiles)
}
if rep.Duplicates != 0 {
t.Errorf("повторов %d: настоящее тело вытеснено подложенным", rep.Duplicates)
}
}
+56
View File
@@ -105,6 +105,62 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
return d, nil return d, nil
} }
// ListDeliveries возвращает учёт доставок в порядке журнала — `(received_at,
// id)`, тем же, в котором их проигрывает пересборка.
//
// Отдаются только **факты журнала**: то, что пришло вместе с доставкой.
// Производные от разбора поля (`parse_status`, `points`, `derived_layer`,
// `uncovered_sections`) сюда не попадают намеренно — перенос их в пересобранную
// базу сделал бы витрину функцией предыдущего прогона. Особенно `derived_layer`:
// доставка, чей повторный разбор отказал, отдала бы в наследование слой
// прежнего разбора, и следующая доставка той же автоматизации унаследовала бы
// его молча.
func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) {
const q = `
SELECT id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
FROM delivery ORDER BY received_at, id`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, fmt.Errorf("select deliveries: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Delivery
for rows.Next() {
var d Delivery
var receivedAt string
if err := rows.Scan(&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID,
&d.Aggregation, &d.Period, &d.SessionID, &d.Bytes, &d.SHA256,
&d.RawPath, &d.Headers); err != nil {
return nil, fmt.Errorf("scan delivery: %w", err)
}
d.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return nil, err
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select deliveries: %w", err)
}
return out, nil
}
// DeliveryStatus возвращает статус разбора доставки.
func (s *Store) DeliveryStatus(ctx context.Context, id string) (string, error) {
var status string
err := s.db.GetContext(ctx, &status, `SELECT parse_status FROM delivery WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("select parse status: %w", err)
}
return status, nil
}
// FinishParse записывает исход разбора доставки. // FinishParse записывает исход разбора доставки.
// //
// Слой сохраняется здесь же, потому что он нужен следующей доставке той же // Слой сохраняется здесь же, потому что он нужен следующей доставке той же
+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)
}
}
}
+79
View File
@@ -5,9 +5,12 @@ package store
import ( import (
"context" "context"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net/url" "net/url"
"strconv"
"strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -38,6 +41,70 @@ func Open(dbPath string) (*Store, error) {
return &Store{db: db}, nil return &Store{db: db}, nil
} }
// ErrSchemaMismatch — версия схемы базы не та, которую знает бинарь.
var ErrSchemaMismatch = errors.New("версия схемы базы не совпадает с версией бинаря")
// OpenForRead открывает базу только для чтения и **без наката миграций**.
//
// Обычный Open мигрирует безусловно, а миграции здесь меняют не только схему, но
// и данные: та, что ввела частичный разбор, переписала parse_status у всех строк.
// Значит утилита, которой достаточно прочитать учёт, обычным открытием нарушала
// бы обещание «рабочую базу не трогаем», — и хуже: свежий бинарь мигрировал бы
// схему под работающим старым сервисом, который держит запросы к прежней.
//
// Расхождение версий — отказ с указанием обеих, а не повод мигрировать.
func OpenForRead(dbPath string) (*Store, error) {
db, err := sqlx.Connect("sqlite", readOnlyDSN(dbPath))
if err != nil {
return nil, fmt.Errorf("open sqlite %q read-only: %w", dbPath, err)
}
want, err := latestMigration()
if err != nil {
_ = db.Close()
return nil, err
}
var got int64
if err := db.Get(&got, `SELECT max(version_id) FROM goose_db_version`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("read schema version: %w", err)
}
if got != want {
_ = db.Close()
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, got, want)
}
return &Store{db: db}, nil
}
// latestMigration — номер последней миграции, вшитой в бинарь.
func latestMigration() (int64, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return 0, fmt.Errorf("read migrations dir: %w", err)
}
var top int64
for _, e := range entries {
name := e.Name()
// Неразобранное имя — отказ, а не пропуск: страж «версия схемы не та»,
// молча не заметивший миграцию, перестаёт страховать, не сказав об этом.
idx := strings.IndexByte(name, '_')
if idx <= 0 {
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
}
v, err := strconv.ParseInt(name[:idx], 10, 64)
if err != nil {
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
}
if v > top {
top = v
}
}
if top == 0 {
return 0, errors.New("миграций не найдено")
}
return top, nil
}
// Close закрывает соединение с БД. // Close закрывает соединение с БД.
func (s *Store) Close() error { func (s *Store) Close() error {
if err := s.db.Close(); err != nil { if err := s.db.Close(); err != nil {
@@ -63,6 +130,18 @@ func dsn(path string) string {
return "file:" + path + "?" + q.Encode() return "file:" + path + "?" + q.Encode()
} }
// readOnlyDSN — подключение только для чтения.
//
// `journal_mode` здесь не задаётся: сменить его на read-only соединении нельзя,
// а читать базу в режиме WAL это не мешает. `_txlock=immediate` тоже не нужен —
// он лечит повышение блокировки с чтения на запись, которого здесь не бывает.
func readOnlyDSN(path string) string {
q := url.Values{}
q.Add("mode", "ro")
q.Add("_pragma", "busy_timeout(5000)")
return "file:" + path + "?" + q.Encode()
}
// migrate накатывает миграции. // migrate накатывает миграции.
// //
// Через Provider, а не через пакетные функции: goose.SetBaseFS и // Через Provider, а не через пакетные функции: goose.SetBaseFS и
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,346 @@
## Context
Витрина объявлена свёрткой по журналу
(`import(экспорт) + replay(доставки по received_at)`), но кода свёртки нет:
`fold.Fold` умеет свернуть **одну** доставку по её идентификатору, а того, кто
перечислит журнал и позовёт её по каждой записи, не существует. Следствия уже
наблюдаемы: после миграции 00005 доставки числятся `pending` (этим разбором не
смотрели), и подобрать их некому; а точки, разобранные прежним кодом, лежат в
объектах и не удаляются никогда — исправление разбора к ним не применится.
Что уже сделано и на что опираемся:
- `fold.Fold(ctx, deliveryID)` читает тело **из архива**, а не из памяти —
ровно потому, что путь чтения у приёма и у пересборки обязан быть один.
- Слияние точек — функция множества кандидатов, а не порядка (частичный
порядок полноты + тотальный тай-брейк), поэтому повторная свёртка той же
доставки ничего не меняет.
- Вывод слоя зависит от **префикса журнала**: доставка без плотных метрик
наследует слой предшествующей доставки той же автоматизации
(`LastDerivedLayer` с границей по `(received_at, id)`).
- `store.Fingerprint` даёт отпечаток витрины по координатам и хешам объектов,
не раскрывая значений. Он уже служит оракулом в `task verify:archive`.
Ограничения окружения: сервис живёт в контейнере и держит базу открытой,
телефон шлёт молча и непрерывно, объём архива за квартал — порядка 2 ГБ.
## Goals / Non-Goals
**Goals:**
- Проиграть журнал целиком и получить состояние, совпадающее с накопленным
приёмом; повторный прогон ничего не меняет.
- Считать журналом **архив**, а не таблицу доставок: тело без учётной записи
тоже событие.
- Дать оракул сходимости прямо в команде — сравнение отпечатков, а не «глазом
по логам».
- Оставить дверь для `healthlog import`: пересборка из архива это вырожденный
случай с пустым снапшотом, а не отдельная утилита.
**Non-Goals:**
- **Подмена рабочей базы.** Команда не заменяет файл базы и не останавливает
сервис (см. решение 2).
- **Импорт родного экспорта Apple.** Стадия снапшота в этой дельте пуста.
- **Ретеншен архива.** Пересборка тел не удаляет; она их только читает.
- **Восстановление верхних слоёв за периоды с удалёнными телами.** Считать их
вниз из `sample` запрещено инвариантом — это была бы наша агрегация под видом
присланной.
- **Онлайн-пересборка под живым приёмом.** Пересборка идёт в отдельный файл, и
доставки, приехавшие во время неё, в него не попадают; это названная граница,
а не дефект (см. риски).
## Три формы решения и компромисс каждой
Рассматривались три, а не одна; выбрана вторая.
**A. Очистить рабочую витрину и проиграть журнал в неё же.** Дёшево, второй
базы нет, результат применяется сам собой. Компромисс: единственная необратимая
операция всей задачи (`DELETE FROM bucket`) выполняется **до** того, как станет
известно, удалась ли пересборка. Отказ на середине оставляет витрину пустой
наполовину, и это состояние ничем не отличается от нормального. Под живым
сервисом — ещё и окно, в котором история отдаётся полупустой как полная.
**B. Собрать витрину в отдельный файл базы, подмену оставить человеку**
(выбрано). Отказ бесплатен: рабочая база не тронута, временный файл удаляется;
результат можно сверить с рабочим прежде, чем применять. Компромисс: результат
не применяется сам — нужна процедура из четырёх команд с остановкой сервиса, а
доставки, приехавшие во время сборки, в новый файл не попадают и подбираются
только следующим прогоном.
**C. Теневая таблица внутри той же базы: собрать в `bucket_new`, затем
переименовать в транзакции.** Подмена атомарна средствами самой SQLite, вторая
база не нужна, остановка сервиса теоретически не требуется. Компромисс
решающий: имя `bucket` зашито литералом во весь слой записи (`internal/store`),
и вариант требует параметризовать таблицей всю запись — то есть переписать
самый опасный код проекта ради операции, которая выполняется раз в полгода.
Вдобавок он не решает того, ради чего затевался: живой приём во время
пересборки пишет в **старую** таблицу, и при подмене его точки пропадают, —
значит приём всё равно надо останавливать, и сверх B вариант не даёт ничего.
## Decisions
### 1. Пересборка идёт в отдельный файл базы, а не поверх рабочей
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в нём
результат старого, неверного разбора — то есть не сделало бы ровно того, ради
чего задача и заведена.
Начать с пустой витрины можно двумя способами: очистить рабочую таблицу и
проиграть журнал в неё же, либо собрать новую витрину рядом и подменить.
Взято второе. Prior art здесь однозначен и стар: это blue-green rebuild
проекции — «вместо усечения существующей модели строим новую в параллельном
хранилище и переключаем чтение, когда она догонит»
([Rebuilding Event-Driven Read Models](https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models),
[Projections and Read Models](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/));
тем же приёмом работает `_reindex` + переключение алиаса в Elasticsearch, и та
же форма у собственного `VACUUM INTO` SQLite — «собери целую копию в новый
файл».
Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины —
единственная **необратимая** операция во всей задаче, и она наступает **до**
того, как станет известно, что пересборка вообще удалась. Отказ на середине
(битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину, причём в состоянии, которое ничем не отличается от нормального.
Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл
удаляется.
Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина —
названа выше; плюс под живым сервисом это ещё и окно, в котором Read API
отдавал бы полупустую историю как полную.
### 2. Подмену рабочей базы делает человек, а не команда
Файл базы держит открытым процесс сервиса. В POSIX переименование не касается
уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а
читатели увидят новый файл — данные разойдутся молча. Документация SQLite
говорит об этом прямо: переименование или удаление файла базы во время записи
оставляет журнал под чужим именем и **портит базу**
([How To Corrupt An SQLite Database File](https://www.sqlite.org/howtocorrupt.html)),
и в форуме проекта то же короче: «никогда не безопасно переименовывать
используемый файл sqlite3».
Значит безопасная подмена требует, чтобы сервис был остановлен. Остановить его
команда не может: сервисом управляет docker compose снаружи, и CLI, который
делает вид, что управляет, обещал бы безопасность, которой не обеспечивает.
Поэтому подмена остаётся процедурой человека (`task down``mv``task up`), а
команда печатает её в отчёте буквально.
Это же совпадает с правилом проекта: спрашиваем про необратимое. Перезапись
рабочей базы — ровно оно.
Отвергнуто: флаг `--replace`, делающий подмену сам. Причина — безопасен он
только при остановленном сервисе, а проверить это изнутри нечем; флаг,
безопасный лишь при невыраженном условии, хуже его отсутствия.
### 3. Журнал перечисляется по архиву, а учёт по нему сверяется
Перечислять только строки `delivery` значило бы пересобирать витрину из
**витрины**. Тело может лежать в архиве без учётной записи: приём пишет тело
на диск раньше строки в базе — намеренно, обратный порядок дал бы учтённую
доставку без данных, — и на отказе вставки в `internal/ingest` уже записано
обещание, что такое тело подберёт пересборка.
Поэтому вход пересборки — объединение двух множеств:
```
тело в архиве + строка delivery → штатная доставка, метаданные из строки
тело в архиве, строки нет → заводится заново: id из имени файла,
received_at из метки ULID, bytes и sha256
пересчитываются по телу
строка есть, тела нет → считается и называется в отчёте, не отказ
```
Третий случай станет штатным, когда появится ретеншен архива: тела до даты
проверенного экспорта срезаются, а строки живут дольше. Отказом он быть не
должен уже сейчас.
Метка приёма для тела без записи берётся из **ULID**, а не из даты каталога:
каталог даёт сутки, а порядок внутри суток важен — от него зависит наследование
слоя. ULID монотонен по времени создания, а создаётся идентификатор в приёме
непосредственно перед меткой `received_at`.
Заголовки доставки при этом не восстанавливаются: **в архиве их нет вовсе**.
Для штатных доставок они берутся из рабочей базы; у подобранного тела их не
будет, и вывод слоя для него опустится на общее правило (нет автоматизации —
нечего наследовать, нет заголовка — нечем подтвердить). Это честная деградация,
и она названа в спеке. Устранять её (класть заголовки в архив рядом с телом —
так делает WARC) в этой дельте нельзя: правка пути приёма стоит дороже всей
остальной задачи.
### 4. Порядок — строго `(received_at, id)`
Слияние точек коммутативно, но слой — нет: он функция префикса журнала. Порядок
обхода каталога (`filepath.Walk` по датам) совпадает с хронологией только
случайно, а внутри суток не даёт ничего. Сортировка по `(received_at, id)`
доопределяет и совпадение меток: `received_at` усечён до секунды, и доставки в
одной секунде без второго ключа шли бы в произвольном порядке.
### 5. Отчёт печатается человеку, оракул — отпечаток, исход — код возврата
Итог пересборки — счётчики (доставок проиграно, свёрнуто, отказов, тел без
учёта, строк без тел, пропущенных файлов, объектов) и **два отпечатка**: рабочей
витрины и пересобранной. Совпали — состояние воспроизводимо; разошлись — это
либо исправленный разбор (ожидаемо), либо расхождение, которое надо смотреть.
Отпечаток значений точек не раскрывает: содержимое входит в него хешем.
Отпечаток рабочей витрины снимается **до** проигрывания, а число доставок — до
и после. Без этого оракул под живым приёмом отвечает «разошлись» всегда: любая
доставка, приехавшая за время прогона, двигает рабочую витрину. Оракул, который
врёт без предупреждения, перестают читать — и он не сработает ровно тогда, когда
разбор действительно разойдётся.
**Отдельное решение — что считать успехом.** Расхождение отпечатков успехом быть
не перестаёт: оно и есть смысл пересборки. А вот пустой журнал успехом не
является, хотя выглядит идеально: отпечаток пустой витрины совпадает с
отпечатком пустой витрины. Все умолчания подыгрывают такому запуску — конфиг
необязателен, и без него пути указывают в рабочий каталог процесса, а каталог
архива по этому пути пересборка **не создаёт**. Поэтому пустой журнал, ноль
свёрнутых доставок, отмена и ошибка окружения дают ненулевой код и не печатают
процедуру подмены; отказ отдельной доставки — не даёт, он штатный.
**Потоки разведены:** отчёт — в stdout человеческим текстом, прогресс — в
stderr. Прогон на полном архиве молчит минутами, и зависший неотличим от
идущего; смешивать прогресс с отчётом нельзя, иначе отчёт нельзя перенаправить.
Рендер отчёта принимает `io.Writer` и не знает про `os.Stdout` — иначе проверка
«отчёт не раскрывает данных о здоровье» превращается в тест на глобальном
состоянии, а это единственная защита новой поверхности вывода.
Логи свёртки при этом остаются логами и пишутся `slog`, как при приёме: один
чекпоинт на доставку, без значений точек. Запрет на имена метрик относится к
отчёту, а не к логу: координаты столкновения разрешены спекой хранения явно.
### 6. Новый пакет `internal/replay`, а не метод у `fold`
`fold` отвечает за одну доставку и ничего не знает ни про каталог архива, ни
про порядок. Проигрывание журнала — другая ответственность: перечислить,
упорядочить, догрузить недостающий учёт, свести отчёт. Имя `replay`, а не
`reindex`, потому что это половина формулы `import + replay`: задача про родной
экспорт добавит стадию снапшота **перед** проигрыванием и переиспользует ту же
операцию, а не заведёт вторую похожую.
### 7. Прогон живого архива переезжает на пересборку — второго проигрывателя не остаётся
`internal/fold/replay_test.go` (он же `task verify:archive`) сегодня проигрывает
живой архив **своими руками**: свой обход каталога, свой порядок (сортировка
путей), свой синтез учёта (все тела под одной автоматизацией). Это и есть второй
проигрыватель, и его правила уже расходятся с дельтой: порядок не
`(received_at, id)`, случаев «учёт без тела» и «имя не тело» у него нет вовсе.
После появления `internal/replay` он продолжил бы зеленеть, проверяя путь,
которым `healthlog reindex` не ходит. Цена ошибки здесь известна: именно этот
прогон поймал дефект `LastDerivedLayer` — тот, из-за которого пересборка давала
1742 объекта вместо 1737 (`docs/review-journal.md`).
Поэтому прогон переписывается поверх `replay.Run`, а его утверждения остаются на
месте — они и есть его ценность. Одно из них по дороге пришлось переписать:
константа «174 координаты `sleep_analysis`» снята на 94 доставках и протухла на
116, потому что производна от размера корпуса. Утверждается теперь само
свойство — координат строго больше, чем различных меток, — а измеренные числа
печатаются.
`task verify:archive` сохраняет имя и смысл, отдельной задачи «прогон
пересборки» в `Taskfile.yml` не появляется.
Отвергнуто: (б) заменить прогон вызовом самой команды на живом архиве — тогда
измеренные утверждения умирают, а остаётся «отработало без ошибки»; (в) держать
оба проигрывателя — каждый будущий правщик порядка или подбора обязан править
два места, а расхождение между ними не поймает никто.
### 8. Рабочая база открывается на чтение и без миграций
Обычное открытие (`store.Open`) накатывает миграции безусловно, а миграции здесь
меняют и **данные**: та, что ввела частичный разбор, переписала `parse_status` у
всех строк. То есть штатный путь чтения нарушал бы собственное требование
«рабочую базу не трогаем», и приёмочный сценарий этого не заметил бы — отпечаток
считается по объектам, а не по учёту.
Вводится отдельный конструктор чтения: без наката миграций, с проверкой версии
схемы. Расхождение версий — отказ с указанием обеих, а не молчаливая миграция
под работающим сервисом.
Отвергнуто: соглашение «запускать на одной версии бинаря». Договорённость с
самим собой не является механизмом, а цена нарушения — DDL под живым приёмом.
### 9. Тождество файла назначения — по файлу, а не по строке пути
Сравнение путей строкой не отвечает на вопрос «это тот же файл»: `..` в пути,
симлинк, другой префикс монтирования внутри контейнера дают ту же цель при
другой строке. Ошибка здесь означает проигрывание журнала прямо в живую рабочую
базу — то самое необратимое, ради предотвращения которого выбрана сборка рядом.
Тождество определяется свойствами файла (устройство и inode); когда файла
назначения ещё нет — свойствами родительского каталога и именем.
### 10. Идемпотентность держится существующим слиянием, а не новым кодом
Повторный прогон не меняет состояния потому, что победитель координаты —
функция множества кандидатов. Пересборка не добавляет к этому ничего своего и
не имеет права: любая её собственная «оптимизация» вроде пропуска доставок по
`parse_status` сделала бы результат зависящим от предыдущего прогона. Поэтому
проигрываются **все** доставки, а дешевизну повтора обеспечивает хеш-детектор
объекта.
## Risks / Trade-offs
- **Доставки, приехавшие во время пересборки, в новый файл не попадут** → они
остаются в архиве и в рабочей базе; после подмены их тела окажутся телами без
учётной записи, и следующий прогон их подберёт (решение 3). Штатная процедура
— остановить сервис на время подмены; окно в минуты закрывают средний и
глубокий проходы синхронизации, у которых окна фиксированные.
- **Остановка сервиса на время подмены — окно, в котором доставка не
принимается** → переживает ли «Since Last Sync» неудачную отправку,
неизвестно (открытый вопрос `docs/local-research.md`). Поэтому на остановку
полагаться нельзя, и страхует её другое: средний и глубокий проходы
синхронизации работают **фиксированными окнами** (сутки и неделя), то есть
переприсылают период целиком независимо от того, что было доставлено.
Практический вывод для процедуры: подменять базу стоит минутами, а не часами,
и не откладывать перезапуск.
- **Пересборка читает рабочую базу, пока сервис в неё пишет** → чтение под WAL
безопасно, но `store.Open` накатывает миграции. На актуальной схеме это
no-op; на устаревшей — миграция под живым трафиком, чего команда не ожидает.
Смягчение: подмена и пересборка выполняются на одной версии бинаря, как и
сказано в процедуре.
- **`sealed` в пересобранной витрине пуст** → правила его выставления ещё нет
(порог глубины досчёта не выбран), так что переносить нечего. Когда правило
появится, признак станет функцией от часа и воспроизведётся сам. Пока это
означает: отпечатки разойдутся, если кто-то выставил `sealed` руками.
- **Время прогона растёт линейно по архиву** → 2 ГБ за квартал, чтение и
разбор каждого тела. Для ручной операции приемлемо; порционность
(«разбивать реплей на куски») из prior art не берём — она нужна миллиардам
событий, а не сотням тел.
- **Отчёт печатает пути и счётчики** → путей внутри `./data` в отчёте
достаточно, чтобы человек сделал `mv`, но ни имён метрик, ни значений точек в
нём нет.
## Migration Plan
Миграций схемы нет. Процедура применения пересобранной витрины (её же печатает
команда):
```
task down # сервис отпускает файл базы И перестаёт принимать
healthlog reindex --config ./config.toml # собирает ./data/healthlog.db.rebuild
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Порядок здесь существен: сервис останавливается **до** пересборки, а не после
неё. Доставки, приехавшие за время прогона, в собранный файл не попадут, и
подмена стёрла бы их учёт вместе с заголовками. Команда это ловит — печатает
разницу числа доставок и в таком случае процедуру подмены не печатает вовсе, —
но платить за это лишним прогоном не нужно. Пересборка без подмены (сверка
отпечатков) при живом сервисе, наоборот, безопасна и полезна.
Откат: рабочая база не тронута до `mv`, поэтому откат — не делать `mv`. После
`mv` откат — повторная пересборка из того же архива: журнал не изменился.
## Open Questions
- Заголовки доставки в архиве не лежат, поэтому пересборка «с нуля», без
рабочей базы, деградирует по выводу слоя. Класть ли рядом с телом его
заголовки (как WARC) — отдельная задача беклога, не эта дельта.
- Нужен ли режим «догнать только неразобранное» для фонового подбора
`pending` — это половина задачи «разнести ответ приёма и свёртку»; здесь не
решается, но `replay` даёт ей готовую операцию.
@@ -0,0 +1,69 @@
## Why
Разбор пишется по реальным данным и будет ошибаться — это норма. Без
пересборки ошибка разбора становится потерей данных: исправленный код не
применится к тому, что уже разобрано неверно, а точки из объекта не удаляются
никогда. Сегодня журнал есть (116 тел в архиве), а кода, который его
проигрывает, нет: после миграции 00005 доставки числятся `pending`, и подобрать
их некому.
Оговорка, которую легко прочитать наоборот: пересборка точнее приёма **не тем,
что видит более длинный ряд**. Слой обязан быть функцией префикса журнала, и
наследование «от последней доставки вообще» уже ловили дефектом — 1737 объектов
против 1742. Точнее она ровно тем, что применяет **исправленный** разбор к
тому, что уже разобрано неверно.
## What Changes
- Новая подкоманда `healthlog reindex`: собирает витрину из журнала —
`import(снапшот) + replay(доставки по received_at)` — и пишет её в **отдельный
файл базы**, не трогая рабочую. Снапшот в этой дельте пуст: `import` появится
вместе с задачей про родной экспорт Apple, и та встроится сюда же, а не
заведёт вторую операцию.
- Журналом считается **архив**, а не таблица доставок: тела, у которых учётной
записи нет (приём успел записать тело и упал на вставке строки), заводятся
заново по имени файла. Это обещание, уже записанное в `internal/ingest`.
- Порядок проигрывания — строго `(received_at, id)`, а не порядок обхода
каталога: слой наследуется от предшествующей доставки той же автоматизации,
и порядок входит в результат.
- Оракул сходимости встроен в команду: отпечаток пересобранной витрины
печатается рядом с отпечатком рабочей, и команда прямо говорит, совпали они
или нет. Отпечаток значений точек не раскрывает.
- Подмена рабочей базы пересобранной **остаётся за человеком** и в команду не
входит: сервис держит открытый дескриптор, и `rename` поверх него оставил бы
процесс писать в отвязанный inode — молча.
- Границы, названные вслух: `sealed` в пересобранной витрине пуст (правила его
выставления ещё нет), а заголовки доставок берутся из рабочей базы — в архиве
их нет вовсе.
- Прогон живого архива (`task verify:archive`) переезжает на новый код: сегодня
он **второй проигрыватель журнала** со своим порядком и своим синтезом учёта,
и после появления настоящей пересборки зеленел бы, проверяя путь, которым
команда не ходит.
## Capabilities
### New Capabilities
- `reindex`: пересборка витрины проигрыванием журнала — состав журнала,
порядок, детерминированность, отчёт и его оракул, граница «что не
восстанавливается».
### Modified Capabilities
Изменённых нет. Правило «тело без учётной записи заводится заново» могло бы
показаться правилом учёта, но оно описывает состав журнала при проигрывании и
живёт в `reindex`; дублировать его в `storage` значило бы завести два места, где
сказано одно и то же. Схема БД, слияние точек и вывод слоя не меняются: вся
дельта — новый потребитель существующей свёртки.
## Impact
- Новый пакет `internal/replay` — проигрывание журнала поверх существующего
`internal/fold`; собственного разбора и собственного слияния не заводит.
- Новый файл `cmd/healthlog/reindex.go`, строка в `main.go`.
- `internal/store`: перечисление доставок в порядке журнала, очистка витрины,
чтение отпечатка (уже есть).
- `internal/ident`: время создания из ULID — метка приёма для тела без учётной
записи.
- Схема БД не меняется, миграций нет.
- `README.md` (`reindex` перестаёт быть «в планах»), `docs/architecture.md`
(почему подмена базы не автоматизируется), `docs/plan.md`, `docs/backlog`.
@@ -0,0 +1,430 @@
## ADDED Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
в порядке обхода каталога архива.
Порядок входит в результат: слой доставки без плотных метрик наследуется от
**предшествующей** доставки той же автоматизации, то есть слой есть функция
префикса журнала. Обход каталога совпадает с хронологией только по датам
каталогов и внутри суток не упорядочивает ничего.
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
а значит два прогона одного журнала могли бы разойтись.
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
запись обратно.
#### Scenario: Порядок не зависит от раскладки файлов в архиве
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
не совпадает с хронологией приёма
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
#### Scenario: Доставки одной секунды упорядочены идентификатором
- **WHEN** две доставки имеют одинаковый `received_at`
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
прогоне
### Requirement: Журналом считается архив, а не таблица доставок
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
доставок, а не перечислять только строки `delivery`. Иначе витрина
пересобиралась бы из витрины.
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
оставляет тело в архиве без учёта.
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
появились бы значения другой природы.
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
слоя: наследовать не от чего и подтверждать нечем.
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
называться в отчёте.
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
разбираться по логу.
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
создавать MUST NOT — создание превратило бы запуск не из того каталога в
успешный прогон по пустому журналу.
#### Scenario: Тело без учётной записи подбирается
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
приёма из ULID в UTC
- **AND** её размер и хеш посчитаны по распакованному телу
- **AND** её точки попадают в витрину
#### Scenario: Учётная запись без тела не роняет пересборку
- **WHEN** у строки `delivery` нет тела в архиве
- **THEN** пересборка продолжается
- **AND** факт учитывается счётчиком в отчёте
- **AND** сама запись переносится в базу назначения, но не сворачивается
#### Scenario: Файл, не являющийся телом, считается отдельно
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
расширение не соответствует форме тела
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
#### Scenario: Повтор идентификатора не срывает прогон
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
- **THEN** проигрывается первое, второе учитывается счётчиком
- **AND** пересборка доходит до конца
#### Scenario: Каталог архива не читается
- **WHEN** корня архива нет либо подкаталог не читается
- **THEN** команда завершается ошибкой и витрину не собирает
### Requirement: Пересборка не трогает рабочую базу
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
никогда, поэтому проигрывание поверх накопленного оставило бы результат
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
**до** того, как известно, что пересборка удалась: отказ на середине (битое
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину в состоянии, неотличимом от нормального.
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
схему под работающим старым сервисом.
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
быть отказом с указанием обеих версий, а не поводом мигрировать.
#### Scenario: Рабочая база остаётся нетронутой
- **WHEN** пересборка отработала успешно
- **THEN** отпечаток рабочей витрины не изменился
- **AND** учёт доставок в рабочей базе не изменился
- **AND** собранная витрина лежит в отдельном файле
#### Scenario: Отказ посреди пересборки не портит рабочую базу
- **WHEN** пересборка прерывается на середине журнала
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
какими были
#### Scenario: Схема рабочей базы старше бинаря
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
- **THEN** команда завершается ошибкой, называя обе версии
- **AND** не пишет в рабочую базу ни одной строки
### Requirement: Файл назначения и его жизненный цикл
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
остаётся переименованием внутри одной файловой системы.
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
убираться вместе со спутниками журнала SQLite.
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
приучил бы обходить защиту от перезаписи флагом принудительности.
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
пустой витрины, а не дописывается в чужое содержимое.
#### Scenario: Файл назначения совпадает с рабочей базой
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
пути
- **THEN** команда завершается ошибкой и не пишет ничего
#### Scenario: Файл назначения уже существует
- **WHEN** файл назначения существует, а перезапись не затребована явно
- **THEN** команда завершается ошибкой и существующий файл не трогает
#### Scenario: Затребованная перезапись даёт ту же витрину
- **WHEN** пересборка выполняется поверх существующего файла назначения с
явно затребованной перезаписью
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
журнала в отсутствующий файл
#### Scenario: Прерванная пересборка не оставляет файла назначения
- **WHEN** пересборка прерывается на середине журнала
- **THEN** файла по пути назначения не существует
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections
← начинаются пустыми
```
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и
выполнять её сама MUST NOT.
Файл базы держит открытым процесс сервиса, а переименование не касается уже
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
переименование используемого файла прямой причиной порчи базы. Безопасная
подмена требует остановленного сервиса, а остановить его команда не может:
сервисом управляет окружение снаружи.
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
команды»).
#### Scenario: Отчёт называет процедуру подмены
- **WHEN** прогон признан успешным
- **THEN** отчёт содержит путь собранного файла и команды подмены
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
называть человеку следует только нештатные отказы.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
### Requirement: Отказ на одной доставке не останавливает пересборку
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
(тело не читается, тело больше предела, слой не выводится, содержимое не
разбирается), и учитывать такие доставки счётчиком отказов.
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
пересборки остальные тела.
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
прерывает человек, и без перевода сигнала прерывания в отмену контекста
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
завершиться ненулевым кодом.
#### Scenario: Битое тело не срывает прогон
- **WHEN** одно из тел архива не распаковывается
- **THEN** остальные доставки проигрываются
- **AND** отказ учитывается счётчиком в отчёте
#### Scenario: Отмена прекращает проигрывание
- **WHEN** сигнал прерывания приходит посреди журнала
- **THEN** проигрывание прекращается, печатается частичный отчёт
- **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся
@@ -0,0 +1,127 @@
## 1. Опоры в существующих пакетах
- [x] 1.1 `internal/ident`: `TimeOf(id string) (time.Time, error)` — время
создания из ULID, **в UTC**, усечённое до секунды (как `store.Now`).
`ulid.Time` внутри зовёт `time.Unix` и отдаёт локальную зону, а
`Truncate` зону не нормализует — значит `.UTC()` обязателен явно.
- [x] 1.2 `internal/archive`: перечисление тел — обход корня, отбор форм тела,
возврат относительных путей и отдельно — пропущенных файлов. Ошибка
чтения каталога (включая отсутствие корня) возвращается наружу, а не
превращается в пустой список; каталог не создаётся.
- [x] 1.3 `internal/store`: `ListDeliveries(ctx)` — доставки в порядке
`(received_at, id)` со **всеми фактами журнала**, включая `headers`.
- [x] 1.4 `internal/store`: открытие рабочей базы **только для чтения и без
наката миграций** + проверка версии схемы; расхождение — ошибка,
называющая обе версии.
## 2. Проигрывание журнала — `internal/replay`
- [x] 2.1 Собрать вход: объединить тела архива с учётом доставок; развести
четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и
отсортировать по `(received_at, id)`.
- [x] 2.2 Перенести учёт в базу назначения по нормированному составу: факты
журнала дословно (включая `headers`), производные от разбора —
пустыми (`parse_status=pending`, `points=0`, `derived_layer=''`,
`uncovered_sections='[]'`). Подобранные тела завести заново: id из имени
файла, метка из ULID в UTC, `bytes`/`sha256` — по **распакованному** телу.
- [x] 2.3 Проиграть журнал последовательно: `fold.Fold` по каждой доставке,
`fold.New` собирается с тем же пределом тела, что и приём
(`cfg.Ingest.MaxBodyMB`). Отказ одной доставки не прекращает прогон,
отмена контекста — прекращает.
- [x] 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной
витрины, признак отмены.
## 3. Команда `healthlog reindex`
- [x] 3.1 `cmd/healthlog/reindex.go`: флаги `--config`, `--out`, `--force`;
умолчание `--out` — сосед рабочей базы; тождество с рабочей базой
проверяется **по файлу** (`os.SameFile`), а для несуществующего файла —
по родительскому каталогу и имени; `flag.ErrHelp` не превращается в
`fatal`.
- [x] 3.2 Жизненный цикл файла назначения: сборка под временным именем,
переименование — последний шаг успеха; при любом ином исходе файла по
пути назначения нет, временный и его спутники (`-wal`, `-shm`) убраны.
- [x] 3.3 Отмена: `signal.NotifyContext(SIGINT, SIGTERM)`, частичный отчёт,
ненулевой код.
- [x] 3.4 `func writeReport(w io.Writer, …)` — отчёт в stdout, прогресс в
stderr; процедура подмены печатается только при успешном исходе.
- [x] 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна
доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка
окружения. Отказ отдельной доставки исхода команды не меняет.
- [x] 3.6 Подключить подкоманду в `main.go`.
## 4. Проверки
- [x] 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу →
отпечатки совпали; второй прогон → отпечаток не изменился.
- [x] 4.2 Порядок: журнал, у которого раскладка файлов расходится с
хронологией, даёт доставке без плотных метрик слой **предшествующей** по
`received_at`, а не соседа по каталогу; доставки одной секунды упорядочены
идентификатором.
- [x] 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают,
`bytes`/`sha256` посчитаны по распакованному телу; учётная запись без тела
считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый
каталог — отказ команды.
- [x] 4.4 Учёт в базе назначения: число строк и `headers` совпадают с рабочей
плюс подобранные; проставленный в рабочей базе `derived_layer` на
результат не влияет (отпечаток тот же, что из учёта без слоёв).
- [x] 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает
проигрывание и не оставляет файла назначения; рабочая база после
прерванной пересборки не изменилась ни витриной, ни учётом.
- [x] 4.6 Аргументы: `--out` — тот же файл, что рабочая база, по другому пути;
существующий файл без `--force`; `--force` даёт ту же витрину, что сборка
в отсутствующий файл.
- [x] 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены;
ноль свёрнутых — то же.
- [x] 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
- [x] 4.9 Прогон живого архива переписан поверх `replay` (см. 6.5), измеренные
утверждения сохранены.
## 5. Приёмочные критерии из ревью дизайна
Рубрика порождена проходом `healthlog-review-rubric` до чтения предложения.
Пункты, уже закрытые разделами выше, отмечены ссылкой.
- [x] 5.1 **Идемпотентность прогона.** Второй прогон даёт то же состояние не
только по отпечатку витрины, но и по учёту доставок в базе назначения
(число строк, `id`, `received_at`, `bytes`, `sha256` подобранных тел).
- [x] 5.2 **Тотальный детерминированный порядок.** Результат не зависит от
порядка обхода каталога, часового пояса процесса и числа перечитываний
каталога (см. 4.2).
- [x] 5.3 **Независимость от «сейчас».** Ни одно поле, влияющее на отпечаток, не
производно от времени прогона: метки берутся из события, а не из
`store.Now`. Проигрывание последовательно.
- [x] 5.4 **Незавершённая сборка неотличимой от завершённой быть не может**
(см. 3.2, 4.5).
- [x] 5.5 **Отмена доводится до конца и различима снаружи** (см. 3.3).
- [x] 5.6 **Оракул успеха не сводится к «ошибок не было»** (см. 3.5, 4.7).
- [x] 5.7 **Политика частичного отказа явная и счётная.** У каждого класса
отказа свой счётчик, сумма счётчиков сходится с числом входов.
- [x] 5.8 **Рабочая база открывается только на чтение и без миграций**
(см. 1.4).
- [x] 5.9 **Расход памяти не растёт с объёмом архива.** Тела читаются по
одному, предел распакованного тела тот же, что у приёма (см. 2.3);
превышение — учтённый отказ доставки, а не падение прогона.
- [x] 5.10 **Длинный прогон наблюдаем** (см. 3.4).
- [x] 5.11 **Аргументы безопасны и обратимы** (см. 3.1, 4.6).
- [x] 5.12 **Невосстановимое названо, а не досчитано.** Отчёт называет классы
ожидаемого расхождения (новые доставки за время прогона, непереносимый
`sealed`, исправленный разбор) отдельно от самого факта расхождения.
- [x] 5.13 **Ни отчёт, ни лог выше `DEBUG` не несут данных о здоровье**
(см. 4.8).
## 6. Документация
- [x] 6.1 `docs/architecture.md`: почему подмена базы не автоматизируется
(открытый дескриптор, порча базы SQLite) и почему пересборка идёт в
отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с
причиной. Там же — правка утверждения «пересчёт при `reindex` идёт по всей
истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
- [x] 6.2 `README.md`: `reindex` перестаёт быть «в планах», процедура применения.
- [x] 6.3 `docs/plan.md`: `reindex` вычеркнут из остатка шага «Разбор и
хранилище».
- [x] 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве
рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без
рабочей базы деградирует по выводу слоя.
- [x] 6.5 `task verify:archive` переезжает на `internal/replay`: отдельной
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.
+439
View File
@@ -0,0 +1,439 @@
# reindex Specification
## Purpose
Пересборка витрины проигрыванием журнала: `import(снапшот) + replay(доставки)`.
Витрина производна, источник истины — сырой архив тел; значит любое повреждение,
включая ошибку нашего же разбора любой давности, лечится пересборкой, а не
восстановлением из бекапа. Здесь живут состав журнала, порядок проигрывания,
детерминированность, оракул сходимости и граница «что не восстанавливается».
## Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
в порядке обхода каталога архива.
Порядок входит в результат: слой доставки без плотных метрик наследуется от
**предшествующей** доставки той же автоматизации, то есть слой есть функция
префикса журнала. Обход каталога совпадает с хронологией только по датам
каталогов и внутри суток не упорядочивает ничего.
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
а значит два прогона одного журнала могли бы разойтись.
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
запись обратно.
#### Scenario: Порядок не зависит от раскладки файлов в архиве
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
не совпадает с хронологией приёма
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
#### Scenario: Доставки одной секунды упорядочены идентификатором
- **WHEN** две доставки имеют одинаковый `received_at`
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
прогоне
### Requirement: Журналом считается архив, а не таблица доставок
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
доставок, а не перечислять только строки `delivery`. Иначе витрина
пересобиралась бы из витрины.
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
оставляет тело в архиве без учёта.
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
появились бы значения другой природы.
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
слоя: наследовать не от чего и подтверждать нечем.
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
называться в отчёте.
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
разбираться по логу.
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
создавать MUST NOT — создание превратило бы запуск не из того каталога в
успешный прогон по пустому журналу.
#### Scenario: Тело без учётной записи подбирается
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
приёма из ULID в UTC
- **AND** её размер и хеш посчитаны по распакованному телу
- **AND** её точки попадают в витрину
#### Scenario: Учётная запись без тела не роняет пересборку
- **WHEN** у строки `delivery` нет тела в архиве
- **THEN** пересборка продолжается
- **AND** факт учитывается счётчиком в отчёте
- **AND** сама запись переносится в базу назначения, но не сворачивается
#### Scenario: Файл, не являющийся телом, считается отдельно
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
расширение не соответствует форме тела
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
#### Scenario: Повтор идентификатора не срывает прогон
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
- **THEN** проигрывается первое, второе учитывается счётчиком
- **AND** пересборка доходит до конца
#### Scenario: Каталог архива не читается
- **WHEN** корня архива нет либо подкаталог не читается
- **THEN** команда завершается ошибкой и витрину не собирает
### Requirement: Пересборка не трогает рабочую базу
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
никогда, поэтому проигрывание поверх накопленного оставило бы результат
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
**до** того, как известно, что пересборка удалась: отказ на середине (битое
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину в состоянии, неотличимом от нормального.
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
схему под работающим старым сервисом.
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
быть отказом с указанием обеих версий, а не поводом мигрировать.
#### Scenario: Рабочая база остаётся нетронутой
- **WHEN** пересборка отработала успешно
- **THEN** отпечаток рабочей витрины не изменился
- **AND** учёт доставок в рабочей базе не изменился
- **AND** собранная витрина лежит в отдельном файле
#### Scenario: Отказ посреди пересборки не портит рабочую базу
- **WHEN** пересборка прерывается на середине журнала
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
какими были
#### Scenario: Схема рабочей базы старше бинаря
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
- **THEN** команда завершается ошибкой, называя обе версии
- **AND** не пишет в рабочую базу ни одной строки
### Requirement: Файл назначения и его жизненный цикл
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
остаётся переименованием внутри одной файловой системы.
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
убираться вместе со спутниками журнала SQLite.
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
приучил бы обходить защиту от перезаписи флагом принудительности.
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
пустой витрины, а не дописывается в чужое содержимое.
#### Scenario: Файл назначения совпадает с рабочей базой
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
пути
- **THEN** команда завершается ошибкой и не пишет ничего
#### Scenario: Файл назначения уже существует
- **WHEN** файл назначения существует, а перезапись не затребована явно
- **THEN** команда завершается ошибкой и существующий файл не трогает
#### Scenario: Затребованная перезапись даёт ту же витрину
- **WHEN** пересборка выполняется поверх существующего файла назначения с
явно затребованной перезаписью
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
журнала в отсутствующий файл
#### Scenario: Прерванная пересборка не оставляет файла назначения
- **WHEN** пересборка прерывается на середине журнала
- **THEN** файла по пути назначения не существует
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections
← начинаются пустыми
```
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и
выполнять её сама MUST NOT.
Файл базы держит открытым процесс сервиса, а переименование не касается уже
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
переименование используемого файла прямой причиной порчи базы. Безопасная
подмена требует остановленного сервиса, а остановить его команда не может:
сервисом управляет окружение снаружи.
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
команды»).
#### Scenario: Отчёт называет процедуру подмены
- **WHEN** прогон признан успешным
- **THEN** отчёт содержит путь собранного файла и команды подмены
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
называть человеку следует только нештатные отказы.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
### Requirement: Отказ на одной доставке не останавливает пересборку
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
(тело не читается, тело больше предела, слой не выводится, содержимое не
разбирается), и учитывать такие доставки счётчиком отказов.
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
пересборки остальные тела.
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
прерывает человек, и без перевода сигнала прерывания в отмену контекста
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
завершиться ненулевым кодом.
#### Scenario: Битое тело не срывает прогон
- **WHEN** одно из тел архива не распаковывается
- **THEN** остальные доставки проигрываются
- **AND** отказ учитывается счётчиком в отчёте
#### Scenario: Отмена прекращает проигрывание
- **WHEN** сигнал прерывания приходит посреди журнала
- **THEN** проигрывание прекращается, печатается частичный отчёт
- **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся