reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
+44 -4
View File
@@ -61,9 +61,12 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind`
половина потока), принимаются, хранятся и честно помечаются как неразобранные.
Чего ещё нет: пересборки хранилища из архива (`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`,
+1 -1
View File
@@ -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
+3
View File
@@ -3,6 +3,7 @@
// Подкоманды:
//
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog healthcheck --config <p> проверить /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:
+307
View File
@@ -0,0 +1,307 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// rebuildSuffix — как зовётся собранная витрина рядом с рабочей базой.
// Соседом, а не во временном каталоге: подмена обязана быть переименованием
// внутри одной файловой системы.
const rebuildSuffix = ".rebuild"
// partialSuffix — под каким именем витрина собирается, пока не готова.
//
// Полусобранная база выглядит как обычная, и файл с именем результата человек
// подменит по напечатанной процедуре не глядя. Поэтому имя результата
// появляется последним шагом успеха, а не первым шагом работы.
const partialSuffix = ".partial"
// progressInterval — как часто печатается прогресс. Прогон на полном архиве
// идёт минутами и молчит; зависший при этом неотличим от идущего.
const progressInterval = 5 * time.Second
// errNothingReplayed — журнал пуст или не свернулось ничего.
var errNothingReplayed = errors.New("проигрывать нечего")
func runReindex(args []string) error {
fs := flag.NewFlagSet("reindex", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
out := fs.String("out", "", "куда собрать витрину (по умолчанию — рабочая база с суффиксом "+rebuildSuffix+")")
force := fs.Bool("force", false, "перезаписать существующий файл назначения")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Лог — в stderr: stdout занят отчётом человеку, и лог в том же потоке
// сделал бы отчёт неразбираемым.
log := logging.NewErr(cfg.Log.Level, cfg.Log.Format)
target, err := resolveTarget(cfg.Storage.DBPath, *out, *force)
if err != nil {
return err
}
// Отмена приходит из сигнала: команду прерывает человек, и без этого вся
// логика отмены недостижима — процесс умирал бы мимо неё.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Прогресс — в поток ошибок: stdout занят отчётом, который человек
// перенаправляет и читает глазами.
rep, err := rebuild(ctx, cfg, target, log, os.Stderr)
if err != nil {
return err
}
writeReport(os.Stdout, rep)
if rep.replay.Canceled {
return errors.New("пересборка отменена")
}
if rep.replay.Bodies == 0 || rep.replay.Folded == 0 {
// Пустая витрина совпадает по отпечатку с пустой витриной, то есть
// пустой прогон выглядит идеальной сходимостью. Успехом он быть не
// может: человек, выполнивший напечатанную процедуру, заменил бы
// накопленное пустым.
return errNothingReplayed
}
return nil
}
// target — куда собираем и как называется промежуточный файл.
type target struct {
final string
partial string
}
// resolveTarget выбирает файл назначения и проверяет, что писать в него можно.
func resolveTarget(dbPath, out string, force bool) (target, error) {
final := out
if final == "" {
final = dbPath + rebuildSuffix
}
// Тождество определяется файлом, а не строкой пути: `..`, симлинк или
// другой префикс монтирования дают ту же цель при другой строке, а ошибка
// здесь означает проигрывание журнала прямо в живую рабочую базу.
same, err := sameFile(final, dbPath)
if err != nil {
return target{}, err
}
if same {
return target{}, fmt.Errorf("файл назначения %q — это рабочая база", final)
}
if _, err := os.Stat(final); err == nil && !force {
return target{}, fmt.Errorf("файл назначения %q уже существует (--force перезапишет)", final)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return target{}, fmt.Errorf("stat %q: %w", final, err)
}
// Имя промежуточного файла уникально: фиксированное затирало бы чужой файл
// с тем же именем ДО всякой проверки, то есть мимо правила «без --force не
// перезаписываем», и обломок прошлого прогона блокировал бы следующий.
return target{final: final, partial: final + "." + ident.NewID() + partialSuffix}, nil
}
// sameFile отвечает, ведут ли два пути к одному файлу.
//
// Когда файла назначения ещё нет, сравниваются каталог-родитель и имя: сам файл
// сравнить не с чем, а совпадение каталога и имени — это и есть тождество
// будущего файла.
func sameFile(a, b string) (bool, error) {
// Совпадение очищенных путей — тождество независимо от того, существуют ли
// файлы. Без этой проверки `--out <db_path>` при отсутствующей рабочей базе
// устанавливал бы витрину прямо на её место, минуя всё правило «подмену
// делает человек при остановленном сервисе».
if filepath.Clean(a) == filepath.Clean(b) {
return true, nil
}
fa, errA := os.Stat(a)
fb, errB := os.Stat(b)
switch {
case errA == nil && errB == nil:
return os.SameFile(fa, fb), nil
case errB != nil:
// Рабочей базы нет: сравнивать не с чем, а совпадение строк уже
// исключено выше.
return false, nil
}
da, err := os.Stat(filepath.Dir(a))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(a), err)
}
db, err := os.Stat(filepath.Dir(b))
if err != nil {
return false, fmt.Errorf("stat %q: %w", filepath.Dir(b), err)
}
return os.SameFile(da, db) && filepath.Base(a) == filepath.Base(b), nil
}
// report — всё, что печатается человеку.
type report struct {
replay replay.Report
target string
dbPath string
sourcePrint string
sourceBuckets int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
}
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
// назначения последним шагом успеха.
func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger, progress io.Writer) (report, error) {
rep := report{target: t.final, dbPath: cfg.Storage.DBPath}
// Отмена — не отказ пересборки, а требование прекратить работу, и застать
// она может на любом шаге, включая снятие отпечатка рабочей витрины.
stopped := func(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
if ctx.Err() != nil {
rep.replay.Canceled = true
return rep, nil
}
arch, err := archive.Existing(cfg.Storage.ArchiveDir)
if err != nil {
return rep, err
}
// Рабочей базы может не быть вовсе — журнал тогда состоит из одних
// подобранных тел. Это законный вход: восстановление после её потери. Но
// заголовки доставок при этом не воскресают, они жили только в ней.
var src *store.Store
if _, err := os.Stat(cfg.Storage.DBPath); errors.Is(err, os.ErrNotExist) {
rep.sourceMissing = true
} else if err != nil {
return rep, fmt.Errorf("stat %q: %w", cfg.Storage.DBPath, err)
} else {
src, err = store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return rep, err
}
defer func() { _ = src.Close() }()
// Отпечаток рабочей витрины снимается ДО проигрывания, иначе под живым
// приёмом он всегда движется, и оракул отвечает «разошлись» независимо
// от того, разошёлся ли разбор.
if rep.sourcePrint, err = src.Fingerprint(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBefore, err = src.CountDeliveries(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
dst, err := store.Open(t.partial)
if err != nil {
return rep, err
}
rep.replay, err = replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
// `mode=replay` в логе не украшение: за один прогон через слияние
// проходит вся история, и её WARN о перезаписях иначе неотличимы от
// аномалий живого приёма в общем логе.
Fold: fold.New(arch, dst, int64(cfg.Ingest.MaxBodyMB)<<20, log.With("mode", "replay")),
Progress: progressEvery(progress, progressInterval, time.Now),
Log: log,
})
if cerr := dst.Close(); err == nil {
err = cerr
}
if err != nil {
removeDB(t.partial)
return rep, err
}
if src != nil && !rep.replay.Canceled {
if rep.sourceAfter, err = src.CountDeliveries(ctx); err != nil {
removeDB(t.partial)
return canceledOr(rep, err, stopped)
}
}
ok := !rep.replay.Canceled && rep.replay.Bodies > 0 && rep.replay.Folded > 0
if !ok {
removeDB(t.partial)
return rep, nil
}
if err := os.Rename(t.partial, t.final); err != nil {
removeDB(t.partial)
return rep, fmt.Errorf("переименование в %q: %w", t.final, err)
}
return rep, nil
}
// progressEvery печатает прогресс не чаще интервала.
//
// Живёт в команде, а не в пакете проигрывания: «куда и как часто печатать» —
// забота адресата вывода. Часы параметром, чтобы функция была проверяема, не
// завися от настоящего времени.
func progressEvery(w io.Writer, every time.Duration, now func() time.Time) func(done, total int) {
last := now()
return func(done, total int) {
if done < total && now().Sub(last) < every {
return
}
last = now()
_, _ = fmt.Fprintf(w, "проиграно %d из %d\n", done, total)
}
}
// canceledOr отличает отмену от настоящего отказа: первая не является ошибкой
// команды, вторая является.
func canceledOr(rep report, err error, stopped func(error) bool) (report, error) {
if stopped(err) {
rep.replay.Canceled = true
return rep, nil
}
return rep, err
}
// removeDB убирает файл базы вместе со спутниками журнала SQLite: оставленный
// `-wal` подцепится к следующему файлу с тем же именем.
func removeDB(path string) {
for _, s := range []string{"", "-wal", "-shm"} {
_ = os.Remove(path + s)
}
}
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// setup собирает рабочее окружение команды: архив с телами и рабочую базу,
// наполненную живым приёмом.
func setup(t *testing.T, bodies int) *config.Config {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{}
cfg.Storage.DBPath = filepath.Join(dir, "healthlog.db")
cfg.Storage.ArchiveDir = filepath.Join(dir, "raw")
cfg.Ingest.MaxBodyMB = 64
cfg.Log.Level = "error"
cfg.Log.Format = "json"
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
t.Fatalf("база: %v", err)
}
defer func() { _ = st.Close() }()
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "minute.json"))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
f := fold.New(arch, st, 64<<20, slog.New(slog.DiscardHandler))
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
for i := range bodies {
id := ident.NewID()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
AutomationID: "auto-1", Bytes: int64(len(body)), SHA256: "-",
RawPath: rawPath, ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
_, _ = f.Fold(context.Background(), id)
}
return cfg
}
func fingerprintOf(t *testing.T, path string) string {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("база %s: %v", path, err)
}
defer func() { _ = st.Close() }()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// Пересборка собирает витрину рядом и рабочую базу не трогает: очистка рабочей
// необратима и наступила бы ДО того, как известно, удалась ли пересборка.
func TestПересборкаНеТрогаетРабочуюБазу(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Folded != 3 {
t.Errorf("свёрнуто %d, ожидалось 3", rep.replay.Folded)
}
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась")
}
if rep.sourcePrint != rep.replay.Fingerprint {
t.Errorf("отпечатки разошлись при неизменном разборе:\n %s\n %s",
rep.sourcePrint, rep.replay.Fingerprint)
}
// Результат появился под именем назначения, промежуточного файла не
// осталось.
if _, err := os.Stat(tgt.final); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
assertGone(t, tgt.partial)
}
// Прерванная пересборка не оставляет файла назначения: полусобранная база
// выглядит как обычная, и человек подменит её по напечатанной процедуре.
func TestПрерваннаяПересборкаНеОставляетФайлаНазначения(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
before := fingerprintOf(t, cfg.Storage.DBPath)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rep, err := rebuild(ctx, cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if !rep.replay.Canceled {
t.Error("отмена не отмечена в отчёте")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
if fingerprintOf(t, cfg.Storage.DBPath) != before {
t.Error("рабочая витрина изменилась при отменённой пересборке")
}
}
// Пустой архив — отказ команды, а не идеальная сходимость двух пустых витрин.
func TestПустойАрхивЭтоОтказКоманды(t *testing.T) {
t.Parallel()
cfg := setup(t, 0)
tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
if err != nil {
t.Fatalf("пересборка: %v", err)
}
if rep.replay.Bodies != 0 {
t.Fatalf("тел %d, ожидался пустой архив", rep.replay.Bodies)
}
// Отпечатки при этом совпадают — обе витрины пусты. Именно поэтому пустой
// журнал не может быть успехом.
if rep.sourcePrint != rep.replay.Fingerprint {
t.Error("две пустые витрины дали разные отпечатки — проверка потеряла смысл")
}
assertGone(t, tgt.final)
assertGone(t, tgt.partial)
}
func assertGone(t *testing.T, path string) {
t.Helper()
for _, s := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(path + s); err == nil {
t.Errorf("остался файл %s", path+s)
}
}
}
// Затребованная перезапись даёт ту же витрину, что и сборка в отсутствующий
// файл: сборка всегда начинается с пустой витрины, а не дописывается в чужое
// содержимое — иначе в результате осталось бы наследие прежнего разбора.
func TestПерезаписьДаётТуЖеВитрину(t *testing.T) {
t.Parallel()
cfg := setup(t, 3)
log := slog.New(slog.DiscardHandler)
first, err := resolveTarget(cfg.Storage.DBPath, "", false)
if err != nil {
t.Fatalf("файл назначения: %v", err)
}
fresh, err := rebuild(context.Background(), cfg, first, log, io.Discard)
if err != nil {
t.Fatalf("первая пересборка: %v", err)
}
// Поверх уже существующего результата, с явно затребованной перезаписью.
again, err := resolveTarget(cfg.Storage.DBPath, first.final, true)
if err != nil {
t.Fatalf("файл назначения (--force): %v", err)
}
over, err := rebuild(context.Background(), cfg, again, log, io.Discard)
if err != nil {
t.Fatalf("пересборка с перезаписью: %v", err)
}
if over.replay.Fingerprint != fresh.replay.Fingerprint {
t.Errorf("перезапись дала другую витрину:\n с нуля %s\n поверх %s",
fresh.replay.Fingerprint, over.replay.Fingerprint)
}
if over.replay.Buckets != fresh.replay.Buckets {
t.Errorf("объектов %d против %d — сборка дописалась в старое содержимое",
over.replay.Buckets, fresh.replay.Buckets)
}
}
// Исход команды целиком: пустой архив даёт ненулевой код, а не «успех»
// с идеально совпавшими пустыми отпечатками.
func TestИсходКомандыНаПустомАрхиве(t *testing.T) {
cfg := setup(t, 0)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
err := runReindex([]string{"--config", cfgPath})
if !errors.Is(err, errNothingReplayed) {
t.Errorf("пустой архив дал %v, ожидался отказ «проигрывать нечего»", err)
}
}
// И обратное: непустой журнал доводится до конца и завершается успехом.
func TestИсходКомандыНаНепустомАрхиве(t *testing.T) {
cfg := setup(t, 2)
cfgPath := filepath.Join(t.TempDir(), "config.toml")
writeConfig(t, cfgPath, cfg)
if err := runReindex([]string{"--config", cfgPath}); err != nil {
t.Errorf("непустой журнал дал отказ: %v", err)
}
if _, err := os.Stat(cfg.Storage.DBPath + rebuildSuffix); err != nil {
t.Errorf("файла назначения нет: %v", err)
}
}
func writeConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()
body := fmt.Sprintf(`[storage]
db_path = %q
archive_dir = %q
[ingest]
max_body_mb = %d
[log]
level = "error"
format = "json"
`, cfg.Storage.DBPath, cfg.Storage.ArchiveDir, cfg.Ingest.MaxBodyMB)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"fmt"
"io"
)
// writeReport печатает итог пересборки человеку.
//
// Отдельной функцией с io.Writer, а не печатью в os.Stdout из недр: отчёт —
// новая поверхность вывода, и единственное, что защищает её от утечки данных о
// здоровье, — тест. Тест на глобальном os.Stdout был бы тестом на глобальном
// состоянии, то есть его бы не написали.
//
// Ни значений точек, ни имён метрик, ни имён устройств здесь нет и быть не
// может: содержимое витрины входит в отчёт только отпечатком, а он берёт его
// хешем.
func writeReport(w io.Writer, r report) {
p := func(format string, args ...any) {
_, _ = fmt.Fprintf(w, format+"\n", args...)
}
p("пересборка витрины из журнала")
p(" архив: тел %d, пропущено файлов %d, повторов идентификатора %d",
r.replay.Bodies, r.replay.SkippedFiles, r.replay.Duplicates)
p(" учёт: подобрано тел без записи %d, не удалось подобрать %d, записей без тела %d",
r.replay.Adopted, r.replay.AdoptFailed, r.replay.Orphans)
p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d",
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther)
p(" слияние: частично разобрано %d, несравнимых наборов %d",
r.replay.Partial, r.replay.Incomparable)
if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
// отмене не снимались. Печатать их сравнение значило бы выдать
// неизмеренное за измеренное — в единственном оракуле задачи.
p("")
p("прогон ОТМЕНЁН: сравнение не проводилось, файл назначения не создан")
return
}
// «Часть журнала не прочитана» — отдельное состояние, и оно обязано быть
// видно рядом с вердиктом отпечатков. Пропущенный симлинк на каталог уносит
// из прогона целый месяц одной строкой в счётчике, а вердикт «СОВПАЛИ»
// выдал бы сертификат воспроизводимости прогону, который этих тел не читал.
partialJournal := r.replay.SkippedFiles > 0 || r.replay.Orphans > 0 || r.replay.Duplicates > 0
// Нештатные отказы. Невыведенный слой сюда не входит: он есть в каждом
// журнале, и предупреждать о нём значило бы отправлять человека искать
// дефект там, где его нет. А вот «содержимое не разбирается» штатным не
// является: тело один раз уже прошло проверку формы на приёме.
badFailures := r.replay.FailedOther > 0 || r.replay.FailedMalformed > 0 || r.replay.AdoptFailed > 0
if r.sourceMissing {
p(" объектов: %d", r.replay.Buckets)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
} else {
// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
switch {
case r.sourcePrint == r.replay.Fingerprint && !partialJournal:
p(" отпечатки СОВПАЛИ — состояние воспроизводимо")
case r.sourcePrint == r.replay.Fingerprint:
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
default:
p(" отпечатки РАЗОШЛИСЬ")
p(" ожидаемые причины: исправленный разбор; признак sealed не")
p(" переносится (правила его выставления ещё нет)")
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
}
}
}
if r.replay.Bodies == 0 || r.replay.Folded == 0 {
p("")
p("проигрывать было нечего: файл назначения не создан.")
p("проверьте storage.archive_dir и каталог запуска — пустая витрина")
p("совпадает по отпечатку с пустой витриной и выглядит идеальной сверкой")
return
}
if d := r.sourceAfter - r.sourceBefore; d != 0 {
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве,
// но не в собранном файле. Подмена стёрла бы их учёт вместе с
// заголовками, восстановить которые неоткуда, — поэтому процедура здесь
// не печатается вовсе.
p("")
p("за время прогона в рабочую базу приехало доставок: %d.", d)
p("подменять этим файлом НЕЛЬЗЯ: учёта новых доставок в нём нет, а вместе")
p("с ним пропали бы их заголовки. Остановите сервис и пересоберите заново.")
return
}
p("")
if partialJournal {
p("ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА: пропущено файлов %d, записей без тела %d,",
r.replay.SkippedFiles, r.replay.Orphans)
p("повторов идентификатора %d. Пересобранная витрина беднее рабочей на",
r.replay.Duplicates)
p("объекты этих доставок — и на объекты тех, кто наследовал от них слой.")
p("Проверьте каталог архива (симлинк на подкаталог обходом не читается)")
p("по DEBUG-строкам лога, прежде чем подменять базу.")
p("")
}
if badFailures {
p("отказы, которых быть не должно (%d прочих, %d по содержимому, %d при подборе) —",
r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed)
p("разберитесь по логу, прежде чем подменять базу.")
p("")
}
p("собрано в %s", r.target)
p("подмена — вручную и при ОСТАНОВЛЕННОМ сервисе: он держит файл открытым,")
p("и переименование поверх живого процесса портит базу молча.")
p("")
p(" task down")
p(" mv %s %s", r.target, r.dbPath)
p(" rm -f %s-wal %s-shm", r.dbPath, r.dbPath)
p(" task up")
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/replay"
)
// Тождество файла назначения определяется файлом, а не строкой пути: `..`,
// симлинк или другой префикс монтирования дают ту же цель при другой строке, а
// ошибка здесь означает проигрывание журнала прямо в живую рабочую базу.
func TestФайлНазначенияНеМожетБытьРабочейБазой(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
link := filepath.Join(dir, "link.db")
if err := os.Symlink(db, link); err != nil {
t.Skipf("символические ссылки недоступны: %v", err)
}
cases := map[string]string{
"тот же путь": db,
// Строкой, а не через filepath.Join: он бы почистил путь, и случай
// выродился бы в совпадение строк.
"через родителя": dir + "/sub/../healthlog.db",
"символическая ссылка": link,
}
for name, out := range cases {
if _, err := resolveTarget(db, out, false); err == nil {
t.Errorf("%s: файл назначения %q принят за отдельный файл", name, out)
}
}
}
// Существующий файл не перезаписывается молча; умолчание — сосед рабочей базы,
// чтобы подмена оставалась переименованием внутри одной файловой системы.
func TestВыборФайлаНазначения(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := filepath.Join(dir, "healthlog.db")
if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
t.Fatalf("подготовка базы: %v", err)
}
tgt, err := resolveTarget(db, "", false)
if err != nil {
t.Fatalf("умолчание: %v", err)
}
if tgt.final != db+rebuildSuffix {
t.Errorf("умолчание %q, ожидался сосед рабочей базы", tgt.final)
}
if filepath.Dir(tgt.partial) != filepath.Dir(tgt.final) {
t.Errorf("промежуточный файл %q не рядом с результатом", tgt.partial)
}
busy := filepath.Join(dir, "занято.db")
if err := os.WriteFile(busy, []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
if _, err := resolveTarget(db, busy, false); err == nil {
t.Error("существующий файл назначения принят без --force")
}
if _, err := resolveTarget(db, busy, true); err != nil {
t.Errorf("--force не разрешил перезапись: %v", err)
}
if _, err := os.Stat(busy); err != nil {
t.Error("проверка аргументов уже что-то удалила — решать это должен прогон")
}
}
// Отчёт — новая поверхность вывода, и единственное, что защищает её от утечки
// данных о здоровье, это проверка. Поэтому рендер принимает io.Writer, а не
// печатает в os.Stdout из недр.
func TestОтчётНеРаскрываетДанныхОЗдоровье(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049,
Fingerprint: "aaaa", Partial: 53,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2040,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
// Ни одного слова, которым могло бы оказаться измерение, имя метрики или
// устройства: в отчёт они попадают только через отпечаток, а он берёт
// содержимое хешем.
for _, forbidden := range []string{
"heart_rate", "sleep_analysis", "active_energy", "qty",
"Apple Watch", "iPhone", "value",
} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт содержит %q", forbidden)
}
}
// Расхождение отпечатков названо, и рядом — направление: «было/стало».
// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
for _, want := range []string{"РАЗОШЛИСЬ", "было 2040, стало 2049", "task down", "mv "} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q", want)
}
}
}
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве, но не
// в собранном файле: подмена стёрла бы их учёт вместе с заголовками, которые
// не восстанавливаются ниоткуда.
func TestПриездДоставокЗаПрогонОтменяетПодмену(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116, Folded: 116, Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBefore: 116,
sourceAfter: 119,
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана, хотя учёт новых доставок в файл не попал")
}
if !strings.Contains(out, "приехало доставок: 3") {
t.Errorf("отчёт не назвал приезд доставок: %s", out)
}
}
// Пустой журнал выглядит идеальной сходимостью: отпечаток пустой витрины
// совпадает с отпечатком пустой витрины. Успехом он быть не может, и процедуру
// подмены печатать нельзя — человек заменил бы накопленное пустым.
func TestПустойЖурналНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 0, Folded: 0, Fingerprint: "same"},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
// Отпечатки совпадают: обе витрины пусты.
sourcePrint: "same",
})
out := buf.String()
if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
t.Error("процедура подмены напечатана при пустом журнале")
}
if !strings.Contains(out, "нечего") {
t.Error("отчёт не говорит, что проигрывать было нечего")
}
}
// Отмена — не успех: файла назначения нет, подменять нечего.
func TestОтменённыйПрогонНеПечатаетПроцедуруПодмены(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{Bodies: 10, Folded: 3, Canceled: true},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBefore: 116,
})
out := buf.String()
if strings.Contains(out, "mv ") {
t.Error("процедура подмены напечатана после отмены")
}
if !strings.Contains(out, "ОТМЕНЁН") {
t.Error("отмена не названа в отчёте")
}
// Ни отпечатки, ни разница доставок при отмене не снимались — печатать их
// значило бы выдать неизмеренное за измеренное.
for _, forbidden := range []string{"СОВПАЛИ", "РАЗОШЛИСЬ", "приехало доставок"} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт после отмены содержит %q — величина не измерялась", forbidden)
}
}
}
// Справка — не отказ: иначе `reindex -h` печатает usage и выходит со словом
// «fatal» и кодом 1, а это первое, что человек наберёт у команды с тремя
// флагами.
func TestСправкаНеЯвляетсяОтказом(t *testing.T) {
if err := runReindex([]string{"-h"}); err != nil {
t.Errorf("reindex -h вернул ошибку: %v", err)
}
}
// Пропущенный файл, запись без тела или повтор означают, что часть журнала не
// прочитана. Вердикт «СОВПАЛИ — состояние воспроизводимо» тогда выдавал бы
// сертификат воспроизводимости прогону, который этих тел не читал.
func TestНепрочитаннаяЧастьЖурналаВидна(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 100, Buckets: 2049, Fingerprint: "aaaa",
// Симлинк на каталог суток уносит из прогона целый месяц одной
// строкой счётчика.
SkippedFiles: 1,
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "aaaa",
sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "СОВПАЛИ — состояние воспроизводимо") {
t.Error("вердикт о воспроизводимости выдан прогону, читавшему не весь журнал")
}
if !strings.Contains(out, "ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА") {
t.Errorf("отчёт не предупредил о непрочитанной части журнала:\n%s", out)
}
}
// Тело, разобранное приёмом, не может перестать разбираться: `content` — не
// штатный отказ, в отличие от невыведенного слоя.
func TestНеразобранноеСодержимоеПредупреждает(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 99, FailedMalformed: 1,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
})
if !strings.Contains(buf.String(), "которых быть не должно") {
t.Errorf("неразобранное содержимое не подняло предупреждения:\n%s", buf.String())
}
}
// Штатный отказ — невыведенный слой — предупреждения поднимать не должен:
// такие доставки есть в каждом журнале.
func TestНевыведенныйСлойНеПоднимаетТревоги(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 100, Folded: 98, FailedLayer: 2,
Buckets: 2049, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
sourcePrint: "aaaa", sourceBuckets: 2049,
})
out := buf.String()
if strings.Contains(out, "которых быть не должно") {
t.Error("штатный отказ поднял тревогу — человека послали искать несуществующий дефект")
}
if !strings.Contains(out, "task down") {
t.Error("процедура подмены не напечатана при штатном исходе")
}
}
// Рабочей базы может не быть — но и тогда файл назначения не может совпасть с
// её путём: иначе витрина устанавливается на место, минуя правило «подмену
// делает человек при остановленном сервисе».
func TestФайлНазначенияНеМожетБытьПутёмОтсутствующейБазы(t *testing.T) {
t.Parallel()
db := filepath.Join(t.TempDir(), "healthlog.db")
if _, err := resolveTarget(db, db, false); err == nil {
t.Error("путь отсутствующей рабочей базы принят как файл назначения")
}
if _, err := resolveTarget(db, db, true); err == nil {
t.Error("--force позволил собрать витрину прямо на место рабочей базы")
}
}
+2
View File
@@ -22,6 +22,8 @@ db_path = "/data/healthlog.db"
archive_dir = "/data/raw"
[ingest]
# Ретроактивен: тем же пределом пересборка читает тела из архива, см.
# config.example.toml.
max_body_mb = 64
[log]
+5
View File
@@ -27,6 +27,11 @@ db_path = "./data/healthlog.db" # файл SQLite; каталог долж
archive_dir = "./data/raw" # корень сырого архива; создаётся при старте
[ingest]
# ВНИМАНИЕ: параметр РЕТРОАКТИВЕН. Тем же пределом читаются тела из архива при
# пересборке (`healthlog reindex`), поэтому понижение выбрасывает из
# пересобранной витрины все уже принятые тела крупнее нового значения — они
# начнут отказывать на каждом прогоне. Понижать только вместе с проверкой, что
# таких тел в архиве нет.
max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413
[log]
+86 -3
View File
@@ -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` наполняют разные слои и
+1 -1
View File
@@ -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 он избыточен
-28
View File
@@ -1,28 +0,0 @@
# Пересборка хранилища из сырого архива
**Приоритет:** высокий
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
Риск в другом: без пересборки ошибка разбора становится потерей данных —
исправленный код не применится к тому, что уже разобрано неверно.
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
агрегации на полном ряду доставок надёжнее, чем на одной.
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
похожими.
Отсюда требование, которое легко упустить: **свёртка обязана быть
детерминированной.** Проигрывание должно давать то же состояние, что приём в
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
одинаково полные точки с разными значениями разрешает порядок — значит
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
приёмом, и повторный прогон ничего не меняет.
Связано: план → шаг «Разбор и хранилище», `docs/architecture.md` → «Сырой архив».
@@ -0,0 +1,40 @@
# Заголовки доставки в архиве рядом с телом
**Приоритет:** средний
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
лежит только **тело**: заголовки запроса (`automation-id`,
`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут
единственной копией — в колонке `delivery.headers`.
Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex`
читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда.
Пока база цела, это работает. Если базу потерять, весь журнал становится
«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего,
заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся
никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с
оговоркой, которой в инварианте нет.
Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его
заголовками именно потому, что тело без метаданных запроса события не
воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и
на то, что заголовки лежат рядом текстом, а не в отдельной базе.
Развилка формы (решать при взятии, не сейчас):
- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и
одна операция, но файл перестаёт быть «телом как пришло»;
- файл-спутник `<ulid>.headers.json` — тело остаётся дословным, зато на доставку
два файла и два fsync, а атомарность пары надо обеспечивать самому;
- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по
операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
же состояние, что пересборка с базой.
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`.
+9 -8
View File
@@ -15,13 +15,14 @@
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
беклоге ноль.
Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После
миграции 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. Самоописание.**
+26
View File
@@ -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`. Правило
общее и годится в конвенции: **в проверке на живом корпусе нельзя утверждать
число, производное от размера корпуса** — утверждать надо инвариант, а число
печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
проверяет настоящий проигрыватель журнала.
+79
View File
@@ -10,11 +10,18 @@ import (
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// bodyExt — расширение файла тела. Всё, что ему не соответствует, телом не
// является: остаток `*.json.gz.tmp` от прерванной записи, чужой файл, каталог.
const bodyExt = ".json.gz"
// Archive — каталог сырых тел, разложенных по дате приёма.
type Archive struct {
root string
@@ -28,6 +35,23 @@ func New(root string) (*Archive, error) {
return &Archive{root: root}, nil
}
// Existing открывает уже существующий архив и каталога не создаёт.
//
// Отличие от New не косметическое: приёму каталог создать надо, а пересборке —
// нельзя. Созданный на лету пустой каталог превращает запуск не из той
// директории в успешный прогон по пустому журналу, а пустая витрина совпадает
// по отпечатку с пустой витриной, то есть выглядит идеальной сходимостью.
func Existing(root string) (*Archive, error) {
fi, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("open archive dir %q: %w", root, err)
}
if !fi.IsDir() {
return nil, fmt.Errorf("archive dir %q: не каталог", root)
}
return &Archive{root: root}, nil
}
// Root возвращает корневой каталог архива.
func (a *Archive) Root() string { return a.root }
@@ -60,6 +84,61 @@ func (a *Archive) Write(id string, at time.Time, body []byte) (string, error) {
return rel, nil
}
// Listing — что нашлось в архиве.
type Listing struct {
// Bodies — относительные пути тел, отсортированные лексикографически.
// Порядок здесь только для воспроизводимости перечисления: журнал
// упорядочивает не он, а время приёма.
Bodies []string
// Skipped — файлы, телом не являющиеся. Считаются, а не выбрасываются:
// молчаливый пропуск означал бы «тело есть, а в отчёте его нет».
Skipped []string
}
// List перечисляет тела архива.
//
// Ошибку чтения каталога отдаёт наружу, а не превращает в пустой список, и
// каталога не создаёт. Различие принципиально для пересборки: нечитаемый или
// отсутствующий каталог означает «неизвестно, есть ли тела», а не «тел нет», —
// а пустой журнал даёт пустую витрину, чей отпечаток совпадает с отпечатком
// любой другой пустой витрины, то есть выглядит идеальной сходимостью.
func (a *Archive) List() (Listing, error) {
var out Listing
err := filepath.WalkDir(a.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Отказ чтения каталога прекращает обход целиком: пропустить его
// значило бы молча потерять сутки журнала.
return fmt.Errorf("walk archive %q: %w", path, err)
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(a.root, path)
if err != nil {
return fmt.Errorf("relative path %q: %w", path, err)
}
if !strings.HasSuffix(d.Name(), bodyExt) {
out.Skipped = append(out.Skipped, rel)
return nil
}
out.Bodies = append(out.Bodies, rel)
return nil
})
if err != nil {
return Listing{}, err //nolint:wrapcheck // ошибка уже обёрнута внутри обхода
}
sort.Strings(out.Bodies)
sort.Strings(out.Skipped)
return out, nil
}
// BodyID возвращает идентификатор доставки по относительному пути тела.
func BodyID(rel string) string {
return strings.TrimSuffix(filepath.Base(rel), bodyExt)
}
// Open открывает сохранённое тело для чтения (распакованным). Нужен для
// пересборки витрины из архива.
func (a *Archive) Open(rel string) (io.ReadCloser, error) {
+75
View File
@@ -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)
}
}
+11
View File
@@ -312,6 +312,17 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
}
}
// ReadBody читает тело из архива с той же границей размера, что и свёртка.
//
// Экспортировано ради пересборки: ей нужно прочесть тело, у которого ещё нет
// учётной записи, чтобы посчитать размер и хеш. Своей копией чтения это делать
// нельзя — граница обязана быть общей, иначе тело, принятое приёмом со `200`,
// начнёт вечно отказывать на каждой пересборке, и договорённость «предел тот
// же» ничем не проверяется.
func (s *Service) ReadBody(rawPath string) ([]byte, error) {
return s.readBody(rawPath)
}
func (s *Service) readBody(rawPath string) ([]byte, error) {
r, err := s.arch.Open(rawPath)
if err != nil {
-207
View File
@@ -1,207 +0,0 @@
package fold_test
import (
"bytes"
"compress/gzip"
"context"
"flag"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// archiveDir включает прогон сходимости на живом архиве.
//
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
// должен висеть на каждом `task gate`. Запускается командой
// `task verify:archive`.
var archiveDir = flag.String("healthlog.archive", "",
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
// разбора, обязан пройти через код без потерь и без расхождений — и повторный
// прогон журнала обязан дать то же состояние.
func TestReplayЖивогоАрхива(t *testing.T) {
if *archiveDir == "" {
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
}
root := *archiveDir
bodies := collectBodies(t, root)
if len(bodies) == 0 {
t.Skipf("живого архива нет в %s — прогон пропущен", root)
}
f, arch, st := newFold(t)
ctx := context.Background()
partial := 0
sections := map[string]int{}
var folded, failed, incomparable int
for _, path := range bodies {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("чтение %s: %v", path, err)
}
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
// путь чтения был ровно тот, каким пойдёт пересборка.
//
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
// Поэтому автоматизация у всех одна: так проверяется в том числе
// наследование слоя по цепочке доставок.
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
res, err := f.Fold(ctx, id)
if err != nil {
failed++
continue
}
folded++
incomparable += res.Incomparable
if len(res.Uncovered) > 0 {
partial++
for _, s := range res.Uncovered {
sections[s]++
}
}
}
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
// полей: их не было ни разу на всём корпусе. Число печатается, а не
// проверяется: появление такого набора — событие для разбора, а не отказ
// сходимости.
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
len(bodies), folded, failed, incomparable)
t.Logf("частично разобрано %d, непокрытые секции: %v", partial, sections)
if folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
// Половина живого потока не несёт metrics вовсе (находка 50): такие
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
// срежет тела, которые для stateOfMind единственный источник.
if partial == 0 {
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
}
// Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
before, err := st.CountBuckets(ctx)
if err != nil {
t.Fatalf("счёт объектов: %v", err)
}
fingerprintBefore, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
var refolded int
for _, path := range bodies {
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
continue
}
refolded++
}
if refolded != folded {
t.Fatalf("повторно свёрнуто %d доставок из %d — проверка идемпотентности вхолостую",
refolded, folded)
}
after, err := st.CountBuckets(ctx)
if err != nil {
t.Fatalf("счёт объектов: %v", err)
}
if before != after {
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
}
fingerprintAfter, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
if fingerprintBefore != fingerprintAfter {
t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s",
fingerprintBefore, fingerprintAfter)
}
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
// живёт — он производен от архива, которого нет ни на одной другой машине.
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter)
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
// интервалу — 174 (docs/local-research.md, находка 47). Если координата
// когда-нибудь схлопнется обратно до метки, здесь станет 170.
if got := countPoints(t, st, "sleep_analysis"); got != 174 {
t.Errorf("координат sleep_analysis %d, измерено 174: ключ схлопнул записи", got)
}
}
// countPoints считает точки метрики во всех слоях. Каталог разрезов — отдельная
// задача, поэтому здесь перебор по известным слоям, а не запрос к нему.
func countPoints(t *testing.T, st *store.Store, metric string) int {
t.Helper()
ctx := context.Background()
total := 0
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
hours, err := st.BucketHours(ctx, metric, layer)
if err != nil {
t.Fatalf("часы объектов: %v", err)
}
for _, h := range hours {
b, err := st.Bucket(ctx, metric, layer, h)
if err != nil {
t.Fatalf("чтение объекта: %v", err)
}
total += len(b.Points)
}
}
return total
}
func collectBodies(t *testing.T, root string) []string {
t.Helper()
var out []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil //nolint:nilerr // архива может не быть — это не отказ теста
}
if !info.IsDir() && filepath.Ext(path) == ".gz" {
out = append(out, path)
}
return nil
})
if err != nil {
return nil
}
sort.Strings(out)
return out
}
func gunzip(t *testing.T, body []byte) []byte {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("распаковка: %v", err)
}
defer func() { _ = gz.Close() }()
out, err := io.ReadAll(gz)
if err != nil {
t.Fatalf("чтение: %v", err)
}
return out
}
+20
View File
@@ -9,6 +9,7 @@ import (
"errors"
"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 при декодировании нечувствителен к
+57
View File
@@ -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)
}
}
}
+8 -1
View File
@@ -90,7 +90,14 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
Bytes: int64(len(body)),
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 {
+23 -5
View File
@@ -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: однозначный порядок событий и
+65
View File
@@ -0,0 +1,65 @@
package logging
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
// Уровень — это адресат, а не громкость: DEBUG предназначен разработчику при
// отладке и в штатной работе наружу не выходит.
func TestУровеньОтсекаетНижние(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := NewTo(&buf, "warn", "json")
log.Debug("отладка")
log.Info("событие")
log.Warn("может стать проблемой")
out := buf.String()
if strings.Contains(out, "отладка") || strings.Contains(out, "событие") {
t.Errorf("уровень warn пропустил записи ниже себя: %s", out)
}
if !strings.Contains(out, "может стать проблемой") {
t.Errorf("запись уровня warn потерялась: %s", out)
}
}
// Время в логах — UTC: иначе порядок событий между машинами не сравнить.
func TestВремяВUTC(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
NewTo(&buf, "info", "json").Info("событие")
var rec map[string]any
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("запись не JSON: %v (%s)", err, buf.String())
}
ts, _ := rec[slog.TimeKey].(string)
if !strings.HasSuffix(ts, "Z") {
t.Errorf("метка времени %q не в UTC", ts)
}
}
// Команды CLI пишут отчёт в stdout, поэтому их лог обязан идти в stderr —
// иначе отчёт не разобрать ни глазами, ни перенаправлением.
func TestNewErrПишетВStderrАNewВStdout(t *testing.T) {
t.Parallel()
// Прямой проверки потока здесь нет намеренно: подмена os.Stdout была бы
// мутацией глобала. Проверяем то, что от этих конструкторов зависит на
// самом деле, — что они собирают разные назначения и оба живые.
if New("info", "json") == nil || NewErr("info", "text") == nil {
t.Fatal("конструктор вернул nil")
}
var buf bytes.Buffer
NewTo(&buf, "info", "text").Info("событие", "ключ", "значение")
if !strings.Contains(buf.String(), "ключ=значение") {
t.Errorf("текстовый формат не применён: %s", buf.String())
}
}
+201
View File
@@ -0,0 +1,201 @@
package replay_test
import (
"bytes"
"compress/gzip"
"context"
"flag"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// archiveDir включает прогон сходимости на живом архиве.
//
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
// должен висеть на каждом `task gate`. Запускается командой
// `task verify:archive`.
var archiveDir = flag.String("healthlog.archive", "",
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
// разбора, обязан пройти через код без потерь и без расхождений — и повторное
// проигрывание журнала обязано дать то же состояние.
//
// Прогон идёт через ту же операцию, которой пересобирает витрину
// `healthlog reindex`. Собственный обход и собственный порядок здесь были
// раньше и были ошибкой: они образовывали второй проигрыватель журнала, чьи
// правила разошлись с настоящим, — а зеленел бы при этом он.
func TestReplayЖивогоАрхива(t *testing.T) {
if *archiveDir == "" {
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
}
bodies := collectBodies(t, *archiveDir)
if len(bodies) == 0 {
t.Skipf("живого архива нет в %s — прогон пропущен", *archiveDir)
}
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
// путь чтения был ровно тот, каким пойдёт пересборка.
//
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
// Поэтому автоматизация у всех одна: так проверяется в том числе
// наследование слоя по цепочке доставок. Метка приёма берётся из ULID,
// то есть хронология журнала настоящая.
for _, path := range bodies {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("чтение %s: %v", path, err)
}
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
at, err := ident.TimeOf(id)
if err != nil {
t.Fatalf("время из ULID %s: %v", id, err)
}
writeBody(t, arch, src, item{id: id, at: at, automationID: "auto"}, gunzip(t, body))
}
first, dst := run(t, ctx, arch, src, filepath.Join(dir, "first.db"))
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
// полей: их не было ни разу на всём корпусе. Число печатается, а не
// проверяется: появление такого набора — событие для разбора, а не отказ
// сходимости.
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
first.FailedOther, first.Incomparable)
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
first.Partial, first.Adopted, first.Orphans)
if first.Folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
// Половина живого потока не несёт metrics вовсе (находка 50): такие
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
// срежет тела, которые для stateOfMind единственный источник.
if first.Partial == 0 {
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
}
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
second, _ := run(t, ctx, arch, src, filepath.Join(dir, "second.db"))
if second.Buckets != first.Buckets {
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
}
if second.Fingerprint != first.Fingerprint {
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
first.Fingerprint, second.Fingerprint)
}
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
// живёт — он производен от архива, которого нет ни на одной другой машине.
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
//
// Проверяется само свойство, а не измеренное когда-то число. Прежняя
// редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела
// молча, когда архив дорос до 116: константа, производная от корпуса,
// протухает с каждой новой доставкой, а прогон живого архива в гейт не
// входит, так что краснота никому не видна.
coords, labels := countSleepKeys(t, dst)
if coords == 0 {
t.Fatal("записей сна в витрине нет — проверять нечего")
}
if coords <= labels {
t.Errorf("координат сна %d при %d различных метках: ключ схлопнул записи до метки",
coords, labels)
}
t.Logf("координат сна %d, различных меток %d", coords, labels)
}
// countSleepKeys возвращает число различных координат записей сна и число
// различных меток начала. Разница между ними и есть то, что теряет ключ по
// метке.
//
// Каталог разрезов — отдельная задача, поэтому здесь перебор по известным
// слоям, а не запрос к нему.
func countSleepKeys(t *testing.T, st *store.Store) (coords, labels int) {
t.Helper()
type key struct{ start, end int64 }
ctx := context.Background()
seenCoord := map[key]struct{}{}
seenLabel := map[int64]struct{}{}
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
hours, err := st.BucketHours(ctx, "sleep_analysis", layer)
if err != nil {
t.Fatalf("часы объектов: %v", err)
}
for _, h := range hours {
b, err := st.Bucket(ctx, "sleep_analysis", layer, h)
if err != nil {
t.Fatalf("чтение объекта: %v", err)
}
for _, p := range b.Points {
seenCoord[key{p.Start.UnixNano(), p.End.UnixNano()}] = struct{}{}
seenLabel[p.Start.UnixNano()] = struct{}{}
}
}
}
return len(seenCoord), len(seenLabel)
}
func collectBodies(t *testing.T, root string) []string {
t.Helper()
var out []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil //nolint:nilerr // архива может не быть — это не отказ теста
}
if !info.IsDir() && filepath.Ext(path) == ".gz" {
out = append(out, path)
}
return nil
})
if err != nil {
return nil
}
sort.Strings(out)
return out
}
func gunzip(t *testing.T, body []byte) []byte {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("распаковка: %v", err)
}
defer func() { _ = gz.Close() }()
out, err := io.ReadAll(gz)
if err != nil {
t.Fatalf("чтение: %v", err)
}
return out
}
+412
View File
@@ -0,0 +1,412 @@
// Package replay — проигрывание журнала доставок в витрину.
//
// Состояние healthlog есть свёртка по журналу:
// `import(снапшот экспорта) + replay(доставки по received_at)`. Здесь живёт
// вторая половина формулы; пересборка из архива — её вырожденный случай с
// пустым снапшотом, а не отдельная операция. Когда появится импорт родного
// экспорта Apple, он добавит стадию снапшота ПЕРЕД проигрыванием и переиспользует
// эту же операцию.
//
// Собственного разбора и собственного слияния пакет не имеет: он зовёт ту же
// свёртку, что и приём, по идентификатору доставки. Второй путь разбора
// разошёлся бы с первым молча — и уже расходился: прежний прогон живого архива
// сортировал тела по путям, а не по времени приёма.
package replay
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sort"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Options — что нужно проигрыванию.
type Options struct {
// Archive — журнал тел. Именно он источник состава: перечислять только
// строки учёта значило бы пересобирать витрину из витрины.
Archive *archive.Archive
// Source — рабочая база, откуда берётся учёт доставок. Открывается на
// чтение: заголовков доставки в архиве нет, а восстановить их неоткуда.
// Пустой источник допустим — тогда весь журнал состоит из подобранных тел.
Source *store.Store
// Target — база назначения, куда собирается витрина.
Target *store.Store
// Fold — свёртка над Target. Собирается вызывающим с тем же пределом
// размера тела, что и у приёма; тем же пределом пересборка читает тело при
// подборе — своей копии границы у неё нет намеренно.
Fold *fold.Service
// Progress зовётся по мере продвижения. Нужен потому, что прогон на полном
// архиве молчит минутами, и зависший неотличим от идущего.
Progress func(done, total int)
Log *slog.Logger
}
// Report — итог проигрывания. Значений точек не несёт: содержимое входит в
// отчёт только отпечатком.
type Report struct {
// Bodies — тел в архиве, признанных телами.
Bodies int
// SkippedFiles — файлов, телом не являющихся (чужое расширение, остаток
// прерванной записи, имя не разбирается как ULID).
SkippedFiles int
// Duplicates — тел, чей идентификатор уже встретился в другом каталоге.
// Проигрывается первое; второе считается, а не роняет прогон.
Duplicates int
// Adopted — тел, у которых учётной записи не было: приём успел записать
// тело и не успел строку.
Adopted int
// AdoptFailed — тел без учёта, которые не удалось прочитать, чтобы завести
// запись. Тело остаётся в архиве.
AdoptFailed int
// Orphans — строк учёта, у которых тела в архиве нет. Станет штатным, когда
// появится ретеншен архива.
Orphans int
// Folded — сколько доставок свернулось.
Folded int
// Отказы разведены по классам, потому что читаются они по-разному.
// FailedLayer — слой не выводится: штатный исход, таких доставок в журнале
// заведомо есть. FailedMalformed — содержимое не разбирается. FailedOther —
// всё прочее (тело не читается, отказ базы); только оно означает, что с
// пересборкой что-то не так. Один общий счётчик отправлял бы человека
// искать дефект там, где его нет.
FailedLayer int
FailedMalformed int
FailedOther int
// Partial — доставок, в теле которых остались непокрытые разбором секции.
// Не отклонение, а половина потока; названо потому, что именно эти тела
// ретеншену трогать нельзя.
Partial int
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей.
Incomparable int
Buckets int64
Fingerprint string
// Canceled — проигрывание прервано отменой, а не дошло до конца.
Canceled bool
}
// Run проигрывает журнал в базу назначения.
//
// Порядок строго `(received_at, id)`: слой доставки без плотных метрик
// наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки той же автоматизации, то есть является
// функцией префикса журнала. Обход каталога совпадает с хронологией только по
// датам каталогов и внутри суток не упорядочивает ничего.
//
// Проигрывание последовательное. Распараллеливать его нельзя по той же причине:
// слой зависит от префикса, а запись часового объекта — это чтение, слияние и
// запись обратно.
func Run(ctx context.Context, o Options) (Report, error) {
var rep Report
log := o.Log
if log == nil {
log = slog.New(slog.DiscardHandler)
}
log = log.With("capability", "replay")
// База назначения обязана быть пустой: проигрывание поверх накопленного
// оставило бы в витрине результат прежнего разбора — точки из объекта не
// удаляются никогда, — то есть не сделало бы того, ради чего пересборка и
// существует.
n, err := o.Target.CountDeliveries(ctx)
if err != nil {
return stopOr(rep, err)
}
if n > 0 {
return rep, fmt.Errorf("база назначения не пуста: %d доставок", n)
}
journal, orphans, rep, err := o.collect(ctx, rep, log)
if err != nil {
return stopOr(rep, err)
}
// Строка старта: прогон идёт минутами, и убитый на середине не оставлял бы
// о себе в логе ни следа — только чужие с виду записи свёртки.
log.InfoContext(ctx, "journal replay started",
"archive_dir", o.Archive.Root(), "bodies", len(journal), "orphans", len(orphans))
// Учётные записи, тела которых в архиве нет, переносятся ПЕРВЫМИ и не
// сворачиваются. Не перенести их значило бы потерять факты журнала —
// заголовки, хеш, автоматизацию — при первой же подмене базы: тела уже нет,
// и восстановить их будет нечем. В наследовании слоя они не участвуют:
// выведенного слоя у них нет.
for _, d := range orphans {
if err := o.Target.CreateDelivery(ctx, d); err != nil {
return stopOr(rep, err)
}
}
for i, d := range journal {
if ctx.Err() != nil {
rep.Canceled = true
return rep, nil
}
if err := o.Target.CreateDelivery(ctx, d); err != nil {
return stopOr(rep, err)
}
st, err := o.Fold.Fold(ctx, d.ID)
switch {
case err == nil:
rep.Folded++
case ctx.Err() != nil:
// Отмена, застигшая свёртку, — не отказ доставки: считать её отказом
// значило бы обвинить разбор в том, чего он не делал, и отправить
// человека искать дефект по логу.
rep.Canceled = true
return rep, nil
case errors.Is(err, hae.ErrLayerUnknown):
// Штатный исход, уже записанный свёрткой в лог и в parse_status:
// журнал заведомо содержит тела без плотных метрик. Останов на
// первом лишил бы пересборки все остальные.
rep.FailedLayer++
case errors.Is(err, hae.ErrMalformed):
rep.FailedMalformed++
default:
rep.FailedOther++
}
if err == nil {
// Счётчики читаются только у успешной свёртки: при ошибке поля Stats
// заполнены частично (Uncovered у отказавшего разбора всегда пуст,
// хотя в базу список записан) — и Partial молча занижался бы. А по
// нему принимается решение о ретеншене тел.
if len(st.Uncovered) > 0 {
rep.Partial++
}
rep.Incomparable += st.Incomparable
}
if o.Progress != nil {
o.Progress(i+1, len(journal))
}
}
// Отмена могла прийти на последней доставке: без этой проверки запросы ниже
// вернули бы context.Canceled как обычную ошибку, и команда завершилась бы
// одной строкой «fatal», не напечатав частичного отчёта.
if ctx.Err() != nil {
rep.Canceled = true
return rep, nil
}
rep.Buckets, err = o.Target.CountBuckets(ctx)
if err != nil {
return stopOr(rep, err)
}
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
if err != nil {
return stopOr(rep, err)
}
log.InfoContext(ctx, "journal replayed",
"bodies", rep.Bodies,
"skipped_files", rep.SkippedFiles,
"adopted", rep.Adopted,
"adopt_failed", rep.AdoptFailed,
"orphans", rep.Orphans,
"duplicates", rep.Duplicates,
"folded", rep.Folded,
"failed_layer", rep.FailedLayer,
"failed_malformed", rep.FailedMalformed,
"failed_other", rep.FailedOther,
"partial", rep.Partial,
"incomparable", rep.Incomparable,
"buckets", rep.Buckets)
return rep, nil
}
// collect собирает состав журнала и упорядочивает его.
//
// Второй возврат — учётные записи, тел которых в архиве нет. Они переносятся в
// базу назначения, но не сворачиваются.
func (o Options) collect(ctx context.Context, rep Report, log *slog.Logger) ([]store.Delivery, []store.Delivery, Report, error) {
listing, err := o.Archive.List()
if err != nil {
// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел
// нет»: пустой журнал дал бы пустую витрину, чей отпечаток совпадает с
// отпечатком любой другой пустой витрины.
return nil, nil, rep, err
}
rep.SkippedFiles = len(listing.Skipped)
for _, rel := range listing.Skipped {
// Путь в архиве — дата и ULID, содержимого тела в нём нет. Без этой
// строки счётчик пропусков в отчёте не на что раскрыть: имя файла не
// узнать иначе как обходом архива руками.
log.DebugContext(ctx, "archive file is not a body", "file", rel)
}
var known []store.Delivery
if o.Source != nil {
known, err = o.Source.ListDeliveries(ctx)
if err != nil {
return nil, nil, rep, err
}
}
byID := make(map[string]store.Delivery, len(known))
for _, d := range known {
byID[d.ID] = d
}
journal := make([]store.Delivery, 0, len(listing.Bodies))
seen := make(map[string]struct{}, len(listing.Bodies))
for _, rel := range listing.Bodies {
// Имя обязано быть КАНОНИЧЕСКИМ идентификатором, а не приводиться к
// нему: ident.Parse — функция входной границы, она обрезает пробелы и
// поднимает регистр, и `\u00a0<ulid>.json.gz` дал бы ту же координату
// журнала, что настоящее тело. Подложенный файл сортируется раньше и
// вытеснил бы настоящее — а невидимые пробелы в этих данных уже
// встречались.
id, err := ident.Parse(archive.BodyID(rel))
if err != nil || archive.BodyID(rel) != id {
rep.SkippedFiles++
log.DebugContext(ctx, "archive file is not a body", "file", rel)
continue
}
if _, dup := seen[id]; dup {
// Одно имя в двух каталогах: копия, восстановленная руками, или
// тело, переложенное не туда. Вторая запись журнала с тем же
// идентификатором сорвала бы весь прогон отказом по первичному
// ключу — то есть один посторонний файл лишал бы пересборки всё
// остальное.
rep.Duplicates++
log.WarnContext(ctx, "archive body id repeats", "file", rel, "delivery_id", id)
continue
}
rep.Bodies++
seen[id] = struct{}{}
if d, ok := byID[id]; ok {
journal = append(journal, prepare(d))
continue
}
d, err := o.adopt(id, rel)
if err != nil {
// Тело есть, а завести по нему запись не вышло: без этой строки
// счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает
// человека «разобраться по логу».
rep.AdoptFailed++
log.WarnContext(ctx, "archive body not adopted", "error", err, "file", rel, "delivery_id", id)
continue
}
rep.Adopted++
journal = append(journal, d)
}
// Учётная запись, тела которой в архиве нет. Переносится, но не
// сворачивается: не перенести значило бы стереть первой же подменой базы
// единственное свидетельство, что доставка была, — тела-то уже нет.
orphans := make([]store.Delivery, 0)
for _, d := range known {
if _, ok := seen[d.ID]; !ok {
rep.Orphans++
// Не `pending`: тела нет и не будет, а «этим разбором ещё не
// смотрели» обещало бы данные, которых не появится, и ретеншен,
// который pending не трогает никогда, берёг бы такие строки вечно.
o := prepare(d)
o.ParseStatus = store.ParseFailed
orphans = append(orphans, o)
log.DebugContext(ctx, "delivery body missing", "delivery_id", d.ID, "file", d.RawPath)
}
}
// Тотальный ключ: `received_at` хранится с секундной точностью, и доставки
// одной секунды без второго ключа шли бы в неопределённом порядке — два
// прогона одного журнала могли бы разойтись.
sort.Slice(journal, func(i, j int) bool {
a, b := journal[i], journal[j]
if !a.ReceivedAt.Equal(b.ReceivedAt) {
return a.ReceivedAt.Before(b.ReceivedAt)
}
return a.ID < b.ID
})
return journal, orphans, rep, nil
}
// stopOr отличает отмену от настоящего отказа: первая не ошибка операции, а
// требование прекратить работу, и застать она может любой шаг.
//
// Единая точка на весь пакет: разбросанные по шагам проверки означали бы, что
// каждый новый шаг обязан вспомнить правило руками, а забытый превращает
// Ctrl-C в «ошибку окружения» без частичного отчёта.
func stopOr(rep Report, err error) (Report, error) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
rep.Canceled = true
return rep, nil
}
return rep, err
}
// prepare оставляет от учётной записи ФАКТЫ ЖУРНАЛА и сбрасывает производные от
// разбора поля.
//
// `parse_status`, `points`, `derived_layer` и `uncovered_sections` — результат
// ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало вместе с доставкой. Перенести их
// значило бы сделать пересобранную витрину функцией прошлого прогона: доставка,
// чей повторный разбор отказал (штатный исход, когда слой не выводится),
// сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и
// следующая доставка той же автоматизации унаследовала бы его молча. Оба прогона
// при этом самосогласованы, поэтому проверка «повторная пересборка ничего не
// меняет» такого не ловит.
//
// Незаполненные здесь колонки получают значения по умолчанию схемы: пустой слой
// и пустой список непокрытых секций.
func prepare(d store.Delivery) store.Delivery {
return store.Delivery{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
AutomationName: d.AutomationName,
AutomationID: d.AutomationID,
Aggregation: d.Aggregation,
Period: d.Period,
SessionID: d.SessionID,
Bytes: d.Bytes,
SHA256: d.SHA256,
RawPath: d.RawPath,
Headers: d.Headers,
ParseStatus: store.ParsePending,
}
}
// adopt заводит учётную запись для тела, лежащего в архиве без неё.
//
// Такое тело — не экзотика: приём кладёт тело на диск раньше строки в базе
// (обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
// оставляет тело без учёта. Обещание подобрать его записано в пакете приёма.
//
// Восстанавливается ровно то, что выводится из самого тела и его имени.
// Заголовков в архиве нет вовсе, поэтому вывод слоя у такой доставки честно
// деградирует: наследовать не от чего и подтверждать нечем.
func (o Options) adopt(id, rel string) (store.Delivery, error) {
at, err := ident.TimeOf(id)
if err != nil {
return store.Delivery{}, err
}
body, err := o.Fold.ReadBody(rel)
if err != nil {
return store.Delivery{}, err
}
sum := sha256.Sum256(body)
return store.Delivery{
ID: id,
ReceivedAt: at,
// Размер и хеш — по РАСПАКОВАННОМУ телу, как их считает приём. Иначе в
// тех же колонках появились бы значения другой природы, и индекс по
// хешу начал бы врать на границе подобранных тел.
Bytes: int64(len(body)),
SHA256: hex.EncodeToString(sum[:]),
RawPath: rel,
ParseStatus: store.ParsePending,
}, nil
}
+627
View File
@@ -0,0 +1,627 @@
package replay_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// item — одна доставка тестового журнала.
type item struct {
id string
at time.Time
automationID string
aggregation string
fixture string
}
func fixture(t *testing.T, name string) []byte {
t.Helper()
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name))
if err != nil {
t.Fatalf("фикстура %s: %v", name, err)
}
return body
}
func openStore(t *testing.T, path string) *store.Store {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("база %s: %v", path, err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func openArchive(t *testing.T, root string) *archive.Archive {
t.Helper()
arch, err := archive.New(root)
if err != nil {
t.Fatalf("архив: %v", err)
}
return arch
}
// live воспроизводит живой приём: тело в архив, строка учёта, свёртка — в том
// порядке и тем кодом, каким это делает `internal/ingest`.
func live(t *testing.T, arch *archive.Archive, st *store.Store, items []item) {
t.Helper()
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
for _, it := range items {
writeBody(t, arch, st, it, fixture(t, it.fixture))
_, _ = f.Fold(context.Background(), it.id)
}
}
func writeBody(t *testing.T, arch *archive.Archive, st *store.Store, it item, body []byte) {
t.Helper()
rawPath, err := arch.Write(it.id, it.at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
if st == nil {
return
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: it.id,
ReceivedAt: it.at,
AutomationID: it.automationID,
Aggregation: it.aggregation,
Bytes: int64(len(body)),
SHA256: "-",
RawPath: rawPath,
Headers: `{"x-test":["1"]}`,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
}
// run проигрывает журнал в свежую базу и возвращает отчёт вместе с ней.
func run(t *testing.T, ctx context.Context, arch *archive.Archive, src *store.Store, out string) (replay.Report, *store.Store) {
t.Helper()
dst := openStore(t, out)
rep, err := replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err != nil {
t.Fatalf("проигрывание: %v", err)
}
return rep, dst
}
func fingerprint(t *testing.T, st *store.Store) string {
t.Helper()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// journal собирает журнал из фикстур с монотонными идентификаторами.
func journal(t *testing.T, fixtures ...string) []item {
t.Helper()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
out := make([]item, 0, len(fixtures))
for i, f := range fixtures {
out = append(out, item{
id: ident.NewID(),
at: base.Add(time.Duration(i+1) * time.Second),
automationID: "auto-1",
aggregation: "Default",
fixture: f,
})
}
return out
}
// Главная проверка задачи: пересборка с нуля даёт то же состояние, что
// накопленный приём, а повторный прогон ничего не меняет.
func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json", "raw.json", "mixed.json", "sparse_sleep.json")
live(t, arch, src, items)
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Bodies != len(items) {
t.Fatalf("тел в журнале %d, ожидалось %d", rep.Bodies, len(items))
}
if rep.Folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
if rep.Adopted != 0 || rep.Orphans != 0 || rep.SkippedFiles != 0 {
t.Errorf("журнал не должен был дать подобранных/сирот/пропусков: %+v", rep)
}
want := fingerprint(t, src)
if rep.Fingerprint != want {
t.Errorf("отпечаток пересобранной витрины не совпал с накопленной:\n приём %s\n пересборка %s",
want, rep.Fingerprint)
}
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
// координаты — функция множества кандидатов, а не порядка прихода.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
}
}
// Порядок проигрывания задаётся журналом, а не раскладкой файлов: доставка без
// плотных метрик наследует слой ПРЕДШЕСТВУЮЩЕЙ доставки той же автоматизации.
//
// Журнал устроен так, что порядок имён файлов ОБРАТЕН хронологии. Проигрывание
// по каталогу поставило бы доставку без плотных метрик первой — наследовать ей
// было бы не от чего, слой не вывелся бы, и точки не сохранились бы вовсе.
func TestПорядокЗадаётсяЖурналомАНеКаталогом(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
ids := []string{ident.NewID(), ident.NewID(), ident.NewID()}
sort.Strings(ids)
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Хронология обратна лексикографике имён: самый ранний файл — самая поздняя
// доставка.
items := []item{
{id: ids[2], at: base.Add(1 * time.Second), automationID: "a", aggregation: "Default", fixture: "hour.json"},
{id: ids[1], at: base.Add(2 * time.Second), automationID: "a", aggregation: "Default", fixture: "minute.json"},
{id: ids[0], at: base.Add(3 * time.Second), automationID: "a", aggregation: "Default", fixture: "sparse_sleep.json"},
}
for _, it := range items {
writeBody(t, arch, src, it, fixture(t, it.fixture))
}
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.FailedLayer+rep.FailedMalformed+rep.FailedOther != 0 {
t.Fatalf("отказов %d: доставка без плотных метрик не нашла предшественника — порядок взят из каталога",
rep.FailedLayer+rep.FailedMalformed+rep.FailedOther)
}
// Предшественник — минутная доставка, значит эпизоды сна легли в minute.
hours, err := dst.BucketHours(ctx, "sleep_analysis", "minute")
if err != nil {
t.Fatalf("часы объектов: %v", err)
}
if len(hours) == 0 {
t.Error("эпизоды сна не унаследовали слой предшествующей доставки")
}
}
// Тело без учётной записи — не экзотика: приём кладёт тело на диск раньше
// строки в базе, и отказ на вставке оставляет тело без учёта.
func TestТелоБезУчётаПодбирается(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
orphanBody := fixture(t, "minute.json")
orphan := item{id: ident.NewID(), at: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)}
// Строки учёта нет — только тело.
writeBody(t, arch, nil, orphan, orphanBody)
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Adopted != 1 {
t.Fatalf("подобрано %d тел, ожидалось 1: %+v", rep.Adopted, rep)
}
if rep.Folded != 1 {
t.Fatalf("свёрнуто %d, ожидалась 1", rep.Folded)
}
if rep.Buckets == 0 {
t.Error("точки подобранного тела не доехали до витрины")
}
// Размер и хеш считаются по РАСПАКОВАННОМУ телу — как их считает приём.
got, err := dst.DeliveryForParse(ctx, orphan.id)
if err != nil {
t.Fatalf("учёт подобранного тела: %v", err)
}
wantAt, err := ident.TimeOf(orphan.id)
if err != nil {
t.Fatalf("время из ULID: %v", err)
}
if !got.ReceivedAt.Equal(wantAt) {
t.Errorf("метка приёма %v, ожидалась из ULID %v", got.ReceivedAt, wantAt)
}
all, err := dst.ListDeliveries(ctx)
if err != nil {
t.Fatalf("учёт: %v", err)
}
if len(all) != 1 || all[0].Bytes != int64(len(orphanBody)) {
t.Errorf("размер подобранного тела %v, ожидался по распакованному %d", all, len(orphanBody))
}
}
// Учётная запись без тела станет штатной, когда появится ретеншен архива:
// тела срезаются до даты проверенного экспорта, а строки живут дольше.
func TestУчётБезТелаНеРоняетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
// Строка есть, тело исчезло.
if err := os.Remove(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")); err != nil {
t.Fatalf("удаление тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Orphans != 1 {
t.Errorf("записей без тела %d, ожидалась 1: %+v", rep.Orphans, rep)
}
if rep.Bodies != 0 {
t.Errorf("тел %d, ожидался 0", rep.Bodies)
}
}
// Файл, телом не являющийся, считается отдельно: молчаливый пропуск означал бы
// «тело есть, а в отчёте его нет».
func TestФайлНеТелоСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
day := filepath.Join(arch.Root(), "2026", "08", "01")
// Остаток прерванной записи и файл с именем, которое не идентификатор.
for _, name := range []string{items[0].id + ".json.gz.tmp", "readme.txt", "не-ulid.json.gz"} {
if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil {
t.Fatalf("подготовка файла: %v", err)
}
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.SkippedFiles != 3 {
t.Errorf("пропущено файлов %d, ожидалось 3: %+v", rep.SkippedFiles, rep)
}
if rep.Bodies != 1 || rep.Folded != 1 {
t.Errorf("тел %d, свёрнуто %d, ожидалось 1 и 1", rep.Bodies, rep.Folded)
}
}
// Слой прошлого разбора не должен доживать до наследования: иначе витрина
// оказывается функцией предыдущего прогона, а не журнала.
func TestСлойПрошлогоРазбораНеНаследуется(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
clean, _ := run(t, ctx, arch, src, filepath.Join(dir, "clean.db"))
// Портим производное поле: как если бы прежний разбор вывел другой слой.
for _, it := range items {
err := src.FinishParse(ctx, it.id, store.ParseOutcome{
Status: store.ParseDone,
Layer: "raw",
})
if err != nil {
t.Fatalf("порча derived_layer: %v", err)
}
}
dirty, _ := run(t, ctx, arch, src, filepath.Join(dir, "dirty.db"))
if dirty.Fingerprint != clean.Fingerprint {
t.Errorf("слой прошлого разбора повлиял на пересборку:\n чистая %s\n с порчей %s",
clean.Fingerprint, dirty.Fingerprint)
}
}
// Отмена прекращает проигрывание: это требование прекратить работу, а не
// свойство доставки.
func TestОтменаПрекращаетПроигрывание(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
items := journal(t, "minute.json", "hour.json", "raw.json")
live(t, arch, src, items)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dst := openStore(t, filepath.Join(dir, "rebuild.db"))
rep, err := replay.Run(ctx, replay.Options{
Archive: arch,
Source: src,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
// Отмена приходит посреди журнала — так же, как её принесёт сигнал.
Progress: func(done, _ int) {
if done == 1 {
cancel()
}
},
})
if err != nil {
t.Fatalf("проигрывание: %v", err)
}
if !rep.Canceled {
t.Error("отмена не отмечена в отчёте")
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d доставок, ожидалась 1 до отмены", rep.Folded)
}
// Отмена до начала работы — тоже отмена, а не отказ.
stopped, stop := context.WithCancel(context.Background())
stop()
early, err := replay.Run(stopped, replay.Options{
Archive: arch,
Source: src,
Target: openStore(t, filepath.Join(dir, "rebuild2.db")),
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err != nil {
t.Fatalf("проигрывание при отменённом контексте: %v", err)
}
if !early.Canceled || early.Folded != 0 {
t.Errorf("отмена до старта дала %+v", early)
}
}
// Нечитаемый или отсутствующий каталог архива — отказ, а не пустой журнал:
// пустая витрина совпадает по отпечатку с пустой витриной и выглядит идеальной
// сходимостью.
func TestОтсутствующийАрхивЭтоОтказ(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if _, err := archive.Existing(filepath.Join(dir, "нет-такого")); err == nil {
t.Fatal("отсутствующий каталог архива не дал отказа")
}
arch := openArchive(t, filepath.Join(dir, "raw"))
if err := os.RemoveAll(arch.Root()); err != nil {
t.Fatalf("удаление каталога: %v", err)
}
dst := openStore(t, filepath.Join(dir, "rebuild.db"))
_, err := replay.Run(context.Background(), replay.Options{
Archive: arch,
Target: dst,
Fold: fold.New(arch, dst, 0, slog.New(slog.DiscardHandler)),
})
if err == nil {
t.Error("исчезнувший каталог архива дал пустой журнал вместо отказа")
}
}
// Битое тело не срывает прогон: остальные доставки обязаны проиграться.
func TestБитоеТелоНеСрываетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
broken := filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")
if err := os.WriteFile(broken, []byte("не gzip"), 0o600); err != nil {
t.Fatalf("порча тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
// Битый gzip — «прочее», а не невыведенный слой: классы разведены именно
// затем, чтобы человек не искал дефект там, где его нет.
if rep.FailedOther != 1 {
t.Errorf("прочих отказов %d, ожидался 1: %+v", rep.FailedOther, rep)
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d, ожидалась 1 — прогон сорвался на битом теле", rep.Folded)
}
}
// Одно имя тела в двух каталогах — копия, восстановленная руками, или тело,
// переложенное не туда. Вторая запись журнала с тем же идентификатором сорвала
// бы весь прогон отказом по первичному ключу.
func TestПовторИдентификатораНеСрываетПрогон(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
// Тот же файл, но под другой датой.
body, err := os.ReadFile(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz"))
if err != nil {
t.Fatalf("чтение тела: %v", err)
}
other := filepath.Join(arch.Root(), "2026", "07", "31")
if err := os.MkdirAll(other, 0o755); err != nil {
t.Fatalf("каталог: %v", err)
}
if err := os.WriteFile(filepath.Join(other, items[0].id+".json.gz"), body, 0o600); err != nil {
t.Fatalf("копия тела: %v", err)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Duplicates != 1 {
t.Errorf("повторов %d, ожидался 1: %+v", rep.Duplicates, rep)
}
if rep.Folded != 1 {
t.Errorf("свёрнуто %d, ожидалась 1 — повтор сорвал прогон", rep.Folded)
}
}
// Доставки одной секунды упорядочиваются идентификатором: `received_at` хранится
// с секундной точностью, и без второго ключа два прогона одного журнала могли бы
// разойтись.
func TestДоставкиОднойСекундыУпорядоченыИдентификатором(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
ids := []string{ident.NewID(), ident.NewID()}
sort.Strings(ids)
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Обе доставки — одна секунда. Первая по идентификатору минутная, вторая без
// плотных метрик: если тай-брейк исчезнет, вторая может пойти первой и
// остаться без предшественника.
writeBody(t, arch, src, item{id: ids[0], at: at, automationID: "a"}, fixture(t, "minute.json"))
writeBody(t, arch, src, item{id: ids[1], at: at, automationID: "a"}, fixture(t, "sparse_sleep.json"))
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.FailedLayer != 0 {
t.Fatalf("слой не вывелся у %d доставок: порядок внутри секунды не задан", rep.FailedLayer)
}
// Повтор в другую базу обязан дать тот же отпечаток.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("порядок внутри секунды не детерминирован:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
}
}
// Учётная запись, тела которой нет, переносится в базу назначения: не перенести
// значило бы стереть первой же подменой единственное свидетельство, что
// доставка была, — тела уже нет, восстановить нечем.
func TestУчётБезТелаПереноситсяВБазуНазначения(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json", "hour.json")
live(t, arch, src, items)
if err := os.Remove(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")); err != nil {
t.Fatalf("удаление тела: %v", err)
}
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Orphans != 1 {
t.Fatalf("записей без тела %d, ожидалась 1", rep.Orphans)
}
got, err := dst.ListDeliveries(ctx)
if err != nil {
t.Fatalf("учёт: %v", err)
}
if len(got) != len(items) {
t.Fatalf("строк учёта %d, ожидалось %d — запись без тела потеряна", len(got), len(items))
}
// Заголовки переносятся дословно: в архиве их нет вовсе.
for _, d := range got {
if d.Headers != `{"x-test":["1"]}` {
t.Errorf("заголовки доставки %s не дошли дословно: %q", d.ID, d.Headers)
}
}
// Статус — `failed`, а не `pending`. Различие несущее: `pending` означает
// «этим разбором ещё не смотрели» и обещает данные, которых не появится —
// тела уже нет, — а ретеншен, который pending не трогает никогда, берёг бы
// такие строки вечно.
b, err := dst.DeliveryStatus(ctx, items[0].id)
if err != nil {
t.Fatalf("статус записи без тела: %v", err)
}
if b != store.ParseFailed {
t.Errorf("статус записи без тела %q, ожидался %q", b, store.ParseFailed)
}
}
// Имя тела обязано быть КАНОНИЧЕСКИМ идентификатором. Разбор с приведением
// (обрезка пробелов, регистр) дал бы одну координату журнала двум файлам, и
// подложенный вытеснил бы настоящий — невидимые пробелы в этих данных уже
// встречались.
func TestИмяТелаОбязаноБытьКаноническим(t *testing.T) {
t.Parallel()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
ctx := context.Background()
items := journal(t, "minute.json")
live(t, arch, src, items)
day := filepath.Join(arch.Root(), "2026", "08", "01")
body, err := os.ReadFile(filepath.Join(day, items[0].id+".json.gz"))
if err != nil {
t.Fatalf("чтение тела: %v", err)
}
// Те же 26 знаков, но с невидимым префиксом и в верхнем регистре: обе формы
// ident.Parse приводит к тому же идентификатору.
for _, name := range []string{" " + items[0].id + ".json.gz", strings.ToUpper(items[0].id) + ".json.gz"} {
if err := os.WriteFile(filepath.Join(day, name), body, 0o600); err != nil {
t.Fatalf("подложенный файл: %v", err)
}
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if rep.Bodies != 1 {
t.Errorf("тел %d, ожидалось 1: подложенное имя принято за тело (%+v)", rep.Bodies, rep)
}
if rep.SkippedFiles != 2 {
t.Errorf("пропущено %d, ожидалось 2", rep.SkippedFiles)
}
if rep.Duplicates != 0 {
t.Errorf("повторов %d: настоящее тело вытеснено подложенным", rep.Duplicates)
}
}
+56
View File
@@ -105,6 +105,62 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
return d, nil
}
// 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 записывает исход разбора доставки.
//
// Слой сохраняется здесь же, потому что он нужен следующей доставке той же
+110
View File
@@ -0,0 +1,110 @@
package store_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Пересборка читает рабочую базу, пока в неё может писать сервис. Обычное
// открытие накатывает миграции безусловно, а миграции меняют и данные — та, что
// ввела частичный разбор, переписала parse_status у всех строк. Значит утилите
// нужен путь чтения, который базу не трогает.
func TestOpenForReadНеПишетВБазу(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
seed(t, path)
ro, err := store.OpenForRead(path)
if err != nil {
t.Fatalf("OpenForRead: %v", err)
}
defer func() { _ = ro.Close() }()
ctx := context.Background()
got, err := ro.ListDeliveries(ctx)
if err != nil {
t.Fatalf("ListDeliveries: %v", err)
}
if len(got) != 2 {
t.Fatalf("доставок %d, ожидалось 2", len(got))
}
// Порядок журнала — (received_at, id), тот же, в котором проигрывает
// пересборка.
if got[0].ID != "01hzzzzzzzzzzzzzzzzzzzzzz1" || got[1].ID != "01hzzzzzzzzzzzzzzzzzzzzzz0" {
t.Errorf("порядок %q, %q — не по времени приёма", got[0].ID, got[1].ID)
}
// Заголовки переносятся дословно: восстановить их неоткуда, в архиве их нет.
if got[0].Headers != `{"x-test":["1"]}` {
t.Errorf("заголовки %q не дошли дословно", got[0].Headers)
}
if err := ro.CreateDelivery(ctx, store.Delivery{
ID: "01hzzzzzzzzzzzzzzzzzzzzzz2", ReceivedAt: store.Now(),
Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending,
}); err == nil {
t.Error("запись в базу, открытую на чтение, удалась")
}
}
// Расхождение версии схемы — отказ, а не повод мигрировать: иначе свежий бинарь
// молча меняет схему под работающим старым сервисом.
func TestOpenForReadОтвергаетЧужуюВерсиюСхемы(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
seed(t, path)
// Откатываем учёт миграций мимо store: имитируем базу, к которой бинарь
// новее.
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
_, err = db.Exec(`DELETE FROM goose_db_version
WHERE version_id = (SELECT max(version_id) FROM goose_db_version)`)
if err != nil {
t.Fatalf("откат версии: %v", err)
}
_ = db.Close()
if _, err := store.OpenForRead(path); !errors.Is(err, store.ErrSchemaMismatch) {
t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err)
}
}
func seed(t *testing.T, path string) {
t.Helper()
st, err := store.Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = st.Close() }()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
// Второй идентификатор меньше первого, а приехал он раньше: так проверяется,
// что порядок берётся из времени приёма, а не из имени.
rows := []store.Delivery{
{ID: "01hzzzzzzzzzzzzzzzzzzzzzz1", ReceivedAt: base},
{ID: "01hzzzzzzzzzzzzzzzzzzzzzz0", ReceivedAt: base.Add(time.Second)},
}
for _, d := range rows {
d.Bytes = 1
d.SHA256 = "-"
d.RawPath = d.ID + ".json.gz"
d.ParseStatus = store.ParsePending
d.Headers = `{"x-test":["1"]}`
if err := st.CreateDelivery(context.Background(), d); err != nil {
t.Fatalf("CreateDelivery: %v", err)
}
}
}
+79
View File
@@ -5,9 +5,12 @@ package store
import (
"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 и
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,346 @@
## Context
Витрина объявлена свёрткой по журналу
(`import(экспорт) + replay(доставки по received_at)`), но кода свёртки нет:
`fold.Fold` умеет свернуть **одну** доставку по её идентификатору, а того, кто
перечислит журнал и позовёт её по каждой записи, не существует. Следствия уже
наблюдаемы: после миграции 00005 доставки числятся `pending` (этим разбором не
смотрели), и подобрать их некому; а точки, разобранные прежним кодом, лежат в
объектах и не удаляются никогда — исправление разбора к ним не применится.
Что уже сделано и на что опираемся:
- `fold.Fold(ctx, deliveryID)` читает тело **из архива**, а не из памяти —
ровно потому, что путь чтения у приёма и у пересборки обязан быть один.
- Слияние точек — функция множества кандидатов, а не порядка (частичный
порядок полноты + тотальный тай-брейк), поэтому повторная свёртка той же
доставки ничего не меняет.
- Вывод слоя зависит от **префикса журнала**: доставка без плотных метрик
наследует слой предшествующей доставки той же автоматизации
(`LastDerivedLayer` с границей по `(received_at, id)`).
- `store.Fingerprint` даёт отпечаток витрины по координатам и хешам объектов,
не раскрывая значений. Он уже служит оракулом в `task verify:archive`.
Ограничения окружения: сервис живёт в контейнере и держит базу открытой,
телефон шлёт молча и непрерывно, объём архива за квартал — порядка 2 ГБ.
## Goals / Non-Goals
**Goals:**
- Проиграть журнал целиком и получить состояние, совпадающее с накопленным
приёмом; повторный прогон ничего не меняет.
- Считать журналом **архив**, а не таблицу доставок: тело без учётной записи
тоже событие.
- Дать оракул сходимости прямо в команде — сравнение отпечатков, а не «глазом
по логам».
- Оставить дверь для `healthlog import`: пересборка из архива это вырожденный
случай с пустым снапшотом, а не отдельная утилита.
**Non-Goals:**
- **Подмена рабочей базы.** Команда не заменяет файл базы и не останавливает
сервис (см. решение 2).
- **Импорт родного экспорта Apple.** Стадия снапшота в этой дельте пуста.
- **Ретеншен архива.** Пересборка тел не удаляет; она их только читает.
- **Восстановление верхних слоёв за периоды с удалёнными телами.** Считать их
вниз из `sample` запрещено инвариантом — это была бы наша агрегация под видом
присланной.
- **Онлайн-пересборка под живым приёмом.** Пересборка идёт в отдельный файл, и
доставки, приехавшие во время неё, в него не попадают; это названная граница,
а не дефект (см. риски).
## Три формы решения и компромисс каждой
Рассматривались три, а не одна; выбрана вторая.
**A. Очистить рабочую витрину и проиграть журнал в неё же.** Дёшево, второй
базы нет, результат применяется сам собой. Компромисс: единственная необратимая
операция всей задачи (`DELETE FROM bucket`) выполняется **до** того, как станет
известно, удалась ли пересборка. Отказ на середине оставляет витрину пустой
наполовину, и это состояние ничем не отличается от нормального. Под живым
сервисом — ещё и окно, в котором история отдаётся полупустой как полная.
**B. Собрать витрину в отдельный файл базы, подмену оставить человеку**
(выбрано). Отказ бесплатен: рабочая база не тронута, временный файл удаляется;
результат можно сверить с рабочим прежде, чем применять. Компромисс: результат
не применяется сам — нужна процедура из четырёх команд с остановкой сервиса, а
доставки, приехавшие во время сборки, в новый файл не попадают и подбираются
только следующим прогоном.
**C. Теневая таблица внутри той же базы: собрать в `bucket_new`, затем
переименовать в транзакции.** Подмена атомарна средствами самой SQLite, вторая
база не нужна, остановка сервиса теоретически не требуется. Компромисс
решающий: имя `bucket` зашито литералом во весь слой записи (`internal/store`),
и вариант требует параметризовать таблицей всю запись — то есть переписать
самый опасный код проекта ради операции, которая выполняется раз в полгода.
Вдобавок он не решает того, ради чего затевался: живой приём во время
пересборки пишет в **старую** таблицу, и при подмене его точки пропадают, —
значит приём всё равно надо останавливать, и сверх B вариант не даёт ничего.
## Decisions
### 1. Пересборка идёт в отдельный файл базы, а не поверх рабочей
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в нём
результат старого, неверного разбора — то есть не сделало бы ровно того, ради
чего задача и заведена.
Начать с пустой витрины можно двумя способами: очистить рабочую таблицу и
проиграть журнал в неё же, либо собрать новую витрину рядом и подменить.
Взято второе. Prior art здесь однозначен и стар: это blue-green rebuild
проекции — «вместо усечения существующей модели строим новую в параллельном
хранилище и переключаем чтение, когда она догонит»
([Rebuilding Event-Driven Read Models](https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models),
[Projections and Read Models](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/));
тем же приёмом работает `_reindex` + переключение алиаса в Elasticsearch, и та
же форма у собственного `VACUUM INTO` SQLite — «собери целую копию в новый
файл».
Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины —
единственная **необратимая** операция во всей задаче, и она наступает **до**
того, как станет известно, что пересборка вообще удалась. Отказ на середине
(битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину, причём в состоянии, которое ничем не отличается от нормального.
Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл
удаляется.
Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина —
названа выше; плюс под живым сервисом это ещё и окно, в котором Read API
отдавал бы полупустую историю как полную.
### 2. Подмену рабочей базы делает человек, а не команда
Файл базы держит открытым процесс сервиса. В POSIX переименование не касается
уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а
читатели увидят новый файл — данные разойдутся молча. Документация SQLite
говорит об этом прямо: переименование или удаление файла базы во время записи
оставляет журнал под чужим именем и **портит базу**
([How To Corrupt An SQLite Database File](https://www.sqlite.org/howtocorrupt.html)),
и в форуме проекта то же короче: «никогда не безопасно переименовывать
используемый файл sqlite3».
Значит безопасная подмена требует, чтобы сервис был остановлен. Остановить его
команда не может: сервисом управляет docker compose снаружи, и CLI, который
делает вид, что управляет, обещал бы безопасность, которой не обеспечивает.
Поэтому подмена остаётся процедурой человека (`task down``mv``task up`), а
команда печатает её в отчёте буквально.
Это же совпадает с правилом проекта: спрашиваем про необратимое. Перезапись
рабочей базы — ровно оно.
Отвергнуто: флаг `--replace`, делающий подмену сам. Причина — безопасен он
только при остановленном сервисе, а проверить это изнутри нечем; флаг,
безопасный лишь при невыраженном условии, хуже его отсутствия.
### 3. Журнал перечисляется по архиву, а учёт по нему сверяется
Перечислять только строки `delivery` значило бы пересобирать витрину из
**витрины**. Тело может лежать в архиве без учётной записи: приём пишет тело
на диск раньше строки в базе — намеренно, обратный порядок дал бы учтённую
доставку без данных, — и на отказе вставки в `internal/ingest` уже записано
обещание, что такое тело подберёт пересборка.
Поэтому вход пересборки — объединение двух множеств:
```
тело в архиве + строка delivery → штатная доставка, метаданные из строки
тело в архиве, строки нет → заводится заново: id из имени файла,
received_at из метки ULID, bytes и sha256
пересчитываются по телу
строка есть, тела нет → считается и называется в отчёте, не отказ
```
Третий случай станет штатным, когда появится ретеншен архива: тела до даты
проверенного экспорта срезаются, а строки живут дольше. Отказом он быть не
должен уже сейчас.
Метка приёма для тела без записи берётся из **ULID**, а не из даты каталога:
каталог даёт сутки, а порядок внутри суток важен — от него зависит наследование
слоя. ULID монотонен по времени создания, а создаётся идентификатор в приёме
непосредственно перед меткой `received_at`.
Заголовки доставки при этом не восстанавливаются: **в архиве их нет вовсе**.
Для штатных доставок они берутся из рабочей базы; у подобранного тела их не
будет, и вывод слоя для него опустится на общее правило (нет автоматизации —
нечего наследовать, нет заголовка — нечем подтвердить). Это честная деградация,
и она названа в спеке. Устранять её (класть заголовки в архив рядом с телом —
так делает WARC) в этой дельте нельзя: правка пути приёма стоит дороже всей
остальной задачи.
### 4. Порядок — строго `(received_at, id)`
Слияние точек коммутативно, но слой — нет: он функция префикса журнала. Порядок
обхода каталога (`filepath.Walk` по датам) совпадает с хронологией только
случайно, а внутри суток не даёт ничего. Сортировка по `(received_at, id)`
доопределяет и совпадение меток: `received_at` усечён до секунды, и доставки в
одной секунде без второго ключа шли бы в произвольном порядке.
### 5. Отчёт печатается человеку, оракул — отпечаток, исход — код возврата
Итог пересборки — счётчики (доставок проиграно, свёрнуто, отказов, тел без
учёта, строк без тел, пропущенных файлов, объектов) и **два отпечатка**: рабочей
витрины и пересобранной. Совпали — состояние воспроизводимо; разошлись — это
либо исправленный разбор (ожидаемо), либо расхождение, которое надо смотреть.
Отпечаток значений точек не раскрывает: содержимое входит в него хешем.
Отпечаток рабочей витрины снимается **до** проигрывания, а число доставок — до
и после. Без этого оракул под живым приёмом отвечает «разошлись» всегда: любая
доставка, приехавшая за время прогона, двигает рабочую витрину. Оракул, который
врёт без предупреждения, перестают читать — и он не сработает ровно тогда, когда
разбор действительно разойдётся.
**Отдельное решение — что считать успехом.** Расхождение отпечатков успехом быть
не перестаёт: оно и есть смысл пересборки. А вот пустой журнал успехом не
является, хотя выглядит идеально: отпечаток пустой витрины совпадает с
отпечатком пустой витрины. Все умолчания подыгрывают такому запуску — конфиг
необязателен, и без него пути указывают в рабочий каталог процесса, а каталог
архива по этому пути пересборка **не создаёт**. Поэтому пустой журнал, ноль
свёрнутых доставок, отмена и ошибка окружения дают ненулевой код и не печатают
процедуру подмены; отказ отдельной доставки — не даёт, он штатный.
**Потоки разведены:** отчёт — в stdout человеческим текстом, прогресс — в
stderr. Прогон на полном архиве молчит минутами, и зависший неотличим от
идущего; смешивать прогресс с отчётом нельзя, иначе отчёт нельзя перенаправить.
Рендер отчёта принимает `io.Writer` и не знает про `os.Stdout` — иначе проверка
«отчёт не раскрывает данных о здоровье» превращается в тест на глобальном
состоянии, а это единственная защита новой поверхности вывода.
Логи свёртки при этом остаются логами и пишутся `slog`, как при приёме: один
чекпоинт на доставку, без значений точек. Запрет на имена метрик относится к
отчёту, а не к логу: координаты столкновения разрешены спекой хранения явно.
### 6. Новый пакет `internal/replay`, а не метод у `fold`
`fold` отвечает за одну доставку и ничего не знает ни про каталог архива, ни
про порядок. Проигрывание журнала — другая ответственность: перечислить,
упорядочить, догрузить недостающий учёт, свести отчёт. Имя `replay`, а не
`reindex`, потому что это половина формулы `import + replay`: задача про родной
экспорт добавит стадию снапшота **перед** проигрыванием и переиспользует ту же
операцию, а не заведёт вторую похожую.
### 7. Прогон живого архива переезжает на пересборку — второго проигрывателя не остаётся
`internal/fold/replay_test.go` (он же `task verify:archive`) сегодня проигрывает
живой архив **своими руками**: свой обход каталога, свой порядок (сортировка
путей), свой синтез учёта (все тела под одной автоматизацией). Это и есть второй
проигрыватель, и его правила уже расходятся с дельтой: порядок не
`(received_at, id)`, случаев «учёт без тела» и «имя не тело» у него нет вовсе.
После появления `internal/replay` он продолжил бы зеленеть, проверяя путь,
которым `healthlog reindex` не ходит. Цена ошибки здесь известна: именно этот
прогон поймал дефект `LastDerivedLayer` — тот, из-за которого пересборка давала
1742 объекта вместо 1737 (`docs/review-journal.md`).
Поэтому прогон переписывается поверх `replay.Run`, а его утверждения остаются на
месте — они и есть его ценность. Одно из них по дороге пришлось переписать:
константа «174 координаты `sleep_analysis`» снята на 94 доставках и протухла на
116, потому что производна от размера корпуса. Утверждается теперь само
свойство — координат строго больше, чем различных меток, — а измеренные числа
печатаются.
`task verify:archive` сохраняет имя и смысл, отдельной задачи «прогон
пересборки» в `Taskfile.yml` не появляется.
Отвергнуто: (б) заменить прогон вызовом самой команды на живом архиве — тогда
измеренные утверждения умирают, а остаётся «отработало без ошибки»; (в) держать
оба проигрывателя — каждый будущий правщик порядка или подбора обязан править
два места, а расхождение между ними не поймает никто.
### 8. Рабочая база открывается на чтение и без миграций
Обычное открытие (`store.Open`) накатывает миграции безусловно, а миграции здесь
меняют и **данные**: та, что ввела частичный разбор, переписала `parse_status` у
всех строк. То есть штатный путь чтения нарушал бы собственное требование
«рабочую базу не трогаем», и приёмочный сценарий этого не заметил бы — отпечаток
считается по объектам, а не по учёту.
Вводится отдельный конструктор чтения: без наката миграций, с проверкой версии
схемы. Расхождение версий — отказ с указанием обеих, а не молчаливая миграция
под работающим сервисом.
Отвергнуто: соглашение «запускать на одной версии бинаря». Договорённость с
самим собой не является механизмом, а цена нарушения — DDL под живым приёмом.
### 9. Тождество файла назначения — по файлу, а не по строке пути
Сравнение путей строкой не отвечает на вопрос «это тот же файл»: `..` в пути,
симлинк, другой префикс монтирования внутри контейнера дают ту же цель при
другой строке. Ошибка здесь означает проигрывание журнала прямо в живую рабочую
базу — то самое необратимое, ради предотвращения которого выбрана сборка рядом.
Тождество определяется свойствами файла (устройство и inode); когда файла
назначения ещё нет — свойствами родительского каталога и именем.
### 10. Идемпотентность держится существующим слиянием, а не новым кодом
Повторный прогон не меняет состояния потому, что победитель координаты —
функция множества кандидатов. Пересборка не добавляет к этому ничего своего и
не имеет права: любая её собственная «оптимизация» вроде пропуска доставок по
`parse_status` сделала бы результат зависящим от предыдущего прогона. Поэтому
проигрываются **все** доставки, а дешевизну повтора обеспечивает хеш-детектор
объекта.
## Risks / Trade-offs
- **Доставки, приехавшие во время пересборки, в новый файл не попадут** → они
остаются в архиве и в рабочей базе; после подмены их тела окажутся телами без
учётной записи, и следующий прогон их подберёт (решение 3). Штатная процедура
— остановить сервис на время подмены; окно в минуты закрывают средний и
глубокий проходы синхронизации, у которых окна фиксированные.
- **Остановка сервиса на время подмены — окно, в котором доставка не
принимается** → переживает ли «Since Last Sync» неудачную отправку,
неизвестно (открытый вопрос `docs/local-research.md`). Поэтому на остановку
полагаться нельзя, и страхует её другое: средний и глубокий проходы
синхронизации работают **фиксированными окнами** (сутки и неделя), то есть
переприсылают период целиком независимо от того, что было доставлено.
Практический вывод для процедуры: подменять базу стоит минутами, а не часами,
и не откладывать перезапуск.
- **Пересборка читает рабочую базу, пока сервис в неё пишет** → чтение под WAL
безопасно, но `store.Open` накатывает миграции. На актуальной схеме это
no-op; на устаревшей — миграция под живым трафиком, чего команда не ожидает.
Смягчение: подмена и пересборка выполняются на одной версии бинаря, как и
сказано в процедуре.
- **`sealed` в пересобранной витрине пуст** → правила его выставления ещё нет
(порог глубины досчёта не выбран), так что переносить нечего. Когда правило
появится, признак станет функцией от часа и воспроизведётся сам. Пока это
означает: отпечатки разойдутся, если кто-то выставил `sealed` руками.
- **Время прогона растёт линейно по архиву** → 2 ГБ за квартал, чтение и
разбор каждого тела. Для ручной операции приемлемо; порционность
(«разбивать реплей на куски») из prior art не берём — она нужна миллиардам
событий, а не сотням тел.
- **Отчёт печатает пути и счётчики** → путей внутри `./data` в отчёте
достаточно, чтобы человек сделал `mv`, но ни имён метрик, ни значений точек в
нём нет.
## Migration Plan
Миграций схемы нет. Процедура применения пересобранной витрины (её же печатает
команда):
```
task down # сервис отпускает файл базы И перестаёт принимать
healthlog reindex --config ./config.toml # собирает ./data/healthlog.db.rebuild
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Порядок здесь существен: сервис останавливается **до** пересборки, а не после
неё. Доставки, приехавшие за время прогона, в собранный файл не попадут, и
подмена стёрла бы их учёт вместе с заголовками. Команда это ловит — печатает
разницу числа доставок и в таком случае процедуру подмены не печатает вовсе, —
но платить за это лишним прогоном не нужно. Пересборка без подмены (сверка
отпечатков) при живом сервисе, наоборот, безопасна и полезна.
Откат: рабочая база не тронута до `mv`, поэтому откат — не делать `mv`. После
`mv` откат — повторная пересборка из того же архива: журнал не изменился.
## Open Questions
- Заголовки доставки в архиве не лежат, поэтому пересборка «с нуля», без
рабочей базы, деградирует по выводу слоя. Класть ли рядом с телом его
заголовки (как WARC) — отдельная задача беклога, не эта дельта.
- Нужен ли режим «догнать только неразобранное» для фонового подбора
`pending` — это половина задачи «разнести ответ приёма и свёртку»; здесь не
решается, но `replay` даёт ей готовую операцию.
@@ -0,0 +1,69 @@
## Why
Разбор пишется по реальным данным и будет ошибаться — это норма. Без
пересборки ошибка разбора становится потерей данных: исправленный код не
применится к тому, что уже разобрано неверно, а точки из объекта не удаляются
никогда. Сегодня журнал есть (116 тел в архиве), а кода, который его
проигрывает, нет: после миграции 00005 доставки числятся `pending`, и подобрать
их некому.
Оговорка, которую легко прочитать наоборот: пересборка точнее приёма **не тем,
что видит более длинный ряд**. Слой обязан быть функцией префикса журнала, и
наследование «от последней доставки вообще» уже ловили дефектом — 1737 объектов
против 1742. Точнее она ровно тем, что применяет **исправленный** разбор к
тому, что уже разобрано неверно.
## What Changes
- Новая подкоманда `healthlog reindex`: собирает витрину из журнала —
`import(снапшот) + replay(доставки по received_at)` — и пишет её в **отдельный
файл базы**, не трогая рабочую. Снапшот в этой дельте пуст: `import` появится
вместе с задачей про родной экспорт Apple, и та встроится сюда же, а не
заведёт вторую операцию.
- Журналом считается **архив**, а не таблица доставок: тела, у которых учётной
записи нет (приём успел записать тело и упал на вставке строки), заводятся
заново по имени файла. Это обещание, уже записанное в `internal/ingest`.
- Порядок проигрывания — строго `(received_at, id)`, а не порядок обхода
каталога: слой наследуется от предшествующей доставки той же автоматизации,
и порядок входит в результат.
- Оракул сходимости встроен в команду: отпечаток пересобранной витрины
печатается рядом с отпечатком рабочей, и команда прямо говорит, совпали они
или нет. Отпечаток значений точек не раскрывает.
- Подмена рабочей базы пересобранной **остаётся за человеком** и в команду не
входит: сервис держит открытый дескриптор, и `rename` поверх него оставил бы
процесс писать в отвязанный inode — молча.
- Границы, названные вслух: `sealed` в пересобранной витрине пуст (правила его
выставления ещё нет), а заголовки доставок берутся из рабочей базы — в архиве
их нет вовсе.
- Прогон живого архива (`task verify:archive`) переезжает на новый код: сегодня
он **второй проигрыватель журнала** со своим порядком и своим синтезом учёта,
и после появления настоящей пересборки зеленел бы, проверяя путь, которым
команда не ходит.
## Capabilities
### New Capabilities
- `reindex`: пересборка витрины проигрыванием журнала — состав журнала,
порядок, детерминированность, отчёт и его оракул, граница «что не
восстанавливается».
### Modified Capabilities
Изменённых нет. Правило «тело без учётной записи заводится заново» могло бы
показаться правилом учёта, но оно описывает состав журнала при проигрывании и
живёт в `reindex`; дублировать его в `storage` значило бы завести два места, где
сказано одно и то же. Схема БД, слияние точек и вывод слоя не меняются: вся
дельта — новый потребитель существующей свёртки.
## Impact
- Новый пакет `internal/replay` — проигрывание журнала поверх существующего
`internal/fold`; собственного разбора и собственного слияния не заводит.
- Новый файл `cmd/healthlog/reindex.go`, строка в `main.go`.
- `internal/store`: перечисление доставок в порядке журнала, очистка витрины,
чтение отпечатка (уже есть).
- `internal/ident`: время создания из ULID — метка приёма для тела без учётной
записи.
- Схема БД не меняется, миграций нет.
- `README.md` (`reindex` перестаёт быть «в планах»), `docs/architecture.md`
(почему подмена базы не автоматизируется), `docs/plan.md`, `docs/backlog`.
@@ -0,0 +1,430 @@
## ADDED Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
в порядке обхода каталога архива.
Порядок входит в результат: слой доставки без плотных метрик наследуется от
**предшествующей** доставки той же автоматизации, то есть слой есть функция
префикса журнала. Обход каталога совпадает с хронологией только по датам
каталогов и внутри суток не упорядочивает ничего.
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
а значит два прогона одного журнала могли бы разойтись.
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
запись обратно.
#### Scenario: Порядок не зависит от раскладки файлов в архиве
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
не совпадает с хронологией приёма
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
#### Scenario: Доставки одной секунды упорядочены идентификатором
- **WHEN** две доставки имеют одинаковый `received_at`
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
прогоне
### Requirement: Журналом считается архив, а не таблица доставок
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
доставок, а не перечислять только строки `delivery`. Иначе витрина
пересобиралась бы из витрины.
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
оставляет тело в архиве без учёта.
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
появились бы значения другой природы.
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
слоя: наследовать не от чего и подтверждать нечем.
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
называться в отчёте.
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
разбираться по логу.
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
создавать MUST NOT — создание превратило бы запуск не из того каталога в
успешный прогон по пустому журналу.
#### Scenario: Тело без учётной записи подбирается
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
приёма из ULID в UTC
- **AND** её размер и хеш посчитаны по распакованному телу
- **AND** её точки попадают в витрину
#### Scenario: Учётная запись без тела не роняет пересборку
- **WHEN** у строки `delivery` нет тела в архиве
- **THEN** пересборка продолжается
- **AND** факт учитывается счётчиком в отчёте
- **AND** сама запись переносится в базу назначения, но не сворачивается
#### Scenario: Файл, не являющийся телом, считается отдельно
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
расширение не соответствует форме тела
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
#### Scenario: Повтор идентификатора не срывает прогон
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
- **THEN** проигрывается первое, второе учитывается счётчиком
- **AND** пересборка доходит до конца
#### Scenario: Каталог архива не читается
- **WHEN** корня архива нет либо подкаталог не читается
- **THEN** команда завершается ошибкой и витрину не собирает
### Requirement: Пересборка не трогает рабочую базу
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
никогда, поэтому проигрывание поверх накопленного оставило бы результат
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
**до** того, как известно, что пересборка удалась: отказ на середине (битое
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину в состоянии, неотличимом от нормального.
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
схему под работающим старым сервисом.
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
быть отказом с указанием обеих версий, а не поводом мигрировать.
#### Scenario: Рабочая база остаётся нетронутой
- **WHEN** пересборка отработала успешно
- **THEN** отпечаток рабочей витрины не изменился
- **AND** учёт доставок в рабочей базе не изменился
- **AND** собранная витрина лежит в отдельном файле
#### Scenario: Отказ посреди пересборки не портит рабочую базу
- **WHEN** пересборка прерывается на середине журнала
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
какими были
#### Scenario: Схема рабочей базы старше бинаря
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
- **THEN** команда завершается ошибкой, называя обе версии
- **AND** не пишет в рабочую базу ни одной строки
### Requirement: Файл назначения и его жизненный цикл
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
остаётся переименованием внутри одной файловой системы.
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
убираться вместе со спутниками журнала SQLite.
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
приучил бы обходить защиту от перезаписи флагом принудительности.
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
пустой витрины, а не дописывается в чужое содержимое.
#### Scenario: Файл назначения совпадает с рабочей базой
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
пути
- **THEN** команда завершается ошибкой и не пишет ничего
#### Scenario: Файл назначения уже существует
- **WHEN** файл назначения существует, а перезапись не затребована явно
- **THEN** команда завершается ошибкой и существующий файл не трогает
#### Scenario: Затребованная перезапись даёт ту же витрину
- **WHEN** пересборка выполняется поверх существующего файла назначения с
явно затребованной перезаписью
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
журнала в отсутствующий файл
#### Scenario: Прерванная пересборка не оставляет файла назначения
- **WHEN** пересборка прерывается на середине журнала
- **THEN** файла по пути назначения не существует
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections
← начинаются пустыми
```
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и
выполнять её сама MUST NOT.
Файл базы держит открытым процесс сервиса, а переименование не касается уже
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
переименование используемого файла прямой причиной порчи базы. Безопасная
подмена требует остановленного сервиса, а остановить его команда не может:
сервисом управляет окружение снаружи.
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
команды»).
#### Scenario: Отчёт называет процедуру подмены
- **WHEN** прогон признан успешным
- **THEN** отчёт содержит путь собранного файла и команды подмены
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
называть человеку следует только нештатные отказы.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
### Requirement: Отказ на одной доставке не останавливает пересборку
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
(тело не читается, тело больше предела, слой не выводится, содержимое не
разбирается), и учитывать такие доставки счётчиком отказов.
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
пересборки остальные тела.
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
прерывает человек, и без перевода сигнала прерывания в отмену контекста
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
завершиться ненулевым кодом.
#### Scenario: Битое тело не срывает прогон
- **WHEN** одно из тел архива не распаковывается
- **THEN** остальные доставки проигрываются
- **AND** отказ учитывается счётчиком в отчёте
#### Scenario: Отмена прекращает проигрывание
- **WHEN** сигнал прерывания приходит посреди журнала
- **THEN** проигрывание прекращается, печатается частичный отчёт
- **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся
@@ -0,0 +1,127 @@
## 1. Опоры в существующих пакетах
- [x] 1.1 `internal/ident`: `TimeOf(id string) (time.Time, error)` — время
создания из ULID, **в UTC**, усечённое до секунды (как `store.Now`).
`ulid.Time` внутри зовёт `time.Unix` и отдаёт локальную зону, а
`Truncate` зону не нормализует — значит `.UTC()` обязателен явно.
- [x] 1.2 `internal/archive`: перечисление тел — обход корня, отбор форм тела,
возврат относительных путей и отдельно — пропущенных файлов. Ошибка
чтения каталога (включая отсутствие корня) возвращается наружу, а не
превращается в пустой список; каталог не создаётся.
- [x] 1.3 `internal/store`: `ListDeliveries(ctx)` — доставки в порядке
`(received_at, id)` со **всеми фактами журнала**, включая `headers`.
- [x] 1.4 `internal/store`: открытие рабочей базы **только для чтения и без
наката миграций** + проверка версии схемы; расхождение — ошибка,
называющая обе версии.
## 2. Проигрывание журнала — `internal/replay`
- [x] 2.1 Собрать вход: объединить тела архива с учётом доставок; развести
четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и
отсортировать по `(received_at, id)`.
- [x] 2.2 Перенести учёт в базу назначения по нормированному составу: факты
журнала дословно (включая `headers`), производные от разбора —
пустыми (`parse_status=pending`, `points=0`, `derived_layer=''`,
`uncovered_sections='[]'`). Подобранные тела завести заново: id из имени
файла, метка из ULID в UTC, `bytes`/`sha256` — по **распакованному** телу.
- [x] 2.3 Проиграть журнал последовательно: `fold.Fold` по каждой доставке,
`fold.New` собирается с тем же пределом тела, что и приём
(`cfg.Ingest.MaxBodyMB`). Отказ одной доставки не прекращает прогон,
отмена контекста — прекращает.
- [x] 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной
витрины, признак отмены.
## 3. Команда `healthlog reindex`
- [x] 3.1 `cmd/healthlog/reindex.go`: флаги `--config`, `--out`, `--force`;
умолчание `--out` — сосед рабочей базы; тождество с рабочей базой
проверяется **по файлу** (`os.SameFile`), а для несуществующего файла —
по родительскому каталогу и имени; `flag.ErrHelp` не превращается в
`fatal`.
- [x] 3.2 Жизненный цикл файла назначения: сборка под временным именем,
переименование — последний шаг успеха; при любом ином исходе файла по
пути назначения нет, временный и его спутники (`-wal`, `-shm`) убраны.
- [x] 3.3 Отмена: `signal.NotifyContext(SIGINT, SIGTERM)`, частичный отчёт,
ненулевой код.
- [x] 3.4 `func writeReport(w io.Writer, …)` — отчёт в stdout, прогресс в
stderr; процедура подмены печатается только при успешном исходе.
- [x] 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна
доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка
окружения. Отказ отдельной доставки исхода команды не меняет.
- [x] 3.6 Подключить подкоманду в `main.go`.
## 4. Проверки
- [x] 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу →
отпечатки совпали; второй прогон → отпечаток не изменился.
- [x] 4.2 Порядок: журнал, у которого раскладка файлов расходится с
хронологией, даёт доставке без плотных метрик слой **предшествующей** по
`received_at`, а не соседа по каталогу; доставки одной секунды упорядочены
идентификатором.
- [x] 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают,
`bytes`/`sha256` посчитаны по распакованному телу; учётная запись без тела
считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый
каталог — отказ команды.
- [x] 4.4 Учёт в базе назначения: число строк и `headers` совпадают с рабочей
плюс подобранные; проставленный в рабочей базе `derived_layer` на
результат не влияет (отпечаток тот же, что из учёта без слоёв).
- [x] 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает
проигрывание и не оставляет файла назначения; рабочая база после
прерванной пересборки не изменилась ни витриной, ни учётом.
- [x] 4.6 Аргументы: `--out` — тот же файл, что рабочая база, по другому пути;
существующий файл без `--force`; `--force` даёт ту же витрину, что сборка
в отсутствующий файл.
- [x] 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены;
ноль свёрнутых — то же.
- [x] 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
- [x] 4.9 Прогон живого архива переписан поверх `replay` (см. 6.5), измеренные
утверждения сохранены.
## 5. Приёмочные критерии из ревью дизайна
Рубрика порождена проходом `healthlog-review-rubric` до чтения предложения.
Пункты, уже закрытые разделами выше, отмечены ссылкой.
- [x] 5.1 **Идемпотентность прогона.** Второй прогон даёт то же состояние не
только по отпечатку витрины, но и по учёту доставок в базе назначения
(число строк, `id`, `received_at`, `bytes`, `sha256` подобранных тел).
- [x] 5.2 **Тотальный детерминированный порядок.** Результат не зависит от
порядка обхода каталога, часового пояса процесса и числа перечитываний
каталога (см. 4.2).
- [x] 5.3 **Независимость от «сейчас».** Ни одно поле, влияющее на отпечаток, не
производно от времени прогона: метки берутся из события, а не из
`store.Now`. Проигрывание последовательно.
- [x] 5.4 **Незавершённая сборка неотличимой от завершённой быть не может**
(см. 3.2, 4.5).
- [x] 5.5 **Отмена доводится до конца и различима снаружи** (см. 3.3).
- [x] 5.6 **Оракул успеха не сводится к «ошибок не было»** (см. 3.5, 4.7).
- [x] 5.7 **Политика частичного отказа явная и счётная.** У каждого класса
отказа свой счётчик, сумма счётчиков сходится с числом входов.
- [x] 5.8 **Рабочая база открывается только на чтение и без миграций**
(см. 1.4).
- [x] 5.9 **Расход памяти не растёт с объёмом архива.** Тела читаются по
одному, предел распакованного тела тот же, что у приёма (см. 2.3);
превышение — учтённый отказ доставки, а не падение прогона.
- [x] 5.10 **Длинный прогон наблюдаем** (см. 3.4).
- [x] 5.11 **Аргументы безопасны и обратимы** (см. 3.1, 4.6).
- [x] 5.12 **Невосстановимое названо, а не досчитано.** Отчёт называет классы
ожидаемого расхождения (новые доставки за время прогона, непереносимый
`sealed`, исправленный разбор) отдельно от самого факта расхождения.
- [x] 5.13 **Ни отчёт, ни лог выше `DEBUG` не несут данных о здоровье**
(см. 4.8).
## 6. Документация
- [x] 6.1 `docs/architecture.md`: почему подмена базы не автоматизируется
(открытый дескриптор, порча базы SQLite) и почему пересборка идёт в
отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с
причиной. Там же — правка утверждения «пересчёт при `reindex` идёт по всей
истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
- [x] 6.2 `README.md`: `reindex` перестаёт быть «в планах», процедура применения.
- [x] 6.3 `docs/plan.md`: `reindex` вычеркнут из остатка шага «Разбор и
хранилище».
- [x] 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве
рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без
рабочей базы деградирует по выводу слоя.
- [x] 6.5 `task verify:archive` переезжает на `internal/replay`: отдельной
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.
+439
View File
@@ -0,0 +1,439 @@
# reindex Specification
## Purpose
Пересборка витрины проигрыванием журнала: `import(снапшот) + replay(доставки)`.
Витрина производна, источник истины — сырой архив тел; значит любое повреждение,
включая ошибку нашего же разбора любой давности, лечится пересборкой, а не
восстановлением из бекапа. Здесь живут состав журнала, порядок проигрывания,
детерминированность, оракул сходимости и граница «что не восстанавливается».
## Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
в порядке обхода каталога архива.
Порядок входит в результат: слой доставки без плотных метрик наследуется от
**предшествующей** доставки той же автоматизации, то есть слой есть функция
префикса журнала. Обход каталога совпадает с хронологией только по датам
каталогов и внутри суток не упорядочивает ничего.
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
а значит два прогона одного журнала могли бы разойтись.
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
запись обратно.
#### Scenario: Порядок не зависит от раскладки файлов в архиве
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
не совпадает с хронологией приёма
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
#### Scenario: Доставки одной секунды упорядочены идентификатором
- **WHEN** две доставки имеют одинаковый `received_at`
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
прогоне
### Requirement: Журналом считается архив, а не таблица доставок
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
доставок, а не перечислять только строки `delivery`. Иначе витрина
пересобиралась бы из витрины.
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
оставляет тело в архиве без учёта.
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
появились бы значения другой природы.
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
слоя: наследовать не от чего и подтверждать нечем.
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
называться в отчёте.
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
разбираться по логу.
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
создавать MUST NOT — создание превратило бы запуск не из того каталога в
успешный прогон по пустому журналу.
#### Scenario: Тело без учётной записи подбирается
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
приёма из ULID в UTC
- **AND** её размер и хеш посчитаны по распакованному телу
- **AND** её точки попадают в витрину
#### Scenario: Учётная запись без тела не роняет пересборку
- **WHEN** у строки `delivery` нет тела в архиве
- **THEN** пересборка продолжается
- **AND** факт учитывается счётчиком в отчёте
- **AND** сама запись переносится в базу назначения, но не сворачивается
#### Scenario: Файл, не являющийся телом, считается отдельно
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
расширение не соответствует форме тела
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
#### Scenario: Повтор идентификатора не срывает прогон
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
- **THEN** проигрывается первое, второе учитывается счётчиком
- **AND** пересборка доходит до конца
#### Scenario: Каталог архива не читается
- **WHEN** корня архива нет либо подкаталог не читается
- **THEN** команда завершается ошибкой и витрину не собирает
### Requirement: Пересборка не трогает рабочую базу
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
никогда, поэтому проигрывание поверх накопленного оставило бы результат
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
**до** того, как известно, что пересборка удалась: отказ на середине (битое
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину в состоянии, неотличимом от нормального.
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
схему под работающим старым сервисом.
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
быть отказом с указанием обеих версий, а не поводом мигрировать.
#### Scenario: Рабочая база остаётся нетронутой
- **WHEN** пересборка отработала успешно
- **THEN** отпечаток рабочей витрины не изменился
- **AND** учёт доставок в рабочей базе не изменился
- **AND** собранная витрина лежит в отдельном файле
#### Scenario: Отказ посреди пересборки не портит рабочую базу
- **WHEN** пересборка прерывается на середине журнала
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
какими были
#### Scenario: Схема рабочей базы старше бинаря
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
- **THEN** команда завершается ошибкой, называя обе версии
- **AND** не пишет в рабочую базу ни одной строки
### Requirement: Файл назначения и его жизненный цикл
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
остаётся переименованием внутри одной файловой системы.
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
убираться вместе со спутниками журнала SQLite.
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
приучил бы обходить защиту от перезаписи флагом принудительности.
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
пустой витрины, а не дописывается в чужое содержимое.
#### Scenario: Файл назначения совпадает с рабочей базой
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
пути
- **THEN** команда завершается ошибкой и не пишет ничего
#### Scenario: Файл назначения уже существует
- **WHEN** файл назначения существует, а перезапись не затребована явно
- **THEN** команда завершается ошибкой и существующий файл не трогает
#### Scenario: Затребованная перезапись даёт ту же витрину
- **WHEN** пересборка выполняется поверх существующего файла назначения с
явно затребованной перезаписью
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
журнала в отсутствующий файл
#### Scenario: Прерванная пересборка не оставляет файла назначения
- **WHEN** пересборка прерывается на середине журнала
- **THEN** файла по пути назначения не существует
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections
← начинаются пустыми
```
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и
выполнять её сама MUST NOT.
Файл базы держит открытым процесс сервиса, а переименование не касается уже
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
переименование используемого файла прямой причиной порчи базы. Безопасная
подмена требует остановленного сервиса, а остановить его команда не может:
сервисом управляет окружение снаружи.
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
команды»).
#### Scenario: Отчёт называет процедуру подмены
- **WHEN** прогон признан успешным
- **THEN** отчёт содержит путь собранного файла и команды подмены
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
называть человеку следует только нештатные отказы.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
### Requirement: Отказ на одной доставке не останавливает пересборку
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
(тело не читается, тело больше предела, слой не выводится, содержимое не
разбирается), и учитывать такие доставки счётчиком отказов.
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
пересборки остальные тела.
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
прерывает человек, и без перевода сигнала прерывания в отмену контекста
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
завершиться ненулевым кодом.
#### Scenario: Битое тело не срывает прогон
- **WHEN** одно из тел архива не распаковывается
- **THEN** остальные доставки проигрываются
- **AND** отказ учитывается счётчиком в отчёте
#### Scenario: Отмена прекращает проигрывание
- **WHEN** сигнал прерывания приходит посреди журнала
- **THEN** проигрывание прекращается, печатается частичный отчёт
- **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся