From 5ae0c5ff8167608f4fb95a27d137f37df243b9a4 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 09:07:46 +0300 Subject: [PATCH] =?UTF-8?q?reindex:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B0=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B3=D1=80=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой. --- README.md | 48 +- Taskfile.yml | 2 +- cmd/healthlog/main.go | 3 + cmd/healthlog/reindex.go | 307 +++++++++ cmd/healthlog/reindex_rebuild_test.go | 270 ++++++++ cmd/healthlog/reindex_report.go | 127 ++++ cmd/healthlog/reindex_test.go | 295 ++++++++ config.docker.toml | 2 + config.example.toml | 5 + docs/architecture.md | 89 ++- docs/backlog/README.md | 2 +- docs/backlog/reindex-iz-arhiva.md | 28 - docs/backlog/zagolovki-dostavki-v-arhive.md | 40 ++ docs/plan.md | 17 +- docs/review-journal.md | 26 + internal/archive/archive.go | 79 +++ internal/archive/archive_test.go | 75 +++ internal/fold/fold.go | 11 + internal/fold/replay_test.go | 207 ------ internal/ident/ident.go | 20 + internal/ident/ident_test.go | 57 ++ internal/ingest/ingest.go | 9 +- internal/logging/logging.go | 28 +- internal/logging/logging_test.go | 65 ++ internal/replay/archive_test.go | 201 ++++++ internal/replay/replay.go | 412 ++++++++++++ internal/replay/replay_test.go | 627 ++++++++++++++++++ internal/store/delivery.go | 56 ++ internal/store/readonly_test.go | 110 +++ internal/store/store.go | 79 +++ .../.openspec.yaml | 2 + .../2026-08-02-reindex-iz-arhiva/design.md | 346 ++++++++++ .../2026-08-02-reindex-iz-arhiva/proposal.md | 69 ++ .../specs/reindex/spec.md | 430 ++++++++++++ .../2026-08-02-reindex-iz-arhiva/tasks.md | 127 ++++ openspec/specs/reindex/spec.md | 439 ++++++++++++ 36 files changed, 4452 insertions(+), 258 deletions(-) create mode 100644 cmd/healthlog/reindex.go create mode 100644 cmd/healthlog/reindex_rebuild_test.go create mode 100644 cmd/healthlog/reindex_report.go create mode 100644 cmd/healthlog/reindex_test.go delete mode 100644 docs/backlog/reindex-iz-arhiva.md create mode 100644 docs/backlog/zagolovki-dostavki-v-arhive.md delete mode 100644 internal/fold/replay_test.go create mode 100644 internal/logging/logging_test.go create mode 100644 internal/replay/archive_test.go create mode 100644 internal/replay/replay.go create mode 100644 internal/replay/replay_test.go create mode 100644 internal/store/readonly_test.go create mode 100644 openspec/changes/archive/2026-08-02-reindex-iz-arhiva/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-reindex-iz-arhiva/design.md create mode 100644 openspec/changes/archive/2026-08-02-reindex-iz-arhiva/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-reindex-iz-arhiva/specs/reindex/spec.md create mode 100644 openspec/changes/archive/2026-08-02-reindex-iz-arhiva/tasks.md create mode 100644 openspec/specs/reindex/spec.md diff --git a/README.md b/README.md index 9937e87..35014a3 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,12 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` — половина потока), принимаются, хранятся и честно помечаются как неразобранные. -Чего ещё нет: пересборки хранилища из архива (`reindex`), каталога метрик с -измеренным родом агрегации и **read API** — данные наружу пока не отдаются -никак. План в [docs/plan.md](docs/plan.md). +Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую +витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел +пересборка воспроизводима и повторный прогон ничего не меняет. + +Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API** — +данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md). Разведка формата закончена: 50 находок на живом потоке, половина расходится с документацией 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 import родной экспорт Apple Health (в планах) -healthlog reindex пересборка хранилища из архива (в планах) +healthlog reindex пересборка витрины из журнала 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`, diff --git a/Taskfile.yml b/Taskfile.yml index 94e784c..7567bd5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,7 +39,7 @@ tasks: # Не входит в `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: desc: Запуск golangci-lint diff --git a/cmd/healthlog/main.go b/cmd/healthlog/main.go index c1942c6..46d0b53 100644 --- a/cmd/healthlog/main.go +++ b/cmd/healthlog/main.go @@ -3,6 +3,7 @@ // Подкоманды: // // healthlog [serve] --config принимать пакеты (по умолчанию) +// healthlog reindex --config пересобрать витрину из журнала // healthlog healthcheck --config

проверить /healthz (для docker HEALTHCHECK) package main @@ -27,6 +28,8 @@ func main() { switch cmd { case "serve": err = runServe(args) + case "reindex": + err = runReindex(args) case "healthcheck": err = runHealthcheck(args) default: diff --git a/cmd/healthlog/reindex.go b/cmd/healthlog/reindex.go new file mode 100644 index 0000000..edb8909 --- /dev/null +++ b/cmd/healthlog/reindex.go @@ -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 ` при отсутствующей рабочей базе + // устанавливал бы витрину прямо на её место, минуя всё правило «подмену + // делает человек при остановленном сервисе». + 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) + } +} diff --git a/cmd/healthlog/reindex_rebuild_test.go b/cmd/healthlog/reindex_rebuild_test.go new file mode 100644 index 0000000..37181b7 --- /dev/null +++ b/cmd/healthlog/reindex_rebuild_test.go @@ -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) + } +} diff --git a/cmd/healthlog/reindex_report.go b/cmd/healthlog/reindex_report.go new file mode 100644 index 0000000..054ef5e --- /dev/null +++ b/cmd/healthlog/reindex_report.go @@ -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") +} diff --git a/cmd/healthlog/reindex_test.go b/cmd/healthlog/reindex_test.go new file mode 100644 index 0000000..d53f366 --- /dev/null +++ b/cmd/healthlog/reindex_test.go @@ -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 позволил собрать витрину прямо на место рабочей базы") + } +} diff --git a/config.docker.toml b/config.docker.toml index df098dd..e188f6d 100644 --- a/config.docker.toml +++ b/config.docker.toml @@ -22,6 +22,8 @@ db_path = "/data/healthlog.db" archive_dir = "/data/raw" [ingest] +# Ретроактивен: тем же пределом пересборка читает тела из архива, см. +# config.example.toml. max_body_mb = 64 [log] diff --git a/config.example.toml b/config.example.toml index edb5a97..229f2dc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -27,6 +27,11 @@ db_path = "./data/healthlog.db" # файл SQLite; каталог долж archive_dir = "./data/raw" # корень сырого архива; создаётся при старте [ingest] +# ВНИМАНИЕ: параметр РЕТРОАКТИВЕН. Тем же пределом читаются тела из архива при +# пересборке (`healthlog reindex`), поэтому понижение выбрасывает из +# пересобранной витрины все уже принятые тела крупнее нового значения — они +# начнут отказывать на каждом прогоне. Понижать только вместе с проверкой, что +# таких тел в архиве нет. max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413 [log] diff --git a/docs/architecture.md b/docs/architecture.md index fac0971..78f2ba3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,6 +213,8 @@ HRV); у накопительных — только `date`. Поэтому то | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | | `hae` | разбор формата HAE, канонизация, хеш содержимого | | `ingest` | use-case приёма, общий для HTTP и CLI `import` | +| `fold` | свёртка одной доставки в часовые объекты | +| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | | `store` | SQLite: доставки, часовые объекты, тренировки, записи | | `httpapi` | приём и read API | @@ -319,7 +321,85 @@ HRV); у накопительных — только `date`. Поэтому то **`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`, — и слой у сводки не выводится, а фиксирован как `day`. Хранение остаётся дословным: разводятся имена, а не содержимое. -Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на -приёме: это ещё одна причина держать сырой архив. +Пересборка применяет к уже разобранному **исправленный** разбор — это и есть +причина держать сырой архив. Точнее она именно этим, а не тем, что видит более +длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование +«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742, +`docs/review-journal.md`). Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c1c57dd..ff1ad99 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -21,7 +21,6 @@ ## высокий - [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика -- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате @@ -41,6 +40,7 @@ - [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка - [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего - [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed +- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке ## низкий - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен diff --git a/docs/backlog/reindex-iz-arhiva.md b/docs/backlog/reindex-iz-arhiva.md deleted file mode 100644 index dc631af..0000000 --- a/docs/backlog/reindex-iz-arhiva.md +++ /dev/null @@ -1,28 +0,0 @@ -# Пересборка хранилища из сырого архива - -**Приоритет:** высокий - -Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск. -Риск в другом: без пересборки ошибка разбора становится потерей данных — -исправленный код не применится к тому, что уже разобрано неверно. - -Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род -агрегации на полном ряду доставок надёжнее, чем на одной. - -Проектировать это надо сразу как **свёртку по журналу**, а не как разовую -утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его -даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда -`reindex` и `import` окажутся одной операцией с разным входом, а не двумя -похожими. - -Отсюда требование, которое легко упустить: **свёртка обязана быть -детерминированной.** Проигрывание должно давать то же состояние, что приём в -реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две -одинаково полные точки с разными значениями разрешает порядок — значит -воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге. - -Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным -приёмом, и повторный прогон ничего не меняет. - -Связано: план → шаг «Разбор и хранилище», `docs/architecture.md` → «Сырой архив». - diff --git a/docs/backlog/zagolovki-dostavki-v-arhive.md b/docs/backlog/zagolovki-dostavki-v-arhive.md new file mode 100644 index 0000000..d25c22e --- /dev/null +++ b/docs/backlog/zagolovki-dostavki-v-arhive.md @@ -0,0 +1,40 @@ +# Заголовки доставки в архиве рядом с телом + +**Приоритет:** средний + +Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве +лежит только **тело**: заголовки запроса (`automation-id`, +`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут +единственной копией — в колонке `delivery.headers`. + +Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex` +читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда. +Пока база цела, это работает. Если базу потерять, весь журнал становится +«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего, +заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся +никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с +оговоркой, которой в инварианте нет. + +Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его +заголовками именно потому, что тело без метаданных запроса события не +воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и +на то, что заголовки лежат рядом текстом, а не в отдельной базе. + +Развилка формы (решать при взятии, не сейчас): + +- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и + одна операция, но файл перестаёт быть «телом как пришло»; +- файл-спутник `.headers.json` — тело остаётся дословным, зато на доставку + два файла и два fsync, а атомарность пары надо обеспечивать самому; +- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по + операциям, но появляется третья сущность. + +Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив, +теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы +старые тела без заголовков продолжали читаться. + +Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то +же состояние, что пересборка с базой. + +Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния», +`internal/replay`. diff --git a/docs/plan.md b/docs/plan.md index 0a5750f..b4ca303 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -15,13 +15,14 @@ недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в беклоге ноль. -Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После -миграции 00005 доставки числятся `pending`, а подобрать их некому: код -пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но -учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки -нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку». +**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки +сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending` +после миграции 00005, подбираются им же — но применяется результат подменой +базы, а её делает человек при остановленном сервисе. Тем же кодом закрывается +половина задачи «разнести ответ и свёртку»: проигрывание журнала теперь готовая +операция. -Потом — остаток разбора: тренировки и записи со своими `id` (это половина +Дальше — остаток разбора: тренировки и записи со своими `id` (это половина потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются), словарь категориальных значений. @@ -32,8 +33,8 @@ - [x] **1. Каркас.** - [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети** -- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со - своими `id`, `reindex` и словарь категориальных значений — нет. +- [~] **3. Разбор и хранилище.** Метрики и `reindex` — сделано; тренировки и + записи со своими `id`, словарь категориальных значений — нет. - [ ] **4. Каталог и род агрегации.** - [ ] **5. Read API.** - [ ] **6. Самоописание.** diff --git a/docs/review-journal.md b/docs/review-journal.md index 7aa9b1e..61ecf11 100644 --- a/docs/review-journal.md +++ b/docs/review-journal.md @@ -47,3 +47,29 @@ обязан иметь границу по `received_at` разбираемой доставки. Тест сходимости на живом архиве (`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`. Правило + общее и годится в конвенции: **в проверке на живом корпусе нельзя утверждать + число, производное от размера корпуса** — утверждать надо инвариант, а число + печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой + протухшей константы, а после этой задачи прогон стал ещё и единственным, кто + проверяет настоящий проигрыватель журнала. diff --git a/internal/archive/archive.go b/internal/archive/archive.go index c9a805c..2b940a6 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -10,11 +10,18 @@ import ( "compress/gzip" "fmt" "io" + "io/fs" "os" "path/filepath" + "sort" + "strings" "time" ) +// bodyExt — расширение файла тела. Всё, что ему не соответствует, телом не +// является: остаток `*.json.gz.tmp` от прерванной записи, чужой файл, каталог. +const bodyExt = ".json.gz" + // Archive — каталог сырых тел, разложенных по дате приёма. type Archive struct { root string @@ -28,6 +35,23 @@ func New(root string) (*Archive, error) { return &Archive{root: root}, nil } +// Existing открывает уже существующий архив и каталога не создаёт. +// +// Отличие от New не косметическое: приёму каталог создать надо, а пересборке — +// нельзя. Созданный на лету пустой каталог превращает запуск не из той +// директории в успешный прогон по пустому журналу, а пустая витрина совпадает +// по отпечатку с пустой витриной, то есть выглядит идеальной сходимостью. +func Existing(root string) (*Archive, error) { + fi, err := os.Stat(root) + if err != nil { + return nil, fmt.Errorf("open archive dir %q: %w", root, err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("archive dir %q: не каталог", root) + } + return &Archive{root: root}, nil +} + // Root возвращает корневой каталог архива. func (a *Archive) Root() string { return a.root } @@ -60,6 +84,61 @@ func (a *Archive) Write(id string, at time.Time, body []byte) (string, error) { return rel, nil } +// Listing — что нашлось в архиве. +type Listing struct { + // Bodies — относительные пути тел, отсортированные лексикографически. + // Порядок здесь только для воспроизводимости перечисления: журнал + // упорядочивает не он, а время приёма. + Bodies []string + // Skipped — файлы, телом не являющиеся. Считаются, а не выбрасываются: + // молчаливый пропуск означал бы «тело есть, а в отчёте его нет». + Skipped []string +} + +// List перечисляет тела архива. +// +// Ошибку чтения каталога отдаёт наружу, а не превращает в пустой список, и +// каталога не создаёт. Различие принципиально для пересборки: нечитаемый или +// отсутствующий каталог означает «неизвестно, есть ли тела», а не «тел нет», — +// а пустой журнал даёт пустую витрину, чей отпечаток совпадает с отпечатком +// любой другой пустой витрины, то есть выглядит идеальной сходимостью. +func (a *Archive) List() (Listing, error) { + var out Listing + + err := filepath.WalkDir(a.root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // Отказ чтения каталога прекращает обход целиком: пропустить его + // значило бы молча потерять сутки журнала. + return fmt.Errorf("walk archive %q: %w", path, err) + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(a.root, path) + if err != nil { + return fmt.Errorf("relative path %q: %w", path, err) + } + if !strings.HasSuffix(d.Name(), bodyExt) { + out.Skipped = append(out.Skipped, rel) + return nil + } + out.Bodies = append(out.Bodies, rel) + return nil + }) + if err != nil { + return Listing{}, err //nolint:wrapcheck // ошибка уже обёрнута внутри обхода + } + + sort.Strings(out.Bodies) + sort.Strings(out.Skipped) + return out, nil +} + +// BodyID возвращает идентификатор доставки по относительному пути тела. +func BodyID(rel string) string { + return strings.TrimSuffix(filepath.Base(rel), bodyExt) +} + // Open открывает сохранённое тело для чтения (распакованным). Нужен для // пересборки витрины из архива. func (a *Archive) Open(rel string) (io.ReadCloser, error) { diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index bb4d6c1..071f34c 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -4,6 +4,7 @@ import ( "io" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -90,3 +91,77 @@ func newArchive(t *testing.T) *archive.Archive { } return a } + +// Перечисление тел — вход пересборки. Всё, что телом не является, обязано быть +// посчитано, а не выброшено молча: тело есть, а в отчёте его нет. +func TestListРазводитТелаИПрочиеФайлы(t *testing.T) { + t.Parallel() + + root := t.TempDir() + a, err := archive.New(root) + if err != nil { + t.Fatalf("New: %v", err) + } + + at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + for _, id := range []string{"b", "a"} { + if _, err := a.Write(id, at, []byte(`{"data":{}}`)); err != nil { + t.Fatalf("Write: %v", err) + } + } + day := filepath.Join(root, "2026", "08", "01") + for _, name := range []string{"a.json.gz.tmp", "readme.txt"} { + if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil { + t.Fatalf("подготовка файла: %v", err) + } + } + + got, err := a.List() + if err != nil { + t.Fatalf("List: %v", err) + } + want := []string{ + filepath.Join("2026", "08", "01", "a.json.gz"), + filepath.Join("2026", "08", "01", "b.json.gz"), + } + if !reflect.DeepEqual(got.Bodies, want) { + t.Errorf("тела %v, ожидались %v", got.Bodies, want) + } + if len(got.Skipped) != 2 { + t.Errorf("пропущено %v, ожидалось два файла", got.Skipped) + } + if id := archive.BodyID(got.Bodies[0]); id != "a" { + t.Errorf("BodyID = %q, ожидался %q", id, "a") + } +} + +// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел нет»: +// пустой журнал даёт пустую витрину, которая по отпечатку совпадает с любой +// другой пустой витриной и выглядит идеальной сходимостью. +func TestОтсутствующийКаталогНеЯвляетсяПустымАрхивом(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "нет-такого") + if _, err := archive.Existing(missing); err == nil { + t.Error("Existing создал или принял отсутствующий каталог") + } + if _, err := os.Stat(missing); err == nil { + t.Error("Existing создал каталог — пересборке этого делать нельзя") + } + + // А приёму каталог создать надо: это и есть разница между конструкторами. + if _, err := archive.New(missing); err != nil { + t.Errorf("New: %v", err) + } + a, err := archive.Existing(missing) + if err != nil { + t.Fatalf("Existing после New: %v", err) + } + list, err := a.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list.Bodies) != 0 { + t.Errorf("в пустом архиве нашлись тела: %v", list.Bodies) + } +} diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 85fe714..0ed88f9 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -312,6 +312,17 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco } } +// ReadBody читает тело из архива с той же границей размера, что и свёртка. +// +// Экспортировано ради пересборки: ей нужно прочесть тело, у которого ещё нет +// учётной записи, чтобы посчитать размер и хеш. Своей копией чтения это делать +// нельзя — граница обязана быть общей, иначе тело, принятое приёмом со `200`, +// начнёт вечно отказывать на каждой пересборке, и договорённость «предел тот +// же» ничем не проверяется. +func (s *Service) ReadBody(rawPath string) ([]byte, error) { + return s.readBody(rawPath) +} + func (s *Service) readBody(rawPath string) ([]byte, error) { r, err := s.arch.Open(rawPath) if err != nil { diff --git a/internal/fold/replay_test.go b/internal/fold/replay_test.go deleted file mode 100644 index c433114..0000000 --- a/internal/fold/replay_test.go +++ /dev/null @@ -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 -} diff --git a/internal/ident/ident.go b/internal/ident/ident.go index a0db154..387dba0 100644 --- a/internal/ident/ident.go +++ b/internal/ident/ident.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/oklog/ulid/v2" ) @@ -22,6 +23,25 @@ func NewID() string { return strings.ToLower(ulid.Make().String()) } +// TimeOf возвращает время создания идентификатора: UTC, секундная точность — +// ровно та форма, в которой время хранится (см. store.Now). +// +// Нужен пересборке витрины: у тела, лежащего в архиве без учётной записи, +// другого источника метки приёма нет. Дата каталога архива не годится — она +// задаёт сутки, а порядок проигрывания нужен внутри суток. +// +// `.UTC()` здесь обязателен и не для красоты: ulid.Time собирает время через +// time.Unix, то есть в локальной зоне машины, а Truncate работает с абсолютной +// длительностью и зону не нормализует. Без приведения метка подобранного тела +// сравнивалась бы с меткой из БД по-разному на разных машинах. +func TimeOf(s string) (time.Time, error) { + id, err := ulid.ParseStrict(strings.ToUpper(strings.TrimSpace(s))) + if err != nil { + return time.Time{}, fmt.Errorf("%w: %q", ErrInvalid, s) + } + return ulid.Time(id.Time()).UTC().Truncate(time.Second), nil +} + // Parse валидирует внешний идентификатор и приводит его к каноническому виду. // Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк // в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к diff --git a/internal/ident/ident_test.go b/internal/ident/ident_test.go index 1eb5eac..23691c8 100644 --- a/internal/ident/ident_test.go +++ b/internal/ident/ident_test.go @@ -4,6 +4,7 @@ import ( "errors" "strings" "testing" + "time" "git.vakhrushev.me/av/healthlog/internal/ident" ) @@ -56,3 +57,59 @@ func TestParseRejectsGarbage(t *testing.T) { } } } + +// Время из ULID нужно телу, лежащему в архиве без учётной записи: другого +// источника метки приёма у него нет. +func TestTimeOfВUTCИСекундах(t *testing.T) { + t.Parallel() + + id := ident.NewID() + at, err := ident.TimeOf(id) + if err != nil { + t.Fatalf("TimeOf(%q): %v", id, err) + } + + // UTC, а не локальная зона: ulid.Time собирает время через time.Unix, то + // есть в зоне машины, и метка подобранного тела сравнивалась бы с меткой из + // БД по-разному на разных машинах. + if at.Location() != time.UTC { + t.Errorf("зона %v, ожидался UTC", at.Location()) + } + // Секундная точность — та же, что у store.Now: метка уезжает в колонку + // фиксированной ширины, где лексикографический порядок равен хронологии. + if at.Nanosecond() != 0 { + t.Errorf("метка %v несёт доли секунды", at) + } + if d := time.Since(at); d < 0 || d > time.Minute { + t.Errorf("метка %v далека от настоящего времени (%v)", at, d) + } +} + +// Монотонность ULID — то, на чём стоит порядок проигрывания журнала для +// подобранных тел. +func TestTimeOfСохраняетПорядок(t *testing.T) { + t.Parallel() + + first, err := ident.TimeOf(ident.NewID()) + if err != nil { + t.Fatalf("TimeOf: %v", err) + } + time.Sleep(1100 * time.Millisecond) + second, err := ident.TimeOf(ident.NewID()) + if err != nil { + t.Fatalf("TimeOf: %v", err) + } + if !second.After(first) { + t.Errorf("порядок меток не сохранился: %v, затем %v", first, second) + } +} + +func TestTimeOfОтвергаетНеИдентификатор(t *testing.T) { + t.Parallel() + + for _, s := range []string{"", "не-ulid", "01kyzbb5zy4cbkbc0agb6ad07", "readme.txt"} { + if _, err := ident.TimeOf(s); !errors.Is(err, ident.ErrInvalid) { + t.Errorf("TimeOf(%q) дал %v, ожидался ErrInvalid", s, err) + } + } +} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index f8e23e7..cf4fa63 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -90,7 +90,14 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e Bytes: int64(len(body)), 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) if err != nil { diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 49fd093..818be92 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -2,6 +2,7 @@ package logging import ( + "io" "log/slog" "os" "strings" @@ -10,21 +11,38 @@ import ( // New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text"). 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} var handler slog.Handler if strings.EqualFold(format, "text") { - handler = slog.NewTextHandler(os.Stdout, opts) + handler = slog.NewTextHandler(w, opts) } else { - handler = slog.NewJSONHandler(os.Stdout, opts) + handler = slog.NewJSONHandler(w, opts) } return slog.New(handler) } -// NewStderr — JSON-логгер в stderr (UTC) для фатальных ошибок старта, когда -// основной логгер ещё не собран (конфиг не прочитан). +// NewStderr — JSON-логгер в stderr для фатальных ошибок старта, когда основной +// логгер ещё не собран (конфиг не прочитан). Уровень и формат брать неоткуда, +// поэтому они фиксированы — этим он и отличается от NewErr. func NewStderr() *slog.Logger { - return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: utcTime})) + return NewTo(os.Stderr, "info", "json") } // utcTime приводит метку времени записи к UTC: однозначный порядок событий и diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..74bcd15 --- /dev/null +++ b/internal/logging/logging_test.go @@ -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()) + } +} diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go new file mode 100644 index 0000000..4455252 --- /dev/null +++ b/internal/replay/archive_test.go @@ -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 +} diff --git a/internal/replay/replay.go b/internal/replay/replay.go new file mode 100644 index 0000000..0b4ba3a --- /dev/null +++ b/internal/replay/replay.go @@ -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.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 +} diff --git a/internal/replay/replay_test.go b/internal/replay/replay_test.go new file mode 100644 index 0000000..1316992 --- /dev/null +++ b/internal/replay/replay_test.go @@ -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) + } +} diff --git a/internal/store/delivery.go b/internal/store/delivery.go index aba127c..c4425ad 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -105,6 +105,62 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) { 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 записывает исход разбора доставки. // // Слой сохраняется здесь же, потому что он нужен следующей доставке той же diff --git a/internal/store/readonly_test.go b/internal/store/readonly_test.go new file mode 100644 index 0000000..baabf59 --- /dev/null +++ b/internal/store/readonly_test.go @@ -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) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 64f8cb5..373d8b9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,9 +5,12 @@ package store import ( "context" "embed" + "errors" "fmt" "io/fs" "net/url" + "strconv" + "strings" "time" "github.com/jmoiron/sqlx" @@ -38,6 +41,70 @@ func Open(dbPath string) (*Store, error) { 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 закрывает соединение с БД. func (s *Store) Close() error { if err := s.db.Close(); err != nil { @@ -63,6 +130,18 @@ func dsn(path string) string { 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 накатывает миграции. // // Через Provider, а не через пакетные функции: goose.SetBaseFS и diff --git a/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/.openspec.yaml b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/design.md b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/design.md new file mode 100644 index 0000000..304ec4b --- /dev/null +++ b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/design.md @@ -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` даёт ей готовую операцию. diff --git a/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/proposal.md b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/proposal.md new file mode 100644 index 0000000..48646ed --- /dev/null +++ b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/proposal.md @@ -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`. diff --git a/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/specs/reindex/spec.md b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/specs/reindex/spec.md new file mode 100644 index 0000000..b61d241 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/specs/reindex/spec.md @@ -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** файла по пути назначения не остаётся diff --git a/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/tasks.md b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/tasks.md new file mode 100644 index 0000000..951f172 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/tasks.md @@ -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`: отдельной + задачи прогона не заводим, второго проигрывателя в проекте не остаётся. diff --git a/openspec/specs/reindex/spec.md b/openspec/specs/reindex/spec.md new file mode 100644 index 0000000..8f5e05d --- /dev/null +++ b/openspec/specs/reindex/spec.md @@ -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** файла по пути назначения не остаётся +