Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e0772ec5
|
||
|
|
8331328134
|
||
|
|
51a5272c96
|
||
|
|
958d4fe970
|
||
|
|
f8200f7f80
|
||
|
|
c28de9796e
|
||
|
|
63bffe2865
|
||
|
|
ebd59af056
|
||
|
|
5ae0c5ff81
|
||
|
|
84bcbbea5c
|
@@ -78,6 +78,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
- `task verify:archive` — сходимость на живом архиве: весь `./data/raw` через
|
||||
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
||||
минута прогона и данные, которых нет ни на какой другой машине
|
||||
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
|
||||
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон
|
||||
- `task tidy` — `go mod tidy`
|
||||
- `task setup` — установка golangci-lint
|
||||
|
||||
@@ -92,16 +94,17 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
проходы — агенты `healthlog-review-*`.
|
||||
|
||||
**Действуем автономно.** Умолчание — делать, а не спрашивать. Вопрос, который
|
||||
решать не мне, **вынимается блокером** в секцию `блокеры` беклога (что решить,
|
||||
варианты с ценой каждого, что стоит без решения, рекомендация), задача
|
||||
переформулируется на остаток, остаток доводится до коммита. Блокеры
|
||||
разбираются пачками.
|
||||
решать не мне, **вынимается блокером** в секцию `блокеры` беклога, задача
|
||||
переформулируется на остаток, остаток доводится до коммита. Блокеры разбираются
|
||||
пачками; из чего состоит пункт блокера — в
|
||||
[индексе беклога](docs/backlog/README.md). Спрашиваем только про
|
||||
**необратимое**: деплой, выкладку наружу, удаление или перезапись данных в
|
||||
`./data`.
|
||||
|
||||
**Развилка или блокер — сперва prior art.** Проект не уникален: прежде чем
|
||||
проектировать своё, смотрим, как это решено в референсах
|
||||
[паспорта](docs/passport.md) и в интернете. Готовое решение либо берётся, либо
|
||||
отвергается с названной причиной — и причина идёт в `architecture.md`. Спрашиваем только про **необратимое**: деплой, выкладку
|
||||
наружу, удаление или перезапись данных в `./data`.
|
||||
отвергается с названной причиной — и причина идёт в `architecture.md`.
|
||||
|
||||
Гейт блокирует: пока `task gate` красный, опиниативные проходы ревью не
|
||||
запускаются.
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
## Зачем
|
||||
|
||||
Данные о здоровье и тренировках нужны сразу нескольким приложениям: анализ
|
||||
здоровья, разбор тренировок, мотиватор по активности. Интегрировать каждое
|
||||
из них с Health Auto Export по отдельности — значит в каждом писать приём,
|
||||
дедупликацию и хранение заново.
|
||||
Данные о здоровье и тренировках нужны сразу трём моим приложениям: агенту-медику
|
||||
(анализ здоровья), трекеру (разбор тренировок) и игре (мотиватор по активности).
|
||||
Интегрировать каждое из них с Health Auto Export по отдельности — значит в
|
||||
каждом писать приём, дедупликацию и хранение заново.
|
||||
|
||||
healthlog делает это один раз. Телефон шлёт данные в него, все остальные
|
||||
проекты берут данные из него.
|
||||
@@ -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`,
|
||||
|
||||
+10
-1
@@ -39,7 +39,16 @@ 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
|
||||
|
||||
verify:busy:
|
||||
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)'
|
||||
cmds:
|
||||
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
|
||||
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
|
||||
# при этом центральное решение задачи «разнести ответ и свёртку»:
|
||||
# занятость базы — обстоятельство, а не свойство доставки.
|
||||
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
|
||||
|
||||
lint:
|
||||
desc: Запуск golangci-lint
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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
|
||||
// sourceWorkouts и sourceRecords — то же «было» для остальных единиц
|
||||
// хранения витрины. Отпечаток отвечает «да/нет» за витрину целиком, поэтому
|
||||
// единица, которой нет в счётчиках, делает расхождение безадресным.
|
||||
sourceWorkouts int64
|
||||
sourceRecords 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)
|
||||
}
|
||||
if rep.sourceWorkouts, err = src.CountWorkouts(ctx); err != nil {
|
||||
return canceledOr(rep, err, stopped)
|
||||
}
|
||||
if rep.sourceRecords, err = src.CountRecords(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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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, отложено %d",
|
||||
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther,
|
||||
r.replay.Deferred)
|
||||
// Удержанные версии сущностей печатаются ВСЕГДА, а не только при ненулевом
|
||||
// значении: ноль здесь утверждение, а не отсутствие новостей. Отпечаток это
|
||||
// правило не проверяет по построению — живой приём и пересборка пользуются
|
||||
// одним правилом и одинаково сойдутся на одинаково удержанной версии, — так
|
||||
// что счётчик и есть единственный способ увидеть, что правило слияния
|
||||
// сущностей стало слишком строгим.
|
||||
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
|
||||
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
|
||||
|
||||
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 || r.replay.Deferred > 0
|
||||
|
||||
if r.sourceMissing {
|
||||
p(" объектов: %d", r.replay.Buckets)
|
||||
p(" тренировок: %d", r.replay.Workouts)
|
||||
p(" записей: %d", r.replay.Records)
|
||||
p("")
|
||||
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
|
||||
p("не восстанавливаются: в архиве их нет.")
|
||||
} else {
|
||||
// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
|
||||
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
|
||||
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
|
||||
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
|
||||
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
|
||||
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
|
||||
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(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
|
||||
p(" секция (её единиц хранения в рабочей базе нет по построению);")
|
||||
p(" признак sealed не переносится (правила его выставления ещё нет)")
|
||||
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 при подборе, %d отложено) —",
|
||||
r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed, r.replay.Deferred)
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"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,
|
||||
Outcome: replay.Outcome{Folded: 116, Partial: 53},
|
||||
Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa",
|
||||
},
|
||||
target: "/data/healthlog.db.rebuild",
|
||||
dbPath: "/data/healthlog.db",
|
||||
sourcePrint: "bbbb",
|
||||
sourceBuckets: 2040,
|
||||
sourceWorkouts: 0,
|
||||
sourceRecords: 0,
|
||||
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",
|
||||
"тренировок: было 0, стало 2", "записей: было 0, стало 2",
|
||||
"покрытая разбором новая", "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,
|
||||
Outcome: replay.Outcome{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, 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, Outcome: replay.Outcome{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,
|
||||
Outcome: replay.Outcome{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,
|
||||
Outcome: replay.Outcome{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,
|
||||
Outcome: replay.Outcome{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 позволил собрать витрину прямо на место рабочей базы")
|
||||
}
|
||||
}
|
||||
|
||||
// Число удержанных версий сущностей печатается ВСЕГДА, включая ноль: здесь ноль
|
||||
// это утверждение, а не отсутствие новостей. Отпечаток правило слияния
|
||||
// сущностей не проверяет по построению — живой приём и пересборка пользуются
|
||||
// одним правилом и одинаково сойдутся на одинаково удержанной версии, — так что
|
||||
// строка отчёта и есть единственный способ увидеть, что правило стало слишком
|
||||
// строгим. Без этого теста её можно удалить, и гейт останется зелёным.
|
||||
func TestОтчётВсегдаНазываетУдержанныеВерсии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, held := range []int{0, 3} {
|
||||
var buf bytes.Buffer
|
||||
writeReport(&buf, report{replay: replay.Report{
|
||||
Outcome: replay.Outcome{EntitiesHeld: held, EntitiesDiverging: held + 1},
|
||||
}})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, fmt.Sprintf("удержано версий сущностей %d", held)) {
|
||||
t.Errorf("при удержаниях %d строки в отчёте нет:\n%s", held, out)
|
||||
}
|
||||
if !strings.Contains(out, fmt.Sprintf("версий одного ключа в одном теле %d", held+1)) {
|
||||
t.Errorf("второй счётчик не напечатан:\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
-22
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -16,11 +18,13 @@ import (
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/logging"
|
||||
"git.vakhrushev.me/av/healthlog/internal/replay"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// shutdownTimeout — сколько ждём завершения активных запросов при остановке.
|
||||
// Приём может быть в середине записи многомегабайтного тела в архив.
|
||||
// shutdownTimeout — общий бюджет остановки: сперва дожидаемся активных
|
||||
// запросов, затем выхода воркера свёртки. Совпадает со `stop_grace_period`
|
||||
// контейнера — за его пределом процесс всё равно убивают.
|
||||
const shutdownTimeout = 30 * time.Second
|
||||
|
||||
func runServe(args []string) error {
|
||||
@@ -34,16 +38,40 @@ func runServe(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := logging.New(cfg.Log.Level, cfg.Log.Format)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
|
||||
}
|
||||
|
||||
// serve поднимает сервис и ведёт его до отмены контекста.
|
||||
//
|
||||
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
|
||||
// цикл — порядок остановки, ожидание воркера, судьба несвёрнутой доставки —
|
||||
// проверялся бы только посылкой сигнала самому себе, то есть не проверялся бы.
|
||||
//
|
||||
// ready, если задан, зовётся с ФАКТИЧЕСКИМ адресом прослушивания: при `:0` в
|
||||
// конфиге узнать порт больше неоткуда.
|
||||
func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func(addr string)) error {
|
||||
st, err := store.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
// Закрытие базы — не `defer`: при исчерпании бюджета остановки воркер может
|
||||
// ещё сворачивать доставку, и закрытая из-под него база дала бы ERROR по
|
||||
// доставке, с которой всё в порядке. Кто закрывает, решает ветка остановки.
|
||||
closed := false
|
||||
closeStore := func() {
|
||||
if !closed {
|
||||
closed = true
|
||||
_ = st.Close()
|
||||
}
|
||||
}
|
||||
|
||||
arch, err := archive.New(cfg.Storage.ArchiveDir)
|
||||
if err != nil {
|
||||
closeStore()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -51,16 +79,23 @@ func runServe(args []string) error {
|
||||
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
|
||||
}
|
||||
|
||||
handler := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, fold.New(arch, st, int64(cfg.Ingest.MaxBodyMB)<<20, log), log),
|
||||
Log: log,
|
||||
WriteTokens: cfg.Auth.WriteTokens,
|
||||
MaxBodyMB: cfg.Ingest.MaxBodyMB,
|
||||
})
|
||||
// Воркер и приём делят одну свёртку: приём её только будит, сворачивает
|
||||
// воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика
|
||||
// не давала при конкурентных доставках.
|
||||
worker := replay.NewWorker(st, fold.New(arch, st, int64(cfg.Ingest.MaxBodyMB)<<20, log), log)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: handler,
|
||||
Handler: httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, worker.Notify, log),
|
||||
Log: log,
|
||||
WriteTokens: cfg.Auth.WriteTokens,
|
||||
MaxBodyMB: cfg.Ingest.MaxBodyMB,
|
||||
// Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО
|
||||
// вызова обработчика и потому покрывает чтение тела, обрывая
|
||||
// медленную загрузку молча. Длинный бюджет нужен одному маршруту,
|
||||
// поэтому и выдаётся ему, а не всему серверу.
|
||||
IngestWriteBudget: cfg.Server.ReadTimeout.D() + cfg.Server.WriteTimeout.D(),
|
||||
}),
|
||||
// ReadTimeout щедрый (большой пакет по мобильной сети), но заголовки
|
||||
// обязаны приехать быстро — иначе полуоткрытое соединение держит слот.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
@@ -68,33 +103,74 @@ func runServe(args []string) error {
|
||||
WriteTimeout: cfg.Server.WriteTimeout.D(),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
ln, err := net.Listen("tcp", cfg.Server.Addr)
|
||||
if err != nil {
|
||||
closeStore()
|
||||
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
|
||||
}
|
||||
|
||||
workerCtx, stopWorker := context.WithCancel(context.Background())
|
||||
defer stopWorker()
|
||||
workerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(workerDone)
|
||||
// Первый проход воркера и есть подбор неразобранного при старте:
|
||||
// отдельного кода для него нет намеренно.
|
||||
worker.Run(workerCtx)
|
||||
}()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("server started",
|
||||
"addr", cfg.Server.Addr,
|
||||
"addr", ln.Addr().String(),
|
||||
"db_path", cfg.Storage.DBPath,
|
||||
"archive_dir", arch.Root(),
|
||||
"max_body_mb", cfg.Ingest.MaxBodyMB)
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("listen: %w", err)
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
}()
|
||||
if ready != nil {
|
||||
ready(ln.Addr().String())
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case serveErr = <-errCh:
|
||||
// Отказ приёма не отменяет остановки воркера: закрыть базу, не дождавшись
|
||||
// его, значит выдернуть её из-под идущей свёртки и получить ERROR по
|
||||
// доставке, с которой всё в порядке. Ошибка не логируется здесь — она
|
||||
// возвращается наверх, и логирует её один раз вызывающий.
|
||||
case <-ctx.Done():
|
||||
log.Info("server stopping")
|
||||
}
|
||||
|
||||
log.Info("server stopping")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Приём прекращается РАНЬШЕ воркера: обратный порядок оставил бы доставки,
|
||||
// принятые после его остановки, никого не разбудившими.
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
// Исчерпание бюджета Shutdown возвращает штатно, и отказом это не
|
||||
// является: приём мог дочитывать многомегабайтное тело.
|
||||
log.Warn("shutdown budget exceeded", "stage", "http")
|
||||
case serveErr == nil:
|
||||
serveErr = fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
stopWorker()
|
||||
select {
|
||||
case <-workerDone:
|
||||
closeStore()
|
||||
case <-shutdownCtx.Done():
|
||||
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
|
||||
// выход процесса, и доставка останется `pending` — то есть будет
|
||||
// подобрана следующим стартом.
|
||||
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
|
||||
}
|
||||
return serveErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/config"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Приём и свёртка разнесены, но связаны: обработчик отвечает `200`, ничего не
|
||||
// сворачивая, а фоновый воркер доводит доставку до витрины. Проверяется целиком,
|
||||
// потому что связь между ними — сигнал, и оборвать его можно, не сломав ни один
|
||||
// модульный тест.
|
||||
func TestServeПринимаетИСворачиваетФоном(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := serveConfig(dir)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
addrCh := make(chan string, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- serve(ctx, cfg, slog.New(slog.DiscardHandler), func(addr string) { addrCh <- addr })
|
||||
}()
|
||||
|
||||
var addr string
|
||||
select {
|
||||
case addr = <-addrCh:
|
||||
case err := <-done:
|
||||
t.Fatalf("сервис не поднялся: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"data":{"metrics":[{"name":"heart_rate","units":"count/min","data":[` +
|
||||
`{"date":"2026-07-31 12:00:00 +0300","Min":60,"Avg":62,"Max":65}]}]}}`)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+addr+"/api/v1/ingest", body)
|
||||
if err != nil {
|
||||
t.Fatalf("запрос: %v", err)
|
||||
}
|
||||
req.Header.Set("automation-aggregation", "Minutes")
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("приём: %v", err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("статус приёма %d, ожидался 200", res.StatusCode)
|
||||
}
|
||||
|
||||
// Сигнал дошёл до воркера, и он довёл доставку до витрины. Опрос, а не сон:
|
||||
// снаружи процесса другого шва нет, а сон превратил бы проверку в лотерею.
|
||||
waitFolded(t, cfg.Storage.DBPath)
|
||||
|
||||
// Остановка: приём прекращается раньше воркера, воркер выходит сам.
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("остановка вернула ошибку: %v", err)
|
||||
}
|
||||
case <-time.After(shutdownTimeout + 10*time.Second):
|
||||
t.Fatal("сервис не остановился в бюджет")
|
||||
}
|
||||
|
||||
// Инвариант остановки: доставка либо свёрнута целиком, либо числится
|
||||
// `pending`; состояния «разобрана, а объектов половина» не существует.
|
||||
st, err := store.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
d, err := st.LastDelivery(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
buckets, err := st.CountBuckets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CountBuckets: %v", err)
|
||||
}
|
||||
switch d.ParseStatus {
|
||||
case store.ParseDone, store.ParsePartial:
|
||||
if buckets == 0 {
|
||||
t.Error("доставка числится разобранной, а объектов нет")
|
||||
}
|
||||
case store.ParsePending:
|
||||
if buckets != 0 {
|
||||
t.Error("доставка числится неразобранной, а объекты записаны")
|
||||
}
|
||||
default:
|
||||
t.Errorf("parse_status = %q", d.ParseStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// waitFolded ждёт, пока фоновый воркер разберёт принятую доставку.
|
||||
func waitFolded(t *testing.T, dbPath string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st, err := store.OpenForRead(dbPath)
|
||||
if err == nil {
|
||||
d, err := st.LastDelivery(context.Background())
|
||||
_ = st.Close()
|
||||
if err == nil && d.ParseStatus != store.ParsePending {
|
||||
if d.ParseStatus != store.ParseDone {
|
||||
t.Fatalf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("воркер не свернул доставку: сигнал от приёма не дошёл")
|
||||
}
|
||||
|
||||
func serveConfig(dir string) *config.Config {
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.Addr = "127.0.0.1:0"
|
||||
cfg.Server.ReadTimeout = config.Duration(30 * time.Second)
|
||||
cfg.Server.WriteTimeout = config.Duration(30 * time.Second)
|
||||
cfg.Storage.DBPath = filepath.Join(dir, "healthlog.db")
|
||||
cfg.Storage.ArchiveDir = filepath.Join(dir, "raw")
|
||||
cfg.Ingest.MaxBodyMB = 1
|
||||
return cfg
|
||||
}
|
||||
+3
-1
@@ -9,7 +9,7 @@
|
||||
[server]
|
||||
addr = ":8080"
|
||||
read_timeout = "5m" # экспорт истории — десятки мегабайт, бывает медленно
|
||||
write_timeout = "30s"
|
||||
write_timeout = "30s" # прочих маршрутов; приём держит свой бюджет, см. config.example.toml
|
||||
|
||||
[auth]
|
||||
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
||||
@@ -22,6 +22,8 @@ db_path = "/data/healthlog.db"
|
||||
archive_dir = "/data/raw"
|
||||
|
||||
[ingest]
|
||||
# Ретроактивен: тем же пределом пересборка читает тела из архива, см.
|
||||
# config.example.toml.
|
||||
max_body_mb = 64
|
||||
|
||||
[log]
|
||||
|
||||
+11
-1
@@ -7,7 +7,12 @@
|
||||
[server]
|
||||
addr = ":8080" # адрес прослушивания; ":8080" — все интерфейсы (нужно, чтобы телефон достучался по локальной сети)
|
||||
read_timeout = "5m" # на всё чтение запроса вместе с телом; Go-duration. Щедро: экспорт истории — десятки мегабайт по мобильной сети
|
||||
write_timeout = "30s" # на отправку ответа; Go-duration
|
||||
# ВНИМАНИЕ: write_timeout в Go покрывает НЕ только отправку ответа. Он ставится
|
||||
# до вызова обработчика и потому включает чтение тела: значение меньше
|
||||
# read_timeout молча обрывает медленную загрузку. Маршрут приёма поэтому держит
|
||||
# собственный бюджет (read_timeout + write_timeout), а это значение остаётся
|
||||
# защитой от застрявшей записи ответа на остальных маршрутах.
|
||||
write_timeout = "30s" # на отправку ответа прочих маршрутов; Go-duration
|
||||
|
||||
[auth]
|
||||
# Токены проверяются как `Authorization: Bearer <токен>`.
|
||||
@@ -27,6 +32,11 @@ db_path = "./data/healthlog.db" # файл SQLite; каталог долж
|
||||
archive_dir = "./data/raw" # корень сырого архива; создаётся при старте
|
||||
|
||||
[ingest]
|
||||
# ВНИМАНИЕ: параметр РЕТРОАКТИВЕН. Тем же пределом читаются тела из архива при
|
||||
# пересборке (`healthlog reindex`), поэтому понижение выбрасывает из
|
||||
# пересобранной витрины все уже принятые тела крупнее нового значения — они
|
||||
# начнут отказывать на каждом прогоне. Понижать только вместе с проверкой, что
|
||||
# таких тел в архиве нет.
|
||||
max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413
|
||||
|
||||
[log]
|
||||
|
||||
+434
-19
@@ -213,6 +213,8 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
||||
| `fold` | свёртка одной доставки в часовые объекты |
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
||||
| `httpapi` | приём и read API |
|
||||
|
||||
@@ -221,9 +223,24 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
```
|
||||
запрос → токен → лимит тела, gzip → проверка формы JSON
|
||||
→ запись тела в архив → строка в delivery → 200
|
||||
→ разбор → запись в витрину
|
||||
↓
|
||||
фоновый воркер: разбор → запись в витрину
|
||||
```
|
||||
|
||||
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
|
||||
означает «тело сохранено и учтено»; разобрано ли оно, говорит
|
||||
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
|
||||
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
|
||||
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
|
||||
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
|
||||
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
|
||||
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
|
||||
заведён инвариант «дыры закрываются сами».
|
||||
|
||||
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
|
||||
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
|
||||
снять защиту от застрявшей записи со всех маршрутов ради одного.
|
||||
|
||||
Код ответа определяется **доставкой**, не разбором:
|
||||
|
||||
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
|
||||
@@ -234,11 +251,77 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
|
||||
`/stats`, а доразобрать их можно командой `reindex`.
|
||||
|
||||
#### Очередь свёртки — таблица, а не структура в памяти
|
||||
|
||||
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
|
||||
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
|
||||
как очередь заданий»: состояние задания пишется той же базой, что и факт
|
||||
события, а фоновый процесс выбирает необработанные строки.
|
||||
|
||||
Три следствия, ради которых так и сделано:
|
||||
|
||||
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
|
||||
«очередь переполнена» невыразимо;
|
||||
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
|
||||
статус остаётся `pending`;
|
||||
- **подбор `pending` при старте не является отдельным кодом** — это обычный
|
||||
проход воркера, а не особый режим.
|
||||
|
||||
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
|
||||
представление того же факта, и эти два расходятся при каждом падении; политика
|
||||
переполнения всё равно требует подбора из базы, то есть того же кода — только в
|
||||
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
|
||||
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
|
||||
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
|
||||
следующей доставки, а ночью телефон молчит часами.
|
||||
|
||||
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
|
||||
слой доставки без плотных метрик наследуется от предшествующей доставки той же
|
||||
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
|
||||
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
|
||||
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
|
||||
самого приёма.
|
||||
|
||||
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
|
||||
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
|
||||
(`partial`) принимается решение о судьбе тела в архиве.
|
||||
|
||||
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
|
||||
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
|
||||
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
|
||||
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
|
||||
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
|
||||
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
|
||||
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
|
||||
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
|
||||
приём ответил бы `500` по доставке, тело которой уже на диске.
|
||||
|
||||
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
|
||||
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
|
||||
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
|
||||
первой — дефект одной доставки становится циклом перезапуска, при котором приём
|
||||
не работает вовсе.
|
||||
|
||||
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
|
||||
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
|
||||
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
|
||||
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
|
||||
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
|
||||
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
|
||||
(беклог, блокеры).
|
||||
|
||||
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
|
||||
после остановки не существует доставки, которая числится разобранной, а записана
|
||||
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
|
||||
(30 с) меньше бюджета свёртки (2 мин).
|
||||
|
||||
#### Частичный разбор
|
||||
|
||||
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
|
||||
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут
|
||||
`metrics` вовсе (находка 50).
|
||||
Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
|
||||
`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
|
||||
поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
|
||||
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
|
||||
редкость, а не половина потока, как было до покрытия сущностей.
|
||||
|
||||
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
|
||||
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
|
||||
@@ -262,7 +345,15 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||||
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
|
||||
переводит `partial`-строки с этим ключом в `pending`.
|
||||
переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
|
||||
`00007`, покрывшая `workouts` и `stateOfMind`.
|
||||
|
||||
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
|
||||
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
|
||||
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
|
||||
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
|
||||
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
|
||||
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
|
||||
|
||||
- **413** — тело больше допустимого. Граница стоит на **распакованном**
|
||||
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
|
||||
@@ -319,7 +410,92 @@ 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`, `skipped_entities`. Перечень
|
||||
пополняется **тем же изменением**, которое заводит новое поле: он единственное
|
||||
место, где сказано, чему нельзя пережить пересборку.
|
||||
|
||||
Это не косметика. Доставка, чей повторный разбор отказал, отдала бы в
|
||||
наследование слой прежнего разбора, и витрина снова стала бы функцией
|
||||
предыдущего прогона, а не журнала. У числа пропущенных сущностей цена та же и
|
||||
хуже: пустота у него означает «не измерялось», и перенесённое число выдавало бы
|
||||
измерение прежнего разбора за измерение текущего — а по нему принимается
|
||||
необратимое решение об удалении тела.
|
||||
|
||||
#### Что не восстанавливается, и это сказано вслух
|
||||
|
||||
@@ -386,18 +562,20 @@ HAE. Значит для него доставки не хвост журнал
|
||||
```
|
||||
delivery(id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status, points,
|
||||
headers, derived_layer)
|
||||
headers, derived_layer, uncovered_sections, skipped_entities NULL)
|
||||
|
||||
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
|
||||
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
|
||||
PK (metric, layer, hour_utc) WITHOUT ROWID
|
||||
|
||||
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
|
||||
payload JSON, delivery_id, updated_at)
|
||||
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
|
||||
payload BLOB, content_hash, delivery_id, delivery_received_at,
|
||||
created_at, updated_at)
|
||||
INDEX (start_utc)
|
||||
|
||||
record(id PK, kind, ts_utc, tz_offset, payload JSON,
|
||||
delivery_id, updated_at)
|
||||
INDEX (kind, ts_utc)
|
||||
record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
|
||||
delivery_id, delivery_received_at, created_at, updated_at)
|
||||
PK (kind, id) INDEX (kind, ts_utc)
|
||||
```
|
||||
|
||||
Зачем пачками:
|
||||
@@ -500,8 +678,11 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
|
||||
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
|
||||
|
||||
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
||||
приёме: это ещё одна причина держать сырой архив.
|
||||
Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
|
||||
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
|
||||
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
|
||||
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
|
||||
`docs/review-journal.md`).
|
||||
|
||||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||||
@@ -652,10 +833,21 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
|
||||
|
||||
### Тренировки и прочие секции
|
||||
|
||||
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
|
||||
может приехать повторно, когда доедет маршрут. `record` держит секции с
|
||||
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`,
|
||||
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же.
|
||||
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
|
||||
`record` держит секции с собственными идентификаторами; разбором покрыт пока
|
||||
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
|
||||
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
|
||||
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
|
||||
полноты целью проекта не является. Модель под них заложена: **миграции схемы**
|
||||
новая секция не требует — она добавляется строкой в множество покрытых имён.
|
||||
Но не одной: правило «покрыли секцию — пересверните» (выше) требует ещё и
|
||||
data-миграции, переводящей уже принятые `partial`-доставки с этим ключом в
|
||||
`pending`, а после появления ретеншена — строки в перечне невосстановимого.
|
||||
Три места, и первое из них — не самое важное.
|
||||
|
||||
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
|
||||
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
|
||||
разных секциях затёр бы одну запись другой молча.
|
||||
|
||||
Пачками они не хранятся: у них есть естественный ключ, они редки, и
|
||||
группировать их по часам незачем.
|
||||
@@ -663,12 +855,202 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
|
||||
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
|
||||
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
|
||||
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
|
||||
таблицы значило бы решить за Apple, что в ней главное.
|
||||
таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
|
||||
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
|
||||
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
|
||||
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
|
||||
законная длительность.
|
||||
|
||||
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
|
||||
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
||||
смешиваются.
|
||||
|
||||
**Значение заголовка не того типа стоит одного поля, а не сущности.** Пять полей
|
||||
(`id`, `name`, `date`, `start`, `end`) читаются мягко: нестроковое значение
|
||||
считается неприсланным. Иначе `name`, приехавшее числом, уносит тренировку
|
||||
вместе с маршрутом, а доставка при этом числится разобранной. Мягкость сделана
|
||||
через `json.Unmarshaler`, а не через разбор ошибки типа постфактум: библиотека
|
||||
дозаполняет поля «как может», но не обязуется дозаполнить те, что стоят **после**
|
||||
проблемного, — то есть исход перестал бы быть функцией тела.
|
||||
|
||||
Исключений два, и оба названы. `id`: без строкового идентификатора сущность не
|
||||
адресуема, а приведение чужого значения к строке было бы выдумыванием
|
||||
идентичности за источник. `start`: непонятое значение не откатывается на `date` —
|
||||
подстановка другого поля дала бы метку **другого момента времени**, неотличимую
|
||||
от настоящей и ничем не считаемую.
|
||||
|
||||
**Граница правила: оно закрывает смену типа, но не смену формата строки.** А
|
||||
наблюдался именно дрейф формата дат. Тренировка с датой в незнакомом формате
|
||||
по-прежнему теряется целиком; закрыть это может только хранение сущности с
|
||||
неразобранной меткой, и это отдельная задача. Пропуск при этом перестал быть
|
||||
невидимым: число пропущенных сущностей лежит в учётной записи доставки, и
|
||||
ретеншен, решающий «что потеряется, если тело удалить», больше не получает
|
||||
ложное «терять нечего». Отсутствие значения в этой колонке означает «не
|
||||
измерялось» и нулю не равно.
|
||||
|
||||
#### Замена версии сущности
|
||||
|
||||
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
|
||||
доставкой, пока источник её досчитывает: на живом архиве одна тренировка
|
||||
приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и
|
||||
`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе
|
||||
полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится
|
||||
задним числом ровно так же, как минутное ведро (находка 10), а набор её полей
|
||||
за весь корпус ни разу не уменьшился.
|
||||
|
||||
Правило:
|
||||
|
||||
```
|
||||
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
|
||||
2. приехавшая несёт всё содержание сохранённой
|
||||
и сверх того → приехавшая замещает целиком
|
||||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
4. содержание равно → версия из более поздней
|
||||
доставки ЖУРНАЛА
|
||||
5. наборы несравнимы → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
```
|
||||
|
||||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||||
значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это
|
||||
проверено выполненной командой: оно гасит отношение включения до «равенства»,
|
||||
когда значения общих ключей разошлись, — а у сущности они расходятся всегда.
|
||||
Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с
|
||||
маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы
|
||||
зелёным.
|
||||
|
||||
Условий покрытия четыре, все по **верхнему уровню**:
|
||||
|
||||
```
|
||||
1. каждый содержательный ключ сохранённой есть у приехавшей и содержателен
|
||||
2. каждый ключ сохранённой, даже пустой, есть у приехавшей
|
||||
3. форма не вырождается: объект остаётся объектом, массив — массивом
|
||||
4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов
|
||||
```
|
||||
|
||||
Условие 2 — тот же второй разряд, что у точек, и с тем же **условием**: оно
|
||||
включается только при равенстве множеств содержательных ключей. Иначе ключ с
|
||||
пустым значением исчезает по жребию тай-брейка — но и обратная крайность
|
||||
проверена оракулом и отвергнута: безусловный второй разряд запирал законный
|
||||
досчёт навсегда. Версия с `totalEnergy: null` и без маршрута оказывалась
|
||||
несравнимой с версией, у которой маршрут приехал, а этого ключа нет, — и
|
||||
маршрут не доезжал **никогда**, причём пересборка проигрывала то же поражение.
|
||||
Второй разряд разрешает спор равных, а не отменяет первый.
|
||||
|
||||
Условие 3 закрывает «скелет»: тело, где каждый вложенный объект заменён числом,
|
||||
а каждый массив — массивом той же длины из `null`, проходило все прежние
|
||||
проверки и по тай-брейку журнала замещало настоящую тренировку целиком.
|
||||
|
||||
Условие 4 добавлено потому, что усечённый маршрут (три точки вместо 593) ключа
|
||||
не теряет, а маршрут из `[null,null,null]` не теряет и длины — притом что
|
||||
маршрут это 95% веса тренировки. Содержательность элемента — **та же пустота**,
|
||||
что у поля точки; второй словарь пустоты дал бы два ответа на один вопрос. Цена
|
||||
названа вслух: ряд настоящих нулей (`[0,0,0]`) считается лишённым содержания, и
|
||||
версия с ним сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||||
правило удерживает, а не затирает, — и видна счётчиком.
|
||||
|
||||
Условия 3 и 4 применяются к ключам, содержательным у сохранённой: у пустоты
|
||||
формы нет, и требовать её сохранения значило бы отличать `[]` от `0` там, где ни
|
||||
то, ни другое ничего не несёт.
|
||||
|
||||
Поле `source` в множества не входит — ни у точки, ни у сущности. Для точки
|
||||
причина измерена (оно нестабильно и переписывается задним числом, находка 36);
|
||||
для сущности она наследуется, и это сказано вслух, потому что список исключений
|
||||
живёт в общем разборе: правка ради точек молча изменит правило удержания
|
||||
сущностей. Верхнеуровневого `source` ни у тренировки, ни у `stateOfMind` живьём
|
||||
не наблюдалось.
|
||||
|
||||
**Предел правила назван вслух и не закрывается: сокращение внутри элемента ряда
|
||||
(точка маршрута без `altitude` при непустом элементе и той же длине) не ловится
|
||||
ничем, кроме сверки с телом в архиве.** Поэлементная сверка содержимого
|
||||
отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
|
||||
значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
|
||||
|
||||
**Проверить это правило отпечатком нельзя.** Живой приём и пересборка пользуются
|
||||
одним правилом и одинаково сойдутся на одинаково удержанной версии — то есть
|
||||
слишком строгое правило, замораживающее тренировку на старой версии, выглядело
|
||||
бы идеальной сходимостью. Поэтому число удержаний идёт в отчёт пересборки и
|
||||
печатается всегда, включая ноль: здесь ноль это утверждение, а не отсутствие
|
||||
новостей.
|
||||
|
||||
**Тай-брейк при равном содержании — позиция доставки в журнале
|
||||
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
|
||||
приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку
|
||||
журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней
|
||||
меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и
|
||||
пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где
|
||||
это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс:
|
||||
доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у
|
||||
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
|
||||
из версий навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же,
|
||||
писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и
|
||||
если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения
|
||||
содержимого не двигается, иначе она становится меткой касания строки и дребезжит
|
||||
двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с
|
||||
момента X» получает шум, неотличимый от настоящего досчёта.
|
||||
|
||||
Слово «провенанс» у сущности и у часового объекта значит **разное**, и это
|
||||
сказано вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||||
поднимается до максимума по журналу среди версий с этим содержимым. У объекта
|
||||
нет замещения версии целиком, у сущности только оно и есть.
|
||||
|
||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||
победитель среди них — **функция множества**, а не порядка элементов массива:
|
||||
отбрасываются строго покрытые (покрыта другой и сама её не покрывает —
|
||||
покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы
|
||||
множество), среди оставшихся берётся минимум канонической формы, а при равных
|
||||
формах — минимум исходных байтов. Последний разряд не украшение: у сущностей
|
||||
версии с равной формой не схлопываются, а порядок ключей в JSON от HAE
|
||||
нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом.
|
||||
Механизм тот же, что у точек, и живёт он одним помощником на обе единицы
|
||||
хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой
|
||||
`[A,B,C]` и `[B,C,A]` выбирали разных победителей.
|
||||
|
||||
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
|
||||
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
|
||||
сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а
|
||||
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
|
||||
сравнения множеств и делает событие наблюдаемым вместо необратимого.
|
||||
|
||||
Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно —
|
||||
в витрине лежит победитель прошлых слияний, а не все кандидаты истории, —
|
||||
поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
|
||||
проигрывания. Тот же предел есть у часового объекта. **Это единственная точка,
|
||||
где витрина не является функцией множества доставок**, и потому утверждение
|
||||
«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом
|
||||
счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти
|
||||
вместе с этим счётчиком.
|
||||
|
||||
Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая
|
||||
новые содержательные ключи и потерявшая пустой, теперь несравнима вместо
|
||||
«полнее». Плата принята сознательно — она направлена в сторону удержания, а не
|
||||
затирания, — и её величину показывает счётчик удержаний в отчёте пересборки.
|
||||
|
||||
#### Отпечаток и отчёт пересборки идут за витриной
|
||||
|
||||
Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией
|
||||
чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при
|
||||
разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом
|
||||
дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и
|
||||
стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом
|
||||
расхождения — иначе первый прогон после такого изменения расходится
|
||||
гарантированно, а человек читает это как дефект.
|
||||
|
||||
#### Предел, который придётся закрыть импортом
|
||||
|
||||
В `export.xml` у элемента `Workout` идентификатора нет вовсе —
|
||||
`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого**
|
||||
(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки,
|
||||
приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом
|
||||
`start + end`. Сегодня импорта нет, и решать это до его формы значило бы
|
||||
угадывать; предел записан в беклоге отдельной задачей.
|
||||
|
||||
### Время
|
||||
|
||||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||||
@@ -847,6 +1229,39 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
|
||||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||||
(с токенами) — отдельно, `0600`.
|
||||
|
||||
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
|
||||
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
|
||||
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
|
||||
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
|
||||
правило «максимум = текущая версия» принадлежат ему, и рукописная копия
|
||||
разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж
|
||||
перестал бы ловить.
|
||||
|
||||
Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не
|
||||
работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон
|
||||
её не перешлёт. Выбор сделан так потому, что откат это действие оператора,
|
||||
который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за
|
||||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||||
`stateOfMind`: у него доставки HAE единственный источник — это и есть цена
|
||||
решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал
|
||||
бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал
|
||||
разобранными, а узнать об этом было бы неоткуда.
|
||||
|
||||
Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим:
|
||||
там отказ даёт любое расхождение версий, включая базу старее бинаря — читать
|
||||
колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и
|
||||
структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии
|
||||
таблицы идёт её создавать, и на соединении «только чтение» это три секунды
|
||||
повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у
|
||||
открытия с накатом.
|
||||
|
||||
**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды
|
||||
миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в
|
||||
миграциях существует для локальной разработки и на рабочей базе не исполнялся ни
|
||||
разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит —
|
||||
чинит только выкатка вперёд. Это цена стража, названная целиком; чем её
|
||||
смягчать, решает отдельная задача беклога.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||||
|
||||
+20
-6
@@ -7,27 +7,34 @@
|
||||
**Блокеры** — вопросы, вынутые из задач. Работа над задачей идёт автономно; если
|
||||
внутри обнаружился вопрос, который решать не мне, он **вынимается** отдельным
|
||||
пунктом сюда, а сама задача переформулируется на остаток и продолжается. Пункт
|
||||
блокера отвечает на три вопроса: что именно решить, какие есть варианты с ценой
|
||||
каждого, и что заблокировано, пока решения нет. Разбираются пачками, а не по
|
||||
одному — прерывать поток ради каждого дороже, чем накопить.
|
||||
блокера отвечает на четыре вопроса: что именно решить, какие есть варианты с
|
||||
ценой каждого, что заблокировано пока решения нет, и какая **рекомендация** —
|
||||
без неё вопрос перекладывается целиком, а решать его всё равно с тем же
|
||||
контекстом. Разбираются пачками, а не по одному: прерывать поток ради каждого
|
||||
дороже, чем накопить.
|
||||
|
||||
Варианты ищутся **не с нуля**: сперва prior art — как это решено в референсах
|
||||
[паспорта](../passport.md) и в интернете, — и только потом своё. Готовое решение
|
||||
либо берётся, либо отвергается с названной причиной.
|
||||
|
||||
## блокеры
|
||||
|
||||
## высокий
|
||||
- [Тренировки и секции с собственными 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) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
|
||||
|
||||
## средний
|
||||
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
||||
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
|
||||
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
|
||||
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
|
||||
- [Деплой на rivendell](deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
|
||||
- [Управление токенами и секретами](upravlenie-sekretami.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
|
||||
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
|
||||
@@ -35,10 +42,16 @@
|
||||
- [Умолчания конфига указывают на прежнюю раскладку](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) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
- [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
||||
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
|
||||
## низкий
|
||||
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [Ретеншен сырого архива](retenshen-syrogo-arhiva.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
|
||||
- [Пересборка держит весь журнал в памяти](pereborka-ne-vlezaet-v-pamyat.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
|
||||
- [Активный алерт «данных нет N часов»](alert-tishina-potoka.md) — Пропажу потока сейчас замечает человек, а не сервис
|
||||
- [[idea] Порог sealed: с какого возраста час считается запечатанным](porog-sealed.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
|
||||
- [[idea] Месячный проход по ручным секциям](mesyachnyj-prohod-ruchnye-sekcii.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
|
||||
@@ -47,4 +60,5 @@
|
||||
- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- [Data-миграции не отбирают строки по обрезаемым спискам](otbor-strok-data-migraciyami.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
|
||||
|
||||
@@ -15,3 +15,15 @@
|
||||
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
|
||||
После деплоя на rivendell поднимется.
|
||||
|
||||
|
||||
## Источник алерта не может жить внутри `serve`
|
||||
|
||||
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
|
||||
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
|
||||
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
|
||||
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
|
||||
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
|
||||
перезапуск хоста или откат деплоя.
|
||||
|
||||
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
|
||||
`negative`).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Проверка целостности собранной витрины перед подменой
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
`healthlog reindex` собирает витрину в отдельный файл и снимает с него
|
||||
отпечаток, а подмену делает человек: остановить сервис, переименовать файл,
|
||||
поднять обратно. Это **единственный необратимый шаг** всей операции — и
|
||||
единственный, перед которым мы ничего не проверяем.
|
||||
|
||||
Отпечаток сегодня снимается на **ещё открытом дескрипторе**: он говорит, что
|
||||
свёртка сошлась, но не говорит, что файл на диске корректен как база SQLite.
|
||||
Между «свёртка сошлась» и «файл цел» помещается всё, о чём предупреждает
|
||||
[How To Corrupt An SQLite Database](https://www.sqlite.org/howtocorrupt.html):
|
||||
оборванный `fsync`, полный диск, ФС, соврала о записи. Человек в этот момент
|
||||
уже удалил рабочую витрину.
|
||||
|
||||
Что делать: после закрытия файла и **до** того, как команда объявит результат
|
||||
годным к подмене, открыть его заново и прогнать `PRAGMA integrity_check`.
|
||||
Не прошёл — команда завершается отказом и прямо говорит, что подменять нечем.
|
||||
|
||||
Дёшево: одна страница кода, один прогон по готовому файлу. Ценно ровно в тот
|
||||
момент, когда всё остальное уже пошло не так.
|
||||
|
||||
Готово, когда `reindex` на заведомо испорченном выходном файле отказывается
|
||||
называть результат годным, а на здоровом — не замедляется заметно.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
|
||||
@@ -52,7 +52,11 @@ Form одной точки 2.2 мкс
|
||||
|
||||
## Связано
|
||||
|
||||
- [otvet-i-svyortka](otvet-i-svyortka.md) — воркер убирает влияние на ответ
|
||||
приёму, но не на блокировку записи; задачи независимы.
|
||||
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбирает доставки, ушедшие в
|
||||
`failed` по этой причине.
|
||||
- Разнесение ответа приёма и свёртки **сделано** (архив change
|
||||
`2026-08-02-otvet-i-svyortka`): воркер убрал влияние на время ответа, но не на
|
||||
блокировку записи — длинная транзакция слияния держит её по-прежнему. Заодно
|
||||
оттуда взято главное смягчение: занятость базы больше не выводит доставку из
|
||||
очереди, она остаётся `pending` и пересворачивается. Оракул окна —
|
||||
`task verify:busy`.
|
||||
- Пересборка (`healthlog reindex`) подбирает доставки, ушедшие в `failed` по
|
||||
другим причинам.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Сущность с id, но неразобранной меткой
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
|
||||
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
|
||||
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
|
||||
с неразбираемой меткой по-прежнему пропускается целиком.
|
||||
|
||||
## Что известно
|
||||
|
||||
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
|
||||
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
|
||||
счётчик `SkippedEntityNoTime` растёт.
|
||||
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
|
||||
и ретеншен получает честный ответ «терять есть что». То есть событие больше
|
||||
не молчит — но содержимое всё ещё не хранится.
|
||||
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
|
||||
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
|
||||
задокументирован (`docs/local-research.md`), то есть вход не выдуман.
|
||||
|
||||
## Что решить
|
||||
|
||||
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
|
||||
|
||||
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
|
||||
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
|
||||
что делает с такими строками, иначе они молча исчезнут из любого ответа.
|
||||
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
|
||||
когда разбор научится читать формат.
|
||||
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
|
||||
вернёт `reindex`. После включения ретеншена окно становится необратимым.
|
||||
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
|
||||
измерение в колонке, по которой идёт выборка.
|
||||
|
||||
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
|
||||
чтения без читателя проектируется вслепую.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Идентичность тренировок при импорте родного экспорта
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
|
||||
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
|
||||
тип, источник, даты и статистика. Значит `healthlog import` не сможет сопоставить
|
||||
тренировку из снапшота с той же тренировкой, уже приехавшей от HAE, и они
|
||||
задвоятся.
|
||||
|
||||
Это ровно та дыра, что была у точек, и там её закрыли ключом `start + end`
|
||||
(находка 47): `HKObject.uuid` в выгрузку не попадает, поэтому модель
|
||||
идентичности обязана выражаться через интервал.
|
||||
|
||||
Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку **хешем
|
||||
содержимого** (`hash_id` в sqlite-utils) — ровно потому, что идентификатора в
|
||||
экспорте нет. Нам это не подходит в лоб: у нас половина тренировок уже лежит под
|
||||
настоящим `id`, и хеш содержимого дал бы третий ключ рядом с двумя.
|
||||
|
||||
Варианты, которые надо будет сравнить:
|
||||
|
||||
- **Второй уникальный ключ `(start_utc, end_utc)`** у тренировки: импорт ищет по
|
||||
нему, HAE — по `id`. Цена: индекс и вопрос, что делать при столкновении двух
|
||||
разных тренировок с одним интервалом (бывает ли такое — неизвестно).
|
||||
- **Сопоставление на стадии импорта**, без изменения схемы: импорт читает уже
|
||||
сохранённые тренировки за период и приписывает найденным их `id`. Цена: логика
|
||||
сопоставления живёт в импорте и не проверяется ничем, кроме него.
|
||||
- **Считать тренировки из экспорта отдельным родом** и не сопоставлять вовсе.
|
||||
Цена: потребитель видит две тренировки вместо одной и обязан схлопывать сам —
|
||||
ровно то, чего проект старается не делать.
|
||||
|
||||
Решать до появления формы `healthlog import` значит угадывать: неизвестно,
|
||||
понадобятся ли тренировки из экспорта вообще (у HAE они полнее — с маршрутом и
|
||||
рядами, а в экспорте маршрут лежит отдельными GPX).
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||
`import-eksporta-apple`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Data-миграции не отбирают строки по обрезаемым спискам
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`).
|
||||
|
||||
## Оракул: механизм доказан, дефект пока пустой
|
||||
|
||||
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
|
||||
секции стоит в `uncovered_sections`:
|
||||
|
||||
```sql
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
WHERE json_each.value IN ('workouts', 'stateOfMind'))
|
||||
```
|
||||
|
||||
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
|
||||
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
|
||||
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
|
||||
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
|
||||
секцией `ecg` за ними даёт список без `ecg`.
|
||||
|
||||
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
|
||||
(`docs/local-research.md`, находка 50), секций восемь, тела с 32 незнакомыми
|
||||
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
|
||||
слепую зону, а к тому времени причину никто не вспомнит.
|
||||
|
||||
## Что делать
|
||||
|
||||
Записать принцип и выбрать форму отбора:
|
||||
|
||||
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
|
||||
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
|
||||
например «эту доставку смотрел разбор старше версии N».
|
||||
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
|
||||
означать безусловное пересворачивание — доставка, у которой список обрезан,
|
||||
про своё покрытие ничего достоверного не говорит.
|
||||
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора
|
||||
окажется общей.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) —
|
||||
именно она следующей сделает секцию покрытой и напишет такую миграцию.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Чем откатывать релиз после наката миграции
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
|
||||
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
|
||||
откат = подмена файла. `Down`-блоки миграций при этом честно называются
|
||||
декорацией для локальной разработки, а не аварийным путём: ни один из них не
|
||||
исполнялся ни разу. Осталось решить при взятии — сколько копий держим и где.
|
||||
Задача естественно склеивается с [деплоем](deploy-rivendell.md). Ниже —
|
||||
исходная постановка блокера, она же ТЗ.
|
||||
|
||||
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(проходы `ops` и `negative`, профиль `deep`).
|
||||
|
||||
## Что именно решить
|
||||
|
||||
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
|
||||
отказ на старте. Решение принято владельцем и здесь не пересматривается. Но у
|
||||
него есть следствие, которое до сих пор нигде не было записано:
|
||||
|
||||
**после того как новый бинарь накатил миграцию, возврат старого бинаря приёма
|
||||
не чинит.** Он теперь отказывается стартовать, а понизить схему нечем:
|
||||
|
||||
- подкоманды миграции у бинаря нет (`serve`, `reindex`, `healthcheck`);
|
||||
- `goose` CLI в образ не кладётся;
|
||||
- блоки `-- +goose Down` в миграциях написаны, но ни один тест их не исполняет,
|
||||
и на рабочей базе они не выполнялись ни разу (`DROP COLUMN` в SQLite через
|
||||
`modernc.org/sqlite` не проверялся вовсе);
|
||||
- `restart: unless-stopped` превращает отказ в цикл перезапуска, а телефон всё
|
||||
это время шлёт в закрытый порт и **не перешлёт** потом.
|
||||
|
||||
То есть аварийный путь придётся изобретать в момент аварии, при остановленном
|
||||
приёме. Цена простоя для метрик закрывается широким и глубоким проходами
|
||||
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
|
||||
единственный источник.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
|
||||
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
|
||||
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
|
||||
импровизацией.
|
||||
2. **Копия файла базы перед накатом** — entrypoint контейнера делает `cp` рядом,
|
||||
откат = подмена файла. Цена: место (база растёт), плюс правило «сколько копий
|
||||
держим». Зато не требует ни кода, ни доверия к `Down`, а база производна от
|
||||
архива — потеря копии не смертельна.
|
||||
3. **`goose` CLI в образ.** Цена: образ перестаёт быть одним статическим
|
||||
бинарём, появляется вторая точка, знающая про схему.
|
||||
4. **Ничего, но записать вслух**: «понижение схемы не поддерживается, лечение —
|
||||
только выкатка вперёд». Цена: в аварии выбора нет.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
(2) плюс уже сделанная запись из (4). Копия файла — единственный вариант,
|
||||
который не требует доверять непроверенному коду ровно в тот момент, когда
|
||||
проверять некогда; а `Down`-блоки при этом честно называются декорацией для
|
||||
локальной разработки.
|
||||
|
||||
## Что стоит, пока решения нет
|
||||
|
||||
Ничего: страж работает, и это правильно. Стоит только аварийный сценарий —
|
||||
он существует ровно в том виде, в каком описан выше. Строка «понижение схемы не
|
||||
поддерживается» уже записана в `docs/architecture.md` (раздел «Деплой»).
|
||||
@@ -1,92 +0,0 @@
|
||||
# Разнести ответ приёма и свёртку доставки
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
|
||||
`deep`, находка №4 триажа, severity major). **Решение принято** — ниже задача.
|
||||
|
||||
## Что не так сегодня
|
||||
|
||||
Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время
|
||||
ответа равно времени свёртки.
|
||||
|
||||
`WriteTimeout` в Go ставится в `readRequest` — **до** чтения тела и до вызова
|
||||
обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит
|
||||
30 секунд по умолчанию это бюджет на всё сразу: дочитать до 64 МиБ по
|
||||
мобильной сети, сделать `fsync` архива, вставить доставку и свернуть.
|
||||
|
||||
Воспроизведено минимальной программой: сервер с `WriteTimeout=200ms`,
|
||||
обработчик спит 500 мс.
|
||||
|
||||
```
|
||||
handler: WriteHeader(200), body Write err=<nil>
|
||||
client: elapsed=501ms err=EOF
|
||||
```
|
||||
|
||||
Сервер считает, что отдал `200` — ошибки записи не видно, ответ ушёл в буфер и
|
||||
сбрасывается позже. Клиент получил обрыв. Код обработчика этого не видит, а
|
||||
`accessLog` честно запишет `status_code=200`: единственный сегодняшний канал
|
||||
наблюдаемости в этом сценарии врёт.
|
||||
|
||||
Стоимость свёртки измерена **до** перехода на одну транзакцию на доставку:
|
||||
|
||||
| тело | объектов | свёртка |
|
||||
|---|---|---|
|
||||
| 80 КиБ | 1001 | 815 мс |
|
||||
| 323 КиБ | 4001 | 3.07 с |
|
||||
| 1302 КиБ | 16001 | 11.07 с |
|
||||
|
||||
Одна транзакция на доставку убрала около 0.7 мс на объект (прогон живого
|
||||
архива ускорился с 64 до 52 секунд), но порядок величины остался: широкая
|
||||
доставка по-прежнему измеряется секундами.
|
||||
|
||||
Бьёт это по **широким проходам** — `Today`, `Previous 7 Days`, ручной
|
||||
экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры
|
||||
закрываются сами».
|
||||
|
||||
## Что решено
|
||||
|
||||
Вариант (а): **отвечать `200` сразу после архивации и учёта; свёртка —
|
||||
воркером в порядке журнала, с подбором `pending` при старте.**
|
||||
|
||||
Почему он, а не альтернативы:
|
||||
|
||||
- Поднять `write_timeout` до согласованного с `foldTimeout` — дёшево, но
|
||||
худший случай (64 МиБ) всё равно минуты, и молчание `accessLog` остаётся.
|
||||
Это лечит симптом.
|
||||
- Оставить как есть — широкие проходы продолжают рваться.
|
||||
|
||||
Вариант (а) решает причину и попутно снимает две смежные дыры: параллельные
|
||||
доставки одной автоматизации перестают гонять наследование слоя (сейчас вторая
|
||||
может не найти слоя первой и уйти в `failed`), и доставка, застрявшая в
|
||||
`pending` из-за сбоя записи, наконец кем-то подбирается.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Воркер свёртки: одна горутина, очередь идентификаторов доставок, обработка
|
||||
**строго в порядке журнала** (`received_at`, `id`) — от этого зависит
|
||||
наследование слоя и воспроизводимость.
|
||||
2. Приём отвечает `200` после архивации и вставки доставки; свёртку ставит в
|
||||
очередь. Очередь переполнена — доставка остаётся `pending`, это не отказ.
|
||||
3. Подбор `pending` при старте, тем же путём. Это половина `reindex`, поэтому
|
||||
код должен быть общим с ним, а не соседним.
|
||||
4. Остановка сервиса дожидается текущей доставки: свёртка — одна транзакция,
|
||||
рвать её нечем, но очередь надо дренировать осознанно.
|
||||
5. Метка «доставка ждала свёртки дольше N» — в наблюдаемость, чтобы отставание
|
||||
воркера было видно до того, как оно станет отставанием на сутки.
|
||||
6. Тесты: порядок журнала соблюдается при конкурентных доставках; `pending`
|
||||
подбирается при старте; отмена контекста не оставляет половинчатого
|
||||
состояния; `task verify:archive` даёт то же состояние.
|
||||
|
||||
## Что стоит без решения
|
||||
|
||||
Ничего: свёртка работает, просто рискует не уложиться в таймаут на самых
|
||||
широких доставках. Данные при этом не теряются — тело ложится в архив **до**
|
||||
свёртки.
|
||||
|
||||
## Связано
|
||||
|
||||
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбор `pending` это её половина;
|
||||
делать одним кодом.
|
||||
- [stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
|
||||
таймаут» и отставание воркера должны попасть туда.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Пересборка держит весь журнал в памяти
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
`healthlog reindex` материализует целиком две вещи: учёт доставок из базы и
|
||||
список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на
|
||||
квартальном (~12 тысяч) — терпимо, а дальше растёт линейно и без предела:
|
||||
журнал по определению не подчищается до следующего проверенного экспорта.
|
||||
|
||||
Отдельно к этому примешивается **размер заголовков**: `MaxHeaderBytes` у
|
||||
сервера не задан, то есть верхней границы у колонки `delivery.headers` нет
|
||||
вовсе. Раздутый заголовок множится на число доставок.
|
||||
|
||||
Порог, за которым это перестаёт быть теорией, не измерен — с него и стоит
|
||||
начинать, если задача берётся. Лечится потоковым перечислением журнала
|
||||
(курсор по учёту, обход каталога партиями по суткам) вместо двух срезов в
|
||||
памяти.
|
||||
|
||||
Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с
|
||||
[ретеншеном сырого архива](retenshen-syrogo-arhiva.md): та задача задаёт, где
|
||||
у журнала конец, эта — как его читать, не поднимая целиком.
|
||||
|
||||
Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением
|
||||
памяти, не зависящим от его длины.
|
||||
|
||||
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Порядок журнала при конкурентных приёмах
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
||||
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
||||
приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает.
|
||||
Задача берётся после [наблюдаемости](stats-nablyudaemost.md); ниже — исходная
|
||||
постановка блокера, она же ТЗ.
|
||||
|
||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
||||
|
||||
## Что решить
|
||||
|
||||
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
|
||||
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
|
||||
коммитом строки проходит запись тела (измерено 184 мс на 62 МиБ) плюс ожидание
|
||||
занятой базы (до пяти секунд, а с повторами транзакции дольше).
|
||||
|
||||
Путь построен и прогнан:
|
||||
|
||||
1. Широкая доставка **A** автоматизации X получает `received_at = T1` и уходит
|
||||
писать тело.
|
||||
2. Узкая доставка **B** той же автоматизации (`T2 > T1`, только `sleep_analysis`,
|
||||
плотных метрик нет) успевает закоммитить строку первой и будит воркер.
|
||||
3. Воркер видит только B, сворачивает её, наследовать слой не от кого →
|
||||
`ErrLayerUnknown` → `failed`.
|
||||
4. `failed` фоновая свёртка не подбирает никогда. Точки B в витрину не попадут.
|
||||
|
||||
Измерено на фикстурах: живой приём даёт `B=failed` и ноль часов
|
||||
`sleep_analysis/minute`; журнальный порядок — `B=parsed` и два часа. То есть
|
||||
живое состояние расходится с тем, что даст `healthlog reindex`, и расхождение
|
||||
молчит: уровень лога у этого исхода `WARN`, такой же, как у штатного «у этой
|
||||
автоматизации плотных метрик не бывает».
|
||||
|
||||
**Это не регресс** — прежде свёртка шла в порядке завершения обработчиков, то
|
||||
есть было хуже. Изменение окно сузило и назвало предел в спеке приёма; вопрос в
|
||||
том, закрывать ли его совсем.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
**а. Резервировать строку учёта в начале `Accept`** (до записи тела), дописывая
|
||||
`raw_path`/`bytes`/`sha256` после. Тогда видимость строки монотонна вместе с
|
||||
`received_at`. Цена: ломается инвариант «тело на диск раньше строки учёта»,
|
||||
заведённый ровно затем, чтобы не было учтённой доставки без данных; появляется
|
||||
новое состояние «строка есть, тела ещё нет», которое обязаны понимать пересборка
|
||||
и ретеншен.
|
||||
|
||||
**б. Откладывать свёртку доставки, пока она не «устоялась»** — не сворачивать
|
||||
моложе N секунд. Цена: задержка N на каждую доставку и произвольное N: окно
|
||||
занятости базы измерено до пяти секунд и зависит от нагрузки, так что N честно
|
||||
не выбрать.
|
||||
|
||||
**в. `ErrLayerUnknown` в живом пути не выводит доставку из очереди** —
|
||||
ограниченное число повторов, потом `failed`. Цена: колонка счётчика попыток
|
||||
(миграция) и политика «сколько попыток достаточно»; зато лечит и прочие случаи
|
||||
«предшественница ещё не доехала». Требует правки спеки хранения («отказ разбора
|
||||
⇒ `failed`»).
|
||||
|
||||
**г. Ничего не делать**, оставив предел названным в спеке. Цена: редкая,
|
||||
молчаливая потеря точек у автоматизаций без плотных метрик; лечится
|
||||
`healthlog reindex` с остановкой сервиса и ручной подменой базы, но узнать о
|
||||
необходимости неоткуда — счётчика `failed` в рантайме нет.
|
||||
|
||||
## Что заблокировано
|
||||
|
||||
Ничего: задача про разнесение ответа и свёртки доведена до конца в объявленных
|
||||
границах, предел записан в спеке приёма. Заблокировано только **закрытие**
|
||||
предела.
|
||||
|
||||
Смежно: пока предел жив, полезно уметь сверять живую витрину с пересборкой —
|
||||
`reindex` уже печатает оба отпечатка, но по расписанию их никто не сравнивает.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**(в)**, но не раньше `/stats`: сперва должно стать видно, сколько доставок
|
||||
числится `failed` и как давно, — иначе повторы будут лечить болезнь, которую
|
||||
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Предел на размер и число заголовков доставки
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
У тела доставки предел есть (`max_body`), у заголовков — нет ни одного:
|
||||
`MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком,
|
||||
сколько бы их ни пришло. В лог они с недавних пор обрезаются, в базу — нет.
|
||||
|
||||
Сегодня отправитель один и он свой, поэтому дефект спит. Просыпается он
|
||||
**вместе с [деплоем](deploy-rivendell.md)**: у приёма, торчащего наружу,
|
||||
отправитель перестаёт быть своим по определению. Оценка сверху при доставке раз
|
||||
в пять минут — сотни мегабайт в сутки в таблице, которую никто не подчищает; а
|
||||
растёт вместе с ней и стоимость пересборки, которая учёт материализует целиком.
|
||||
|
||||
Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел
|
||||
на то, что уходит в колонку. Разумно делать одной правкой с
|
||||
[управлением токенами](upravlenie-sekretami.md) — оба пункта про одно и то же:
|
||||
приём перестаёт доверять тому, кто с ним говорит.
|
||||
|
||||
Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда.
|
||||
Отказ по превышению обязан наступать **до** записи тела, а не после, и быть
|
||||
отличим в логе от отказа обстоятельств.
|
||||
|
||||
Готово, когда доставка с заведомо раздутыми заголовками получает внятный отказ,
|
||||
не оставляя следа в базе, а обычная доставка проходит как раньше.
|
||||
|
||||
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
|
||||
@@ -0,0 +1,70 @@
|
||||
# Пределы на размер сущности и потоковый расчёт формы
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
|
||||
сущности из транзакции и перестала считать каноническую форму дважды. Осталось
|
||||
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
|
||||
считаются материализацией значения целиком.
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
|
||||
на теле в пределах приёма (64 МиБ):
|
||||
|
||||
```
|
||||
тело 40 МиБ → пик HeapAlloc 768.3 МиБ
|
||||
тело 63 МиБ → повторная доставка держит блокировку 5.019 с при busy_timeout 5000
|
||||
```
|
||||
|
||||
При `_txlock=immediate` конкурентный `CreateDelivery` получает `SQLITE_BUSY`,
|
||||
`inTx` повторяет до пяти раз и на исчерпании отдаёт `store.ErrBusy` — приём
|
||||
отвечает 500 по доставке, тело которой уже в архиве. Осиротевшее тело подберёт
|
||||
`reindex`, но узнать о нём можно только из лога.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Предел на размер **одной сущности** и на суммарный размер секции, отдельно
|
||||
от предела тела (64 МиБ). Сегодня одна тренировка законно может занять всё
|
||||
тело целиком. Вход, превышающий предел, обязан отклоняться **до**
|
||||
канонизации, а не после.
|
||||
2. Потоковый расчёт канонической формы и хеша: `canon.Form` разворачивает
|
||||
значение в дерево `any`, из-за чего пик кучи кратен размеру входа (замер даёт
|
||||
множитель около 19×). Хеш считается по потоку; форма нужна целиком только для
|
||||
сравнения, и только когда хеш разошёлся.
|
||||
3. Разбор **сохранённой** версии всё ещё идёт внутри транзакции: её содержимое
|
||||
читается оттуда же. Убрать это можно оптимистичным чтением до транзакции —
|
||||
но только с перепроверкой хеша и провенанса **внутри** транзакции, иначе две
|
||||
конкурентные свёртки одного `id` дадут потерянное обновление и исход снова
|
||||
станет функцией порядка коммитов, а не журнала.
|
||||
|
||||
## Условия, пришедшие из закрывающей задачи
|
||||
|
||||
1. Мягкое чтение заголовка сущности увеличило долю тел, доходящих до
|
||||
канонизации: сущность, которая раньше отсекалась на `json.Unmarshal`
|
||||
заголовка почти бесплатно, теперь разбирается и канонизируется целиком. То
|
||||
есть худший случай по памяти стал достижим на входах, которые до него не
|
||||
доходили, — предел из пункта 1 после этого **обязателен**, а не желателен.
|
||||
|
||||
2. Каноническая форма и множества ключей всех версий доставки теперь
|
||||
**удерживаются** до конца транзакции слияния (раньше считались лениво и на
|
||||
одной доставке из сорока четырёх). Расход стал пропорционален размеру
|
||||
ДОСТАВКИ, а не самой большой её сущности; предел обязан считать суммарный
|
||||
размер секции, а не только одной сущности.
|
||||
|
||||
3. **Потолок на число версий одного ключа в одной доставке.** Выбор победителя
|
||||
квадратичен по числу кандидатов; версии с совпавшей канонической формой
|
||||
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
|
||||
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
|
||||
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
|
||||
вопрос открыт для точек на одной координате: `cena-sliyaniya-na-shirokoj-dostavke.md`,
|
||||
пункт 4.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) —
|
||||
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
|
||||
часа). Задачи делать вместе: половина решения общая — `canon`.
|
||||
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
|
||||
отброшено как предел по конструкции, но условием ложится сюда.
|
||||
@@ -36,3 +36,10 @@
|
||||
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
||||
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
||||
поток приносил, и сравнение с известным набором закрывает задачу.
|
||||
|
||||
Модель под секции с собственным `id` заложена (change
|
||||
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
|
||||
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
|
||||
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications` —
|
||||
их формы никто не видел, и разбор вслепую сознательно не писался.
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
|
||||
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
|
||||
|
||||
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
|
||||
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
|
||||
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
|
||||
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
|
||||
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
|
||||
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
|
||||
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
|
||||
|
||||
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
|
||||
каждый, а в ответе всегда видно `layer`, `bucket` и `aggregation`.
|
||||
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||
видно `layer`, `bucket` и `aggregation`.
|
||||
|
||||
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Пересборка хранилища из сырого архива
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
|
||||
Риск в другом: без пересборки ошибка разбора становится потерей данных —
|
||||
исправленный код не применится к тому, что уже разобрано неверно.
|
||||
|
||||
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
|
||||
агрегации на полном ряду доставок надёжнее, чем на одной.
|
||||
|
||||
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
|
||||
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
|
||||
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
|
||||
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
|
||||
похожими.
|
||||
|
||||
Отсюда требование, которое легко упустить: **свёртка обязана быть
|
||||
детерминированной.** Проигрывание должно давать то же состояние, что приём в
|
||||
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
|
||||
одинаково полные точки с разными значениями разрешает порядок — значит
|
||||
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
|
||||
|
||||
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
|
||||
приёмом, и повторный прогон ничего не меняет.
|
||||
|
||||
Связано: план → шаг «Разбор и хранилище», `docs/architecture.md` → «Сырой архив».
|
||||
|
||||
@@ -26,14 +26,55 @@
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
## Предусловие снято
|
||||
## Предусловие снова открыто
|
||||
|
||||
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией
|
||||
имеет статус `partial` и список непокрытых ключей
|
||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать
|
||||
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
|
||||
неоткуда — в экспорте Apple секции нет.
|
||||
Признак «доставка с непокрытой секцией» появился в change
|
||||
`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
|
||||
`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
|
||||
|
||||
Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
|
||||
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
|
||||
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
|
||||
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
|
||||
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
|
||||
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
|
||||
состояния разума навсегда.
|
||||
|
||||
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
|
||||
Варианты:
|
||||
|
||||
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
|
||||
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
|
||||
так же, как `uncovered_sections`, и одним запросом.
|
||||
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
|
||||
Цена та же, но смысл шире и требует решения, что считать единственным
|
||||
источником для будущих секций.
|
||||
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
|
||||
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
|
||||
её **текущей** версии, а копий у записи бывает по 26.
|
||||
|
||||
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
|
||||
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
|
||||
семантику.
|
||||
|
||||
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену
|
||||
позволено смотреть на `partial` только пока правило соблюдается.
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
|
||||
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
|
||||
соблюдается.
|
||||
|
||||
## Что читать перед удалением тела
|
||||
|
||||
Две колонки учётной записи, и обе обязательны:
|
||||
|
||||
- `uncovered_sections` — непустой список означает, что в теле есть секции,
|
||||
которых разбор не покрывает; удалять нельзя;
|
||||
- `skipped_entities` — число сущностей с собственным `id`, которые разбор не
|
||||
понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят
|
||||
доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не
|
||||
досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело
|
||||
тренировки, маршрута которой нет больше нигде: в экспорте Apple его не
|
||||
существует.
|
||||
|
||||
Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого
|
||||
различия.
|
||||
|
||||
@@ -14,5 +14,21 @@
|
||||
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
|
||||
когда.
|
||||
|
||||
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
|
||||
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
|
||||
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
|
||||
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
|
||||
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
|
||||
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
|
||||
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
|
||||
|
||||
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
|
||||
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
|
||||
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
|
||||
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
|
||||
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
|
||||
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
|
||||
десятков тысяч доставок.
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Сверка живой витрины с пересборкой
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
`healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей —
|
||||
то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение
|
||||
живого состояния с тем, что даёт проигрывание журнала, сегодня обнаруживается
|
||||
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
|
||||
|
||||
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
|
||||
[«Порядок журнала при конкурентных приёмах»](poryadok-zhurnala-na-priyome.md):
|
||||
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
|
||||
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
|
||||
— единственный способ узнать, что он сработал.
|
||||
|
||||
Инвариант «состояние есть свёртка журнала» проверяем ровно этим: пересобрать в
|
||||
отдельный файл (рабочая база не трогается — это уже так и устроено), сверить
|
||||
отпечатки, расхождение — событие, которое видно. Прогон не бесплатный
|
||||
(на квартальном журнале десятки минут), поэтому это регламент, а не фоновая
|
||||
задача сервиса.
|
||||
|
||||
Развилка при взятии: кто запускает — `cron` на хосте рядом с деплоем или сам
|
||||
сервис по расписанию. Первое честнее (пересборка уже сейчас команда, а не
|
||||
режим сервиса), но требует места под второй файл базы.
|
||||
|
||||
Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того,
|
||||
догадался ли человек посмотреть.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-nablyudaemost.md),
|
||||
[деплой](deploy-rivendell.md).
|
||||
@@ -1,23 +0,0 @@
|
||||
# Тренировки и секции с собственными id
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то,
|
||||
ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий),
|
||||
состояние разума — агенту-медику.
|
||||
|
||||
Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки,
|
||||
и по часам их группировать незачем. Тренировка **перезаписывается** целиком —
|
||||
она приезжает повторно, когда доедет маршрут.
|
||||
|
||||
Шаги:
|
||||
- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`,
|
||||
`cycleTracking`, `medications`, `heartRateNotifications` — модель одна);
|
||||
- заголовок тренировки колонками, маршрут и внутренние ряды — блобом;
|
||||
- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы.
|
||||
|
||||
Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а
|
||||
`stateOfMind` виден записями.
|
||||
|
||||
Связано: `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`.
|
||||
+48
-2
@@ -66,6 +66,12 @@
|
||||
При сомнении логируем факт наличия, не значение.
|
||||
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
|
||||
и с обрезкой по длине.
|
||||
- **Текст ошибки разбора не содержит значений из входа** — только род токена
|
||||
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
|
||||
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
|
||||
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
|
||||
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
|
||||
разбора не узнает.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
@@ -89,8 +95,38 @@
|
||||
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
||||
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
||||
невалидный id — 404 без похода в БД.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample`
|
||||
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout` —
|
||||
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
|
||||
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
|
||||
в двух секциях затёр бы одну запись другой молча).
|
||||
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
|
||||
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
|
||||
единица, которой нет в счётчиках, делает расхождение безадресным: человек
|
||||
видит «не совпало» при неизменившемся числе объектов и принимает по этому
|
||||
необратимое решение о подмене базы.
|
||||
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
||||
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
||||
пересборкой молча.
|
||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
||||
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
|
||||
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
|
||||
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
|
||||
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
|
||||
тело.
|
||||
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
|
||||
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
|
||||
она становится меткой касания, и запрос «что изменилось с момента X» получает
|
||||
столько ложных изменений, сколько раз источник переприслал то же самое (у
|
||||
тренировки — двадцать шесть).
|
||||
- **Новая производная от разбора колонка в момент появления вносится в перечень
|
||||
того, что пересборка не переносит.** Перечень — единственное место, где это
|
||||
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
|
||||
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
|
||||
прогона.
|
||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||
@@ -115,3 +151,13 @@
|
||||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||||
выражается — отсюда проза.
|
||||
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
|
||||
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
|
||||
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
|
||||
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
|
||||
равной формой ловит другое: неединственный минимум, при котором победителем
|
||||
оказывается просто первый в срезе, то есть порядок элементов на проводе.
|
||||
- **Изменение правила разбора или слияния сопровождается замером на живом
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
|
||||
+64
-1
@@ -28,7 +28,24 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
│ headers TEXT │ │ updated_at TEXT │
|
||||
│ derived_layer TEXT │ └──────────────────────────────┘
|
||||
│ uncovered_sections TEXT │
|
||||
│ skipped_entities INTEGER? │
|
||||
└────────────────────────────┘
|
||||
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
┊ │ workout │ │ record │
|
||||
┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │
|
||||
└┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │
|
||||
лежит сейчас │ name TEXT │ │ id TEXT ┘PK│
|
||||
│ start_utc TEXT │ │ ts_utc TEXT │
|
||||
│ end_utc TEXT │ │ tz_offset INTEGER│
|
||||
│ tz_offset INTEGER│ │ payload BLOB │
|
||||
│ duration_sec REAL? │ │ content_hash TEXT │
|
||||
│ payload BLOB │ │ delivery_id TEXT │
|
||||
│ content_hash TEXT │ │ delivery_received_at TEXT│
|
||||
│ delivery_id TEXT │ │ created_at TEXT │
|
||||
│ delivery_received_at TEXT│ │ updated_at TEXT │
|
||||
│ created_at TEXT │ └──────────────────────────┘
|
||||
│ updated_at TEXT │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
||||
@@ -53,10 +70,18 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
| `points` | сколько точек дал разбор |
|
||||
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
|
||||
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
|
||||
| `skipped_entities` | сколько сущностей с собственным `id` разбор пропустил (нет `id`, `id` длиннее предела, метка не разбирается, элемент не объект). Вторая половина ответа на «что потеряется, если тело удалить»: список непокрытых секций про пропущенную сущность молчит. **NULL означает «не измерялось»** и нулю не равен — так выглядят доставки, свёрнутые разбором, который пропусков не считал; читатель, принимающий по счётчику необратимое решение, обязан трактовать NULL как «не удалять» |
|
||||
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
|
||||
|
||||
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
|
||||
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации).
|
||||
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации),
|
||||
`delivery_pending` (очередь свёртки).
|
||||
|
||||
`delivery_pending` **частичный** — только строки со статусом `pending`. Таблица
|
||||
и есть очередь фоновой свёртки: воркер выбирает неразобранные доставки в
|
||||
порядке журнала чаще, чем раз в минуту. В установившемся режиме в индексе
|
||||
ноль-одна строка, тогда как полный индекс по `parse_status` хранил бы всю
|
||||
историю ради выборки из одной.
|
||||
|
||||
## `bucket` — часовой объект точек
|
||||
|
||||
@@ -82,3 +107,41 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
||||
|
||||
## `workout` и `record` — сущности с собственным `id`
|
||||
|
||||
Вторая единица хранения витрины. Часовой объект им не подходит: у них есть
|
||||
естественный ключ, они редки (за двое суток потока — две тренировки и две
|
||||
записи состояния разума при 44 и 52 доставленных копиях), и группировать их по
|
||||
часам незачем.
|
||||
|
||||
Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||
которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая
|
||||
таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из
|
||||
шести.
|
||||
|
||||
| Колонка | Смысл |
|
||||
|---|---|
|
||||
| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога |
|
||||
| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча |
|
||||
| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций |
|
||||
| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` |
|
||||
| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся |
|
||||
| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду |
|
||||
| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` |
|
||||
| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) |
|
||||
| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты |
|
||||
|
||||
Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос
|
||||
трекера), `record_kind_ts` («записи такого-то рода за период» — единственная
|
||||
форма запроса к таблице).
|
||||
|
||||
**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики
|
||||
`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это
|
||||
разные таблицы, и смешение задвоило бы ряд.
|
||||
|
||||
**Замена версии условна.** Приехавшая побеждает, если не теряет содержания
|
||||
сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых
|
||||
массивов); при равных наборах выигрывает версия из более поздней доставки
|
||||
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||||
`architecture.md`, раздел «Тренировки и прочие секции».
|
||||
|
||||
@@ -1658,6 +1658,73 @@ apple_stand_time 14
|
||||
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
|
||||
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
|
||||
|
||||
## 51. Тренировка досчитывается задним числом, но поля у неё только прибывают
|
||||
|
||||
Замер по всем 118 доставкам архива, группировка элементов секций по `id`:
|
||||
|
||||
| сущность | копий | различных содержимых | набор полей рос | набор полей убывал |
|
||||
|---|---:|---:|---|---|
|
||||
| тренировка A | 26 | 3 | да | нет |
|
||||
| тренировка B | 18 | 1 | — | — |
|
||||
| `stateOfMind` #1 | 26 | 1 | — | — |
|
||||
| `stateOfMind` #2 | 26 | 1 | — | — |
|
||||
|
||||
Что менялось у тренировки A между версиями:
|
||||
|
||||
```
|
||||
версия 0 → 1 +stepCadence, +stepCount, изменилось значение ряда activeEnergy
|
||||
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
|
||||
```
|
||||
|
||||
Два вывода, и оба вошли в правило замены версии.
|
||||
|
||||
**Тренировка правится задним числом ровно так же, как минутное ведро**
|
||||
(находка 10): при неизменном наборе полей значения досчитываются. Значит
|
||||
правило «при равной полноте побеждает тот, чья каноническая форма меньше» —
|
||||
то, что действует для точек, — заморозило бы тренировку на произвольной версии
|
||||
навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
**Набор полей за весь корпус ни разу не уменьшился.** Обеднённая версия —
|
||||
событие, которого поток не производит; но маршрут это 95% веса тренировки
|
||||
(находка 22), а восстановление требует пересборки всего журнала. Поэтому
|
||||
удержание сохранённой версии стоит одного сравнения множеств, а событие делается
|
||||
наблюдаемым — счётчиком и `WARN`, — вместо необратимого.
|
||||
|
||||
**Правило полноты, написанное для точек, здесь неприменимо.** Оно требует, чтобы
|
||||
значения общих содержательных ключей совпали, иначе отношение включения гасится
|
||||
до «равенства». У точки это верно (надмножество имён при других значениях
|
||||
означает другое измерение), у сущности — нет: значения между версиями
|
||||
расходятся всегда. Проверено на копии пакета `canon`:
|
||||
|
||||
```
|
||||
сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset
|
||||
сохранённая с маршрутом vs обеднённая, значения общих полей иные : equal
|
||||
сохранённая vs версия с усечённым маршрутом (2 точки → 1) : equal
|
||||
```
|
||||
|
||||
Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому
|
||||
сравнивается ещё и длина верхнеуровневых массивов.
|
||||
|
||||
## 52. Половина потока — не `metrics`: перемер на 118 доставках
|
||||
|
||||
Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей `data`:
|
||||
|
||||
| набор ключей `data` | доставок |
|
||||
|---|---|
|
||||
| `metrics` | 65 |
|
||||
| `workouts` | 27 |
|
||||
| `stateOfMind` | 26 |
|
||||
|
||||
Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна
|
||||
доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну
|
||||
секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя —
|
||||
за двое суток наблюдения смешанная доставка просто не успела бы случиться.
|
||||
|
||||
С покрытием `workouts` и `stateOfMind` разбором эти 53 доставки перестали быть
|
||||
`partial`. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов
|
||||
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
|
||||
2 записи; повторное проигрывание дало тот же отпечаток.
|
||||
|
||||
## Инструмент
|
||||
|
||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
проект — так, чтобы ни один из них не писал приём, дедупликацию и хранение
|
||||
заново.
|
||||
|
||||
**Потребителей три**, и в остальных документах они зовутся так:
|
||||
|
||||
| Как называем | Что это | Что ему нужно от нас |
|
||||
| --- | --- | --- |
|
||||
| **агент-медик** | анализ здоровья через MCP | актуальная сводка, влезающая в контекст |
|
||||
| **трекер** | разбор тренировок | тренировка целиком, с маршрутом и рядом пульса |
|
||||
| **игра** | мотиватор по активности | шаги и энергия с суточной разбивкой |
|
||||
|
||||
Список закрытый: он определяет, что считать нужным, а что — интересным. Появится
|
||||
четвёртый — строка добавляется сюда, а не подразумевается.
|
||||
|
||||
Цель достигнута, когда одновременно верно:
|
||||
|
||||
- телефон шлёт непрерывно, и поток не требует внимания неделями;
|
||||
|
||||
+13
-12
@@ -12,18 +12,19 @@
|
||||
## Ближайшая цель
|
||||
|
||||
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
|
||||
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
|
||||
беклоге ноль.
|
||||
недифференцированной кучей. Приём отвечает `200`, не дожидаясь свёртки: её ведёт
|
||||
фоновый воркер, для которого очередью служит сама таблица доставок.
|
||||
|
||||
Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После
|
||||
миграции 00005 доставки числятся `pending`, а подобрать их некому: код
|
||||
пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но
|
||||
учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки
|
||||
нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку».
|
||||
**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
|
||||
сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`
|
||||
после миграции 00005, подбираются им же — но применяется результат подменой
|
||||
базы, а её делает человек при остановленном сервисе. Тем же кодом закрывается
|
||||
половина задачи «разнести ответ и свёртку»: проигрывание журнала теперь готовая
|
||||
операция.
|
||||
|
||||
Потом — остаток разбора: тренировки и записи со своими `id` (это половина
|
||||
потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются),
|
||||
словарь категориальных значений.
|
||||
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind` —
|
||||
половина потока — перестали лежать неразобранными. От разбора остался словарь
|
||||
категориальных значений; дальше — каталог и род агрегации.
|
||||
|
||||
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
||||
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
|
||||
@@ -32,8 +33,8 @@
|
||||
|
||||
- [x] **1. Каркас.**
|
||||
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
|
||||
- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со
|
||||
своими `id`, `reindex` и словарь категориальных значений — нет.
|
||||
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
|
||||
`reindex` — сделано; словарь категориальных значений — нет.
|
||||
- [ ] **4. Каталог и род агрегации.**
|
||||
- [ ] **5. Read API.**
|
||||
- [ ] **6. Самоописание.**
|
||||
|
||||
@@ -47,3 +47,66 @@
|
||||
обязан иметь границу по `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`. Правило
|
||||
общее и годится в конвенции: **в проверке на живом корпусе нельзя утверждать
|
||||
число, производное от размера корпуса** — утверждать надо инвариант, а число
|
||||
печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой
|
||||
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
|
||||
проверяет настоящий проигрыватель журнала.
|
||||
|
||||
## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё
|
||||
|
||||
- **Где:** конвейер, а не код: коммит `f8200f7` («тренировки и записи с
|
||||
собственным `id`»), шаг 7 скилла `healthlog-task-pipeline`, профиль `deep`.
|
||||
- **Симптом:** изменение было закоммичено и заархивировано как прошедшее ревью.
|
||||
Дозапуск трёх пропущенных проходов на **уже закоммиченном** коде дал девять
|
||||
причин, семь из которых пошли в работу с прогнанными оракулами: скелет из
|
||||
`null` затирает маршрут молча и необратимо; одно поле не той формы уносит
|
||||
тренировку, а доставка при этом числится разобранной; откат бинаря поверх
|
||||
новой схемы стартует без слова; победитель внутри доставки зависит от порядка
|
||||
элементов на проводе; провенанс устаревает на каждой повторной присылке;
|
||||
канонизация идёт внутри транзакции вопреки собственному комментарию (768 МиБ
|
||||
пика, 5.019 с удержания блокировки); тело в 8 МиБ целиком уезжает в текст
|
||||
ошибки и оттуда в `WARN`.
|
||||
- **Причина:** сабагент, проводивший задачу, на чекпоинте кода запустил не все
|
||||
проходы профиля `deep` — не отработали `adversary`, `ops` и архитектурный.
|
||||
Отчёт триажа при этом был выпущен и выглядел полным: он агрегирует то, что
|
||||
ему подали, и о непоступивших проходах не знает. Секция границ покрытия
|
||||
обязана была это назвать, но она заполняется тем же триажем — то есть
|
||||
единственный, кто мог заметить пропуск, узнаёт о нём из того же источника,
|
||||
который его допустил.
|
||||
- **Почему не поймали:** пропуск прохода **не отличим от прохода без находок**.
|
||||
Гейт зелёный, спеки сошлись, applicative-проходы отработали — снаружи это
|
||||
выглядит как чистое ревью. Все семь находок принадлежат ровно тем классам,
|
||||
которые applicative-проходы не достают по построению: враждебно
|
||||
сконструированный вход (`adversary`), поведение под откатом и конкуренцией
|
||||
(`ops`), второй способ делать уже сделанное (архитектура). Recall чек-листа
|
||||
равен длине чек-листа, а этих пунктов в чек-листах нет и быть не может.
|
||||
- **Что меняем:** отчёт ревью обязан перечислять запущенные проходы **поимённо
|
||||
и с исходом**, а оркестратор задачи — сверять этот перечень с составом
|
||||
профиля до того, как коммитить; непущенный проход идёт в границы покрытия
|
||||
строкой «не запускался», а не отсутствует. Правилом линтера это не
|
||||
выражается, автоматической проверки нет — но пропуск, названный в отчёте,
|
||||
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
|
||||
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
|
||||
сработали ровно так, как задуманы, — их просто не позвали.
|
||||
|
||||
@@ -10,11 +10,18 @@ import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bodyExt — расширение файла тела. Всё, что ему не соответствует, телом не
|
||||
// является: остаток `*.json.gz.tmp` от прерванной записи, чужой файл, каталог.
|
||||
const bodyExt = ".json.gz"
|
||||
|
||||
// Archive — каталог сырых тел, разложенных по дате приёма.
|
||||
type Archive struct {
|
||||
root string
|
||||
@@ -28,6 +35,23 @@ func New(root string) (*Archive, error) {
|
||||
return &Archive{root: root}, nil
|
||||
}
|
||||
|
||||
// Existing открывает уже существующий архив и каталога не создаёт.
|
||||
//
|
||||
// Отличие от New не косметическое: приёму каталог создать надо, а пересборке —
|
||||
// нельзя. Созданный на лету пустой каталог превращает запуск не из той
|
||||
// директории в успешный прогон по пустому журналу, а пустая витрина совпадает
|
||||
// по отпечатку с пустой витриной, то есть выглядит идеальной сходимостью.
|
||||
func Existing(root string) (*Archive, error) {
|
||||
fi, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open archive dir %q: %w", root, err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return nil, fmt.Errorf("archive dir %q: не каталог", root)
|
||||
}
|
||||
return &Archive{root: root}, nil
|
||||
}
|
||||
|
||||
// Root возвращает корневой каталог архива.
|
||||
func (a *Archive) Root() string { return a.root }
|
||||
|
||||
@@ -60,6 +84,61 @@ func (a *Archive) Write(id string, at time.Time, body []byte) (string, error) {
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// Listing — что нашлось в архиве.
|
||||
type Listing struct {
|
||||
// Bodies — относительные пути тел, отсортированные лексикографически.
|
||||
// Порядок здесь только для воспроизводимости перечисления: журнал
|
||||
// упорядочивает не он, а время приёма.
|
||||
Bodies []string
|
||||
// Skipped — файлы, телом не являющиеся. Считаются, а не выбрасываются:
|
||||
// молчаливый пропуск означал бы «тело есть, а в отчёте его нет».
|
||||
Skipped []string
|
||||
}
|
||||
|
||||
// List перечисляет тела архива.
|
||||
//
|
||||
// Ошибку чтения каталога отдаёт наружу, а не превращает в пустой список, и
|
||||
// каталога не создаёт. Различие принципиально для пересборки: нечитаемый или
|
||||
// отсутствующий каталог означает «неизвестно, есть ли тела», а не «тел нет», —
|
||||
// а пустой журнал даёт пустую витрину, чей отпечаток совпадает с отпечатком
|
||||
// любой другой пустой витрины, то есть выглядит идеальной сходимостью.
|
||||
func (a *Archive) List() (Listing, error) {
|
||||
var out Listing
|
||||
|
||||
err := filepath.WalkDir(a.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// Отказ чтения каталога прекращает обход целиком: пропустить его
|
||||
// значило бы молча потерять сутки журнала.
|
||||
return fmt.Errorf("walk archive %q: %w", path, err)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(a.root, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relative path %q: %w", path, err)
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), bodyExt) {
|
||||
out.Skipped = append(out.Skipped, rel)
|
||||
return nil
|
||||
}
|
||||
out.Bodies = append(out.Bodies, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Listing{}, err //nolint:wrapcheck // ошибка уже обёрнута внутри обхода
|
||||
}
|
||||
|
||||
sort.Strings(out.Bodies)
|
||||
sort.Strings(out.Skipped)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BodyID возвращает идентификатор доставки по относительному пути тела.
|
||||
func BodyID(rel string) string {
|
||||
return strings.TrimSuffix(filepath.Base(rel), bodyExt)
|
||||
}
|
||||
|
||||
// Open открывает сохранённое тело для чтения (распакованным). Нужен для
|
||||
// пересборки витрины из архива.
|
||||
func (a *Archive) Open(rel string) (io.ReadCloser, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -90,3 +91,77 @@ func newArchive(t *testing.T) *archive.Archive {
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Перечисление тел — вход пересборки. Всё, что телом не является, обязано быть
|
||||
// посчитано, а не выброшено молча: тело есть, а в отчёте его нет.
|
||||
func TestListРазводитТелаИПрочиеФайлы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
a, err := archive.New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
for _, id := range []string{"b", "a"} {
|
||||
if _, err := a.Write(id, at, []byte(`{"data":{}}`)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
day := filepath.Join(root, "2026", "08", "01")
|
||||
for _, name := range []string{"a.json.gz.tmp", "readme.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(day, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("подготовка файла: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := a.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
filepath.Join("2026", "08", "01", "a.json.gz"),
|
||||
filepath.Join("2026", "08", "01", "b.json.gz"),
|
||||
}
|
||||
if !reflect.DeepEqual(got.Bodies, want) {
|
||||
t.Errorf("тела %v, ожидались %v", got.Bodies, want)
|
||||
}
|
||||
if len(got.Skipped) != 2 {
|
||||
t.Errorf("пропущено %v, ожидалось два файла", got.Skipped)
|
||||
}
|
||||
if id := archive.BodyID(got.Bodies[0]); id != "a" {
|
||||
t.Errorf("BodyID = %q, ожидался %q", id, "a")
|
||||
}
|
||||
}
|
||||
|
||||
// Нечитаемый каталог означает «неизвестно, есть ли тела», а не «тел нет»:
|
||||
// пустой журнал даёт пустую витрину, которая по отпечатку совпадает с любой
|
||||
// другой пустой витриной и выглядит идеальной сходимостью.
|
||||
func TestОтсутствующийКаталогНеЯвляетсяПустымАрхивом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "нет-такого")
|
||||
if _, err := archive.Existing(missing); err == nil {
|
||||
t.Error("Existing создал или принял отсутствующий каталог")
|
||||
}
|
||||
if _, err := os.Stat(missing); err == nil {
|
||||
t.Error("Existing создал каталог — пересборке этого делать нельзя")
|
||||
}
|
||||
|
||||
// А приёму каталог создать надо: это и есть разница между конструкторами.
|
||||
if _, err := archive.New(missing); err != nil {
|
||||
t.Errorf("New: %v", err)
|
||||
}
|
||||
a, err := archive.Existing(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("Existing после New: %v", err)
|
||||
}
|
||||
list, err := a.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list.Bodies) != 0 {
|
||||
t.Errorf("в пустом архиве нашлись тела: %v", list.Bodies)
|
||||
}
|
||||
}
|
||||
|
||||
+215
-11
@@ -41,7 +41,52 @@ const SignificantDigits = 12
|
||||
// числа округлены до SignificantDigits значащих цифр.
|
||||
//
|
||||
// Форма предназначена для сравнения и хеширования, а не для хранения.
|
||||
//
|
||||
// Возвращаемый срез принадлежит вызывающему целиком: буфер, в котором форма
|
||||
// собрана, наружу больше не показывается.
|
||||
func Form(raw []byte) ([]byte, error) {
|
||||
return form(raw)
|
||||
}
|
||||
|
||||
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
|
||||
//
|
||||
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
|
||||
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
|
||||
// проход переприсылает неделю, но почти все сравнения сходятся.
|
||||
func Hash(raw []byte) (string, error) {
|
||||
f, err := form(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hashOf(f), nil
|
||||
}
|
||||
|
||||
// FormAndHash отдаёт каноническую форму и её хеш ЗА ОДИН проход.
|
||||
//
|
||||
// Нужен тем, кому требуется и то, и другое: сущность хешируется ради
|
||||
// хеш-детектора и канонизируется ради сравнения полноты, и считать форму дважды
|
||||
// над теми же байтами значит платить дважды за самую дорогую операцию
|
||||
// хранилища (тело 40 МиБ даёт пик кучи 768 МиБ).
|
||||
//
|
||||
// Отдельной функции «хеш по готовой форме» здесь нет намеренно: она вводила бы
|
||||
// контракт очерёдности, в котором передача сырых байт вместо формы даёт
|
||||
// правдоподобный, но неверный хеш, а компилятор такую подмену не ловит.
|
||||
//
|
||||
// Обратной ошибки — «Form считает хеш и выбрасывает» — здесь тоже нет: общий
|
||||
// низ у трёх функций один и хеша не считает. Иначе каждая точка при каждом
|
||||
// слиянии платила бы SHA-256, который никто не смотрит: Form зовётся из SortKey
|
||||
// на каждый кандидат координаты, из HashAll на каждую точку часа и дважды на
|
||||
// каждое сравнение в Equal.
|
||||
func FormAndHash(raw []byte) ([]byte, string, error) {
|
||||
f, err := form(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, hashOf(f), nil
|
||||
}
|
||||
|
||||
// form — общий низ: каноническая форма и ничего сверх неё.
|
||||
func form(raw []byte) ([]byte, error) {
|
||||
v, err := decode(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -54,18 +99,9 @@ func Form(raw []byte) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
|
||||
//
|
||||
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
|
||||
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
|
||||
// проход переприсылает неделю, но почти все сравнения сходятся.
|
||||
func Hash(raw []byte) (string, error) {
|
||||
form, err := Form(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
func hashOf(form []byte) string {
|
||||
sum := sha256.Sum256(form)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// HashAll возвращает хеш канонической формы последовательности значений —
|
||||
@@ -246,6 +282,174 @@ func (f Fields) Relate(g Fields) Fullness {
|
||||
return FullnessEqual
|
||||
}
|
||||
|
||||
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g.
|
||||
//
|
||||
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
|
||||
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
|
||||
// для точки (надмножество имён при других значениях означает другое
|
||||
// измерение), но неверно для сущности с собственным `id`: у неё вторая версия
|
||||
// есть тот же объект, пересчитанный источником, и значения между версиями
|
||||
// расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия
|
||||
// получила бы «равенство» и заместила бы сохранённую вместе с маршрутом
|
||||
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
|
||||
// зелёным.
|
||||
//
|
||||
// Условий четыре, все по ВЕРХНЕМУ уровню:
|
||||
//
|
||||
// 1. каждый содержательный ключ g есть у f и содержателен;
|
||||
//
|
||||
// 2. если множества содержательных ключей СОВПАЛИ — каждый ключ g, даже
|
||||
// пустой, есть у f. Тот же второй разряд, что у Relate, и с тем же
|
||||
// условием: иначе ключ с пустым значением исчезает по жребию тай-брейка.
|
||||
//
|
||||
// Условность разряда проверена оракулом, а не выведена. Безусловный
|
||||
// вариант («строже — значит правильнее») оказался хуже: версия с
|
||||
// `totalEnergy: null` и без маршрута запирала законный досчёт навсегда —
|
||||
// приехавшая теряла пустой ключ, сохранённая теряла содержательный
|
||||
// `route`, и пара становилась несравнимой. Маршрут не доезжал НИКОГДА, и
|
||||
// пересборка проигрывала то же поражение. Второй разряд разрешает спор
|
||||
// равных, а не отменяет первый;
|
||||
//
|
||||
// 3. форма значения не вырождается: где у g объект — у f объект, где массив —
|
||||
// массив. Без этого «скелет» (каждый вложенный объект заменён числом)
|
||||
// признаётся равным настоящей тренировке и выигрывает тай-брейк журнала;
|
||||
//
|
||||
// 4. верхнеуровневый массив не теряет ни длины, ни СОДЕРЖАТЕЛЬНЫХ элементов:
|
||||
// усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
|
||||
// [null,null,null] не теряет и длины. Досчёт ряды удлиняет, поэтому и
|
||||
// укорачивание, и опустошение элементов — законные признаки «приехало
|
||||
// меньше».
|
||||
//
|
||||
// Условия 3 и 4 применяются к ключам, содержательным у g: у пустоты формы нет,
|
||||
// и требовать её сохранения значило бы отличать `[]` от `0` там, где ни то, ни
|
||||
// другое ничего не несёт.
|
||||
//
|
||||
// Содержательность элемента ряда — ТА ЖЕ пустота, что у поля (isEmpty): второй
|
||||
// словарь пустоты дал бы два ответа на один вопрос. Цена названа вслух: ряд
|
||||
// настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с ним
|
||||
// сохранённую не заместит. Ошибка направлена в безопасную сторону — правило
|
||||
// удерживает, а не затирает, и событие видно счётчиком; наблюдённые ряды HAE
|
||||
// состоят из объектов.
|
||||
//
|
||||
// Предел правила назван вслух и не закрывается: сокращение ВНУТРИ элемента ряда
|
||||
// (точка маршрута без altitude при непустом элементе и той же длине) не ловится
|
||||
// ничем, кроме сверки с телом в архиве. Поэлементная сверка содержимого
|
||||
// отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
|
||||
// значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
|
||||
//
|
||||
// Поле `source` в множества не входит (см. Analyze) — исключение придумано для
|
||||
// точек, где оно измерено, и наследуется сущностью молча. Названо здесь потому,
|
||||
// что список исключений живёт в Analyze: правка ради точек изменит и правило
|
||||
// удержания сущностей, а ни один тест сущностей этого не заметит.
|
||||
//
|
||||
// Формы и длины считаются здесь, а не в Analyze: Analyze зовётся на каждый
|
||||
// кандидат слияния точек, и разбор heartbeatSeries на каждой точке стоил бы
|
||||
// дороже самого сравнения.
|
||||
func (f Fields) Covers(g Fields) bool {
|
||||
for k, gv := range g.full {
|
||||
fv, ok := f.full[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !shapeKept(fv, gv) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Первый разряд пройден. Второй включается ТОЛЬКО при равенстве множеств
|
||||
// содержательных ключей: если f несёт содержание сверх g, спор уже решён в
|
||||
// её пользу, и пустой ключ его не отменяет.
|
||||
if len(f.full) != len(g.full) {
|
||||
return true
|
||||
}
|
||||
for k := range g.all {
|
||||
if _, ok := f.all[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// shapeKept говорит, сохраняет ли значение fv форму и наполнение gv.
|
||||
func shapeKept(fv, gv json.RawMessage) bool {
|
||||
switch literalKind(gv) {
|
||||
case kindObject:
|
||||
return literalKind(fv) == kindObject
|
||||
case kindArray:
|
||||
// Третий возврат смотрится У ОБЕИХ сторон. Неразобравшийся массив у g
|
||||
// дал бы нули, то есть покрывался бы даже пустым `[]`. Из тела HAE это
|
||||
// недостижимо (значения приходят разобранным JSON), но сохранённая
|
||||
// версия приезжает сюда из `payload` базы, а вторым источником сущностей
|
||||
// планируется импорт родного экспорта Apple — там байты формирует другой
|
||||
// код.
|
||||
gTotal, gFull, gok := arrayShape(gv)
|
||||
fTotal, fFull, fok := arrayShape(fv)
|
||||
return gok && fok && fTotal >= gTotal && fFull >= gFull
|
||||
default:
|
||||
// Скаляр покрывается чем угодно: у f может быть и объект — это форма
|
||||
// богаче, а не беднее.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// literalKind — род значения по первому байту литерала, как это делает сам
|
||||
// сканер encoding/json. Материализовать значение ради рода незачем.
|
||||
type literalKindT int
|
||||
|
||||
const (
|
||||
kindScalar literalKindT = iota
|
||||
kindObject
|
||||
kindArray
|
||||
)
|
||||
|
||||
func literalKind(raw json.RawMessage) literalKindT {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
if len(lit) == 0 {
|
||||
return kindScalar
|
||||
}
|
||||
switch lit[0] {
|
||||
case '{':
|
||||
return kindObject
|
||||
case '[':
|
||||
return kindArray
|
||||
default:
|
||||
return kindScalar
|
||||
}
|
||||
}
|
||||
|
||||
// arrayShape возвращает число элементов верхнеуровневого массива и число
|
||||
// СОДЕРЖАТЕЛЬНЫХ среди них. Третий возврат — является ли значение массивом.
|
||||
//
|
||||
// Элементы проглатываются в выбрасываемый RawMessage: материализация маршрута в
|
||||
// дерево значений стоила бы того же, от чего отказался разбор тела. Проверка
|
||||
// пустоты идёт по литералу элемента и обхода не добавляет — он уже здесь был
|
||||
// ради счёта.
|
||||
func arrayShape(raw json.RawMessage) (total, contentful int, ok bool) {
|
||||
if literalKind(raw) != kindArray {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
if _, err := dec.Token(); err != nil { // открывающая скобка
|
||||
return 0, 0, false
|
||||
}
|
||||
// Буфер объявлен НАД циклом: RawMessage.UnmarshalJSON делает
|
||||
// `append((*m)[0:0], data...)`, то есть переиспользует ёмкость. Объявление
|
||||
// внутри цикла обнуляло бы срез каждый виток и давало аллокацию на элемент —
|
||||
// маршрут в 593 точки стоил бы 593 аллокаций на каждую проверку покрытия,
|
||||
// притом что комментарий выше обещает обратное.
|
||||
var elem json.RawMessage
|
||||
for dec.More() {
|
||||
if err := dec.Decode(&elem); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
total++
|
||||
if !isEmpty(elem) {
|
||||
contentful++
|
||||
}
|
||||
}
|
||||
return total, contentful, true
|
||||
}
|
||||
|
||||
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
|
||||
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
|
||||
// расхождением не считаются.
|
||||
|
||||
@@ -447,3 +447,161 @@ func FuzzForm(f *testing.F) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// covers — сахар для таблиц ниже: Covers работает на разобранных множествах.
|
||||
func covers(a, b string) bool {
|
||||
return canon.Analyze([]byte(a)).Covers(canon.Analyze([]byte(b)))
|
||||
}
|
||||
|
||||
// Покрытие — отношение «не потеряем содержания», и проверяется оно по четырём
|
||||
// условиям сразу. Оракулы взяты из враждебного прохода ревью: тело, которым
|
||||
// отправитель управляет целиком, строится так, чтобы пройти проверку и вынести
|
||||
// маршрут — 95% содержимого тренировки, которого нет в экспорте Apple.
|
||||
func TestCoversЧетыреУсловия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
// Настоящая тренировка (форма — из testdata/workout_indoor.json).
|
||||
real = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
|
||||
"maxHeartRate":{"qty":199,"units":"count/min"},
|
||||
"heartRate":{"max":{"qty":199},"avg":{"qty":47.2}},
|
||||
"heartRateData":[{"Max":199,"Avg":86.1},{"Max":150,"Avg":80.0}],
|
||||
"activeEnergy":[{"qty":49.4},{"qty":12.1}],
|
||||
"totalEnergy":{"qty":66.4},"duration":11.1}`
|
||||
// «Скелет»: те же имена ключей, те же длины массивов, содержания нет.
|
||||
skeleton = `{"id":"w7","name":"x","isIndoor":false,
|
||||
"maxHeartRate":1,"heartRate":1,
|
||||
"heartRateData":[null,null],"activeEnergy":[null,null],
|
||||
"totalEnergy":1,"duration":1}`
|
||||
route3 = `{"id":"w9","route":[{"lat":1,"lon":10},{"lat":2},{"lat":3}]}`
|
||||
routeNull3 = `{"id":"w9","route":[null,null,null]}`
|
||||
routeEmpty = `{"id":"w9","route":[{},{},{}]}`
|
||||
routeShort = `{"id":"w9","route":[{"lat":1,"lon":10}]}`
|
||||
withEmpty = `{"id":"w9","qty":10,"context":null}`
|
||||
noEmpty = `{"id":"w9","qty":10}`
|
||||
richer = `{"id":"w9","qty":10,"context":null,"stepCount":900}`
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"скелет не покрывает настоящую", skeleton, real, false},
|
||||
{"настоящая покрывает скелет", real, skeleton, true},
|
||||
{"ряд из null не покрывает содержательный", routeNull3, route3, false},
|
||||
{"ряд из пустых объектов не покрывает содержательный", routeEmpty, route3, false},
|
||||
{"содержательный ряд покрывает пустой той же длины", route3, routeNull3, true},
|
||||
{"усечённый ряд не покрывает полный", routeShort, route3, false},
|
||||
{"ключ с пустым значением не исчезает", noEmpty, withEmpty, false},
|
||||
{"версия с пустым ключом покрывает версию без него", withEmpty, noEmpty, true},
|
||||
{"более полная покрывает", richer, withEmpty, true},
|
||||
{"менее полная не покрывает", withEmpty, richer, false},
|
||||
{"версия покрывает саму себя", real, real, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := covers(c.a, c.b); got != c.want {
|
||||
t.Errorf("Covers = %v, ожидалось %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Запрет вырождения формы: покрывающая версия не может подменить объект или
|
||||
// массив скаляром. Обратное разрешено — объект вместо скаляра богаче формой.
|
||||
func TestCoversЗапретВырожденияФормы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"скаляр не покрывает объект", `{"hr":1}`, `{"hr":{"qty":199}}`, false},
|
||||
{"скаляр не покрывает массив", `{"hr":1}`, `{"hr":[{"qty":199}]}`, false},
|
||||
{"объект не покрывает массив", `{"hr":{"qty":1}}`, `{"hr":[{"qty":1}]}`, false},
|
||||
{"массив не покрывает объект", `{"hr":[{"qty":1}]}`, `{"hr":{"qty":1}}`, false},
|
||||
{"объект покрывает скаляр", `{"hr":{"qty":1}}`, `{"hr":1}`, true},
|
||||
{"строка покрывает число", `{"hr":"x"}`, `{"hr":1}`, true},
|
||||
{"пустой ключ формы не требует", `{"hr":0,"id":"a"}`, `{"hr":[],"id":"a"}`, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := covers(c.a, c.b); got != c.want {
|
||||
t.Errorf("Covers = %v, ожидалось %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Покрытие — частичный порядок, и на транзитивности стоит выбор победителя из
|
||||
// МНОЖЕСТВА версий: без неё «непревзойдённые» определены неоднозначно, и
|
||||
// победитель становится функцией порядка элементов на проводе.
|
||||
func TestCoversТранзитивно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
versions := []string{
|
||||
`{"id":"w","a":1}`,
|
||||
`{"id":"w","a":1,"b":null}`,
|
||||
`{"id":"w","a":1,"b":2}`,
|
||||
`{"id":"w","a":1,"b":2,"c":[1,2]}`,
|
||||
`{"id":"w","a":1,"b":2,"c":[1,2,3]}`,
|
||||
`{"id":"w","a":1,"b":2,"c":[null,null,null]}`,
|
||||
`{"id":"w","a":1,"c":3}`,
|
||||
`{"id":"w"}`,
|
||||
}
|
||||
for i, x := range versions {
|
||||
for j, y := range versions {
|
||||
if !covers(x, y) {
|
||||
continue
|
||||
}
|
||||
for k, z := range versions {
|
||||
if !covers(y, z) {
|
||||
continue
|
||||
}
|
||||
if !covers(x, z) {
|
||||
t.Errorf("нетранзитивно: %d ⊇ %d ⊇ %d, но %d не покрывает %d", i, j, k, i, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Форма и хеш обязаны быть одной функцией: сравнение по одной канонизации и
|
||||
// хеширование по другой разошлись бы молча, а хеш-детектор превратился бы в
|
||||
// перезапись недели каждым глубоким проходом.
|
||||
func TestFormAndHashСовпадаетСОтдельнымиВызовами(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raws := []string{
|
||||
`{"qty":1.50,"date":"2025-06-05 07:00:00 +0300"}`,
|
||||
`{"b":[1,2,{"z":null}],"a":"строка"}`,
|
||||
`[1,2,3]`,
|
||||
`null`,
|
||||
}
|
||||
for _, raw := range raws {
|
||||
form, hash, err := canon.FormAndHash([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("FormAndHash(%s): %v", raw, err)
|
||||
}
|
||||
wantForm, err := canon.Form([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Form(%s): %v", raw, err)
|
||||
}
|
||||
wantHash, err := canon.Hash([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Hash(%s): %v", raw, err)
|
||||
}
|
||||
if !bytes.Equal(form, wantForm) {
|
||||
t.Errorf("форма разошлась: %s против %s", form, wantForm)
|
||||
}
|
||||
if hash != wantHash {
|
||||
t.Errorf("хеш разошёлся: %s против %s", hash, wantHash)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := canon.FormAndHash([]byte(`{"qty":`)); err == nil {
|
||||
t.Error("усечённый JSON принят за корректный")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite" // тот же чистый Go-драйвер, что и у хранилища
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Прогон под удерживаемой блокировкой намеренно не входит в `task test` и
|
||||
// `task gate`: `busy_timeout` — пять секунд, повторов транзакции пять, то есть
|
||||
// один этот тест стоит около двадцати пяти секунд, а гейт гоняет тесты трижды
|
||||
// (обычно, на флаки и под детектором гонок).
|
||||
//
|
||||
// Проверяет он при этом центральное решение задачи «разнести ответ и свёртку»:
|
||||
// занятость базы — обстоятельство, а не свойство доставки, и доставка обязана
|
||||
// остаться в очереди. Ошибка здесь означает молчаливую потерю: `failed` фоновая
|
||||
// свёртка не подбирает никогда, а вернуть доставку может только пересборка с
|
||||
// остановкой сервиса и ручной подменой базы.
|
||||
var runBusy = flag.Bool("healthlog.busy", false,
|
||||
"прогнать свёртку под удерживаемой блокировкой базы (около 25 секунд)")
|
||||
|
||||
func TestBusyЗанятаяБазаОставляетДоставкуВОчереди(t *testing.T) {
|
||||
if !*runBusy {
|
||||
t.Skip("прогон под блокировкой выключен: задайте -healthlog.busy")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "healthlog.db")
|
||||
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
var logs bytes.Buffer
|
||||
f := fold.New(arch, st, 0, slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
||||
|
||||
// Второе соединение держит запись, как её держит свёртка широкой доставки:
|
||||
// измерено 11 секунд на 16 тысячах объектов, то есть окно реальное.
|
||||
holder, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(100)&_txlock=immediate")
|
||||
if err != nil {
|
||||
t.Fatalf("второе соединение: %v", err)
|
||||
}
|
||||
defer func() { _ = holder.Close() }()
|
||||
|
||||
tx, err := holder.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("удержание записи: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE delivery SET points = points WHERE id = 'd1'`); err != nil {
|
||||
t.Fatalf("удержание записи: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
_, err = f.Fold(context.Background(), "d1")
|
||||
if !errors.Is(err, store.ErrBusy) {
|
||||
t.Fatalf("ошибка свёртки = %v, ожидалась %v", err, store.ErrBusy)
|
||||
}
|
||||
|
||||
status, err := st.DeliveryStatus(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeliveryStatus: %v", err)
|
||||
}
|
||||
if status != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q: занятость базы вывела доставку из очереди",
|
||||
status, store.ParsePending)
|
||||
}
|
||||
|
||||
// Статуса мало: пока база занята, запись `failed` тоже не проходит, и
|
||||
// `pending` получился бы и без правила. Различает их лог — свёртка обязана
|
||||
// сказать «отложено», а не «отказ».
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "delivery fold deferred") {
|
||||
t.Errorf("нет записи об отложенной свёртке:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "delivery fold failed") {
|
||||
t.Errorf("занятость базы записана отказом доставки:\n%s", out)
|
||||
}
|
||||
}
|
||||
+201
-43
@@ -65,38 +65,60 @@ func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger
|
||||
const finishTimeout = 10 * time.Second
|
||||
|
||||
// Stats — итог свёртки одной доставки.
|
||||
//
|
||||
// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование
|
||||
// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия
|
||||
// сущности) принято решение не объединять поля — забытая строка присваивания
|
||||
// отменила бы наблюдение при зелёных тестах хранилища.
|
||||
type Stats struct {
|
||||
store.MergeStats
|
||||
|
||||
Metrics int
|
||||
Points int
|
||||
Stored int
|
||||
Buckets int
|
||||
Unchanged int
|
||||
Overwrites int
|
||||
Incomparable int
|
||||
SealedHits int
|
||||
UnitsConflicts int
|
||||
SkippedNoTime int
|
||||
SkippedMalformed int
|
||||
SkippedBadEnd int
|
||||
Layer string
|
||||
LayerMismatch bool
|
||||
Collisions []store.Collision
|
||||
IncomparableAt []store.Collision
|
||||
// Счётчики пропуска сущностей: у каждого класса свой, потому что тело в
|
||||
// архиве остаётся, а вернуть сущность может только пересборка.
|
||||
SkippedNoID int
|
||||
SkippedEntityNoTime int
|
||||
SkippedEntityMalformed int
|
||||
Layer string
|
||||
LayerMismatch bool
|
||||
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
|
||||
// Это ответ на вопрос «что останется потерянным, если тело удалить»:
|
||||
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
|
||||
// Это ответ на вопрос «что останется потерянным, если тело удалить».
|
||||
Uncovered []string
|
||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||
UncoveredDropped int
|
||||
}
|
||||
|
||||
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
|
||||
// пересборка вернёт её, когда дефект будет исправлен.
|
||||
var ErrPanicked = errors.New("свёртка паниковала")
|
||||
|
||||
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
|
||||
//
|
||||
// Это единственный логирующий чекпоинт свёртки: транспорт и приём исход
|
||||
// разбора не логируют. Значения точек и имена устройств в лог не попадают —
|
||||
// данные о здоровье чувствительнее токенов.
|
||||
func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
var stats Stats
|
||||
//
|
||||
// Паника перехватывается ЗДЕСЬ, у той же границы, что пишет исход разбора.
|
||||
// Пока свёртка шла внутри HTTP-обработчика, панику ловил middleware.Recoverer и
|
||||
// она стоила одного ответа; из фоновой горутины она валит процесс целиком, а
|
||||
// `restart: unless-stopped` поднимает его снова — и первый же проход берёт ту
|
||||
// же доставку, то есть дефект превращается в цикл перезапуска, при котором
|
||||
// приём не работает вовсе. Перехват у этой границы, а не у вызывающего,
|
||||
// оставляет писателя `parse_status` единственным.
|
||||
func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err error) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
stats = Stats{}
|
||||
err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления
|
||||
s.fail(ctx, deliveryID, err, parseResidue{})
|
||||
}()
|
||||
|
||||
d, err := s.store.DeliveryForParse(ctx, deliveryID)
|
||||
if err != nil {
|
||||
@@ -109,7 +131,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
|
||||
body, err := s.readBody(d.RawPath)
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err, nil)
|
||||
s.fail(ctx, deliveryID, err, parseResidue{})
|
||||
return stats, err
|
||||
}
|
||||
|
||||
@@ -127,7 +149,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
// невосстановимая секция.
|
||||
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
||||
//
|
||||
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
|
||||
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
|
||||
// записать этот ноль значило бы объявить доставку проверенной.
|
||||
s.fail(ctx, deliveryID, err, residueOf(parsed))
|
||||
return stats, err
|
||||
}
|
||||
|
||||
@@ -138,24 +164,26 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
stats.SkippedNoTime = parsed.SkippedNoTime
|
||||
stats.SkippedMalformed = parsed.SkippedMalformed
|
||||
stats.SkippedBadEnd = parsed.SkippedBadEnd
|
||||
stats.SkippedNoID = parsed.SkippedNoID
|
||||
stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime
|
||||
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
|
||||
stats.Layer = string(parsed.Layer)
|
||||
stats.LayerMismatch = parsed.LayerMismatch
|
||||
|
||||
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
|
||||
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
|
||||
ID: d.ID,
|
||||
ReceivedAt: d.ReceivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
||||
// Здесь разбор досчитал: отказало слияние. Значит счётчик пропусков
|
||||
// измерен и обязан дойти до учёта — в отличие от ветки выше.
|
||||
s.fail(ctx, deliveryID, err, parseResidue{
|
||||
uncovered: parsed.Uncovered,
|
||||
skipped: skippedEntities(parsed),
|
||||
})
|
||||
return stats, err
|
||||
}
|
||||
|
||||
stats.Stored = merge.Stored
|
||||
stats.Buckets = merge.Buckets
|
||||
stats.Unchanged = merge.Unchanged
|
||||
stats.Overwrites = merge.Overwrites
|
||||
stats.Incomparable = merge.Incomparable
|
||||
stats.SealedHits = merge.SealedHits
|
||||
stats.UnitsConflicts = merge.UnitsConflicts
|
||||
stats.Collisions = merge.Collisions
|
||||
stats.IncomparableAt = merge.IncomparableAt
|
||||
stats.MergeStats = merge
|
||||
|
||||
// Источник истины — список; статус производен от него и от факта отказа.
|
||||
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
|
||||
@@ -165,10 +193,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
status = store.ParsePartial
|
||||
}
|
||||
out := store.ParseOutcome{
|
||||
Status: status,
|
||||
Points: int64(stats.Points),
|
||||
Layer: stats.Layer,
|
||||
Uncovered: parsed.Uncovered,
|
||||
Status: status,
|
||||
Points: int64(stats.Points),
|
||||
Layer: stats.Layer,
|
||||
Uncovered: parsed.Uncovered,
|
||||
SkippedEntities: skippedEntities(parsed),
|
||||
}
|
||||
if err := s.finish(ctx, deliveryID, out); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
|
||||
@@ -192,6 +221,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
// значения.
|
||||
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
|
||||
skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed
|
||||
|
||||
attrs := []any{
|
||||
"delivery_id", deliveryID,
|
||||
@@ -208,6 +238,17 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
"skipped_no_time", st.SkippedNoTime,
|
||||
"skipped_malformed", st.SkippedMalformed,
|
||||
"skipped_bad_end", st.SkippedBadEnd,
|
||||
// Сущности с собственным `id`: пришло, легло и удержано. Координаты
|
||||
// (род и идентификатор) разрешены, содержимое — нет: маршрут
|
||||
// тренировки это геотрек до дома, а метки состояния разума —
|
||||
// измерение душевного состояния.
|
||||
"workouts", st.Workouts,
|
||||
"workouts_written", st.WorkoutsWritten,
|
||||
"records", st.Records,
|
||||
"records_written", st.RecordsWritten,
|
||||
"entities_held", st.EntitiesHeld,
|
||||
"entities_diverging", st.EntitiesDiverging,
|
||||
"skipped_entities", skippedEntities,
|
||||
"layer", st.Layer,
|
||||
"layer_mismatch", st.LayerMismatch,
|
||||
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
|
||||
@@ -222,13 +263,37 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
if len(st.IncomparableAt) > 0 {
|
||||
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
|
||||
}
|
||||
if len(st.HeldAt) > 0 {
|
||||
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
|
||||
}
|
||||
if len(st.DivergingAt) > 0 {
|
||||
attrs = append(attrs, "diverging_at", formatEntityRefs(st.DivergingAt))
|
||||
}
|
||||
|
||||
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
||||
// штатная работа. Без этого условия смена формата метки выглядела бы как
|
||||
// здоровый поток: 200, parsed, INFO, points=0.
|
||||
allSkipped := st.Points == 0 && skipped > 0
|
||||
// То же для сущностей: доставка из одних тренировок, у которой не осталось
|
||||
// ни одной, — это сменившийся формат, а не пустая секция.
|
||||
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
|
||||
|
||||
switch {
|
||||
case st.EntitiesHeld > 0:
|
||||
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
|
||||
// за отказ объединять поля: событие обязано быть видно, потому что на
|
||||
// живом потоке оно не наступало ни разу и правило держится на этом.
|
||||
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
|
||||
case st.EntitiesDiverging > 0:
|
||||
// Событие другого рода и с другим лечением: в одном теле приехали
|
||||
// версии одного ключа с разным содержанием. Победитель лёг в витрину
|
||||
// целиком, терять нечего — но корпус такого не производил, и молчать
|
||||
// об этом нельзя. Отдельной ветвью, а не общей с удержанием: сообщение
|
||||
// «удержана обеднённая версия» отправляло бы владельца искать то, чего
|
||||
// не случилось.
|
||||
s.log.WarnContext(ctx, "delivery folded, entity versions diverge in one body", attrs...)
|
||||
case allEntitiesSkipped:
|
||||
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
|
||||
case st.UncoveredDropped > 0:
|
||||
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
|
||||
// а границу выбило больше тридцати двух.
|
||||
@@ -268,6 +333,16 @@ func formatCollisions(cs []store.Collision) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// formatEntityRefs превращает координаты сущностей в строку для лога.
|
||||
// Содержимого не несёт: род и идентификатор — координаты, а не измерение.
|
||||
func formatEntityRefs(refs []store.EntityRef) string {
|
||||
parts := make([]string, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
parts = append(parts, r.Kind+"/"+r.ID)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// keepLayer — значение слоя, означающее «оставить как было».
|
||||
const keepLayer = ""
|
||||
|
||||
@@ -282,9 +357,56 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse
|
||||
return nil
|
||||
}
|
||||
|
||||
// fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт
|
||||
// пересборка — приём при этом не затрагивается: сохранили значит приняли.
|
||||
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
|
||||
// fail записывает исход неудачной свёртки.
|
||||
//
|
||||
// Исход отражает ДОСТАВКУ, а не обстоятельства. Отмена снаружи и занятость базы
|
||||
// работой доставки не являются: они означают «не сделано», а не «не выходит».
|
||||
// Статус в этих случаях не трогается вовсе — доставка остаётся `pending` и
|
||||
// подбирается следующим проходом. Иначе конкуренция за базу (после разнесения
|
||||
// ответа и свёртки она штатная) выводила бы доставку из очереди навсегда:
|
||||
// `failed` возвращает только пересборка, то есть ручная операция с остановкой
|
||||
// сервиса.
|
||||
//
|
||||
// Всё прочее — непонятое содержимое, невыводимый слой, нечитаемое или слишком
|
||||
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
|
||||
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не
|
||||
// затрагивается: сохранили значит приняли.
|
||||
// parseResidue — то, что разбор успел узнать о доставке до отказа и что обязано
|
||||
// пережить его в учёте: список непокрытых секций и число пропущенных сущностей.
|
||||
//
|
||||
// Структурой, а не двумя параметрами: у `fail` их стало бы четыре, и следующий
|
||||
// счётчик неизбежно перепутали бы местами с предыдущим. Пустое значение —
|
||||
// «разбор до этого не дошёл», и оно честно: отказ на чтении тела ничего о
|
||||
// содержимом не знает.
|
||||
type parseResidue struct {
|
||||
uncovered []string
|
||||
// skipped — nil означает «разбор до конца не дошёл, пропусков никто не
|
||||
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
|
||||
// ретеншен принимает необратимое решение об удалении тела.
|
||||
skipped *int64
|
||||
}
|
||||
|
||||
func residueOf(parsed hae.Result) parseResidue {
|
||||
return parseResidue{uncovered: parsed.Uncovered}
|
||||
}
|
||||
|
||||
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Сумма трёх классов, а не три колонки: ретеншен спрашивает «есть ли что
|
||||
// терять», а не «почему», а разбор класса живёт в логе свёртки, где все три
|
||||
// счётчика идут атрибутами.
|
||||
func skippedEntities(parsed hae.Result) *int64 {
|
||||
n := int64(parsed.SkippedNoID + parsed.SkippedEntityNoTime + parsed.SkippedEntityMalformed)
|
||||
return &n
|
||||
}
|
||||
|
||||
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, residue parseResidue) {
|
||||
if store.Transient(cause) {
|
||||
// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
|
||||
// повтор не выглядел беспричинным.
|
||||
s.log.WarnContext(ctx, "delivery fold deferred", "error", cause, "delivery_id", deliveryID)
|
||||
return
|
||||
}
|
||||
|
||||
level := slog.LevelError
|
||||
switch {
|
||||
case errors.Is(cause, hae.ErrLayerUnknown):
|
||||
@@ -303,15 +425,27 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
|
||||
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
|
||||
// результат пересборки журнала.
|
||||
out := store.ParseOutcome{
|
||||
Status: store.ParseFailed,
|
||||
Layer: keepLayer,
|
||||
Uncovered: uncovered,
|
||||
Status: store.ParseFailed,
|
||||
Layer: keepLayer,
|
||||
Uncovered: residue.uncovered,
|
||||
SkippedEntities: residue.skipped,
|
||||
}
|
||||
if err := s.finish(ctx, deliveryID, out); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -329,10 +463,10 @@ func (s *Service) readBody(rawPath string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func toIncoming(points []hae.Point) []store.IncomingPoint {
|
||||
out := make([]store.IncomingPoint, 0, len(points))
|
||||
for _, p := range points {
|
||||
out = append(out, store.IncomingPoint{
|
||||
func toIncoming(parsed hae.Result) store.Incoming {
|
||||
points := make([]store.IncomingPoint, 0, len(parsed.Points))
|
||||
for _, p := range parsed.Points {
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: p.Metric,
|
||||
Layer: string(p.Layer),
|
||||
Units: p.Units,
|
||||
@@ -344,5 +478,29 @@ func toIncoming(points []hae.Point) []store.IncomingPoint {
|
||||
},
|
||||
})
|
||||
}
|
||||
return store.Incoming{
|
||||
Points: points,
|
||||
Workouts: toEntities(parsed.Workouts),
|
||||
Records: toEntities(parsed.Records),
|
||||
}
|
||||
}
|
||||
|
||||
func toEntities(in []hae.Entity) []store.IncomingEntity {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]store.IncomingEntity, 0, len(in))
|
||||
for _, e := range in {
|
||||
out = append(out, store.IncomingEntity{
|
||||
ID: e.ID,
|
||||
Kind: e.Kind,
|
||||
Name: e.Name,
|
||||
Start: e.Start,
|
||||
End: e.End,
|
||||
OffsetSeconds: e.OffsetSeconds,
|
||||
Duration: e.Duration,
|
||||
Raw: e.Raw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+192
-2
@@ -257,8 +257,8 @@ func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T)
|
||||
if d.ParseStatus != store.ParsePartial {
|
||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
|
||||
}
|
||||
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
|
||||
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
|
||||
if !strings.Contains(d.UncoveredSections, "ecg") {
|
||||
t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections)
|
||||
}
|
||||
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
|
||||
if stats.Points == 0 {
|
||||
@@ -324,3 +324,193 @@ func TestFoldПересвёрткаОчищаетСписок(t *testing.T) {
|
||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена снаружи не превращается в свойство доставки: работа не сделана, но
|
||||
// доставка остаётся в очереди и будет свёрнута снова. Иначе остановка сервиса в
|
||||
// неудачный момент выводила бы доставку из очереди навсегда — `failed` фоновая
|
||||
// свёртка не подбирает никогда, и вернуть её могла бы только пересборка с
|
||||
// остановкой сервиса и ручной подменой базы.
|
||||
func TestFoldОтменаОставляетДоставкуВОчереди(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := f.Fold(ctx, "d1"); err == nil {
|
||||
t.Fatal("свёртка на отменённом контексте прошла успешно")
|
||||
}
|
||||
|
||||
status, err := st.DeliveryStatus(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeliveryStatus: %v", err)
|
||||
}
|
||||
if status != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q", status, store.ParsePending)
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CountBuckets: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("объектов %d: прерванная свёртка оставила половину", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Непонятое содержимое, наоборот, свойство самой доставки: повторять её
|
||||
// бесполезно, и она выводится из очереди.
|
||||
func TestFoldНепонятоеСодержимоеВыводитИзОчереди(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Метрика есть, но слой определить нечем: плотных метрик нет, заголовок
|
||||
// ничего не означает, наследовать не от чего.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` +
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`)
|
||||
deliver(t, arch, st, "d1", "Default", "auto-1", body)
|
||||
|
||||
if _, err := f.Fold(ctx, "d1"); err == nil {
|
||||
t.Fatal("свёртка непонятого содержимого прошла успешно")
|
||||
}
|
||||
|
||||
status, err := st.DeliveryStatus(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeliveryStatus: %v", err)
|
||||
}
|
||||
if status != store.ParseFailed {
|
||||
t.Errorf("parse_status = %q, ожидался %q", status, store.ParseFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Пропущенная сущность обязана быть видна в БАЗЕ, а не только в логе. Ретеншен
|
||||
// сырого архива решает «что потеряется, если тело удалить», по учётной записи,
|
||||
// и до этого счётчика получал ответ «терять нечего» ровно там, где потеряна
|
||||
// тренировка с маршрутом: сущность в витрину не попала, список непокрытых
|
||||
// секций пуст, статус `parsed`.
|
||||
func TestFoldПропускиСущностейВидныВУчёте(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
stats, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
skipped := stats.SkippedNoID + stats.SkippedEntityNoTime + stats.SkippedEntityMalformed
|
||||
if skipped == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.SkippedEntities == nil {
|
||||
t.Fatal("счётчик пропусков пуст: доставка выглядит как «не измерялась»")
|
||||
}
|
||||
if *d.SkippedEntities != int64(skipped) {
|
||||
t.Errorf("в базе %d пропусков, разбор дал %d", *d.SkippedEntities, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// Число замещает прежнее значение целиком, включая замещение нулём: доставка,
|
||||
// пропуски которой исчезли вместе с поумневшим разбором, не должна остаться
|
||||
// помеченной навсегда.
|
||||
func TestFoldПересвёрткаБезПропусковОбнуляетСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
before, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if before.SkippedEntities == nil || *before.SkippedEntities == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
// Тело подменяется на такое же, но без кривых элементов: ровно то, что
|
||||
// произойдёт при пересвёртке поумневшим разбором.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "workout_indoor.json"))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("повторная свёртка: %v", err)
|
||||
}
|
||||
after, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if after.SkippedEntities == nil || *after.SkippedEntities != 0 {
|
||||
t.Errorf("счётчик %v, ожидался ноль", after.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка, свёрнутая разбором, который пропусков не считал, обязана быть
|
||||
// отличима от доставки с нулём: подстановка нуля объявила бы её проверенной, и
|
||||
// ретеншен получил бы ложное «терять нечего» с видом измерения.
|
||||
func TestFoldДоНачалаУчётаПропускиНеИзмерены(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json"))
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.SkippedEntities != nil {
|
||||
t.Errorf("несвёрнутая доставка отдаёт %d вместо «не измерялось»", *d.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ, при котором разбор не досчитал, обязан оставить счётчик НЕТРОНУТЫМ.
|
||||
// Ноль здесь означал бы «проверено, терять нечего» — то самое ложное измерение,
|
||||
// ради отказа от которого колонка заведена без умолчания. Тело при этом может
|
||||
// нести сотни тренировок, ни одна из которых не сохранена.
|
||||
func TestFoldОтказРазбораНеПодделываетСчётчикПропусков(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Сперва успешная свёртка: счётчик измерен и ненулевой.
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
before, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if before.SkippedEntities == nil || *before.SkippedEntities == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
// Теперь тело, которое разбор не понимает: секция есть, но конверт оборван.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", []byte(`{"data":{"workouts":[`))
|
||||
if _, err := f.Fold(ctx, "d2"); err == nil {
|
||||
t.Fatal("разбор оборванного тела не отказал")
|
||||
}
|
||||
after, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if after.ID != "d2" {
|
||||
t.Fatalf("прочитана доставка %q, ожидалась d2", after.ID)
|
||||
}
|
||||
if after.SkippedEntities != nil {
|
||||
t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
+130
-5
@@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
{"date":"2025-06-05 10:07:00 +0300","qty":8},
|
||||
{"date":"2025-06-05 10:08:00 +0300","qty":9},
|
||||
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
|
||||
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
|
||||
"ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
@@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "stateOfMind") {
|
||||
if !strings.Contains(out, "ecg") {
|
||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||
}
|
||||
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
|
||||
if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
|
||||
t.Error("содержимое непокрытой секции утекло в лог")
|
||||
}
|
||||
|
||||
@@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
if rec.Level != "INFO" {
|
||||
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
|
||||
}
|
||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
|
||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
|
||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
|
||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
|
||||
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
|
||||
// Разрешены только координаты: род, идентификатор, интервал.
|
||||
func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
f := fold.New(arch, st, 0, log)
|
||||
ctx := context.Background()
|
||||
|
||||
const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||
`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
|
||||
`"totalEnergy":{"qty":404040.4}}],` +
|
||||
`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
|
||||
`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`
|
||||
|
||||
// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
|
||||
// пишется WARN об удержанной версии и где велик соблазн приписать «что
|
||||
// именно потерялось».
|
||||
const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||
`"totalEnergy":{"qty":505050.5}}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка первой доставки: %v", err)
|
||||
}
|
||||
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("свёртка второй доставки: %v", err)
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
|
||||
}
|
||||
}
|
||||
// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
|
||||
// говорит, какая сущность пострадала.
|
||||
if !strings.Contains(logged, "w-открытый") {
|
||||
t.Error("координат удержанной сущности в логе нет")
|
||||
}
|
||||
if !strings.Contains(logged, "poorer entity version held") {
|
||||
t.Error("удержание обеднённой версии не отмечено записью WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
|
||||
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
|
||||
// слияния встроены структурой. Тест закрепляет имена атрибутов.
|
||||
func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
f := fold.New(arch, st, 0, log)
|
||||
|
||||
// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
|
||||
// элемент, не являющийся объектом: три класса пропуска плюс успех.
|
||||
const body = `{"data":{"workouts":[
|
||||
{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
|
||||
{"start":"2025-06-05 11:00:00 +0300"},
|
||||
{"id":"w3","start":"позавчера"},
|
||||
"строка вместо объекта"]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
Workouts int `json:"workouts"`
|
||||
WorkoutsWritten int `json:"workouts_written"`
|
||||
Records int `json:"records"`
|
||||
RecordsWritten int `json:"records_written"`
|
||||
EntitiesHeld int `json:"entities_held"`
|
||||
SkippedEntities int `json:"skipped_entities"`
|
||||
}
|
||||
line := strings.TrimSpace(buf.String())
|
||||
if i := strings.LastIndex(line, "\n"); i >= 0 {
|
||||
line = line[i+1:]
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("запись лога не разбирается: %v", err)
|
||||
}
|
||||
|
||||
if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
|
||||
t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
|
||||
}
|
||||
if rec.SkippedEntities != 3 {
|
||||
t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
|
||||
}
|
||||
if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
|
||||
t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
|
||||
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package hae
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxEntityID — предел длины идентификатора сущности.
|
||||
//
|
||||
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
|
||||
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
|
||||
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
|
||||
// оно снимает класс, а не случай.
|
||||
const maxEntityID = 128
|
||||
|
||||
// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
|
||||
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
|
||||
const rfc3339Layout = time.RFC3339
|
||||
|
||||
// softString — строка заголовка, которая переживает значение не того типа.
|
||||
//
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
|
||||
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
|
||||
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
|
||||
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
|
||||
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
|
||||
//
|
||||
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
|
||||
// encoding/json при несовпадении типа «skips that field and completes the
|
||||
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
|
||||
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
|
||||
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
|
||||
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
|
||||
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
|
||||
// ключей на проводе, а он у HAE нестабилен.
|
||||
//
|
||||
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
|
||||
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
|
||||
// не в «нет id».
|
||||
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
|
||||
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
|
||||
// существенно: см. фолбэк `start → date` ниже.
|
||||
//
|
||||
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
|
||||
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
|
||||
// тренировку на момент времени из другого поля — молча и без счётчика.
|
||||
type softString struct {
|
||||
value string
|
||||
// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
|
||||
// присутствующий ключ, поэтому признак взводится безусловно.
|
||||
present bool
|
||||
}
|
||||
|
||||
func (s *softString) UnmarshalJSON(raw []byte) error {
|
||||
// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
|
||||
// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
|
||||
// семантикой «побеждает последнее» — так работает соседний Duration и весь
|
||||
// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
|
||||
// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
|
||||
// тренировку с маршрутом при валидной последней метке.
|
||||
*s = softString{present: true}
|
||||
|
||||
var v string
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
// Значение не строка — поле считается непрочитанным. Ошибку глушим
|
||||
// сознательно: это и есть мягкость, ради которой тип заведён.
|
||||
return nil
|
||||
}
|
||||
s.value = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
|
||||
// хранится дословно.
|
||||
type entityHead struct {
|
||||
ID softString `json:"id"`
|
||||
Name softString `json:"name"`
|
||||
Date softString `json:"date"`
|
||||
Start softString `json:"start"`
|
||||
End softString `json:"end"`
|
||||
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
}
|
||||
|
||||
// decodeEntities разбирает элементы одной покрытой секции в сущности.
|
||||
//
|
||||
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
|
||||
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
|
||||
// научится понимать пропущенное.
|
||||
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
if len(raws) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]Entity, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
|
||||
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
|
||||
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
|
||||
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
|
||||
if !isJSONObject(raw) {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
var head entityHead
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
// Идентификатор исключение из мягкости: без строкового `id` сущность не
|
||||
// адресуема, а приведение чужого нестрокового значения к строке было бы
|
||||
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
|
||||
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
|
||||
id := head.ID.value
|
||||
if id == "" || len(id) > maxEntityID {
|
||||
res.SkippedNoID++
|
||||
continue
|
||||
}
|
||||
|
||||
// Фолбэк `start → date` существует для сущностей, у которых ключа
|
||||
// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
|
||||
// (число, объект, `null`), фолбэк не срабатывает: композиция двух
|
||||
// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
|
||||
// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
|
||||
// меткой.
|
||||
if head.Start.present && head.Start.value == "" {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
}
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
|
||||
if !ok {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
}
|
||||
|
||||
// Конец, которого нет или который не читается, равен началу. У точки то
|
||||
// же вырождение запрещено — там оно схлопнуло бы две записи в одну
|
||||
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
|
||||
// Истина при этом остаётся в Raw дословно.
|
||||
end := start
|
||||
if head.End.value != "" {
|
||||
if e, ok := parseEntityTime(head.End.value); ok {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
|
||||
_, offset := start.Zone()
|
||||
e := Entity{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Name: head.Name.value,
|
||||
Start: start.UTC(),
|
||||
End: end.UTC(),
|
||||
OffsetSeconds: offset,
|
||||
Duration: parseDuration(head.Duration),
|
||||
Raw: raw,
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
|
||||
//
|
||||
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
|
||||
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
|
||||
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
|
||||
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
|
||||
// такого выравнивания.
|
||||
//
|
||||
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
|
||||
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
|
||||
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
|
||||
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
|
||||
func parseEntityTime(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if t, err := time.Parse(timeLayout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(rfc3339Layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseDuration переводит длительность в секунды.
|
||||
//
|
||||
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
|
||||
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
|
||||
// «источник не прислал» от «измерено ноль». Вычислять длительность из
|
||||
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
|
||||
func parseDuration(raw json.RawMessage) *float64 {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
if len(lit) == 0 {
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(string(lit), 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// isJSONObject говорит, является ли значение объектом JSON, по первому байту
|
||||
// литерала — так же, как это делает сканер encoding/json.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
return len(lit) > 0 && lit[0] == '{'
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
func parseFixture(t *testing.T, name string) hae.Result {
|
||||
t.Helper()
|
||||
|
||||
res, err := hae.Parse(load(t, name), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор %s: %v", name, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Тренировка хранится дословно: маршрут и внутренние ряды остаются теми же
|
||||
// байтами, какими пришли. Раскладывать их по колонкам значило бы решить за
|
||||
// Apple, что в тренировке главное.
|
||||
func TestParseТренировкаСМаршрутом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_route.json")
|
||||
|
||||
var w hae.Entity
|
||||
for _, e := range res.Workouts {
|
||||
if strings.Contains(string(e.Raw), `"route"`) {
|
||||
w = e
|
||||
}
|
||||
}
|
||||
if w.ID == "" {
|
||||
t.Fatal("уличной тренировки с маршрутом в фикстуре не нашлось")
|
||||
}
|
||||
if w.Name == "" {
|
||||
t.Error("имя пусто — по нему идёт выборка заголовков")
|
||||
}
|
||||
if !w.End.After(w.Start) {
|
||||
t.Errorf("интервал %v — %v", w.Start, w.End)
|
||||
}
|
||||
if w.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||
}
|
||||
if w.Duration == nil || *w.Duration <= 0 {
|
||||
t.Errorf("длительность %v — обязана браться из тела", w.Duration)
|
||||
}
|
||||
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Raw, &body); err != nil {
|
||||
t.Fatalf("содержимое не разбирается: %v", err)
|
||||
}
|
||||
for _, key := range []string{"route", "heartRateData", "activeEnergy", "heartRateRecovery"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("в содержимом нет %q — внутренние ряды обязаны храниться дословно", key)
|
||||
}
|
||||
}
|
||||
// Точки маршрута проходят исходными байтами: их метка (`timestamp`)
|
||||
// меткой сущности не является и не разбирается.
|
||||
if !strings.Contains(string(body["route"]), "timestamp") {
|
||||
t.Error("точки маршрута потеряли своё поле времени")
|
||||
}
|
||||
}
|
||||
|
||||
// Пульс приезжает дважды — в общем потоке метрик и внутри тренировки. Это
|
||||
// разные таблицы; смешение задвоило бы ряд.
|
||||
func TestParseРядПульсаТренировкиНеСтановитсяМетрикой(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_route.json")
|
||||
|
||||
if len(res.Points) != 0 {
|
||||
t.Errorf("точек %d, ожидалось 0: доставка несёт только тренировки", len(res.Points))
|
||||
}
|
||||
if res.Metrics != 0 {
|
||||
t.Errorf("метрик %d, ожидалось 0", res.Metrics)
|
||||
}
|
||||
if len(res.Uncovered) != 0 {
|
||||
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Набор полей тренировки зависит от её типа: у домашней нет маршрута, зато
|
||||
// есть температура и влажность. Фиксированной схемы не существует.
|
||||
func TestParseТренировкаБезМаршрута(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_indoor.json")
|
||||
|
||||
if len(res.Workouts) == 0 {
|
||||
t.Fatal("тренировок нет")
|
||||
}
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(res.Workouts[0].Raw, &body); err != nil {
|
||||
t.Fatalf("содержимое не разбирается: %v", err)
|
||||
}
|
||||
if _, ok := body["route"]; ok {
|
||||
t.Error("у домашней тренировки взялся маршрут")
|
||||
}
|
||||
for _, key := range []string{"temperature", "humidity", "intensity"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("в содержимом нет %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stateOfMind живёт по другим соглашениям: RFC 3339 в UTC, коды HealthKit
|
||||
// вместо переводов, поля source нет вовсе.
|
||||
func TestParseСостояниеРазума(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "state_of_mind.json")
|
||||
|
||||
if len(res.Records) == 0 {
|
||||
t.Fatal("записей нет")
|
||||
}
|
||||
for _, r := range res.Records {
|
||||
if r.Kind != "stateOfMind" {
|
||||
t.Errorf("род %q, ожидался stateOfMind — имя секции хранится дословно", r.Kind)
|
||||
}
|
||||
if r.ID == "" {
|
||||
t.Error("идентификатор пуст")
|
||||
}
|
||||
// HAE прислал UTC — офсет ноль. Это значит «источник прислал UTC», а не
|
||||
// «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе.
|
||||
if r.OffsetSeconds != 0 {
|
||||
t.Errorf("офсет %d, ожидался 0", r.OffsetSeconds)
|
||||
}
|
||||
if r.Start.IsZero() {
|
||||
t.Error("метка не разобралась — RFC 3339 обязан приниматься")
|
||||
}
|
||||
}
|
||||
if len(res.Uncovered) != 0 {
|
||||
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Пропуск одного элемента не уносит соседей, и у каждого класса свой счётчик:
|
||||
// тело остаётся в архиве, а вернуть сущность может только пересборка.
|
||||
func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "handmade_entities.json")
|
||||
|
||||
byID := make(map[string]hae.Entity, len(res.Workouts))
|
||||
for _, w := range res.Workouts {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
|
||||
// счётчик на четыре случая: исход у них общий, сущность не адресуема.
|
||||
if res.SkippedNoID != 4 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
|
||||
}
|
||||
// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
|
||||
if res.SkippedEntityNoTime != 3 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
|
||||
}
|
||||
// Элемент, не являющийся объектом.
|
||||
if res.SkippedEntityMalformed != 1 {
|
||||
t.Errorf("пропущено по форме %d, ожидалось 1", res.SkippedEntityMalformed)
|
||||
}
|
||||
|
||||
t.Run("нечитаемый конец не отбрасывает тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-000000000003"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с нечитаемым концом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), "никогда") {
|
||||
t.Error("исходное значение конца не сохранилось дословно")
|
||||
}
|
||||
})
|
||||
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
|
||||
// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
|
||||
// этом числится разобранной.
|
||||
t.Run("имя числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000c"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с именем-числом потерялась целиком")
|
||||
}
|
||||
if w.Name != "" {
|
||||
t.Errorf("имя %q, ожидалось пустое", w.Name)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), `"lat"`) {
|
||||
t.Error("маршрут не сохранился дословно")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("конец числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000d"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с концом-числом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
})
|
||||
|
||||
// Фолбэк `start → date` существует для сущностей, у которых `start` не
|
||||
// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
|
||||
// другого поля дала бы метку другого момента времени, неотличимую от
|
||||
// настоящей и ничем не считаемую.
|
||||
t.Run("начало числом не подменяется полем date", func(t *testing.T) {
|
||||
if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
|
||||
t.Error("нестроковое начало молча заменено меткой из date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
|
||||
for id := range byID {
|
||||
if id == "42" {
|
||||
t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000004"]
|
||||
if w.Duration != nil {
|
||||
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("отсутствующая длительность отличима от нуля", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000005"]
|
||||
if w.Duration != nil {
|
||||
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("начало берётся из date, когда start отсутствует", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-000000000006"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с меткой в date потерялась")
|
||||
}
|
||||
if w.Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("незнакомое поле переживает разбор дословно", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000009"]
|
||||
for _, lit := range []string{"невиданноеПоле", "1.0", "9007199254740993", "0.123456789012345678"} {
|
||||
if !strings.Contains(string(w.Raw), lit) {
|
||||
t.Errorf("литерал %q потерян при разборе", lit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("запись со временем в формате метрик тоже разбирается", func(t *testing.T) {
|
||||
var daily *hae.Entity
|
||||
for i, r := range res.Records {
|
||||
if strings.Contains(string(r.Raw), "daily_mood") {
|
||||
daily = &res.Records[i]
|
||||
}
|
||||
}
|
||||
if daily == nil {
|
||||
t.Fatal("запись daily_mood потерялась")
|
||||
}
|
||||
if daily.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800: формат метрик обязан приниматься", daily.OffsetSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Отказ разбора — операция «всё или ничего»: ошибка после уже разобранной
|
||||
// секции не имеет права оставить сущности в результате.
|
||||
func TestParseОбрывПослеСекцииТренировокНеОтдаётСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],"metrics":`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("оборванное тело разобралось без ошибки")
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("сущностей %d, ожидалось 0", len(res.Workouts))
|
||||
}
|
||||
}
|
||||
|
||||
// Невыводимый слой — тоже «всё или ничего»: доставка целиком уходит в failed,
|
||||
// иначе она получила бы failed при частично записанной витрине.
|
||||
func TestParseНевыводимыйСлойНеОтдаётСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{
|
||||
"metrics":[{"name":"vo2_max","units":"ml/kg*min","data":[{"date":"2025-06-05 10:11:12 +0300","qty":1}]}],
|
||||
"stateOfMind":[{"id":"e1","start":"2025-06-05T18:00:00Z","valence":0.5}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||||
if err == nil {
|
||||
t.Fatal("доставка без выводимого слоя разобралась без ошибки")
|
||||
}
|
||||
if len(res.Records) != 0 {
|
||||
t.Errorf("записей %d, ожидалось 0", len(res.Records))
|
||||
}
|
||||
if len(res.Points) != 0 {
|
||||
t.Errorf("точек %d, ожидалось 0", len(res.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа покрытой секции объединяет её, а не отдаёт победу последней.
|
||||
func TestParseПовторСекцииТренировокОбъединяет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{
|
||||
"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],
|
||||
"workouts":[{"id":"w2","start":"2025-06-05 11:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 2 {
|
||||
t.Errorf("тренировок %d, ожидалось 2 — секции обязаны объединиться", len(res.Workouts))
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор самого члена `data` накапливает результаты: присваивание теряло бы
|
||||
// секции первого члена молча — их имена уже отмечены и во второй список не
|
||||
// попали бы.
|
||||
func TestParseПовторЧленаDataНакапливает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"ecg":[{"id":"e"}]},"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Errorf("тренировок %d, ожидалась 1", len(res.Workouts))
|
||||
}
|
||||
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
|
||||
t.Errorf("непокрытые %v, ожидался [ecg] — иначе секция потеряна молча", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Непокрытой секцией с собственными `id` остаются те, чьей формы никто не
|
||||
// видел: разбор вслепую хуже честного «не покрыто».
|
||||
func TestParseНепокрытыеСекцииССобственнымиID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"ecg":[{"id":"e1","start":"2025-06-05 10:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Records) != 0 {
|
||||
t.Errorf("записей %d, ожидалось 0", len(res.Records))
|
||||
}
|
||||
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
|
||||
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
|
||||
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
|
||||
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.SkippedEntityMalformed != 4 {
|
||||
t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
|
||||
}
|
||||
if res.SkippedNoID != 0 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
|
||||
res.SkippedNoID)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
|
||||
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
|
||||
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
|
||||
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
|
||||
}
|
||||
if res.Workouts[0].Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
}
|
||||
|
||||
// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
|
||||
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
|
||||
// от настоящей и ничем не считаемую.
|
||||
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
|
||||
`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
|
||||
}
|
||||
if res.SkippedEntityNoTime != 1 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
|
||||
}
|
||||
}
|
||||
+240
-47
@@ -95,12 +95,66 @@ type Point struct {
|
||||
local time.Time
|
||||
}
|
||||
|
||||
// Entity — сущность с собственным идентификатором: тренировка или запись
|
||||
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
|
||||
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
|
||||
// в интерфейсе HAE не бывает.
|
||||
type Entity struct {
|
||||
// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
|
||||
// уезжает и в первичный ключ, и в записи лога.
|
||||
ID string
|
||||
// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||||
// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||||
// именам секций, а переименование после того, как значение легло в базу,
|
||||
// стоило бы миграции данных. У тренировки род один и в ключ не входит.
|
||||
Kind string
|
||||
// Name — имя тренировки как прислал HAE, локализованное («В помещении
|
||||
// Ходьба»). У записей пустое.
|
||||
Name string
|
||||
|
||||
// Start и End — координаты в UTC. Конец, которого нет или который не
|
||||
// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
|
||||
// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
|
||||
// две записи в одну координату.
|
||||
Start time.Time
|
||||
End time.Time
|
||||
|
||||
// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
|
||||
// смену зоны дала бы два разных.
|
||||
OffsetSeconds int
|
||||
|
||||
// Duration — длительность тренировки в секундах, как прислал HAE. Не
|
||||
// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
|
||||
// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
|
||||
Duration *float64
|
||||
|
||||
// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
|
||||
// маршрут и внутренние ряды.
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
|
||||
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
|
||||
// дело, и терять из-за неё остальное нельзя.
|
||||
type Result struct {
|
||||
Points []Point
|
||||
|
||||
// Workouts и Records — сущности с собственным идентификатором. Разведены,
|
||||
// потому что у тренировки есть заголовок (имя, интервал, длительность), по
|
||||
// которому идёт выборка, а у записи его нет.
|
||||
Workouts []Entity
|
||||
Records []Entity
|
||||
|
||||
// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
|
||||
// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
|
||||
// различает их только тело, лежащее в архиве.
|
||||
SkippedNoID int
|
||||
// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
|
||||
// метки времени.
|
||||
SkippedEntityNoTime int
|
||||
// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
|
||||
SkippedEntityMalformed int
|
||||
|
||||
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
|
||||
// отсортированные и без повторов. Половина живого потока состоит из таких
|
||||
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
|
||||
@@ -166,17 +220,21 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
res = Result{}
|
||||
err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r)
|
||||
err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
|
||||
}
|
||||
}()
|
||||
|
||||
metrics, uncovered, dropped, err := decodeEnvelope(body)
|
||||
env, err := decodeEnvelope(body)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res.Uncovered = uncovered
|
||||
res.UncoveredDropped = dropped
|
||||
res.Uncovered = env.uncovered
|
||||
res.UncoveredDropped = env.dropped
|
||||
|
||||
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
|
||||
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
|
||||
|
||||
metrics := env.metrics
|
||||
res.Metrics = len(metrics)
|
||||
if len(metrics) == 0 {
|
||||
return res, nil
|
||||
@@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
||||
// решит, что терять нечего.
|
||||
//
|
||||
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
|
||||
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
|
||||
// мы их, доставка получила бы `failed` при частично записанной витрине,
|
||||
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
|
||||
// такая доставка доедет пересборкой, а тело ждёт в архиве.
|
||||
return Result{
|
||||
Metrics: res.Metrics,
|
||||
Uncovered: res.Uncovered,
|
||||
@@ -262,16 +326,53 @@ type group struct {
|
||||
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
||||
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
||||
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
||||
// metricsSection — единственная секция, которую разбор покрывает сегодня.
|
||||
const metricsSection = "metrics"
|
||||
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
|
||||
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
|
||||
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
|
||||
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
|
||||
// проекта не является.
|
||||
const (
|
||||
metricsSection = "metrics"
|
||||
workoutsSection = "workouts"
|
||||
stateOfMindSection = "stateOfMind"
|
||||
)
|
||||
|
||||
// covered говорит, покрывает ли разбор секцию с таким именем.
|
||||
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
|
||||
// говорит, взялся ли он за неё.
|
||||
//
|
||||
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
|
||||
// одному источнику, поэтому состояние «секция разбирается, но числится
|
||||
// непокрытой» невыразимо.
|
||||
func covered(section string) bool {
|
||||
return section == metricsSection
|
||||
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
|
||||
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
|
||||
// но числится непокрытой» невыразимо по построению. Отдельный предикат
|
||||
// `covered` разошёлся бы с этим switch при первой же новой секции.
|
||||
//
|
||||
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
||||
// побеждает последняя: терять данные молча нельзя.
|
||||
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
|
||||
switch name {
|
||||
case metricsSection:
|
||||
var part []metricEnvelope
|
||||
if err := dec.Decode(&part); err != nil {
|
||||
return true, err
|
||||
}
|
||||
env.metrics = append(env.metrics, part...)
|
||||
return true, nil
|
||||
case workoutsSection:
|
||||
part, err := decodeSection(dec)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
env.workouts = append(env.workouts, part...)
|
||||
return true, nil
|
||||
case stateOfMindSection:
|
||||
part, err := decodeSection(dec)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
env.stateOfMind = append(env.stateOfMind, part...)
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
||||
@@ -317,9 +418,11 @@ type pointHead struct {
|
||||
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
|
||||
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
|
||||
// копия одна и живёт до следующего члена.
|
||||
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) {
|
||||
fail := func(e error) ([]metricEnvelope, []string, int, error) {
|
||||
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||
func decodeEnvelope(body []byte) (envelope, error) {
|
||||
var env envelope
|
||||
|
||||
fail := func(e error) (envelope, error) {
|
||||
return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
@@ -327,6 +430,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
|
||||
// список не идут — иначе в одном списке смешались бы имена секций и мусор
|
||||
// конверта, а форму `{"data": …}` проверяет приём.
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
@@ -335,10 +439,10 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
||||
// двигаются — она добавляет список, а не строгость.
|
||||
if tok == nil {
|
||||
return nil, nil, 0, nil
|
||||
return envelope{}, nil
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for dec.More() {
|
||||
@@ -352,8 +456,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
}
|
||||
continue
|
||||
}
|
||||
metrics, uncovered, dropped, err = decodeData(dec, seen)
|
||||
if err != nil {
|
||||
// Повтор самого члена `data` JSON допускает, и результаты
|
||||
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
|
||||
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
|
||||
// второй список непокрытых не попали бы. Правило то же, что уровнем
|
||||
// ниже для повтора ключа секции.
|
||||
if err := decodeData(dec, seen, &env); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
@@ -363,76 +471,160 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
|
||||
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
||||
// значение уезжает в базу и сравнивается между доставками.
|
||||
sort.Strings(uncovered)
|
||||
return metrics, uncovered, dropped, nil
|
||||
sort.Strings(env.uncovered)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
|
||||
// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
|
||||
type envelope struct {
|
||||
metrics []metricEnvelope
|
||||
workouts []json.RawMessage
|
||||
stateOfMind []json.RawMessage
|
||||
uncovered []string
|
||||
dropped int
|
||||
}
|
||||
|
||||
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
|
||||
// имена непокрытых.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
|
||||
var (
|
||||
metrics []metricEnvelope
|
||||
uncovered []string
|
||||
dropped int
|
||||
)
|
||||
for dec.More() {
|
||||
name, err := memberName(dec)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
|
||||
if covered(name) {
|
||||
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
||||
// побеждает последняя: терять точки молча нельзя.
|
||||
var part []metricEnvelope
|
||||
if err := dec.Decode(&part); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
metrics = append(metrics, part...)
|
||||
handled, err := decodeCovered(name, dec, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := swallow(dec); err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if len(uncovered) >= maxUncovered {
|
||||
dropped++
|
||||
if len(env.uncovered) >= maxUncovered {
|
||||
env.dropped++
|
||||
continue
|
||||
}
|
||||
uncovered = append(uncovered, clipSection(name))
|
||||
env.uncovered = append(env.uncovered, clipSection(name))
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
return metrics, uncovered, dropped, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeSection читает секцию сущностей элементами исходных байтов.
|
||||
//
|
||||
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
|
||||
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
|
||||
// чего защищает `Point.Raw`.
|
||||
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
|
||||
var part []json.RawMessage
|
||||
if err := dec.Decode(&part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
|
||||
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
||||
// escape-последовательностей, поэтому границы считаются по декодированному.
|
||||
func memberName(dec *json.Decoder) (string, error) {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name, ok := tok.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
|
||||
//
|
||||
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
|
||||
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
|
||||
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
|
||||
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
|
||||
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
const maxCauseLen = 200
|
||||
|
||||
// ClipCause переводит чужую ошибку в ограниченную по длине строку.
|
||||
//
|
||||
// Экспортировано ради приёма: он проверяет форму конверта тем же
|
||||
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
|
||||
// через соседний пакет.
|
||||
func ClipCause(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return clip(err.Error())
|
||||
}
|
||||
|
||||
func clip(s string) string {
|
||||
if len(s) <= maxCauseLen {
|
||||
return s
|
||||
}
|
||||
// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
|
||||
cut := maxCauseLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "…"
|
||||
}
|
||||
|
||||
// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
|
||||
// во входе.
|
||||
//
|
||||
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
|
||||
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
|
||||
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
|
||||
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
|
||||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
//
|
||||
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
|
||||
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
|
||||
// он из фиксированного набора и содержимого не раскрывает.
|
||||
//
|
||||
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
|
||||
// последнего возвращённого токена, и снятое после оно указывало бы на конец
|
||||
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
|
||||
func tokenDesc(tok json.Token, at int64) string {
|
||||
kind := "?"
|
||||
switch v := tok.(type) {
|
||||
case json.Delim:
|
||||
kind = fmt.Sprintf("%q", string(v))
|
||||
case string:
|
||||
kind = "string"
|
||||
case json.Number, float64:
|
||||
kind = "number"
|
||||
case bool:
|
||||
kind = "bool"
|
||||
case nil:
|
||||
kind = "null"
|
||||
}
|
||||
return fmt.Sprintf("%s на смещении %d", kind, at)
|
||||
}
|
||||
|
||||
// swallow проглатывает значение целиком, ничего не удерживая.
|
||||
func swallow(dec *json.Decoder) error {
|
||||
var skip json.RawMessage
|
||||
@@ -440,12 +632,13 @@ func swallow(dec *json.Decoder) error {
|
||||
}
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||||
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
|
||||
return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+96
-11
@@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) {
|
||||
})
|
||||
}
|
||||
|
||||
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
|
||||
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
|
||||
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
|
||||
// бы тела, которые для stateOfMind единственный источник.
|
||||
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
|
||||
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
|
||||
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
|
||||
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
|
||||
// играют секции, которых поток ещё не приносил.
|
||||
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing.
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stateOfMind", "workouts"}
|
||||
want := []string{"ecg", "symptoms"}
|
||||
if !slices.Equal(res.Uncovered, want) {
|
||||
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
|
||||
}
|
||||
@@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
|
||||
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
|
||||
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
if len(res.Points) != 0 {
|
||||
@@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
t.Parallel()
|
||||
|
||||
bodies := []string{
|
||||
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
|
||||
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
|
||||
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
|
||||
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
|
||||
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
|
||||
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
|
||||
}
|
||||
want := []string{"ecg", "stateOfMind", "workouts"}
|
||||
want := []string{"ecg", "medications", "symptoms"}
|
||||
for _, b := range bodies {
|
||||
res, err := hae.Parse([]byte(b), hae.Meta{})
|
||||
if err != nil {
|
||||
@@ -701,3 +702,87 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Инвариант «тела запросов только на DEBUG и с обрезкой» обходился одним `%v`:
|
||||
// строка в 8 МиБ на месте ожидаемого объекта давала текст ошибки в 8 МиБ, и он
|
||||
// уезжал атрибутом `error` на уровень WARN — то есть содержимое доставки
|
||||
// оказывалось в логе целиком и без обрезки.
|
||||
//
|
||||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
func TestParseОшибкаНеНесётЗначенийИзТела(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "СЕКРЕТНОЕ-ЗНАЧЕНИЕ-ИЗ-ТЕЛА"
|
||||
cases := map[string]string{
|
||||
"строка вместо data": `{"data":"` + secret + strings.Repeat("A", 1<<20) + `"}`,
|
||||
"строка вместо тела": `"` + secret + strings.Repeat("A", 1<<20) + `"`,
|
||||
"число вместо имени секции": `{"data":{"metrics":[]},"` + secret + `":1}`,
|
||||
"число вместо содержимого": `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Skip("вход разобрался — проверять нечего")
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) > 512 {
|
||||
t.Errorf("текст ошибки %d Б: длина зависит от длины значения во входе", len(msg))
|
||||
}
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("значение из тела доехало до сообщения: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Смещение обязано указывать на место ПЕРЕД виновным токеном, а не за ним.
|
||||
// Декодер сообщает позицию как конец последнего возвращённого токена, поэтому
|
||||
// снятая ПОСЛЕ чтения она отличалась бы от начала проблемы ровно на длину
|
||||
// значения — на восемь мегабайт в том самом случае, ради которого требование и
|
||||
// написано. Проверяется само свойство: смещение не зависит от длины значения.
|
||||
func TestParseОшибкаНазываетТипТокенаИНачало(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
short := `{"data":"` + strings.Repeat("A", 16) + `"}`
|
||||
long := `{"data":"` + strings.Repeat("A", 1<<20) + `"}`
|
||||
|
||||
msgs := make([]string, 0, 2)
|
||||
for _, body := range []string{short, long} {
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
msgs = append(msgs, err.Error())
|
||||
}
|
||||
|
||||
if !strings.Contains(msgs[0], "string") {
|
||||
t.Errorf("тип токена не назван словарём JSON: %s", msgs[0])
|
||||
}
|
||||
// `{"data"` — семь байт: разбор виновного значения начинается здесь.
|
||||
if !strings.Contains(msgs[0], "смещении 7") {
|
||||
t.Errorf("смещение не указывает на место перед токеном: %s", msgs[0])
|
||||
}
|
||||
if msgs[0] != msgs[1] {
|
||||
t.Errorf("смещение поехало вместе с длиной значения:\n %s\n %s", msgs[0], msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибка может прийти не только от нашего разбора, но и из encoding/json, а его
|
||||
// UnmarshalTypeError кладёт в текст ЛИТЕРАЛ значения: тело из миллиона цифр
|
||||
// давало текст ошибки в мегабайт. Предел держится на границе пакета.
|
||||
func TestParseЧужаяПричинаОбрезается(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if len(err.Error()) > 512 {
|
||||
t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,13 @@ func TestParseУдержаниеКучиНепокрытойСекции(t *test
|
||||
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
|
||||
res.Uncovered, retained>>20, len(body)>>20)
|
||||
|
||||
// Замер обязан идти по ветке проглатывания. Без этой проверки расширение
|
||||
// множества покрытых секций превращает сторож в зелёную пустышку — что уже
|
||||
// однажды и произошло.
|
||||
if len(res.Uncovered) == 0 {
|
||||
t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
|
||||
}
|
||||
|
||||
if retained > limit {
|
||||
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
|
||||
"похоже, секции удерживаются, а не проглатываются",
|
||||
@@ -149,9 +156,13 @@ func bodyWithUncovered(size int) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
|
||||
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
|
||||
b.WriteString(`]}],"stateOfMind":[`)
|
||||
// Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
|
||||
// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
|
||||
// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
|
||||
// его разбором, обезоружила сторож молча.
|
||||
b.WriteString(`]}],"ecg":[`)
|
||||
|
||||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},`
|
||||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
|
||||
for b.Len() < size {
|
||||
b.WriteString(entry)
|
||||
}
|
||||
|
||||
Vendored
+21
@@ -12,6 +12,19 @@
|
||||
переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
|
||||
имена устройств.
|
||||
|
||||
Для сущностей с собственным `id` вычищается дополнительно: сами
|
||||
идентификаторы (псевдо-UUID той же формы), метки RFC 3339 и `route[].timestamp`
|
||||
(сдвигаются, как и прочие даты), а также словарные значения `stateOfMind` —
|
||||
`kind`, `valenceClassification`, `labels`, `associations`. Последнее не
|
||||
перестраховка: это измерение душевного состояния, самое чувствительное, что
|
||||
есть в потоке. Значения подменяются другими кодами из того же словаря HealthKit,
|
||||
поэтому форма (snake_case, строка против массива строк) сохраняется, а смысл —
|
||||
нет. Побочный эффект подмены: в `labels` могут появиться повторы, которых HAE не
|
||||
шлёт; разбору это безразлично.
|
||||
|
||||
Координаты маршрута вычищаются как обычные числа — они не отличаются от прочих
|
||||
измерений и после подмены указывают в никуда.
|
||||
|
||||
| Файл | Что проверяет |
|
||||
|---|---|
|
||||
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
|
||||
@@ -20,7 +33,12 @@
|
||||
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
|
||||
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
|
||||
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
|
||||
| `workout_route.json` | уличная тренировка с маршрутом и внутренними рядами; ряды урезаны `--limit`, форма точки маршрута сохранена |
|
||||
| `workout_indoor.json` | тренировка без маршрута: набор полей зависит от типа (`temperature`, `humidity`, `intensity` вместо `route`, `avgSpeed`, `flightsClimbed`) |
|
||||
| `state_of_mind.json` | `stateOfMind`: RFC 3339 в UTC, коды вместо переводов, поля `source` нет вовсе |
|
||||
| `uncovered_sections.json` | одна доставка со всеми родами секций сразу — покрытыми и непокрытыми. Рукотворная: живой поток шлёт по одной секции за раз |
|
||||
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
|
||||
| `handmade_entities.json` | краевые случаи сущностей: пустой, отсутствующий и слишком длинный `id`; неразбираемая метка и её отсутствие; неразбираемый `end`; нечисловая и отсутствующая длительность; две версии одного `id` в одном теле; элемент, не являющийся объектом |
|
||||
|
||||
`handmade_edge.json` собран руками, скриптом не воспроизводится:
|
||||
|
||||
@@ -43,3 +61,6 @@ python3 tmp/research/fixtures.py --list
|
||||
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
|
||||
--out internal/hae/testdata/<имя>.json
|
||||
```
|
||||
|
||||
`--limit` режет и внутренние ряды тренировки (маршрут, пульс, энергия): фикстуре
|
||||
нужна форма точки ряда, а не 593 её экземпляра.
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"_comment": "Рукотворная фикстура: краевые случаи сущностей с собственным id, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается, значения выдуманы целиком.",
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"id": "",
|
||||
"name": "Пустой идентификатор",
|
||||
"start": "2025-06-05 10:00:00 +0300",
|
||||
"end": "2025-06-05 10:10:00 +0300",
|
||||
"duration": 600
|
||||
},
|
||||
{
|
||||
"name": "Идентификатора нет вовсе",
|
||||
"start": "2025-06-05 10:00:00 +0300",
|
||||
"end": "2025-06-05 10:10:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"name": "Метка не разбирается",
|
||||
"start": "вчера вечером",
|
||||
"end": "2025-06-05 10:10:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"name": "Метки нет вовсе",
|
||||
"duration": 60
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000003",
|
||||
"name": "Конец не разбирается",
|
||||
"start": "2025-06-05 11:00:00 +0300",
|
||||
"end": "никогда",
|
||||
"duration": 61.5
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000004",
|
||||
"name": "Длительность не число",
|
||||
"start": "2025-06-05 12:00:00 +0300",
|
||||
"end": "2025-06-05 12:01:00 +0300",
|
||||
"duration": "минута"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "Длительности нет",
|
||||
"start": "2025-06-05 13:00:00 +0300",
|
||||
"end": "2025-06-05 13:01:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000006",
|
||||
"name": "Начало берётся из date",
|
||||
"date": "2025-06-05 14:00:00 +0300",
|
||||
"duration": 30
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "Две версии в одном теле, первая",
|
||||
"start": "2025-06-05 15:00:00 +0300",
|
||||
"end": "2025-06-05 15:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"totalEnergy": {"qty": 100.5, "units": "kJ"}
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "Две версии в одном теле, вторая",
|
||||
"start": "2025-06-05 15:00:00 +0300",
|
||||
"end": "2025-06-05 15:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"totalEnergy": {"qty": 110.5, "units": "kJ"}
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000008-и-ещё-очень-длинный-хвост-который-заведомо-выходит-за-предел-длины-идентификатора-принятый-разбором-сущностей",
|
||||
"name": "Идентификатор длиннее предела",
|
||||
"start": "2025-06-05 16:00:00 +0300",
|
||||
"end": "2025-06-05 16:01:00 +0300"
|
||||
},
|
||||
"не объект вовсе",
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000c",
|
||||
"name": 5,
|
||||
"start": "2025-06-05 18:00:00 +0300",
|
||||
"end": "2025-06-05 18:10:00 +0300",
|
||||
"route": [{"lat": 1}]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000d",
|
||||
"name": "Конец числом",
|
||||
"start": "2025-06-05 19:00:00 +0300",
|
||||
"end": 0
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Идентификатор числом",
|
||||
"start": "2025-06-05 20:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000e",
|
||||
"name": "Начало числом при живом date",
|
||||
"start": 1749100000,
|
||||
"date": "2025-06-05 22:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000009",
|
||||
"name": "Незнакомое поле и дословные литералы",
|
||||
"start": "2025-06-05 17:00:00 +0300",
|
||||
"end": "2025-06-05 17:05:00 +0300",
|
||||
"duration": 300,
|
||||
"невиданноеПоле": {"вложенное": [1.0, 9007199254740993, 0.123456789012345678]},
|
||||
"isIndoor": false
|
||||
}
|
||||
],
|
||||
"stateOfMind": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000a",
|
||||
"kind": "momentary_emotion",
|
||||
"start": "2025-06-05T18:00:00Z",
|
||||
"end": "2025-06-05T18:00:00Z",
|
||||
"valence": 0.5,
|
||||
"valenceClassification": "pleasant",
|
||||
"labels": ["calm"],
|
||||
"associations": ["hobbies"]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000b",
|
||||
"kind": "daily_mood",
|
||||
"start": "2025-06-05 21:00:00 +0300",
|
||||
"end": "2025-06-06 21:00:00 +0300",
|
||||
"valence": -0.25,
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"labels": [],
|
||||
"associations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": {
|
||||
"stateOfMind": [
|
||||
{
|
||||
"associations": [
|
||||
"fitness"
|
||||
],
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"valence": 0.021376147736425923,
|
||||
"end": "2025-06-05T18:03:51Z",
|
||||
"labels": [
|
||||
"peaceful",
|
||||
"peaceful"
|
||||
],
|
||||
"kind": "momentary_emotion",
|
||||
"id": "50244944-3582-4508-7804-525425479700",
|
||||
"start": "2025-06-05T18:03:51Z"
|
||||
},
|
||||
{
|
||||
"kind": "daily_mood",
|
||||
"end": "2025-06-05T18:03:17Z",
|
||||
"id": "87868435-7832-4736-7633-078876535422",
|
||||
"associations": [
|
||||
"hobbies"
|
||||
],
|
||||
"start": "2025-06-05T18:03:17Z",
|
||||
"valence": 0.12057562819279189,
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"labels": [
|
||||
"relieved",
|
||||
"relieved"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+90
-15
@@ -5,16 +5,56 @@
|
||||
"name": "step_count",
|
||||
"units": "count",
|
||||
"data": [
|
||||
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"}
|
||||
{
|
||||
"date": "2025-06-05 09:00:00 +0300",
|
||||
"qty": 41.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:01:00 +0300",
|
||||
"qty": 17.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:02:00 +0300",
|
||||
"qty": 82.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:03:00 +0300",
|
||||
"qty": 5.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:04:00 +0300",
|
||||
"qty": 63.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:05:00 +0300",
|
||||
"qty": 28.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:06:00 +0300",
|
||||
"qty": 94.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:07:00 +0300",
|
||||
"qty": 12.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:08:00 +0300",
|
||||
"qty": 71.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:09:00 +0300",
|
||||
"qty": 36.0,
|
||||
"source": "Device A"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -26,11 +66,28 @@
|
||||
"end": "2025-06-05 08:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"heartRateData": [
|
||||
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"},
|
||||
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"}
|
||||
{
|
||||
"date": "2025-06-05 08:00:07 +0300",
|
||||
"Min": 91.0,
|
||||
"Avg": 94.5,
|
||||
"Max": 98.0,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 08:00:21 +0300",
|
||||
"Min": 93.0,
|
||||
"Avg": 95.5,
|
||||
"Max": 99.0,
|
||||
"units": "count/min"
|
||||
}
|
||||
],
|
||||
"route": [
|
||||
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"}
|
||||
{
|
||||
"lat": 10.0,
|
||||
"lon": 20.0,
|
||||
"altitude": 30.0,
|
||||
"timestamp": "2025-06-05 08:00:07 +0300"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -41,9 +98,27 @@
|
||||
"end": "2025-06-05T05:12:33Z",
|
||||
"kind": "momentary_emotion",
|
||||
"valence": 0.25,
|
||||
"labels": ["slightly_pleasant"],
|
||||
"labels": [
|
||||
"slightly_pleasant"
|
||||
],
|
||||
"associations": []
|
||||
}
|
||||
],
|
||||
"ecg": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"start": "2025-06-05 07:00:00 +0300",
|
||||
"classification": "sinusRhythm"
|
||||
}
|
||||
],
|
||||
"symptoms": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"start": "2025-06-05 06:00:00 +0300",
|
||||
"name": "Головная боль",
|
||||
"severity": "mild"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"_comment": "Рукотворная фикстура: одна доставка со всеми родами секций сразу — покрытыми (metrics, workouts, stateOfMind) и непокрытыми (ecg, symptoms). Живой поток такого не даёт: автоматизация HAE шлёт одну секцию за раз."
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"id": "19336133-5198-0770-0219-503343909539",
|
||||
"maxHeartRate": {
|
||||
"qty": 199,
|
||||
"units": "count/min"
|
||||
},
|
||||
"temperature": {
|
||||
"units": "degC",
|
||||
"qty": 25.642900109344569
|
||||
},
|
||||
"name": "В помещении Ходьба",
|
||||
"avgHeartRate": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"isIndoor": true,
|
||||
"duration": 11.104378534563007,
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"qty": 0.051178653222924688,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 0.0022744856624713684,
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"intensity": {
|
||||
"qty": 1.1101433306047975,
|
||||
"units": "kcal/hr·kg"
|
||||
},
|
||||
"start": "2025-06-05 21:07:25 +0300",
|
||||
"end": "2025-06-05 21:08:56 +0300",
|
||||
"activeEnergy": [
|
||||
{
|
||||
"units": "kJ",
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"qty": 49.484435766191329,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 1.2893292371894432,
|
||||
"units": "kJ",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"basalEnergy": [
|
||||
{
|
||||
"units": "kJ",
|
||||
"qty": 8.118214886809112,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"source": "Device A ",
|
||||
"qty": 8.3170473912094565
|
||||
}
|
||||
],
|
||||
"location": "В помещении",
|
||||
"metadata": {},
|
||||
"distance": {
|
||||
"units": "km",
|
||||
"qty": 0.097916506499049191
|
||||
},
|
||||
"totalEnergy": {
|
||||
"qty": 66.474914481955616,
|
||||
"units": "kJ"
|
||||
},
|
||||
"activeEnergyBurned": {
|
||||
"units": "kJ",
|
||||
"qty": 21.260499065517783
|
||||
},
|
||||
"speed": {
|
||||
"qty": 3.842565481147650,
|
||||
"units": "km/hr"
|
||||
},
|
||||
"heartRateData": [
|
||||
{
|
||||
"Max": 199,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"Avg": 86.126023892474565,
|
||||
"source": "Device A ",
|
||||
"Min": 68,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Min": 62,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"Avg": 42.420241306988357,
|
||||
"Max": 11,
|
||||
"units": "count/min"
|
||||
}
|
||||
],
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Avg": 18,
|
||||
"units": "count/min",
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:01 +0300",
|
||||
"Min": 18,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Min": 18,
|
||||
"date": "2025-06-05 21:09:05 +0300",
|
||||
"Avg": 18,
|
||||
"Max": 18,
|
||||
"units": "count/min",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:07 +0300",
|
||||
"Avg": 18,
|
||||
"source": "Device A ",
|
||||
"Min": 18,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Max": 64,
|
||||
"Avg": 64,
|
||||
"Min": 64,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:44 +0300",
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Avg": 84,
|
||||
"Max": 84,
|
||||
"units": "count/min",
|
||||
"Min": 84,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:49 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A |Device B",
|
||||
"Min": 16,
|
||||
"units": "count/min",
|
||||
"Max": 16,
|
||||
"Avg": 16,
|
||||
"date": "2025-06-05 21:10:54 +0300"
|
||||
}
|
||||
],
|
||||
"humidity": {
|
||||
"units": "%",
|
||||
"qty": 49
|
||||
},
|
||||
"heartRate": {
|
||||
"max": {
|
||||
"qty": 199,
|
||||
"units": "count/min"
|
||||
},
|
||||
"avg": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"min": {
|
||||
"qty": 68,
|
||||
"units": "count/min"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"elevationDown": {
|
||||
"qty": 58,
|
||||
"units": "m"
|
||||
},
|
||||
"end": "2025-06-06 10:14:23 +0300",
|
||||
"activeEnergy": [
|
||||
{
|
||||
"qty": 19.843076834478356,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:04:28 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"qty": 36.215835452964045,
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 44.740245661570856,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 11.685483741512438,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:11:28 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"source": "Device A ",
|
||||
"units": "kJ",
|
||||
"qty": 57.203753970215048
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 92.751506515081775,
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"basalEnergy": [
|
||||
{
|
||||
"qty": 1.7719543711823085,
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "kJ",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"qty": 3.5941548161054400
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 8.4754498192994867
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"qty": 3.5994877058893775
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"units": "kJ",
|
||||
"qty": 1.3821773496019762,
|
||||
"date": "2025-06-06 10:12:28 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"qty": 3.5575445948756234,
|
||||
"units": "kJ",
|
||||
"date": "2025-06-06 10:13:28 +0300"
|
||||
}
|
||||
],
|
||||
"maxSpeed": {
|
||||
"qty": 1.7500239301072799,
|
||||
"units": "km"
|
||||
},
|
||||
"heartRate": {
|
||||
"avg": {
|
||||
"qty": 580.51441585109356,
|
||||
"units": "count/min"
|
||||
},
|
||||
"min": {
|
||||
"units": "count/min",
|
||||
"qty": 304
|
||||
},
|
||||
"max": {
|
||||
"units": "count/min",
|
||||
"qty": 149
|
||||
}
|
||||
},
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Min": 530,
|
||||
"units": "count/min",
|
||||
"Avg": 530,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:14:26 +0300",
|
||||
"Max": 530
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"Max": 509,
|
||||
"Min": 509,
|
||||
"units": "count/min",
|
||||
"Avg": 509,
|
||||
"date": "2025-06-06 10:14:29 +0300"
|
||||
},
|
||||
{
|
||||
"Min": 103,
|
||||
"Avg": 103,
|
||||
"Max": 103,
|
||||
"date": "2025-06-06 10:14:37 +0300",
|
||||
"units": "count/min",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Avg": 744,
|
||||
"date": "2025-06-06 10:16:12 +0300",
|
||||
"source": "Device A ",
|
||||
"Max": 744,
|
||||
"Min": 744,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Min": 744,
|
||||
"units": "count/min",
|
||||
"Avg": 744,
|
||||
"source": "Device A ",
|
||||
"Max": 744,
|
||||
"date": "2025-06-06 10:16:17 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"Max": 556,
|
||||
"Min": 556,
|
||||
"Avg": 556,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:16:22 +0300"
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"heartRateData": [
|
||||
{
|
||||
"Min": 304,
|
||||
"Avg": 378.98897969841650,
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "count/min",
|
||||
"Max": 147,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Avg": 989.39259425621223,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"Max": 485,
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"Min": 933
|
||||
},
|
||||
{
|
||||
"Min": 859,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"Max": 485,
|
||||
"units": "count/min",
|
||||
"Avg": 763.09312691469744
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"Min": 925,
|
||||
"source": "Device A ",
|
||||
"Avg": 930.87887543899777,
|
||||
"units": "count/min",
|
||||
"Max": 744
|
||||
},
|
||||
{
|
||||
"Max": 136,
|
||||
"Avg": 194.26919436633345,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"Min": 225,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Min": 136,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"Max": 149,
|
||||
"Avg": 503.16594564319518
|
||||
}
|
||||
],
|
||||
"totalEnergy": {
|
||||
"qty": 492.41220115508732,
|
||||
"units": "kJ"
|
||||
},
|
||||
"isIndoor": false,
|
||||
"avgSpeed": {
|
||||
"qty": 1.3262919151335835,
|
||||
"units": "km"
|
||||
},
|
||||
"id": "88509471-0669-5670-1525-588189120267",
|
||||
"flightsClimbed": {
|
||||
"qty": 3,
|
||||
"units": "count"
|
||||
},
|
||||
"avgHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 580.51441585109356
|
||||
},
|
||||
"route": [
|
||||
{
|
||||
"speedAccuracy": 1.1423493491291924,
|
||||
"courseAccuracy": -8,
|
||||
"horizontalAccuracy": 37.394709386127353,
|
||||
"course": -8,
|
||||
"speed": 6.370761754418105,
|
||||
"longitude": 19.710543914850393,
|
||||
"altitude": 11.113585029955223,
|
||||
"timestamp": "2025-06-06 10:04:31 +0300",
|
||||
"latitude": 19.536524809019373,
|
||||
"verticalAccuracy": 1.1014334704737449
|
||||
},
|
||||
{
|
||||
"speedAccuracy": 3.6635325607233020,
|
||||
"timestamp": "2025-06-06 10:04:32 +0300",
|
||||
"longitude": 42.942268598619119,
|
||||
"horizontalAccuracy": 60.170973672383984,
|
||||
"verticalAccuracy": 13.574261948723033,
|
||||
"speed": 0,
|
||||
"courseAccuracy": -8,
|
||||
"altitude": 95.460693244383061,
|
||||
"course": -8,
|
||||
"latitude": 55.963120726050803
|
||||
},
|
||||
{
|
||||
"altitude": 98.38291790977828,
|
||||
"speed": 0.24397521532364589,
|
||||
"course": -8,
|
||||
"speedAccuracy": 0.61821384896158206,
|
||||
"courseAccuracy": -8,
|
||||
"latitude": 89.43783944027558,
|
||||
"verticalAccuracy": 9,
|
||||
"timestamp": "2025-06-06 10:04:33 +0300",
|
||||
"longitude": 38.436129962995469,
|
||||
"horizontalAccuracy": 7.0942497223602135
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-06-06 10:14:21 +0300",
|
||||
"latitude": 13.560422352014850,
|
||||
"verticalAccuracy": 9,
|
||||
"speed": 0.43710174633556862,
|
||||
"course": 138.44753628780219,
|
||||
"speedAccuracy": 0.9626723648822914,
|
||||
"horizontalAccuracy": 4.2411766781980601,
|
||||
"courseAccuracy": 974.54474058674578,
|
||||
"longitude": 16.879590022407365,
|
||||
"altitude": 27.895395296040802
|
||||
},
|
||||
{
|
||||
"courseAccuracy": 77.457067793746674,
|
||||
"altitude": 68.180480909094274,
|
||||
"course": 260.10957552178519,
|
||||
"verticalAccuracy": 9,
|
||||
"horizontalAccuracy": 8.6389325539171599,
|
||||
"speedAccuracy": 0.66244428495829519,
|
||||
"speed": 0.73309170745262559,
|
||||
"latitude": 25.591167408963750,
|
||||
"longitude": 84.179710558888322,
|
||||
"timestamp": "2025-06-06 10:14:22 +0300"
|
||||
},
|
||||
{
|
||||
"verticalAccuracy": 9,
|
||||
"course": 626.70356760825397,
|
||||
"altitude": 35.541136651044451,
|
||||
"latitude": 16.540540353133693,
|
||||
"horizontalAccuracy": 6.5267643473591963,
|
||||
"courseAccuracy": 28.348317314778372,
|
||||
"longitude": 54.812184540297820,
|
||||
"timestamp": "2025-06-06 10:14:23 +0300",
|
||||
"speed": 0.50936788164417521,
|
||||
"speedAccuracy": 0.6056449968288769
|
||||
}
|
||||
],
|
||||
"speed": {
|
||||
"units": "km/hr",
|
||||
"qty": 1.8553272323937848
|
||||
},
|
||||
"distance": {
|
||||
"qty": 0.45535883774475302,
|
||||
"units": "km"
|
||||
},
|
||||
"location": "На улице",
|
||||
"stepCount": [
|
||||
{
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "count",
|
||||
"qty": 578.41514946060714,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"qty": 888.32265222984684,
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"units": "count"
|
||||
},
|
||||
{
|
||||
"units": "count",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"qty": 389.76300874239161,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 856.17715741799695,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"units": "count"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"source": "Device A |Device B",
|
||||
"units": "count",
|
||||
"qty": 139.21282675041696
|
||||
},
|
||||
{
|
||||
"qty": 35.029304391445824,
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"units": "count",
|
||||
"source": "Device A |Device B"
|
||||
}
|
||||
],
|
||||
"duration": 914.68731011451610,
|
||||
"name": "На улице Ходьба",
|
||||
"start": "2025-06-06 10:04:28 +0300",
|
||||
"activeEnergyBurned": {
|
||||
"qty": 103.57106764020185,
|
||||
"units": "kJ"
|
||||
},
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"qty": 0.013796342030844388,
|
||||
"source": "Device A ",
|
||||
"units": "km"
|
||||
},
|
||||
{
|
||||
"qty": 0.074232043027597205,
|
||||
"units": "km",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 0.026811024049784521,
|
||||
"units": "km",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A |Device B",
|
||||
"qty": 0.014294089476468883
|
||||
},
|
||||
{
|
||||
"units": "km",
|
||||
"qty": 0.01426458841320012,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-06 10:12:28 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"source": "Device A |Device B",
|
||||
"qty": 0.050652946782496564,
|
||||
"units": "km"
|
||||
}
|
||||
],
|
||||
"stepCadence": {
|
||||
"units": "count/min",
|
||||
"qty": 494.09651343334029
|
||||
},
|
||||
"maxHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 149
|
||||
}
|
||||
},
|
||||
{
|
||||
"distance": {
|
||||
"qty": 0.097916506499049191,
|
||||
"units": "km"
|
||||
},
|
||||
"name": "В помещении Ходьба",
|
||||
"activeEnergyBurned": {
|
||||
"units": "kJ",
|
||||
"qty": 21.260499065517783
|
||||
},
|
||||
"temperature": {
|
||||
"units": "degC",
|
||||
"qty": 25.642900109344569
|
||||
},
|
||||
"stepCount": [
|
||||
{
|
||||
"qty": 36.176779283023652,
|
||||
"units": "count",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:07:25 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 41.246641544770624,
|
||||
"units": "count",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"stepCadence": {
|
||||
"qty": 67.312908402573276,
|
||||
"units": "count/min"
|
||||
},
|
||||
"isIndoor": true,
|
||||
"basalEnergy": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 8.118214886809112,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 4.9653503151527592
|
||||
}
|
||||
],
|
||||
"activeEnergy": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"qty": 49.484435766191329,
|
||||
"source": "Device A ",
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 8.3710606151472032,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300"
|
||||
}
|
||||
],
|
||||
"id": "19336133-5198-0770-0219-503343909539",
|
||||
"intensity": {
|
||||
"units": "kcal/hr·kg",
|
||||
"qty": 1.1101433306047975
|
||||
},
|
||||
"heartRateData": [
|
||||
{
|
||||
"units": "count/min",
|
||||
"Min": 68,
|
||||
"source": "Device A ",
|
||||
"Max": 199,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"Avg": 86.126023892474565
|
||||
},
|
||||
{
|
||||
"units": "count/min",
|
||||
"source": "Device A ",
|
||||
"Max": 11,
|
||||
"Min": 62,
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"Avg": 42.420241306988357
|
||||
}
|
||||
],
|
||||
"end": "2025-06-05 21:08:56 +0300",
|
||||
"location": "В помещении",
|
||||
"speed": {
|
||||
"qty": 3.842565481147650,
|
||||
"units": "km/hr"
|
||||
},
|
||||
"totalEnergy": {
|
||||
"qty": 67.010279114793536,
|
||||
"units": "kJ"
|
||||
},
|
||||
"maxHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 199
|
||||
},
|
||||
"avgHeartRate": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Min": 18,
|
||||
"Avg": 18,
|
||||
"source": "Device A ",
|
||||
"Max": 18,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-05 21:09:01 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:09:05 +0300",
|
||||
"source": "Device A ",
|
||||
"Avg": 18,
|
||||
"units": "count/min",
|
||||
"Min": 18,
|
||||
"Max": 18
|
||||
},
|
||||
{
|
||||
"Min": 18,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:07 +0300",
|
||||
"Avg": 18
|
||||
},
|
||||
{
|
||||
"Max": 64,
|
||||
"Min": 64,
|
||||
"Avg": 64,
|
||||
"units": "count/min",
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:44 +0300"
|
||||
},
|
||||
{
|
||||
"Min": 84,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-05 21:10:49 +0300",
|
||||
"Max": 84,
|
||||
"Avg": 84,
|
||||
"source": "Device A |Device B"
|
||||
},
|
||||
{
|
||||
"units": "count/min",
|
||||
"source": "Device A |Device B",
|
||||
"Min": 16,
|
||||
"Avg": 16,
|
||||
"Max": 16,
|
||||
"date": "2025-06-05 21:10:54 +0300"
|
||||
}
|
||||
],
|
||||
"heartRate": {
|
||||
"avg": {
|
||||
"units": "count/min",
|
||||
"qty": 47.271142352144859
|
||||
},
|
||||
"max": {
|
||||
"units": "count/min",
|
||||
"qty": 199
|
||||
},
|
||||
"min": {
|
||||
"units": "count/min",
|
||||
"qty": 68
|
||||
}
|
||||
},
|
||||
"humidity": {
|
||||
"units": "%",
|
||||
"qty": 49
|
||||
},
|
||||
"duration": 11.104378534563007,
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"source": "Device A ",
|
||||
"units": "km",
|
||||
"qty": 0.051178653222924688
|
||||
},
|
||||
{
|
||||
"units": "km",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 0.0022744856624713684
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"start": "2025-06-05 21:07:25 +0300"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,22 +23,28 @@ type Options struct {
|
||||
Log *slog.Logger
|
||||
WriteTokens []string
|
||||
MaxBodyMB int
|
||||
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
||||
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
||||
// сервера», и полагаться на него нельзя, см. handleIngest.
|
||||
IngestWriteBudget time.Duration
|
||||
}
|
||||
|
||||
type api struct {
|
||||
ingest *ingest.Service
|
||||
log *slog.Logger
|
||||
writeTokens []string
|
||||
maxBody int64
|
||||
ingest *ingest.Service
|
||||
log *slog.Logger
|
||||
writeTokens []string
|
||||
maxBody int64
|
||||
ingestBudget time.Duration
|
||||
}
|
||||
|
||||
// New собирает HTTP-роутер.
|
||||
func New(o Options) http.Handler {
|
||||
a := &api{
|
||||
ingest: o.Ingest,
|
||||
log: o.Log,
|
||||
writeTokens: o.WriteTokens,
|
||||
maxBody: int64(o.MaxBodyMB) << 20,
|
||||
ingest: o.Ingest,
|
||||
log: o.Log,
|
||||
writeTokens: o.WriteTokens,
|
||||
maxBody: int64(o.MaxBodyMB) << 20,
|
||||
ingestBudget: o.IngestWriteBudget,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,9 +11,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
@@ -259,6 +260,28 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Транспорт, не умеющий дедлайнов, приём не роняет: цена отказа здесь наивысшая
|
||||
// в проекте — доставка, не попавшая в архив, не попадает и в журнал.
|
||||
func TestПриёмРаботаетНаТранспортеБезДедлайнов(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
|
||||
strings.NewReader(`{"data":{"metrics":[]}}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d, ожидался 200", rec.Code)
|
||||
}
|
||||
n, err := st.CountDeliveries(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CountDeliveries: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("доставок в учёте %d, ожидалась 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -276,10 +299,13 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, fold.New(arch, st, 0, log), log),
|
||||
Ingest: ingest.New(arch, st, nil, log),
|
||||
Log: log,
|
||||
WriteTokens: writeTokens,
|
||||
MaxBodyMB: 1,
|
||||
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||
IngestWriteBudget: time.Minute,
|
||||
})
|
||||
return h, st
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
)
|
||||
@@ -22,6 +23,8 @@ type ingestResponse struct {
|
||||
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
|
||||
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
|
||||
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
a.extendWriteDeadline(w, r)
|
||||
|
||||
body, err := readBody(w, r, a.maxBody)
|
||||
if err != nil {
|
||||
writeReadError(w, err)
|
||||
@@ -46,6 +49,31 @@ func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// extendWriteDeadline даёт маршруту приёма собственный бюджет ответа.
|
||||
//
|
||||
// `WriteTimeout` сервера ставится в `readRequest`, то есть ДО вызова
|
||||
// обработчика, и потому покрывает не только запись ответа, но и чтение тела:
|
||||
// при `read_timeout` в пять минут и `write_timeout` в тридцать секунд загрузка
|
||||
// длиннее тридцати секунд обрывается, а `read_timeout` при этом обещает пять
|
||||
// минут. Обрывается молча — обработчик ошибки записи не видит, а `accessLog`
|
||||
// пишет `status_code=200`.
|
||||
//
|
||||
// Лечится это здесь, а не подъёмом общего таймаута: длинный бюджет нужен
|
||||
// одному маршруту, и подъём снял бы защиту от застрявшей записи со всех
|
||||
// остальных.
|
||||
//
|
||||
// Транспорт, не умеющий дедлайнов, отказом приёма не является: приём —
|
||||
// единственное место, где поток вообще существует, и терять доставку из-за
|
||||
// неподдержанной оптимизации нельзя.
|
||||
func (a *api) extendWriteDeadline(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ingestBudget <= 0 {
|
||||
return
|
||||
}
|
||||
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(a.ingestBudget)); err != nil {
|
||||
a.log.DebugContext(r.Context(), "write deadline not set", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// metaFromHeaders достаёт то, что автоматизация Health Auto Export
|
||||
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
|
||||
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
@@ -22,6 +23,25 @@ func NewID() string {
|
||||
return strings.ToLower(ulid.Make().String())
|
||||
}
|
||||
|
||||
// TimeOf возвращает время создания идентификатора: UTC, секундная точность —
|
||||
// ровно та форма, в которой время хранится (см. store.Now).
|
||||
//
|
||||
// Нужен пересборке витрины: у тела, лежащего в архиве без учётной записи,
|
||||
// другого источника метки приёма нет. Дата каталога архива не годится — она
|
||||
// задаёт сутки, а порядок проигрывания нужен внутри суток.
|
||||
//
|
||||
// `.UTC()` здесь обязателен и не для красоты: ulid.Time собирает время через
|
||||
// time.Unix, то есть в локальной зоне машины, а Truncate работает с абсолютной
|
||||
// длительностью и зону не нормализует. Без приведения метка подобранного тела
|
||||
// сравнивалась бы с меткой из БД по-разному на разных машинах.
|
||||
func TimeOf(s string) (time.Time, error) {
|
||||
id, err := ulid.ParseStrict(strings.ToUpper(strings.TrimSpace(s)))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%w: %q", ErrInvalid, s)
|
||||
}
|
||||
return ulid.Time(id.Time()).UTC().Truncate(time.Second), nil
|
||||
}
|
||||
|
||||
// Parse валидирует внешний идентификатор и приводит его к каноническому виду.
|
||||
// Вызывается на входных границах (HTTP, CLI) до запроса к БД: сравнение строк
|
||||
// в SQLite побайтовое, а base32 ULID при декодировании нечувствителен к
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
)
|
||||
@@ -56,3 +57,59 @@ func TestParseRejectsGarbage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Время из ULID нужно телу, лежащему в архиве без учётной записи: другого
|
||||
// источника метки приёма у него нет.
|
||||
func TestTimeOfВUTCИСекундах(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := ident.NewID()
|
||||
at, err := ident.TimeOf(id)
|
||||
if err != nil {
|
||||
t.Fatalf("TimeOf(%q): %v", id, err)
|
||||
}
|
||||
|
||||
// UTC, а не локальная зона: ulid.Time собирает время через time.Unix, то
|
||||
// есть в зоне машины, и метка подобранного тела сравнивалась бы с меткой из
|
||||
// БД по-разному на разных машинах.
|
||||
if at.Location() != time.UTC {
|
||||
t.Errorf("зона %v, ожидался UTC", at.Location())
|
||||
}
|
||||
// Секундная точность — та же, что у store.Now: метка уезжает в колонку
|
||||
// фиксированной ширины, где лексикографический порядок равен хронологии.
|
||||
if at.Nanosecond() != 0 {
|
||||
t.Errorf("метка %v несёт доли секунды", at)
|
||||
}
|
||||
if d := time.Since(at); d < 0 || d > time.Minute {
|
||||
t.Errorf("метка %v далека от настоящего времени (%v)", at, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Монотонность ULID — то, на чём стоит порядок проигрывания журнала для
|
||||
// подобранных тел.
|
||||
func TestTimeOfСохраняетПорядок(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := ident.TimeOf(ident.NewID())
|
||||
if err != nil {
|
||||
t.Fatalf("TimeOf: %v", err)
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
second, err := ident.TimeOf(ident.NewID())
|
||||
if err != nil {
|
||||
t.Fatalf("TimeOf: %v", err)
|
||||
}
|
||||
if !second.After(first) {
|
||||
t.Errorf("порядок меток не сохранился: %v, затем %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeOfОтвергаетНеИдентификатор(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range []string{"", "не-ulid", "01kyzbb5zy4cbkbc0agb6ad07", "readme.txt"} {
|
||||
if _, err := ident.TimeOf(s); !errors.Is(err, ident.ErrInvalid) {
|
||||
t.Errorf("TimeOf(%q) дал %v, ожидался ErrInvalid", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
-35
@@ -14,7 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -48,33 +48,56 @@ type Result struct {
|
||||
RawPath string
|
||||
}
|
||||
|
||||
// foldTimeout — сколько отводится свёртке принятой доставки.
|
||||
// recordTimeout — сколько отводится записи учёта доставки.
|
||||
//
|
||||
// Свёртка идёт на контексте, отвязанном от запроса, поэтому собственный
|
||||
// дедлайн обязателен: без него зависшая запись держала бы горутину до конца
|
||||
// жизни процесса.
|
||||
const foldTimeout = 2 * time.Minute
|
||||
// Учёт ведётся на контексте, переживающем обрыв соединения (см. Accept),
|
||||
// поэтому собственный дедлайн обязателен: без него отказ базы держал бы
|
||||
// обработчик неограниченно.
|
||||
const recordTimeout = 10 * time.Second
|
||||
|
||||
// Service принимает пакеты: сохраняет тело в архив, учитывает доставку и
|
||||
// запускает её свёртку.
|
||||
// Notify — «есть работа»: сигнал тому, кто сворачивает принятое.
|
||||
//
|
||||
// Функцией, а не интерфейсом: сигнал ничего не несёт и ничего не возвращает,
|
||||
// а приёму незачем знать, кто именно свернёт доставку.
|
||||
type Notify func()
|
||||
|
||||
// Service принимает пакеты: сохраняет тело в архив, учитывает доставку и будит
|
||||
// свёртку.
|
||||
//
|
||||
// Сворачивать сам он не умеет намеренно. Свёртка широкой доставки идёт
|
||||
// секундами, а `WriteTimeout` в Go ставится до вызова обработчика — то есть
|
||||
// синхронная свёртка тратила бы бюджет ответа и обрывала бы соединение молча,
|
||||
// с записью `status_code=200` в журнале доступа.
|
||||
type Service struct {
|
||||
arch *archive.Archive
|
||||
store *store.Store
|
||||
fold *fold.Service
|
||||
log *slog.Logger
|
||||
arch *archive.Archive
|
||||
store *store.Store
|
||||
notify Notify
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает use-case приёма.
|
||||
func New(arch *archive.Archive, st *store.Store, f *fold.Service, log *slog.Logger) *Service {
|
||||
return &Service{arch: arch, store: st, fold: f, log: log.With("capability", "ingest")}
|
||||
//
|
||||
// Нулевой notify означает «о свёртке заботится вызывающий» и приводится к
|
||||
// пустой функции здесь же, один раз: проверка на nil в месте вызова рано или
|
||||
// поздно окажется забытой, а паника там наступила бы ПОСЛЕ того, как тело уже
|
||||
// записано и доставка учтена, — то есть отправитель получил бы отказ по
|
||||
// сохранённой доставке.
|
||||
func New(arch *archive.Archive, st *store.Store, notify Notify, log *slog.Logger) *Service {
|
||||
if notify == nil {
|
||||
notify = func() {}
|
||||
}
|
||||
return &Service{arch: arch, store: st, notify: notify, log: log.With("capability", "ingest")}
|
||||
}
|
||||
|
||||
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив и
|
||||
// заводит запись о доставке.
|
||||
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив, заводит
|
||||
// запись о доставке и будит свёртку.
|
||||
//
|
||||
// Порядок важен: сначала тело оказывается на диске, и только потом появляется
|
||||
// учётная запись. Обратный порядок дал бы учтённую доставку без данных.
|
||||
//
|
||||
// Возврат означает «сохранено и учтено», а не «разобрано»: доставка уезжает в
|
||||
// очередь свёртки статусом `pending`, и её исход появится позже.
|
||||
//
|
||||
// Это единственный логирующий чекпоинт приёма — транспорт исход не логирует.
|
||||
func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, error) {
|
||||
if err := checkEnvelope(body); err != nil {
|
||||
@@ -90,7 +113,19 @@ 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, и два разных источника разошлись бы
|
||||
// на границе секунды — а от порядка журнала зависит наследование слоя.
|
||||
// Ошибка здесь означает, что наш же генератор выдал неразбираемый
|
||||
// идентификатор. Второго источника времени тут быть не может — он разошёлся
|
||||
// бы с меткой, которую пересборка восстанавливает из ULID; поэтому отказ, а
|
||||
// не подмена. Тело на диск ещё не легло, так что доставка не теряется.
|
||||
receivedAt, err := ident.TimeOf(res.DeliveryID)
|
||||
if err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID)
|
||||
return Result{}, fmt.Errorf("метка приёма из идентификатора: %w", err)
|
||||
}
|
||||
|
||||
rawPath, err := s.arch.Write(res.DeliveryID, receivedAt, body)
|
||||
if err != nil {
|
||||
@@ -99,7 +134,15 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
}
|
||||
res.RawPath = rawPath
|
||||
|
||||
err = s.store.CreateDelivery(ctx, store.Delivery{
|
||||
// Учёт ведётся на контексте, ПЕРЕЖИВАЮЩЕМ обрыв соединения. Тело к этому
|
||||
// моменту уже на диске (arch.Write контекста не берёт), и отказ вставки
|
||||
// из-за ушедшего клиента оставил бы тело сиротой: доставки в журнале нет,
|
||||
// а вернуть её может только пересборка с ручной подменой базы. Проверка
|
||||
// формы выше остаётся отменяемой — там отмена уместна.
|
||||
recordCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = s.store.CreateDelivery(recordCtx, store.Delivery{
|
||||
ID: res.DeliveryID,
|
||||
ReceivedAt: receivedAt,
|
||||
Headers: encodeHeaders(meta.Headers),
|
||||
@@ -114,9 +157,19 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
// Тело уже на диске — данные не потеряны, но учёта нет. Разбор архива
|
||||
// на следующем шаге проекта такую доставку подберёт.
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath)
|
||||
// Тело уже на диске — данные не потеряны, но учёта нет. Такое тело
|
||||
// подберёт пересборка (`healthlog reindex`), заведя запись заново.
|
||||
//
|
||||
// Занятость базы называется отдельно. Уровень от этого не меняется:
|
||||
// тело осиротело в любом случае, и вернуть его в журнал может только
|
||||
// пересборка. Но лечится занятость не тем, чем сбой диска или испорченная
|
||||
// база, — это конкуренция за запись, и она будет повторяться. Признак
|
||||
// снимается с доменной ошибки, а не с предиката «обстоятельства вообще»:
|
||||
// тот включает ещё и отмену снаружи, а здесь она невозможна по
|
||||
// построению — учёт ведётся на контексте, переживающем обрыв соединения.
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err,
|
||||
"delivery_id", res.DeliveryID, "raw_path", rawPath,
|
||||
"db_busy", errors.Is(err, store.ErrBusy))
|
||||
return Result{}, fmt.Errorf("record delivery: %w", err)
|
||||
}
|
||||
|
||||
@@ -124,24 +177,37 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
"delivery_id", res.DeliveryID,
|
||||
"bytes", res.Bytes,
|
||||
"raw_path", rawPath,
|
||||
"automation_name", meta.AutomationName,
|
||||
"aggregation", meta.Aggregation,
|
||||
"period", meta.Period)
|
||||
"automation_name", clip(meta.AutomationName),
|
||||
"aggregation", clip(meta.Aggregation),
|
||||
"period", clip(meta.Period))
|
||||
|
||||
// Свёртка идёт после того, как доставка учтена, и на контексте, ОТВЯЗАННОМ
|
||||
// от запроса: обрыв соединения клиентом или прокси на середине оставил бы
|
||||
// часть объектов записанной, а доставку — со статусом, по которому её
|
||||
// никто не подберёт. Исход свёртки на код ответа не влияет — сохранили
|
||||
// значит приняли.
|
||||
foldCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), foldTimeout)
|
||||
defer cancel()
|
||||
// Ошибку не возвращаем: она уже записана в лог и в parse_status свёрткой,
|
||||
// а доставка принята.
|
||||
_, _ = s.fold.Fold(foldCtx, res.DeliveryID)
|
||||
// Сигнал идёт последним — после того, как строка учёта закоммичена: иначе
|
||||
// воркер мог бы проснуться раньше, чем увидит доставку, и потратить проход
|
||||
// впустую. Потеря сигнала отказом не является: доставка числится `pending`,
|
||||
// и её подберёт следующий сигнал, тик воркера или старт сервиса.
|
||||
s.notify()
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// maxAttrLen — сколько байт значения заголовка попадает в лог.
|
||||
//
|
||||
// Заголовки контролирует отправитель целиком, а `MaxHeaderBytes` у Go — мегабайт
|
||||
// на запрос: без границы одна доставка выдавливает из ротации логов всю недавнюю
|
||||
// историю, включая записи, по которым эту же доставку потом разыскивают. Та же
|
||||
// граница по той же причине стоит на именах метрик и секций.
|
||||
const maxAttrLen = 128
|
||||
|
||||
// clip обрезает значение, пришедшее от отправителя, до пригодного для лога.
|
||||
func clip(s string) string {
|
||||
if len(s) <= maxAttrLen {
|
||||
return s
|
||||
}
|
||||
// Обрезка названа в самом значении: молча укороченное имя автоматизации
|
||||
// выглядит как другое имя.
|
||||
return s[:maxAttrLen] + "…(обрезано)"
|
||||
}
|
||||
|
||||
// encodeHeaders сериализует заголовки для хранения. Ключи json.Marshal
|
||||
// сортирует сам, поэтому запись стабильна и её удобно сравнивать между
|
||||
// доставками. Сбой сериализации не должен ронять приём: заголовки —
|
||||
@@ -172,7 +238,11 @@ func checkEnvelope(body []byte) error {
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причину наружу не раскрываем, она уходит в лог
|
||||
// Причина обрезается тем же пределом, что и в разборе: UnmarshalTypeError
|
||||
// кладёт в текст ЛИТЕРАЛ значения, и тело из миллиона цифр давало
|
||||
// мегабайт содержимого доставки в логе. Инвариант «тела только на DEBUG
|
||||
// и с обрезкой» относится и к DEBUG.
|
||||
return fmt.Errorf("%w: %s", ErrMalformed, hae.ClipCause(err))
|
||||
}
|
||||
if len(env.Data) == 0 {
|
||||
return fmt.Errorf("%w: нет объекта data", ErrMalformed)
|
||||
|
||||
+170
-45
@@ -1,18 +1,20 @@
|
||||
package ingest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -123,32 +125,9 @@ func TestAcceptRejectsMalformed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Разбор не влияет на исход приёма: сохранили — значит приняли. Непонятое
|
||||
// содержимое даёт принятую доставку с parse_status=failed, а не отказ.
|
||||
func TestAcceptНепонятоеСодержимоеПринимается(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Метрика есть, но слой определить нечем: плотных метрик нет, заголовок
|
||||
// ничего не означает, наследовать не от чего.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` +
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`)
|
||||
|
||||
if _, err := svc.Accept(ctx, body, ingest.Meta{Aggregation: "Default"}); err != nil {
|
||||
t.Fatalf("Accept отверг доставку из-за разбора: %v", err)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParseFailed {
|
||||
t.Errorf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Разобранная доставка отмечается разобранной, и точки доезжают до объектов.
|
||||
func TestAcceptРазобраннаяДоставкаОтмечена(t *testing.T) {
|
||||
// Ответ отдаётся ДО свёртки: принятая доставка ждёт разбора в очереди, а не
|
||||
// приезжает разобранной. Это смена контракта, и она проверяется явно.
|
||||
func TestAcceptОставляетДоставкуВОчереди(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -165,32 +144,106 @@ func TestAcceptРазобраннаяДоставкаОтмечена(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParseDone {
|
||||
t.Fatalf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
if d.Points == 0 {
|
||||
t.Error("точек 0: разбор не дошёл до учёта")
|
||||
if d.ParseStatus != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParsePending)
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountBuckets: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Error("объектов 0: точки не доехали до хранилища")
|
||||
if n != 0 {
|
||||
t.Errorf("объектов %d: свёртка произошла внутри приёма", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Свёртка идёт на контексте, отвязанном от запроса (context.WithoutCancel в
|
||||
// Accept), чтобы обрыв соединения не оставил часть объектов записанной.
|
||||
// Автотестом это не покрыто: отмену надо подать РОВНО между учётом доставки и
|
||||
// свёрткой, а такого шва снаружи нет, и заводить его ради теста дороже, чем
|
||||
// проверять глазами. Атомарность самой записи проверена в store
|
||||
// (TestMergePointsОтменаНеОставляетПоловины).
|
||||
|
||||
func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
// Сигнал уходит после того, как доставка учтена: воркер, разбуженный раньше,
|
||||
// потратил бы проход впустую.
|
||||
func TestAcceptБудитСвёрткуПослеУчёта(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
|
||||
var seen int64
|
||||
notify := func() {
|
||||
n, err := st.CountDeliveries(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("CountDeliveries: %v", err)
|
||||
}
|
||||
seen = n
|
||||
}
|
||||
svc := ingest.New(arch, st, notify, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Errorf("на момент сигнала доставок в учёте %d, ожидалась 1", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевой сигнал — законный вход (свёрткой заведует вызывающий), и приём от
|
||||
// него не падает. Паника здесь наступила бы ПОСЛЕ записи тела и учёта, то есть
|
||||
// отправитель получил бы отказ по сохранённой доставке.
|
||||
func TestAcceptБезСигналаНеПадает(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
svc := ingest.New(arch, st, nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обрыв соединения после записи тела не должен оставлять тело без учёта:
|
||||
// доставка, не попавшая в журнал, восстанавливается только пересборкой с
|
||||
// ручной подменой базы.
|
||||
func TestAcceptУчитываетДоставкуПослеОбрываСоединения(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := svc.Accept(ctx, []byte(`{"data":{"metrics":[]}}`), ingest.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Accept на отменённом контексте: %v", err)
|
||||
}
|
||||
|
||||
status, err := st.DeliveryStatus(context.Background(), res.DeliveryID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeliveryStatus: %v", err)
|
||||
}
|
||||
if status != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q", status, store.ParsePending)
|
||||
}
|
||||
}
|
||||
|
||||
// Учёта нет, а тело есть: приём кладёт тело на диск раньше строки в базе, и
|
||||
// отказ на вставке оставляет тело в архиве. Такое тело подберёт пересборка.
|
||||
func TestAcceptПриОтказеУчётаОставляетТелоВАрхиве(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
svc := ingest.New(arch, st, nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
// База закрыта — учесть доставку нечем.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err == nil {
|
||||
t.Fatal("приём не заметил, что доставка не учтена")
|
||||
}
|
||||
|
||||
entries, err := filepath.Glob(filepath.Join(arch.Root(), "*", "*", "*", "*.json.gz"))
|
||||
if err != nil {
|
||||
t.Fatalf("обход архива: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("тел в архиве %d, ожидалось 1: тело потеряно вместе с учётом", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func newDeps(t *testing.T, dir string) (*store.Store, *archive.Archive) {
|
||||
t.Helper()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
@@ -202,7 +255,79 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store)
|
||||
if err != nil {
|
||||
t.Fatalf("archive.New: %v", err)
|
||||
}
|
||||
return st, arch
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
return ingest.New(arch, st, fold.New(arch, st, 0, log), log), arch, st
|
||||
func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
st, arch := newDeps(t, t.TempDir())
|
||||
return ingest.New(arch, st, nil, slog.New(slog.DiscardHandler)), arch, st
|
||||
}
|
||||
|
||||
// Отказ учёта после того, как тело легло в архив, обязан называть класс
|
||||
// причины: занятость базы — конкуренция за запись, которая будет повторяться, и
|
||||
// лечится она не тем же, чем сбой диска. Уровень при этом остаётся ERROR: тело
|
||||
// осиротело в любом случае, и вернуть его в журнал может только пересборка.
|
||||
func TestAcceptОтказУчётаНазываетКлассПричины(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
svc := ingest.New(arch, st, nil, log)
|
||||
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err == nil {
|
||||
t.Fatal("приём не заметил, что доставка не учтена")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &v); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
if v["msg"] == "delivery failed" {
|
||||
rec = v
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("отказ учёта не залогирован")
|
||||
}
|
||||
if rec["level"] != "ERROR" {
|
||||
t.Errorf("уровень %v, ожидался ERROR: тело осиротело", rec["level"])
|
||||
}
|
||||
busy, ok := rec["db_busy"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("класс причины не назван: %v", rec)
|
||||
}
|
||||
// Закрытая база — не занятость: признак обязан различать, а не стоять всегда.
|
||||
if busy {
|
||||
t.Error("закрытая база названа занятой — признак не различает причины")
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант «тела запросов только на DEBUG и с обрезкой» относится и к DEBUG:
|
||||
// проверка формы конверта идёт через encoding/json, чей UnmarshalTypeError
|
||||
// кладёт в текст литерал значения.
|
||||
func TestAcceptОтказФормыНеНесётТелаВЛог(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
// Ни архив, ни база не нужны: тело неверной формы отвергается проверкой
|
||||
// конверта до всякой записи.
|
||||
svc := ingest.New(nil, nil, nil, log)
|
||||
|
||||
body := []byte(`{"data":` + strings.Repeat("9", 1<<20) + `}`)
|
||||
if _, err := svc.Accept(context.Background(), body, ingest.Meta{}); err == nil {
|
||||
t.Fatal("тело неверной формы принято")
|
||||
}
|
||||
if buf.Len() > 4096 {
|
||||
t.Errorf("строка лога %d Б: содержимое тела уехало в лог", buf.Len())
|
||||
}
|
||||
if strings.Contains(buf.String(), strings.Repeat("9", 256)) {
|
||||
t.Error("литерал из тела виден в логе")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: однозначный порядок событий и
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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): это
|
||||
// тренировки и состояние разума. С тех пор как обе секции покрыты,
|
||||
// частично разобранных доставок в архиве нет — и вместо них проверяется то,
|
||||
// ради чего покрытие делалось: сущности доехали до витрины.
|
||||
//
|
||||
// Проверяется свойство, а не число: корпус растёт с каждой доставкой, а
|
||||
// прогон живого архива в гейт не входит, так что протухшая константа
|
||||
// покраснела бы молча.
|
||||
if first.Workouts == 0 {
|
||||
t.Error("тренировок в витрине нет — секция workouts не разбирается")
|
||||
}
|
||||
if first.Records == 0 {
|
||||
t.Error("записей в витрине нет — секция stateOfMind не разбирается")
|
||||
}
|
||||
// Удержанные версии сущностей печатаются рядом с их числом. Число — это
|
||||
// «сколько лежит», а удержания — «сколько правило слияния не пустило», и
|
||||
// второе отпечатком не проверяется по построению: живой приём и пересборка
|
||||
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
|
||||
// версии. Печатается, а не утверждается: удержание — событие для разбора,
|
||||
// а не отказ сходимости.
|
||||
t.Logf("тренировок %d, записей %d, удержано версий сущностей %d",
|
||||
first.Workouts, first.Records, first.EntitiesHeld)
|
||||
|
||||
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
//
|
||||
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
|
||||
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
|
||||
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
|
||||
// заведомо сломанном правиле.
|
||||
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.Workouts != first.Workouts || second.Records != first.Records {
|
||||
t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d",
|
||||
first.Workouts, first.Records, second.Workouts, second.Records)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Классификация исхода — та половина, которую пересборка и фоновый воркер
|
||||
// обязаны делить. Проверяется перебором классов, без базы и без архива: второй
|
||||
// классификатор разошёлся бы с первым молча, а по счётчику `partial`
|
||||
// принимается решение о судьбе тела в архиве.
|
||||
func TestClassifyРазводитИсходыПоКлассам(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want Outcome
|
||||
}{
|
||||
{"успех", nil, Outcome{Folded: 1}},
|
||||
{"слой не выведен", hae.ErrLayerUnknown, Outcome{FailedLayer: 1}},
|
||||
{"слой не выведен, обёрнут", fmt.Errorf("свёртка: %w", hae.ErrLayerUnknown), Outcome{FailedLayer: 1}},
|
||||
{"содержимое не разбирается", hae.ErrMalformed, Outcome{FailedMalformed: 1}},
|
||||
{"база занята", store.ErrBusy, Outcome{Deferred: 1}},
|
||||
{"база занята, обёрнута", fmt.Errorf("слияние: %w", store.ErrBusy), Outcome{Deferred: 1}},
|
||||
{"работу прекратили снаружи", context.Canceled, Outcome{Deferred: 1}},
|
||||
// Дедлайн — свойство доставки, а не обстоятельств: она не уложится в
|
||||
// бюджет и в следующий раз, а повтор безнадёжного останавливает очередь.
|
||||
{"свёртка не уложилась в бюджет", context.DeadlineExceeded, Outcome{FailedOther: 1}},
|
||||
{"прочее", errors.New("диск отвалился"), Outcome{FailedOther: 1}},
|
||||
// Паника — дефект нашего кода, а не обстоятельство: доставка выводится
|
||||
// из очереди, тело ждёт пересборки.
|
||||
{"свёртка паниковала", fold.ErrPanicked, Outcome{FailedOther: 1}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := classify(c.err); got != c.want {
|
||||
t.Errorf("Classify(%v) = %+v, ожидалось %+v", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Накопление — сложение по классам: у пересборки и у воркера один набор имён
|
||||
// для одних исходов.
|
||||
func TestOutcomeAddСкладываетПоКлассам(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var total Outcome
|
||||
total.Add(Outcome{Folded: 1, Partial: 1})
|
||||
total.Add(Outcome{Folded: 1, Incomparable: 2})
|
||||
total.Add(Outcome{Deferred: 1})
|
||||
|
||||
want := Outcome{Folded: 2, Deferred: 1, Partial: 1, Incomparable: 2}
|
||||
if total != want {
|
||||
t.Errorf("сумма %+v, ожидалась %+v", total, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Outcome — исход свёртки: одной доставки или их последовательности.
|
||||
//
|
||||
// Классы разведены потому, что читаются по-разному. FailedLayer — штатный исход
|
||||
// (слой не выводится, таких тел в журнале заведомо есть), FailedMalformed —
|
||||
// содержимое не разбирается, Deferred — работа не сделана по обстоятельствам,
|
||||
// и доставка осталась в очереди. Только FailedOther означает, что что-то не так
|
||||
// с самой свёрткой. Один общий счётчик отправлял бы человека искать дефект там,
|
||||
// где его нет.
|
||||
type Outcome struct {
|
||||
Folded int
|
||||
FailedLayer int
|
||||
FailedMalformed int
|
||||
// Deferred — доставка осталась `pending`: отмена или занятость базы. Не
|
||||
// отказ доставки, а несделанная работа; её подберёт следующий проход.
|
||||
Deferred int
|
||||
FailedOther int
|
||||
// Partial — доставок, в теле которых остались непокрытые разбором секции.
|
||||
// Не отклонение, а половина потока; названо потому, что именно эти тела
|
||||
// ретеншену трогать нельзя.
|
||||
Partial int
|
||||
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
|
||||
// их не было ни разу, и на этом стоит отказ от объединения полей.
|
||||
Incomparable int
|
||||
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
|
||||
// содержания». Без него правило слияния сущностей проверить нечем:
|
||||
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
|
||||
// пересборка пользуются одним правилом и одинаково сойдутся на одинаково
|
||||
// удержанной версии. То есть слишком строгое правило (замораживающее
|
||||
// тренировку на старой версии) выглядело бы идеальной сходимостью.
|
||||
EntitiesHeld int
|
||||
// EntitiesDiverging — версии одного ключа, приехавшие в одном теле с разным
|
||||
// содержанием. Событие другого рода, чем удержание, и считается отдельно:
|
||||
// смешанное число не отвечало бы ни на один из двух вопросов.
|
||||
EntitiesDiverging int
|
||||
}
|
||||
|
||||
// Add накапливает исход одной доставки в общий.
|
||||
func (o *Outcome) Add(other Outcome) {
|
||||
o.Folded += other.Folded
|
||||
o.FailedLayer += other.FailedLayer
|
||||
o.FailedMalformed += other.FailedMalformed
|
||||
o.Deferred += other.Deferred
|
||||
o.FailedOther += other.FailedOther
|
||||
o.Partial += other.Partial
|
||||
o.Incomparable += other.Incomparable
|
||||
o.EntitiesHeld += other.EntitiesHeld
|
||||
o.EntitiesDiverging += other.EntitiesDiverging
|
||||
}
|
||||
|
||||
// classify раскладывает ошибку свёртки по классам исхода.
|
||||
//
|
||||
// Чистая функция, и это не украшение: она и есть та половина, которую задача
|
||||
// требовала не дублировать между пересборкой и фоновым воркером, — а
|
||||
// проверяется она перебором классов, без базы и без архива.
|
||||
//
|
||||
// Неэкспортируемая намеренно: её результат содержит поля `Partial` и
|
||||
// `Incomparable`, `EntitiesHeld` и `EntitiesDiverging`, которые дописывает только Play, — вторая публичная дверь
|
||||
// молча занижала бы именно тот счётчик, по которому принимается решение о
|
||||
// судьбе тела в архиве.
|
||||
func classify(err error) Outcome {
|
||||
var out Outcome
|
||||
switch {
|
||||
case err == nil:
|
||||
out.Folded++
|
||||
case store.Transient(err):
|
||||
// Статус доставки свёртка в этих случаях не трогает: она осталась
|
||||
// `pending` и будет свёрнута снова. Правило одно на обоих — то, по
|
||||
// которому свёртка решает не писать исход.
|
||||
out.Deferred++
|
||||
case errors.Is(err, hae.ErrLayerUnknown):
|
||||
out.FailedLayer++
|
||||
case errors.Is(err, hae.ErrMalformed):
|
||||
out.FailedMalformed++
|
||||
default:
|
||||
out.FailedOther++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Player сворачивает доставку по идентификатору и классифицирует исход.
|
||||
//
|
||||
// Общий и для пересборки журнала, и для фонового воркера приёма — второй
|
||||
// классификатор разошёлся бы с первым молча, а по одному из его счётчиков
|
||||
// (`Partial`) принимается решение о судьбе тела в архиве.
|
||||
type Player struct {
|
||||
Fold *fold.Service
|
||||
}
|
||||
|
||||
// Play сворачивает одну доставку и возвращает её исход.
|
||||
//
|
||||
// Классифицируется ТОЛЬКО ошибка свёртки: на контекст Play не смотрит, и это
|
||||
// существенно. У двух вызывающих отменённый контекст означает противоположное —
|
||||
// у пересборки в свёртку уходит тот же отменяемый контекст («нас остановили»),
|
||||
// у воркера отвязанный от остановки, с собственным дедлайном («доставка не
|
||||
// уложилась в бюджет»). Решение «работу прекратили снаружи» принимает цикл,
|
||||
// каждый по своему контексту.
|
||||
func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
|
||||
st, err := p.Fold.Fold(ctx, deliveryID)
|
||||
out := classify(err)
|
||||
|
||||
if err == nil {
|
||||
// Счётчики читаются только у успешной свёртки: при ошибке поля Stats
|
||||
// заполнены частично (Uncovered у отказавшего разбора всегда пуст, хотя
|
||||
// в базу список записан) — и Partial молча занижался бы. А по нему
|
||||
// принимается решение о ретеншене тел.
|
||||
if len(st.Uncovered) > 0 {
|
||||
out.Partial++
|
||||
}
|
||||
out.Incomparable += st.Incomparable
|
||||
out.EntitiesHeld += st.EntitiesHeld
|
||||
out.EntitiesDiverging += st.EntitiesDiverging
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// 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/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
|
||||
|
||||
// Outcome — счётчики свёртки, те же самые, что считает фоновый воркер
|
||||
// приёма. Встроены, а не продублированы именами: два набора имён для одних
|
||||
// исходов разошлись бы при первой же правке классификации.
|
||||
Outcome
|
||||
|
||||
Buckets int64
|
||||
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом
|
||||
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
|
||||
// решение о подмене базы необратимо и требует направления расхождения.
|
||||
Workouts int64
|
||||
Records 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)
|
||||
}
|
||||
}
|
||||
|
||||
player := Player{Fold: o.Fold}
|
||||
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)
|
||||
}
|
||||
out, _ := player.Play(ctx, d.ID)
|
||||
// Отмена, застигшая свёртку, — не отказ доставки: считать её отказом
|
||||
// значило бы обвинить разбор в том, чего он не делал, и отправить
|
||||
// человека искать дефект по логу. Решение принимает цикл по СВОЕМУ
|
||||
// контексту — тому же, на котором шла свёртка; классификатор о нём не
|
||||
// знает намеренно, у воркера тот же признак означает другое.
|
||||
if ctx.Err() != nil {
|
||||
rep.Canceled = true
|
||||
return rep, nil
|
||||
}
|
||||
rep.Add(out)
|
||||
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.Workouts, err = o.Target.CountWorkouts(ctx)
|
||||
if err != nil {
|
||||
return stopOr(rep, err)
|
||||
}
|
||||
rep.Records, err = o.Target.CountRecords(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,
|
||||
"deferred", rep.Deferred,
|
||||
"failed_other", rep.FailedOther,
|
||||
"partial", rep.Partial,
|
||||
"incomparable", rep.Incomparable,
|
||||
"entities_held", rep.EntitiesHeld,
|
||||
"entities_diverging", rep.EntitiesDiverging,
|
||||
"buckets", rep.Buckets,
|
||||
"workouts", rep.Workouts,
|
||||
"records", rep.Records)
|
||||
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` и
|
||||
// `skipped_entities` — результат ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало
|
||||
// вместе с доставкой. У последнего пустота означает «не измерялось», так что
|
||||
// перенос выдал бы измерение прежнего разбора за измерение текущего — а по нему
|
||||
// решают, можно ли удалить тело. Перенести их
|
||||
// значило бы сделать пересобранную витрину функцией прошлого прогона: доставка,
|
||||
// чей повторный разбор отказал (штатный исход, когда слой не выводится),
|
||||
// сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и
|
||||
// следующая доставка той же автоматизации унаследовала бы его молча. Оба прогона
|
||||
// при этом самосогласованы, поэтому проверка «повторная пересборка ничего не
|
||||
// меняет» такого не ловит.
|
||||
//
|
||||
// Незаполненные здесь колонки получают значения по умолчанию схемы: пустой слой
|
||||
// и пустой список непокрытых секций.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Число пропущенных сущностей — производное от разбора поле, и пересборка его
|
||||
// не переносит. Пустота у него значит «не измерялось», а перенесённое число
|
||||
// выдавало бы измерение ПРЕЖНЕГО разбора за измерение текущего — притом что по
|
||||
// нему принимается необратимое решение об удалении тела.
|
||||
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)
|
||||
|
||||
// Проставляем счётчик в исходной базе, как если бы его измерил прежний
|
||||
// разбор, и убираем тело: пересборке будет нечего пересчитывать.
|
||||
n := int64(7)
|
||||
for _, it := range items {
|
||||
err := src.FinishParse(ctx, it.id, store.ParseOutcome{
|
||||
Status: store.ParseDone,
|
||||
SkippedEntities: &n,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("простановка счётчика: %v", err)
|
||||
}
|
||||
}
|
||||
// Тело убираем: доставка становится записью без тела, пересборка её не
|
||||
// сворачивает — и производные поля обязаны начаться пустыми, а не приехать
|
||||
// из журнала.
|
||||
raw := filepath.Join(dir, "raw")
|
||||
if err := os.RemoveAll(raw); err != nil {
|
||||
t.Fatalf("удаление тел: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(raw, 0o755); err != nil {
|
||||
t.Fatalf("пересоздание каталога архива: %v", err)
|
||||
}
|
||||
|
||||
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
|
||||
if rep.Orphans == 0 {
|
||||
t.Fatal("доставка не стала записью без тела — тест проверяет не то")
|
||||
}
|
||||
|
||||
got, err := dst.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if got.SkippedEntities != nil {
|
||||
t.Errorf("пересборка перенесла счётчик прежнего разбора: %d", *got.SkippedEntities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// batchSize — сколько неразобранных доставок берётся одним запросом.
|
||||
//
|
||||
// Не ради страниц, а ради памяти: задолженность после миграции, переводящей
|
||||
// строки в `pending`, равна всему архиву, и материализовать её целиком незачем —
|
||||
// проход всё равно идёт по одной.
|
||||
const batchSize = 256
|
||||
|
||||
// foldTimeout — сколько отводится свёртке одной доставки.
|
||||
//
|
||||
// Свёртка идёт на контексте, отвязанном от остановки, поэтому собственный
|
||||
// дедлайн обязателен: без него зависшая запись держала бы единственного воркера
|
||||
// до конца жизни процесса, и очередь перестала бы двигаться вовсе.
|
||||
const foldTimeout = 2 * time.Minute
|
||||
|
||||
// tickInterval — как часто воркер просыпается сам, без сигнала.
|
||||
//
|
||||
// Сигнал приносит приём, и для свежей доставки его достаточно. Тик закрывает
|
||||
// два случая, которых сигнал не закрывает: доставка, оставшаяся в очереди из-за
|
||||
// занятости базы, иначе ждала бы СЛЕДУЮЩЕЙ доставки (а ночью телефон молчит
|
||||
// часами), и метка отставания иначе не вычислялась бы вовсе — «работа есть,
|
||||
// прогресса нет» было бы неотличимо от здорового пустого потока.
|
||||
const tickInterval = time.Minute
|
||||
|
||||
// lagThreshold — с какого ожидания доставка считается задержанной.
|
||||
//
|
||||
// Период быстрого прохода синхронизации: если доставка ждала дольше, чем
|
||||
// интервал между доставками, очередь растёт, а не рассасывается.
|
||||
const lagThreshold = 5 * time.Minute
|
||||
|
||||
// Worker — фоновая свёртка принятых доставок.
|
||||
//
|
||||
// Очередь — сама таблица: доставка ждёт свёртки в статусе `pending`, а канал
|
||||
// несёт только бит «есть работа». Отсюда три свойства, ради которых так и
|
||||
// сделано: переполнять нечего, падение процесса очереди не теряет, а подбор
|
||||
// неразобранного при старте не является отдельным кодом — это обычный проход.
|
||||
type Worker struct {
|
||||
store *store.Store
|
||||
player Player
|
||||
log *slog.Logger
|
||||
|
||||
// wake — сигнал «есть работа», ёмкость 1 и неблокирующая отправка. Та же
|
||||
// форма, что у os/signal.Notify: сигнал ничего не несёт, и потерять лишний
|
||||
// не только можно, но и нужно.
|
||||
wake chan struct{}
|
||||
|
||||
// startupDone — первый проход завершён.
|
||||
//
|
||||
// До него метка отставания молчит: задолженность, накопленная ДО старта,
|
||||
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
|
||||
// запуске обесценила бы уровень.
|
||||
startupDone bool
|
||||
}
|
||||
|
||||
// NewWorker собирает воркер над рабочей базой.
|
||||
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
|
||||
return &Worker{
|
||||
store: st,
|
||||
player: Player{Fold: f},
|
||||
log: log.With("capability", "fold-worker"),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
|
||||
//
|
||||
// Потеря сигнала отказом не является: доставка от этого не перестаёт числиться
|
||||
// `pending`, и её подберёт следующий сигнал, тик или старт.
|
||||
func (w *Worker) Notify() {
|
||||
select {
|
||||
case w.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run ведёт воркер до отмены контекста.
|
||||
//
|
||||
// Отмена проверяется МЕЖДУ доставками: свёртка идёт на отвязанном контексте и
|
||||
// рваться не должна. Обещания «текущая доставка непременно досворачивается» тут
|
||||
// нет — бюджет остановки меньше бюджета свёртки; гарантируется другое: после
|
||||
// выхода не существует доставки, которая числится разобранной, а записана
|
||||
// наполовину.
|
||||
//
|
||||
// Первый проход делается сразу, без ожидания сигнала: он и есть подбор
|
||||
// неразобранного при старте.
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
if n, err := w.store.CountPendingDeliveries(ctx); err != nil {
|
||||
if ctx.Err() == nil {
|
||||
w.log.ErrorContext(ctx, "pending backlog not counted", "error", err)
|
||||
}
|
||||
} else if n > 0 {
|
||||
// Размер задолженности — ответ на вопрос «что сервис будет делать
|
||||
// первые минуты после рестарта». Одной строкой и один раз.
|
||||
w.log.InfoContext(ctx, "pending backlog at start", "deliveries", n)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if _, err := w.Pass(ctx); err != nil && ctx.Err() == nil {
|
||||
// Отказ прохода не убивает цикл: воркер, умерший от временного
|
||||
// отказа базы, остановил бы свёртку до конца жизни процесса, пока
|
||||
// приём продолжал бы отвечать 200.
|
||||
//
|
||||
// Отмена сюда не попадает: штатная остановка не отказ, а ERROR о
|
||||
// ней обесценил бы уровень, по которому вмешиваются.
|
||||
w.log.ErrorContext(ctx, "fold pass failed", "error", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.wake:
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass делает один проход по очереди и возвращает его исход.
|
||||
//
|
||||
// Синхронный шов: тесты зовут его напрямую и не ждут по часам. Без него
|
||||
// проверки «все свёрнуты», «проход конечен», «метка не сработала на первом
|
||||
// проходе» писались бы опросом базы с таймаутом.
|
||||
//
|
||||
// Курсор строго возрастает, и это нужно не ради страниц, а ради завершимости:
|
||||
// доставка, у которой не удалось записать даже исход разбора, остаётся
|
||||
// `pending`, и проход без курсора выбирал бы её бесконечно.
|
||||
func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
|
||||
var total Outcome
|
||||
var cursor store.PendingDelivery
|
||||
var lag lagged
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
batch, err := w.store.PendingDeliveries(ctx, cursor, batchSize)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
// Флаг снимается ТОЛЬКО здесь — у прохода, дошедшего до пустой
|
||||
// выборки. Взведённый на любом выходе (отказ базы, отмена), он
|
||||
// включал бы метку задержки после прохода, который ничего не
|
||||
// свернул, и следующий проход выдал бы WARN на всю задолженность —
|
||||
// ровно тот шквал, против которого метка и подавляется при старте.
|
||||
//
|
||||
// Порядок двух строк существен: на задолженности ПЕРВОГО прохода
|
||||
// метка молчит — та ждала не воркера, а его появления.
|
||||
w.warnLag(ctx, lag)
|
||||
w.startupDone = true
|
||||
return total, nil
|
||||
}
|
||||
|
||||
for _, d := range batch {
|
||||
if ctx.Err() != nil {
|
||||
return total, nil
|
||||
}
|
||||
lag.add(d)
|
||||
total.Add(w.foldOne(ctx, d.ID))
|
||||
cursor = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lagged копит отставание прохода: сколько доставок ждали свёртки и дольше всех
|
||||
// ждала какая.
|
||||
//
|
||||
// Считается на ВЫБОРКЕ, а не по факту успешной свёртки: иначе застрявшая
|
||||
// доставка молчала бы ровно в том состоянии, ради которого метка и заведена.
|
||||
type lagged struct {
|
||||
count int
|
||||
worst time.Duration
|
||||
worstID string
|
||||
}
|
||||
|
||||
func (l *lagged) add(d store.PendingDelivery) {
|
||||
waited := store.Now().Sub(d.ReceivedAt)
|
||||
if waited < lagThreshold {
|
||||
return
|
||||
}
|
||||
l.count++
|
||||
if waited > l.worst {
|
||||
l.worst = waited
|
||||
l.worstID = d.ID
|
||||
}
|
||||
}
|
||||
|
||||
// foldOne сворачивает доставку на контексте, ОТВЯЗАННОМ от остановки.
|
||||
//
|
||||
// Отмена снаружи не должна превращаться в свойство доставки: свёртка пишет
|
||||
// исход на переживающем отмену контексте, и оборванная на середине пометила бы
|
||||
// доставку так, что воркер её больше не подберёт. Собственный дедлайн при этом
|
||||
// остаётся и означает именно отказ доставки.
|
||||
func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
|
||||
foldCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), foldTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Ошибку не возвращаем: она уже записана свёрткой в лог и в parse_status,
|
||||
// а отказ одной доставки прохода не прекращает. Паника тоже: её
|
||||
// перехватывает сама свёртка — там же, где живёт единственный писатель
|
||||
// исхода разбора.
|
||||
out, _ := w.player.Play(foldCtx, deliveryID)
|
||||
return out
|
||||
}
|
||||
|
||||
// warnLag называет отставание одной строкой на проход.
|
||||
//
|
||||
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
|
||||
// одинаковых WARN каждую минуту, и уровень, по которому вмешиваются, перестал
|
||||
// бы что-либо значить.
|
||||
func (w *Worker) warnLag(ctx context.Context, l lagged) {
|
||||
if !w.startupDone || l.count == 0 {
|
||||
return
|
||||
}
|
||||
w.log.WarnContext(ctx, "deliveries waited for fold",
|
||||
"deliveries", l.count,
|
||||
"worst_delivery_id", l.worstID,
|
||||
"worst_waited_sec", int64(l.worst.Seconds()))
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package replay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// newWorker собирает воркер над свежей базой и архивом, отдавая заодно то, чем
|
||||
// проверяют его следы в логе.
|
||||
func newWorker(t *testing.T, dir string) (*replay.Worker, *store.Store, *archive.Archive, *logSink) {
|
||||
t.Helper()
|
||||
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
st := openStore(t, filepath.Join(dir, "live.db"))
|
||||
sink := newLogSink()
|
||||
log := slog.New(slog.NewJSONHandler(sink, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
|
||||
return w, st, arch, sink
|
||||
}
|
||||
|
||||
// logSink собирает записи лога, чтобы проверять их без гонок и без ожиданий по
|
||||
// часам: тест синхронизируется появлением строки, а не сном.
|
||||
type logSink struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
watch map[string]*watcher
|
||||
}
|
||||
|
||||
// watcher ждёт n-го появления строки.
|
||||
type watcher struct {
|
||||
left int
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func newLogSink() *logSink { return &logSink{watch: map[string]*watcher{}} }
|
||||
|
||||
func (s *logSink) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
line := string(p)
|
||||
s.lines = append(s.lines, line)
|
||||
if w, ok := s.watch[msgOf(line)]; ok {
|
||||
w.left--
|
||||
if w.left == 0 {
|
||||
close(w.ch)
|
||||
delete(s.watch, msgOf(line))
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// expect регистрирует ожидание n-го появления строки до того, как она может
|
||||
// появиться: синхронизация идёт событием, а не сном.
|
||||
func (s *logSink) expect(msg string, n int) <-chan struct{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
w := &watcher{left: n, ch: make(chan struct{})}
|
||||
s.watch[msg] = w
|
||||
return w.ch
|
||||
}
|
||||
|
||||
func msgOf(line string) string {
|
||||
var rec struct {
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
return ""
|
||||
}
|
||||
return rec.Msg
|
||||
}
|
||||
|
||||
// count считает записи с данным msg.
|
||||
func (s *logSink) count(msg string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
n := 0
|
||||
for _, line := range s.lines {
|
||||
if msgOf(line) == msg {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *logSink) dump() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return strings.Join(s.lines, "")
|
||||
}
|
||||
|
||||
// Подбор неразобранного — обычный проход воркера, а не отдельный режим: после
|
||||
// миграции 00005 неразобранными числятся все доставки архива, и подобрать их
|
||||
// сегодня может только пересборка с ручной подменой базы.
|
||||
func TestПроходПодбираетЗадолженность(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, st, arch, _ := newWorker(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
items := journal(t, "minute.json", "hour.json", "raw.json")
|
||||
for _, it := range items {
|
||||
writeBody(t, arch, st, it, fixture(t, it.fixture))
|
||||
}
|
||||
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход: %v", err)
|
||||
}
|
||||
if out.Folded != len(items) {
|
||||
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
|
||||
}
|
||||
|
||||
n, err := st.CountPendingDeliveries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPendingDeliveries: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("неразобранными остались %d доставок", n)
|
||||
}
|
||||
|
||||
buckets, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountBuckets: %v", err)
|
||||
}
|
||||
if buckets == 0 {
|
||||
t.Error("объектов 0: точки не доехали до хранилища")
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок задаётся ЖУРНАЛОМ, а не порядком, в котором доставки попали в учёт.
|
||||
// Проверяется наблюдаемым следствием: доставка без плотных метрик наследует
|
||||
// слой предшествующей ей по `(received_at, id)`.
|
||||
//
|
||||
// Учёт заполняется в обратном хронологии порядке — так выглядит гонка двух
|
||||
// конкурентных приёмов, где поздняя доставка закоммитила строку первой.
|
||||
func TestПроходИдётВПорядкеЖурналаАНеВставки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, st, arch, _ := newWorker(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
minute := item{id: ident.NewID(), at: base.Add(1 * time.Second), automationID: "a", aggregation: "Default", fixture: "minute.json"}
|
||||
sleep := item{id: ident.NewID(), at: base.Add(2 * time.Second), automationID: "a", aggregation: "Default", fixture: "sparse_sleep.json"}
|
||||
|
||||
// Сначала учитывается ПОЗДНЯЯ доставка.
|
||||
writeBody(t, arch, st, sleep, fixture(t, sleep.fixture))
|
||||
writeBody(t, arch, st, minute, fixture(t, minute.fixture))
|
||||
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход: %v", err)
|
||||
}
|
||||
if out.FailedLayer != 0 {
|
||||
t.Fatalf("слой не вывелся у %d доставок: проход пошёл в порядке вставки", out.FailedLayer)
|
||||
}
|
||||
|
||||
// Предшественник — минутная доставка, значит эпизоды сна легли в minute.
|
||||
hours, err := st.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()
|
||||
w, st, arch, _ := newWorker(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
items := journal(t, "minute.json", "hour.json")
|
||||
for _, it := range items {
|
||||
writeBody(t, arch, st, it, fixture(t, it.fixture))
|
||||
}
|
||||
// У первой доставки тела больше нет — свернуть её нечем.
|
||||
if err := os.Remove(filepath.Join(arch.Root(), "2026", "08", "01", items[0].id+".json.gz")); err != nil {
|
||||
t.Fatalf("удаление тела: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan replay.Outcome, 1)
|
||||
go func() {
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("проход: %v", err)
|
||||
}
|
||||
done <- out
|
||||
}()
|
||||
|
||||
var out replay.Outcome
|
||||
select {
|
||||
case out = <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("проход не завершился: курсор не двигается")
|
||||
}
|
||||
if out.Folded != 1 || out.FailedOther != 1 {
|
||||
t.Fatalf("исход прохода %+v: ожидались одна свёрнутая и одна отказавшая", out)
|
||||
}
|
||||
|
||||
// Отказавшая доставка выбыла из очереди — иначе следующий проход брал бы её
|
||||
// снова и снова.
|
||||
n, err := st.CountPendingDeliveries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPendingDeliveries: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("неразобранными числятся %d доставок, ожидалось 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Метка задержки молчит на задолженности первого прохода и говорит после него:
|
||||
// доставки, накопленные до старта, ждали не воркера, а его появления.
|
||||
func TestМеткаЗадержкиВключаетсяПослеПервогоПрохода(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, st, arch, sink := newWorker(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
old := item{
|
||||
id: ident.NewID(), at: store.Now().Add(-time.Hour),
|
||||
automationID: "a", aggregation: "Minutes", fixture: "minute.json",
|
||||
}
|
||||
writeBody(t, arch, st, old, fixture(t, old.fixture))
|
||||
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("первый проход: %v", err)
|
||||
}
|
||||
if n := sink.count("deliveries waited for fold"); n != 0 {
|
||||
t.Errorf("на задолженности первого прохода %d предупреждений о задержке:\n%s", n, sink.dump())
|
||||
}
|
||||
|
||||
late := item{
|
||||
id: ident.NewID(), at: store.Now().Add(-time.Hour),
|
||||
automationID: "a", aggregation: "Minutes", fixture: "hour.json",
|
||||
}
|
||||
writeBody(t, arch, st, late, fixture(t, late.fixture))
|
||||
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("второй проход: %v", err)
|
||||
}
|
||||
if n := sink.count("deliveries waited for fold"); n != 1 {
|
||||
t.Errorf("предупреждений о задержке %d, ожидалось 1:\n%s", n, sink.dump())
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена контекста завершает цикл — без ожиданий по часам: синхронизация идёт
|
||||
// возвратом Run, а не сном.
|
||||
func TestRunЗавершаетсяПоОтмене(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, _, _, sink := newWorker(t, dir)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w.Run(ctx)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("Run не вышел по отмене:\n%s", sink.dump())
|
||||
}
|
||||
}
|
||||
|
||||
// Задолженность при старте называется одной строкой: это ответ на вопрос «что
|
||||
// сервис будет делать первые минуты после рестарта».
|
||||
func TestRunНазываетЗадолженностьПриСтарте(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, st, arch, sink := newWorker(t, dir)
|
||||
|
||||
items := journal(t, "minute.json", "hour.json")
|
||||
for _, it := range items {
|
||||
writeBody(t, arch, st, it, fixture(t, it.fixture))
|
||||
}
|
||||
|
||||
// Ожидание регистрируется ДО запуска: тест синхронизируется появлением
|
||||
// строки, а не сном.
|
||||
said := sink.expect("pending backlog at start", 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-said:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("строки о задолженности нет:\n%s", sink.dump())
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
// Notify не блокирует и не копит: сигнал ничего не несёт, и лишний теряется
|
||||
// намеренно.
|
||||
func TestNotifyНеБлокирует(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w, _, _, _ := newWorker(t, t.TempDir())
|
||||
for range 100 {
|
||||
w.Notify()
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ прохода не убивает цикл: воркер, умерший от временного отказа базы,
|
||||
// остановил бы свёртку до конца жизни процесса, пока приём продолжал бы
|
||||
// отвечать 200.
|
||||
func TestRunПереживаетОтказПрохода(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
w, st, _, sink := newWorker(t, dir)
|
||||
|
||||
// База закрыта — выборка неразобранных отказывает на каждом проходе.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
// Второй отказ доказывает, что цикл пережил первый. Разбудить второй проход
|
||||
// без ожидания по часам может только сигнал: тик идёт раз в минуту.
|
||||
twice := sink.expect("fold pass failed", 2)
|
||||
w.Notify()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-twice:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("цикл не пережил отказ прохода:\n%s", sink.dump())
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
+243
-68
@@ -72,6 +72,35 @@ type MergeStats struct {
|
||||
Collisions []Collision
|
||||
// IncomparableAt — то же для несравнимых наборов.
|
||||
IncomparableAt []Collision
|
||||
|
||||
// Workouts и Records — сколько сущностей пришло с доставкой.
|
||||
Workouts int
|
||||
Records int
|
||||
// WorkoutsWritten и RecordsWritten — сколько из них действительно легло в
|
||||
// витрину. Разница с пришедшими — работа хеша-детектора: тренировка
|
||||
// переприсылается каждой доставкой, пока не доедет маршрут.
|
||||
WorkoutsWritten int
|
||||
RecordsWritten int
|
||||
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
|
||||
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
|
||||
// объединять поля: событие считается, а не предотвращается молча.
|
||||
//
|
||||
// На него опирается ЕДИНСТВЕННЫЙ контроль того, что правило покрытия не
|
||||
// стало слишком строгим: сходимость отпечатка этого не проверяет по
|
||||
// построению — живой приём и пересборка пользуются одним правилом и
|
||||
// одинаково сойдутся на одинаково удержанной версии. Поэтому счётчик
|
||||
// обязан считать ровно удержания и ничего сверх.
|
||||
EntitiesHeld int
|
||||
// HeldAt — координаты первых таких сущностей, для записи в лог.
|
||||
HeldAt []EntityRef
|
||||
// EntitiesDiverging — версии одного ключа, приехавшие в ОДНОМ теле с разным
|
||||
// содержанием. Событие другого рода: победитель ложится в витрину целиком,
|
||||
// терять нечего, лечится оно не тем же. Считается отдельно от удержаний,
|
||||
// иначе одно число отвечало бы на два вопроса — и число удержаний, по
|
||||
// которому судят о строгости правила, стало бы неотличимо от шума.
|
||||
EntitiesDiverging int
|
||||
// DivergingAt — координаты первых таких сущностей.
|
||||
DivergingAt []EntityRef
|
||||
}
|
||||
|
||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||
@@ -102,35 +131,77 @@ func clipMetric(metric string) string {
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// MergePoints раскладывает точки по часовым объектам и сливает их с
|
||||
// сохранёнными.
|
||||
// Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым
|
||||
// объектам, сущности — по своим таблицам.
|
||||
//
|
||||
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
|
||||
// другой выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект.
|
||||
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и
|
||||
// не отдельной транзакцией на сущности.
|
||||
//
|
||||
// Транзакция на объект давала недетерминированное частичное состояние: обход
|
||||
// карты групп рандомизирован, и при отказе посреди доставки набор уже
|
||||
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
|
||||
// доставки — семь разных состояний). Это ломает инвариант «состояние
|
||||
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
|
||||
// хеш-детектор после расхождения переписывал бы «неизменившееся».
|
||||
// хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение
|
||||
// «секции не смешиваются в одной доставке» собрано за двое суток и основанием
|
||||
// для второй транзакции не является.
|
||||
//
|
||||
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
|
||||
// то есть 8.5 мс на килобайт тела.
|
||||
func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) {
|
||||
groups := groupByHour(in)
|
||||
func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) {
|
||||
groups := groupByHour(in.Points)
|
||||
keys := sortedKeys(groups)
|
||||
|
||||
// Каноническая форма сущности и её хеш считаются ОДИН раз на версию и здесь
|
||||
// — до входа в транзакцию. Транзакция открыта `immediate`, то есть блокирует
|
||||
// запись, и повторяется до пяти раз при занятости базы: канонизация внутри
|
||||
// неё умножала бы и пик кучи, и время удержания блокировки. Измерено: тело
|
||||
// 40 МиБ даёт 768 МиБ пика, 63 МиБ удерживают блокировку 5.019 с при
|
||||
// busy_timeout 5000, после чего конкурентный CreateDelivery исчерпывает
|
||||
// повторы.
|
||||
//
|
||||
// Пределов остаётся два, и оба названы вслух. Первый: разбор СОХРАНЁННОЙ
|
||||
// версии остаётся внутри транзакции — её содержимое читается оттуда же и
|
||||
// только когда хеш разошёлся; удержание блокировки пропорционально её
|
||||
// размеру. Второй: форма и множества ключей всех версий доставки
|
||||
// УДЕРЖИВАЮТСЯ в памяти до конца транзакции, то есть расход пропорционален
|
||||
// размеру доставки, а не самой большой её сущности. Про процессор здесь
|
||||
// стало лучше, про память — хуже, и закрыть оба может лишь предел на размер
|
||||
// сущности вместе с потоковым расчётом.
|
||||
workouts, err := prepareEntities(in.Workouts, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
records, err := prepareEntities(in.Records, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
workouts, workoutsDiverging, workoutsDivergingAt, err := dedupeEntities(ctx, workouts)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
records, recordsDiverging, recordsDivergingAt, err := dedupeEntities(ctx, records)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
|
||||
var stats MergeStats
|
||||
err := s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
err = s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
||||
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
||||
stats = MergeStats{}
|
||||
stats = MergeStats{
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesDiverging: workoutsDiverging + recordsDiverging,
|
||||
DivergingAt: clipRefs(append(append([]EntityRef{},
|
||||
workoutsDivergingAt...), recordsDivergingAt...)),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
res, err := mergeBucket(ctx, tx, key, group, deliveryID)
|
||||
res, err := mergeBucket(ctx, tx, key, group, from.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,6 +229,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
|
||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
||||
}
|
||||
}
|
||||
|
||||
now := Now()
|
||||
written, held, heldAt, err := mergeEntities(ctx, tx, workoutTable, workouts, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats.WorkoutsWritten = written
|
||||
stats.EntitiesHeld += held
|
||||
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||
|
||||
written, held, heldAt, err = mergeEntities(ctx, tx, recordTable, records, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats.RecordsWritten = written
|
||||
stats.EntitiesHeld += held
|
||||
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -245,7 +333,10 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
return res, err
|
||||
}
|
||||
|
||||
merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
|
||||
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.overwrites = overwrites
|
||||
res.incomparable = incomparable
|
||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||
@@ -301,7 +392,7 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
// не заговорит.
|
||||
//
|
||||
// Точки из объекта не удаляются никогда.
|
||||
func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incomparable int) {
|
||||
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
|
||||
type coord struct {
|
||||
start int64
|
||||
end int64
|
||||
@@ -349,7 +440,10 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
out := make([]Point, 0, len(order))
|
||||
for _, c := range order {
|
||||
cands := byCoord[c]
|
||||
winner, unrelated := resolve(cands)
|
||||
winner, unrelated, err := resolve(ctx, cands)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
// Перезаписей столько, сколько точек уступило: при двух кандидатах
|
||||
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
|
||||
// столкновение считалось на каждую приехавшую точку.
|
||||
@@ -370,7 +464,7 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
}
|
||||
return out[i].End.Before(out[j].End)
|
||||
})
|
||||
return out, overwrites, incomparable
|
||||
return out, overwrites, incomparable, nil
|
||||
}
|
||||
|
||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||
@@ -389,14 +483,11 @@ func newCandidate(p Point) candidate {
|
||||
|
||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||
//
|
||||
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.
|
||||
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой
|
||||
// (полнота — частичный порядок, поэтому «непревзойдённые» определены
|
||||
// однозначно), затем среди оставшихся берётся минимум по каноническому
|
||||
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят
|
||||
// только от состава множества, поэтому пересборка журнала даёт то же
|
||||
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
|
||||
// ничего.
|
||||
// Механизм общий с выбором версии сущности — pickBest: отбрасываем
|
||||
// превзойдённых по частичному порядку, среди оставшихся берём минимум по
|
||||
// тотальному. Отношения разные (полнота у точек, покрытие у сущностей), а
|
||||
// рассуждение одно, и второй его экземпляр однажды уже разошёлся со стандартом
|
||||
// нетранзитивностью.
|
||||
//
|
||||
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
|
||||
// не конструирует. Каноническая форма существует только в момент сравнения, и
|
||||
@@ -406,46 +497,36 @@ func newCandidate(p Point) candidate {
|
||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
||||
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
|
||||
// реализовано: вместо него счётчик, который скажет, если событие наступит.
|
||||
func resolve(cands []candidate) (Point, bool) {
|
||||
if len(cands) == 1 {
|
||||
return cands[0].pt, false
|
||||
}
|
||||
|
||||
maximal := make([]candidate, 0, len(cands))
|
||||
for i, a := range cands {
|
||||
dominated := false
|
||||
for j, b := range cands {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if b.fields.Relate(a.fields) == canon.FullnessSuperset {
|
||||
dominated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dominated {
|
||||
maximal = append(maximal, a)
|
||||
}
|
||||
}
|
||||
|
||||
best := maximal[0]
|
||||
for _, c := range maximal[1:] {
|
||||
if bytes.Compare(c.key, best.key) < 0 {
|
||||
best = c
|
||||
}
|
||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
|
||||
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
|
||||
if err != nil {
|
||||
return Point{}, false, err
|
||||
}
|
||||
|
||||
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
|
||||
// наборами полей и разными значениями тоже остаются обе, и это рядовой
|
||||
// тай-брейк. Считается только то, ради чего отложено объединение полей:
|
||||
// у каждой из двух есть содержательный ключ, которого нет у другой.
|
||||
return best.pt, hasIncomparablePair(maximal)
|
||||
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
|
||||
}
|
||||
|
||||
func hasIncomparablePair(cands []candidate) bool {
|
||||
for i := range cands {
|
||||
for j := i + 1; j < len(cands); j++ {
|
||||
if cands[i].fields.Relate(cands[j].fields) == canon.FullnessIncomparable {
|
||||
// pointDominates — строгое превосходство по полноте. Relate возвращает
|
||||
// FullnessSuperset только когда a несёт всё, что b, и сверх того, поэтому
|
||||
// отношение уже строгое.
|
||||
func pointDominates(a, b candidate) bool {
|
||||
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
||||
}
|
||||
|
||||
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
|
||||
// кандидаты с равной формой схлопываются ещё при сборе множества.
|
||||
func pointLess(a, b candidate) bool {
|
||||
return bytes.Compare(a.key, b.key) < 0
|
||||
}
|
||||
|
||||
func hasIncomparablePair(cands []candidate, maximal []int) bool {
|
||||
for i := range maximal {
|
||||
for j := i + 1; j < len(maximal); j++ {
|
||||
if cands[maximal[i]].fields.Relate(cands[maximal[j]].fields) == canon.FullnessIncomparable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -591,27 +672,44 @@ func encodePayload(points []Point) ([]byte, error) {
|
||||
return nil, fmt.Errorf("сериализация точек: %w", err)
|
||||
}
|
||||
|
||||
return gzipBytes(raw.Bytes())
|
||||
}
|
||||
|
||||
// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины,
|
||||
// общее для часового объекта и для сущности с собственным `id`. Второй кадр
|
||||
// упаковки разошёлся бы с первым при первой же правке — например, забытым
|
||||
// Close, который оставляет усечённый блоб.
|
||||
func gzipBytes(raw []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(raw.Bytes()); err != nil {
|
||||
return nil, fmt.Errorf("сжатие точек: %w", err)
|
||||
if _, err := gz.Write(raw); err != nil {
|
||||
return nil, fmt.Errorf("сжатие содержимого: %w", err)
|
||||
}
|
||||
// Close дописывает хвост gzip; без него блоб читается лишь частично.
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("закрытие gzip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodePayload(payload []byte) ([]Point, error) {
|
||||
func gunzipBytes(payload []byte) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("распаковка точек: %w", err)
|
||||
return nil, fmt.Errorf("распаковка содержимого: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение точек: %w", err)
|
||||
return nil, fmt.Errorf("чтение содержимого: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodePayload(payload []byte) ([]Point, error) {
|
||||
raw, err := gunzipBytes(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sp []storedPoint
|
||||
@@ -700,7 +798,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
|
||||
// и хешам всех объектов в детерминированном порядке.
|
||||
// и хешам всех её сущностей в детерминированном порядке.
|
||||
//
|
||||
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
|
||||
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
|
||||
@@ -708,25 +806,58 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
||||
// отпечаток — нет.
|
||||
//
|
||||
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
|
||||
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
|
||||
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
|
||||
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
|
||||
// данные.
|
||||
//
|
||||
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
|
||||
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
|
||||
// и «тренировки после» — ложное расхождение у единственного оракула.
|
||||
//
|
||||
// Значений он не раскрывает: содержимое участвует только своим хешем.
|
||||
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin read tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
h := sha256.New()
|
||||
if err := fingerprintBuckets(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fingerprintEntities(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Признак раздела впереди строки: без него строка одного раздела может совпасть
|
||||
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
|
||||
const (
|
||||
fpBucket = "b"
|
||||
fpWorkout = "w"
|
||||
fpRecord = "r"
|
||||
)
|
||||
|
||||
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||
const q = `
|
||||
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
|
||||
ORDER BY metric, layer, hour_utc`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
rows, err := tx.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
return fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
for rows.Next() {
|
||||
var metric, layer, hour, hash, units string
|
||||
var points int
|
||||
var sealed bool
|
||||
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
|
||||
return "", fmt.Errorf("scan bucket: %w", err)
|
||||
return fmt.Errorf("scan bucket: %w", err)
|
||||
}
|
||||
// Поля переменной длины идут с длиной впереди: разделитель, который
|
||||
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
|
||||
@@ -734,13 +865,57 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
|
||||
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
|
||||
// заведён. Тот же приём в canon.HashAll и по той же причине.
|
||||
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
|
||||
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||
fpBucket, len(metric), metric, len(layer), layer, hour, hash, points,
|
||||
len(units), units, sealed)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
return fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintEntities(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||
queries := []struct {
|
||||
tag string
|
||||
sql string
|
||||
}{
|
||||
// Род и идентификатор идут ОТДЕЛЬНЫМИ полями, каждое со своей длиной, а
|
||||
// не склейкой `kind || '/' || id`: склейка выполняется до взятия длины,
|
||||
// и пара (`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`). Отпечаток —
|
||||
// единственный оракул сходимости, по нему принимается необратимое
|
||||
// решение о подмене базы; два разных состояния витрины не имеют права
|
||||
// дать один отпечаток. У тренировки род один и в строку не идёт.
|
||||
{fpWorkout, `SELECT '', id, start_utc, content_hash FROM workout ORDER BY id`},
|
||||
{fpRecord, `SELECT kind, id, ts_utc, content_hash FROM record ORDER BY kind, id`},
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if err := fingerprintRows(ctx, tx, h, q.tag, q.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintRows(ctx context.Context, tx *sql.Tx, h io.Writer, tag, query string) error {
|
||||
rows, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select entities: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var kind, id, ts, hash string
|
||||
if err := rows.Scan(&kind, &id, &ts, &hash); err != nil {
|
||||
return fmt.Errorf("scan entity: %w", err)
|
||||
}
|
||||
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s\n", tag, len(kind), kind, len(id), id, ts, hash)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("select entities: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
|
||||
|
||||
@@ -51,7 +51,7 @@ func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPo
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
func TestMergeКладётЧасОднимОбъектом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -63,7 +63,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, in, "delivery-1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "delivery-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
|
||||
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
|
||||
// сохранённые остаются. Точки из объекта не удаляются никогда.
|
||||
func TestMergePointsДозаписьНеТеряетСохранённое(t *testing.T) {
|
||||
func TestMergeДозаписьНеТеряетСохранённое(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -104,10 +104,10 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, second, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
||||
|
||||
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
|
||||
// делает широкие проходы синхронизации дешёвыми.
|
||||
func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
func TestMergeПовторНеПишет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -136,7 +136,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -144,7 +144,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, in, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повторное слияние: %v", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
|
||||
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
|
||||
// имеет права от него зависеть.
|
||||
func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) {
|
||||
func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -185,7 +185,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
}
|
||||
reversed := []store.IncomingPoint{in[2], in[1], in[0]}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -193,7 +193,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, reversed, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние в обратном порядке: %v", err)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
|
||||
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
|
||||
// по времени приёма неприменим в принципе.
|
||||
func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
||||
func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -225,7 +225,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
|
||||
// Повтор той же тройки не задваивает: координата включает интервал, и он
|
||||
// совпадает.
|
||||
stats, err := st.MergePoints(ctx, in, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повтор: %v", err)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
|
||||
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
|
||||
// выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||
func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
func TestMergeЧасПоНачалуИнтервала(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -259,7 +259,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
in := []store.IncomingPoint{
|
||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
// Правило разрешения столкновений: выигрывает более полная точка, а не
|
||||
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
|
||||
// сама не несёт.
|
||||
func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
||||
func TestMergeБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -285,10 +285,10 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":60,"Min":55,"Max":70}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -311,7 +311,7 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
||||
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
|
||||
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
|
||||
// восстановить его можно было бы только из сырого архива, пока он жив.
|
||||
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||
func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -322,10 +322,10 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
||||
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{hollow}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{measured}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
||||
|
||||
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
|
||||
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
|
||||
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||
func TestMergeРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -356,10 +356,10 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":12}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
||||
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||
func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -386,10 +386,10 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
||||
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"mealTime":"До еды"}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -420,7 +420,7 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
||||
|
||||
// Список координат упирается в потолок, счётчик — нет: обрезанный список
|
||||
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
|
||||
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||
func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -434,10 +434,10 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
||||
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, second, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -453,7 +453,7 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
||||
|
||||
// source нестабилен: то же измерение приезжает то с одним именем устройства,
|
||||
// то с другим. Он не входит в ключ и не считается полнотой.
|
||||
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||
func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -464,10 +464,10 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
||||
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"qty":1,"source":"Apple Watch Ultra 3"}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
||||
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
||||
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
||||
// времени, а внутри одной доставки время приёма общее.
|
||||
func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -494,7 +494,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
||||
winner := func(order []store.IncomingPoint) string {
|
||||
st := open(t)
|
||||
for _, p := range order {
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -515,7 +515,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
||||
// Содержимое точки хранится исходными байтами: пересборка повторной
|
||||
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
|
||||
// разобранное с разобранным.
|
||||
func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
func TestMergeХранитТочкуДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -525,7 +525,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
in := []store.IncomingPoint{
|
||||
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
|
||||
// Конкурентное слияние того же часа не имеет права терять точки: между
|
||||
// чтением и записью может вклиниться другая доставка.
|
||||
func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
||||
func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -568,7 +568,7 @@ func TestMergePointsКонкурентноеСлияниеНеТеряетТоч
|
||||
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
|
||||
},
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
@@ -607,7 +607,7 @@ func itoa(n int) string {
|
||||
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
|
||||
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
|
||||
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
|
||||
func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) {
|
||||
func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -617,7 +617,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
first := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
|
||||
@@ -627,7 +627,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
late := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, late, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: late}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("досчёт запечатанного часа: %v", err)
|
||||
}
|
||||
@@ -649,7 +649,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
|
||||
// Отмена посреди слияния не имеет права оставить половину: объект либо
|
||||
// прежний, либо полный.
|
||||
func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) {
|
||||
func TestMergeОтменаНеОставляетПоловины(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -657,19 +657,30 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
|
||||
base := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
}
|
||||
if _, err := st.MergePoints(context.Background(), base, "d1"); err != nil {
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: base}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
more := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
// Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на
|
||||
// той половине, где отмена вероятнее. Вынесение их во вторую транзакцию —
|
||||
// напрашивающаяся правка при жалобе на длину транзакции с маршрутом, и она
|
||||
// прошла бы зелёной, сломав «состояние пересобираемо»: доставка получила бы
|
||||
// failed при записанной тренировке.
|
||||
more := store.Incoming{
|
||||
Points: []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
},
|
||||
Workouts: []store.IncomingEntity{workout(t, "w-отменённая", `{"id":"w-отменённая","qty":1}`)},
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, more, "d2"); err == nil {
|
||||
if _, err := st.Merge(ctx, more, store.DeliveryRef{ID: "d2"}); err == nil {
|
||||
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||
}
|
||||
if _, err := st.Workout(context.Background(), "w-отменённая"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("тренировка отменённой доставки осталась в витрине: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
@@ -704,7 +715,7 @@ func cycleTriple(t *testing.T) []store.IncomingPoint {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||
func TestMergeПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -714,7 +725,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
||||
state := func() (string, string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -738,7 +749,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||
func TestMergeИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -752,7 +763,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
st := open(t)
|
||||
if split {
|
||||
for _, i := range order {
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -761,7 +772,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
for _, i := range order {
|
||||
batch = append(batch, in[i])
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, batch, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -785,7 +796,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
|
||||
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
|
||||
// равны, и решал второй разряд — по ключам, а не по содержанию.
|
||||
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) {
|
||||
func TestMergeПадингНеВытесняетИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -808,7 +819,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -824,7 +835,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
||||
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
|
||||
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
|
||||
// вытесняет из ротации логов всю недавнюю историю.
|
||||
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||
func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -835,7 +846,7 @@ func TestMergePointsИмяМетрикиВКоординатеОбрезано(t
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
+186
-8
@@ -51,9 +51,30 @@ type Delivery struct {
|
||||
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
|
||||
// ретеншен обязан спрашивать его прежде, чем срезать тело.
|
||||
UncoveredSections string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Второй половина ответа на тот же вопрос: сущность, которую разбор не
|
||||
// понял, в витрину не попала, а список непокрытых секций про неё молчит.
|
||||
//
|
||||
// Отсутствие значения означает «не измерялось» и НЕ равно нулю: так
|
||||
// выглядят доставки, свёрнутые разбором, который пропусков не считал, и те,
|
||||
// чей разбор не досчитал. Читатель, принимающий по счётчику необратимое
|
||||
// решение, обязан трактовать отсутствие как «не удалять».
|
||||
//
|
||||
// Указателем, а не sql.NullInt64: поле уедет в JSON `/stats` и в MCP, а
|
||||
// NullInt64 сериализуется формой драйвера (`{"Int64":0,"Valid":false}`) —
|
||||
// первый, кто про это забудет, опубликует её наружу, и она станет
|
||||
// контрактом. Указатель даёт `null` бесплатно и означает ровно то же.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
// CreateDelivery записывает факт приёма пакета.
|
||||
//
|
||||
// Через ту же транзакцию с повторами, что и слияние точек, и это не симметрия
|
||||
// ради симметрии. Свёртка держит запись всю доставку целиком — измерено 11
|
||||
// секунд на 16 тысячах объектов, — а с фоновым воркером конкуренция за базу
|
||||
// стала штатной. Одиночный `Exec` пересиживал бы только `busy_timeout`, после
|
||||
// чего приём ответил бы `500` по доставке, тело которой уже на диске: доставка
|
||||
// исчезла бы из журнала, а телефон её не перешлёт.
|
||||
func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
|
||||
const q = `
|
||||
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
||||
@@ -66,10 +87,13 @@ func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
|
||||
headers = "{}"
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
d.ID, FormatTime(d.ReceivedAt), d.AutomationName, d.AutomationID,
|
||||
d.Aggregation, d.Period, d.SessionID, d.Bytes, d.SHA256,
|
||||
d.RawPath, d.ParseStatus, d.Points, headers)
|
||||
err := s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, q,
|
||||
d.ID, FormatTime(d.ReceivedAt), d.AutomationName, d.AutomationID,
|
||||
d.Aggregation, d.Period, d.SessionID, d.Bytes, d.SHA256,
|
||||
d.RawPath, d.ParseStatus, d.Points, headers)
|
||||
return err //nolint:wrapcheck // обёртка одна, на выходе
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert delivery: %w", err)
|
||||
}
|
||||
@@ -82,21 +106,26 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status,
|
||||
points, headers, uncovered_sections
|
||||
points, headers, uncovered_sections, skipped_entities
|
||||
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
|
||||
|
||||
var d Delivery
|
||||
var receivedAt string
|
||||
// sql.NullInt64 живёт ровно на границе сканирования и наружу не выходит.
|
||||
var skipped sql.NullInt64
|
||||
err := s.db.QueryRowxContext(ctx, q).Scan(
|
||||
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
|
||||
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections)
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections, &skipped)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Delivery{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("select last delivery: %w", err)
|
||||
}
|
||||
if skipped.Valid {
|
||||
d.SkippedEntities = &skipped.Int64
|
||||
}
|
||||
|
||||
d.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
@@ -105,6 +134,133 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ListDeliveries возвращает учёт доставок в порядке журнала — `(received_at,
|
||||
// id)`, тем же, в котором их проигрывает пересборка.
|
||||
//
|
||||
// Отдаются только **факты журнала**: то, что пришло вместе с доставкой.
|
||||
// Производные от разбора поля (`parse_status`, `points`, `derived_layer`,
|
||||
// `uncovered_sections`, `skipped_entities`) сюда не попадают намеренно —
|
||||
// перенос их в пересобранную базу сделал бы витрину функцией предыдущего
|
||||
// прогона. Особенно `derived_layer`: доставка, чей повторный разбор отказал,
|
||||
// отдала бы в наследование слой прежнего разбора, и следующая доставка той же
|
||||
// автоматизации унаследовала бы его молча. У `skipped_entities` цена та же и
|
||||
// хуже: пустота у него значит «не измерялось», и перенесённое число выдавало бы
|
||||
// измерение прежнего разбора за измерение текущего — а по нему принимается
|
||||
// необратимое решение об удалении тела.
|
||||
//
|
||||
// Перечень пополняется ТЕМ ЖЕ изменением, которое заводит новое поле: он
|
||||
// единственное место, где сказано, чему нельзя пережить пересборку, и следующий
|
||||
// автор решает по нему. Поле, не внесённое сюда, однажды перенесут «для полноты
|
||||
// учёта».
|
||||
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
|
||||
}
|
||||
|
||||
// PendingDelivery — доставка, ожидающая свёртки. Она же курсор обхода: место в
|
||||
// журнале задаётся парой `(received_at, id)`, и вызывающему достаточно передать
|
||||
// обратно последнюю полученную строку.
|
||||
//
|
||||
// Метка приёма отдаётся не для порядка (его держит SQL), а для метки отставания:
|
||||
// «доставка ждала свёртки дольше N» считается от неё.
|
||||
type PendingDelivery struct {
|
||||
ID string
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
// PendingDeliveries возвращает неразобранные доставки в порядке журнала,
|
||||
// строго после курсора. Нулевой курсор означает «с начала».
|
||||
//
|
||||
// Курсор нужен не ради страниц, а ради завершимости обхода: доставка, у которой
|
||||
// не удалось записать даже исход разбора, остаётся `pending`, и выборка без
|
||||
// курсора выдавала бы её бесконечно.
|
||||
func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, limit int) ([]PendingDelivery, error) {
|
||||
// Сравнение кортежем, а не через OR: развёрнутая форма даёт SCAN по
|
||||
// индексу вместо SEARCH (проверено EXPLAIN QUERY PLAN). Тот же приём уже
|
||||
// применён в LastDerivedLayer.
|
||||
const q = `
|
||||
SELECT id, received_at FROM delivery
|
||||
WHERE parse_status = ? AND (received_at, id) > (?, ?)
|
||||
ORDER BY received_at, id LIMIT ?`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, ParsePending, FormatTime(after.ReceivedAt), after.ID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select pending deliveries: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []PendingDelivery
|
||||
for rows.Next() {
|
||||
var d PendingDelivery
|
||||
var receivedAt string
|
||||
if err := rows.Scan(&d.ID, &receivedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan pending 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 pending deliveries: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
|
||||
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
|
||||
// разобрать, прежде чем витрина станет полной.
|
||||
func (s *Store) CountPendingDeliveries(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n,
|
||||
`SELECT count(*) FROM delivery WHERE parse_status = ?`, ParsePending); err != nil {
|
||||
return 0, fmt.Errorf("count pending deliveries: %w", err)
|
||||
}
|
||||
return n, 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 записывает исход разбора доставки.
|
||||
//
|
||||
// Слой сохраняется здесь же, потому что он нужен следующей доставке той же
|
||||
@@ -126,6 +282,17 @@ type ParseOutcome struct {
|
||||
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
|
||||
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
|
||||
Uncovered []string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Пишется, когда разбор ДОСЧИТАЛ, включая ноль: доставка, пропуски которой
|
||||
// исчезли вместе с поумневшим разбором, не должна остаться помеченной
|
||||
// навсегда.
|
||||
//
|
||||
// nil означает «не измерялось» и колонку НЕ ТРОГАЕТ — та же идиома, что у
|
||||
// пустого Layer. Без неё отказ на чтении тела или паника разбора писали бы
|
||||
// ноль, то есть «проверено, терять нечего», в доставку, содержимое которой
|
||||
// никто не смотрел: ровно та подстановка, ради отказа от которой колонка
|
||||
// заведена без DEFAULT.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error {
|
||||
@@ -133,7 +300,8 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
UPDATE delivery
|
||||
SET parse_status = ?, points = ?,
|
||||
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
|
||||
uncovered_sections = ?
|
||||
uncovered_sections = ?,
|
||||
skipped_entities = CASE WHEN ? THEN ? ELSE skipped_entities END
|
||||
WHERE id = ?`
|
||||
|
||||
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как
|
||||
@@ -147,7 +315,17 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
return fmt.Errorf("encode uncovered sections: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer, string(encoded), id)
|
||||
// Отсутствие числа не пишется нулём: ноль означает «измерено, пропусков не
|
||||
// было», а нам нужно «не измерялось». Колонка остаётся какой была — та же
|
||||
// форма, что у слоя строкой выше.
|
||||
measured := out.SkippedEntities != nil
|
||||
var skipped int64
|
||||
if measured {
|
||||
skipped = *out.SkippedEntities
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer,
|
||||
string(encoded), measured, skipped, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update parse status: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/canon"
|
||||
)
|
||||
|
||||
// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
|
||||
// заголовок, по которому идёт выборка, а у записи его нет.
|
||||
const (
|
||||
workoutTable = "workout"
|
||||
recordTable = "record"
|
||||
)
|
||||
|
||||
// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
|
||||
//
|
||||
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
|
||||
// сериализацией, теряет литерал ровно так же, как точка.
|
||||
type IncomingEntity struct {
|
||||
ID string
|
||||
Kind string
|
||||
Name string
|
||||
|
||||
Start time.Time
|
||||
End time.Time
|
||||
// OffsetSeconds — смещение зоны начала.
|
||||
OffsetSeconds int
|
||||
// Duration — длительность тренировки в секундах. nil означает «источник не
|
||||
// прислал» и отличим от нуля: ноль — законная длительность.
|
||||
Duration *float64
|
||||
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
|
||||
// не точка и не сущность: частичное состояние ломает инвариант «состояние
|
||||
// пересобираемо».
|
||||
type Incoming struct {
|
||||
Points []IncomingPoint
|
||||
Workouts []IncomingEntity
|
||||
Records []IncomingEntity
|
||||
}
|
||||
|
||||
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
|
||||
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
|
||||
// журнала задан парой `(received_at, id)`.
|
||||
type DeliveryRef struct {
|
||||
ID string
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (d DeliveryRef) before(other DeliveryRef) bool {
|
||||
if !d.ReceivedAt.Equal(other.ReceivedAt) {
|
||||
return d.ReceivedAt.Before(other.ReceivedAt)
|
||||
}
|
||||
return d.ID < other.ID
|
||||
}
|
||||
|
||||
// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
|
||||
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
|
||||
// измерение душевного состояния.
|
||||
type EntityRef struct {
|
||||
Kind string
|
||||
ID string
|
||||
}
|
||||
|
||||
// maxEntityRefsReported — сколько координат сущностей попадает в лог.
|
||||
const maxEntityRefsReported = 5
|
||||
|
||||
// prepareEntities считает хеш каждой сущности.
|
||||
//
|
||||
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
|
||||
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
|
||||
// базы.
|
||||
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]entityVersion, 0, len(in))
|
||||
for _, e := range in {
|
||||
v, err := newEntityVersion(e, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// clipRefs держит список координат в потолке: он зацепка для разбора, а не
|
||||
// отчёт; масштаб события считает счётчик.
|
||||
func clipRefs(refs []EntityRef) []EntityRef {
|
||||
if len(refs) <= maxEntityRefsReported {
|
||||
return refs
|
||||
}
|
||||
return refs[:maxEntityRefsReported]
|
||||
}
|
||||
|
||||
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
|
||||
// победителя.
|
||||
//
|
||||
// Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость
|
||||
// здесь была мнимой: хеш всё равно требует полной канонической формы, то есть
|
||||
// самая дорогая работа платилась на каждой копии и так, а отложенный разбор
|
||||
// считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая
|
||||
// открыта `immediate` и повторяется до пяти раз при занятости базы.
|
||||
//
|
||||
// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока
|
||||
// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша
|
||||
// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без
|
||||
// разворачивания значений. Внутри транзакции для приехавших версий не остаётся
|
||||
// ничего.
|
||||
type entityVersion struct {
|
||||
raw json.RawMessage
|
||||
hash string
|
||||
from DeliveryRef
|
||||
key []byte
|
||||
fields canon.Fields
|
||||
|
||||
// head — заголовок, который пишется колонками. У сохранённой версии он не
|
||||
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
|
||||
head IncomingEntity
|
||||
}
|
||||
|
||||
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
|
||||
v, err := analyzeVersion(e.Raw, from)
|
||||
if err != nil {
|
||||
return entityVersion{}, err
|
||||
}
|
||||
v.head = e
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// newStoredVersion собирает версию, прочитанную из витрины.
|
||||
//
|
||||
// Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой
|
||||
// версии — единственная работа, которая осталась внутри транзакции, открытой
|
||||
// `immediate`. Замер на тренировке в 168 КБ: полная канонизация с хешем — 4.5 мс
|
||||
// и 2.3 МБ на 38 тысячах аллокаций, множества ключей — 1.3 мс и 174 КБ на
|
||||
// тридцати. Хеш сохранённой уже лежит колонкой, а форма нужна ровно в одной
|
||||
// ветке тай-брейка (равные позиции журнала — та же доставка, свёрнутая
|
||||
// повторно) и считается там лениво.
|
||||
func newStoredVersion(raw json.RawMessage, hash string, from DeliveryRef) entityVersion {
|
||||
return entityVersion{
|
||||
raw: raw,
|
||||
hash: hash,
|
||||
from: from,
|
||||
fields: canon.Analyze(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeVersion(raw json.RawMessage, from DeliveryRef) (entityVersion, error) {
|
||||
form, hash, err := canon.FormAndHash(raw)
|
||||
if err != nil {
|
||||
return entityVersion{}, fmt.Errorf("канонизация сущности: %w", err)
|
||||
}
|
||||
return entityVersion{
|
||||
raw: raw,
|
||||
hash: hash,
|
||||
from: from,
|
||||
key: form,
|
||||
fields: canon.Analyze(raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sortKey отдаёт каноническую форму версии, считая её при необходимости.
|
||||
//
|
||||
// Ленивость здесь одна на весь файл и нужна ровно сохранённой версии: у неё
|
||||
// форма требуется только в тай-брейке равных позиций журнала, а стоит она
|
||||
// втрое дороже разбора и платится под блокировкой записи.
|
||||
func (v *entityVersion) sortKey() []byte {
|
||||
if v.key == nil {
|
||||
v.key = canon.SortKey(v.raw)
|
||||
}
|
||||
return v.key
|
||||
}
|
||||
|
||||
// pickEntity выбирает между сохранённой и приехавшей версией.
|
||||
//
|
||||
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
|
||||
// есть плата за отказ объединять поля: событие не предотвращается молча, а
|
||||
// считается и уходит в WARN.
|
||||
//
|
||||
// 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
|
||||
// 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
|
||||
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
||||
// 4. наборы несравнимы → сохранённая
|
||||
//
|
||||
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
|
||||
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
|
||||
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
|
||||
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
|
||||
// кроме отпечатка.
|
||||
//
|
||||
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
||||
// решает минимум канонической формы, потому что порядок элементов в
|
||||
// JSON-массиве нестабилен.
|
||||
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
||||
switch v := compareEntities(stored, incoming); v {
|
||||
case entityIncomingRicher:
|
||||
return true, false
|
||||
case entityStoredRicher:
|
||||
return false, true
|
||||
case entityIncomparable:
|
||||
// Несравнимы: у каждой версии есть содержание, которого нет у другой.
|
||||
// Объединение полей отвергнуто там же и по той же причине, что для
|
||||
// точек, — на живом потоке событие не наступало ни разу, — а из двух
|
||||
// версий остаётся сохранённая: правило называется «не теряет
|
||||
// содержания», и приехавшая его теряет.
|
||||
//
|
||||
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
|
||||
// журнала: в витрине лежит победитель прошлых слияний, а не все
|
||||
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
|
||||
// при разном порядке. Порядок свёртки журналу не равен — доставка,
|
||||
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
|
||||
// проходом, — так что живой приём и пересборка на несравнимых версиях
|
||||
// законно расходятся. Это единственная точка, где витрина не является
|
||||
// функцией множества доставок; она названа вслух в architecture.md, и
|
||||
// счётчик удержаний ниже — единственное, что о ней сообщает.
|
||||
return false, true
|
||||
default:
|
||||
return laterInJournal(stored, incoming), false
|
||||
}
|
||||
}
|
||||
|
||||
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
|
||||
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
|
||||
type entityVerdict int
|
||||
|
||||
const (
|
||||
// entityEqualContent — множества содержательных ключей совпадают, длины
|
||||
// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
|
||||
// сравнение здесь неприменимо (см. canon.Fields.Covers).
|
||||
entityEqualContent entityVerdict = iota + 1
|
||||
entityIncomingRicher
|
||||
entityStoredRicher
|
||||
entityIncomparable
|
||||
)
|
||||
|
||||
func compareEntities(stored, incoming *entityVersion) entityVerdict {
|
||||
storedCovers := stored.fields.Covers(incoming.fields)
|
||||
incomingCovers := incoming.fields.Covers(stored.fields)
|
||||
|
||||
switch {
|
||||
case incomingCovers && storedCovers:
|
||||
return entityEqualContent
|
||||
case incomingCovers:
|
||||
return entityIncomingRicher
|
||||
case storedCovers:
|
||||
return entityStoredRicher
|
||||
default:
|
||||
return entityIncomparable
|
||||
}
|
||||
}
|
||||
|
||||
// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
|
||||
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
|
||||
// там решает минимум канонической формы — порядок тотальный и от порядка
|
||||
// элементов в массиве не зависит.
|
||||
func laterInJournal(stored, incoming *entityVersion) bool {
|
||||
if stored.from.before(incoming.from) {
|
||||
return true
|
||||
}
|
||||
if incoming.from.before(stored.from) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0
|
||||
}
|
||||
|
||||
// entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и
|
||||
// не покрывается в ответ.
|
||||
//
|
||||
// Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии
|
||||
// могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и
|
||||
// отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе.
|
||||
func entityDominates(a, b entityVersion) bool {
|
||||
return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields)
|
||||
}
|
||||
|
||||
// entityLess — тотальный порядок на версиях равного содержания.
|
||||
//
|
||||
// Сперва каноническая форма, потом ИСХОДНЫЕ БАЙТЫ. Второй разряд не украшение:
|
||||
// у сущностей версии с равной формой не схлопываются (в отличие от точек, где
|
||||
// это делает дедупликация по ключу), а у HAE порядок ключей в JSON и запись
|
||||
// числа нестабильны — то есть без него минимум неединствен, и в витрину лёг бы
|
||||
// тот элемент, что стоял в массиве раньше. Порядок элементов на проводе не
|
||||
// имеет права решать, какие байты хранятся.
|
||||
func entityLess(a, b entityVersion) bool {
|
||||
if c := bytes.Compare(a.key, b.key); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
return bytes.Compare(a.raw, b.raw) < 0
|
||||
}
|
||||
|
||||
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки — до сравнения
|
||||
// с сохранённой.
|
||||
//
|
||||
// Победитель здесь — функция МНОЖЕСТВА версий, а не порядка элементов массива:
|
||||
// сперва отбрасываются строго покрытые, среди оставшихся берётся минимум
|
||||
// тотального порядка. Попарная свёртка была неверна ровно так же, как она была
|
||||
// неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и
|
||||
// вместе они дают нетранзитивную победу, при которой [A,B,C] и [B,C,A] дают
|
||||
// разных победителей.
|
||||
//
|
||||
// Версии с СОВПАВШЕЙ канонической формой схлопываются ДО выбора победителя, и
|
||||
// это не оптимизация ради красоты: выбор квадратичен по числу кандидатов, а их
|
||||
// число приходит из чужого тела. Точки схлопываются так же и в том же месте
|
||||
// (см. mergePoints). Внутри схлопнутой группы остаются минимальные байты —
|
||||
// тот же второй разряд тотального порядка, что и между группами.
|
||||
//
|
||||
// Второй возврат — счётчик «в одном теле приехали версии одного ключа с РАЗНЫМ
|
||||
// содержанием», симметричный по построению: считаются кандидаты, чья форма
|
||||
// отличается от формы победителя. По форме, а не по байтам: порядок ключей у
|
||||
// HAE нестабилен и дребезг последнего разряда тоже, так что побайтовый счётчик
|
||||
// срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
|
||||
// понадобился бы.
|
||||
//
|
||||
// Счётчик отдельный от «удержаний», а не общий с ними. Две версии в одном теле —
|
||||
// это НЕ потеря содержания: победитель ложится в витрину целиком, и удерживать
|
||||
// нечего. Смешивать их значило бы отвечать одним числом на два вопроса, которые
|
||||
// лечатся по-разному, — а на число удержаний опирается единственный контроль
|
||||
// того, что правило покрытия не стало слишком строгим.
|
||||
func dedupeEntities(ctx context.Context, versions []entityVersion) ([]entityVersion, int, []EntityRef, error) {
|
||||
byKey := make(map[EntityRef][]entityVersion, len(versions))
|
||||
order := make([]EntityRef, 0, len(versions))
|
||||
|
||||
for _, v := range versions {
|
||||
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
|
||||
if _, seen := byKey[ref]; !seen {
|
||||
order = append(order, ref)
|
||||
}
|
||||
byKey[ref] = append(byKey[ref], v)
|
||||
}
|
||||
|
||||
out := make([]entityVersion, 0, len(order))
|
||||
diverging := 0
|
||||
var divergingAt []EntityRef
|
||||
|
||||
for _, ref := range order {
|
||||
cands, dropped := collapseEqualForms(byKey[ref])
|
||||
winner, _, err := pickBest(ctx, cands, entityDominates, entityLess)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
out = append(out, cands[winner])
|
||||
|
||||
// Схлопнутые копии победителя различием не считаются: их форма ему
|
||||
// равна. Считаются все прочие — и оставшиеся кандидаты, и те, что
|
||||
// схлопнулись в них.
|
||||
differing := 0
|
||||
for i := range cands {
|
||||
if i != winner {
|
||||
differing += 1 + dropped[i]
|
||||
}
|
||||
}
|
||||
if differing > 0 {
|
||||
diverging += differing
|
||||
if len(divergingAt) < maxEntityRefsReported {
|
||||
divergingAt = append(divergingAt, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, diverging, divergingAt, nil
|
||||
}
|
||||
|
||||
// collapseEqualForms схлопывает версии с одинаковой канонической формой в одну,
|
||||
// оставляя минимальные исходные байты. Второй возврат — сколько копий сложилось
|
||||
// в каждого оставшегося кандидата (нужно счётчику различий).
|
||||
//
|
||||
// Схлопывание обязательно, а не желательно: без него тело с двадцатью тысячами
|
||||
// повторов одного `id` даёт четыреста миллионов сравнений покрытия, каждое с
|
||||
// обходом массивов. Тело в пределах приёма такое вмещает.
|
||||
func collapseEqualForms(versions []entityVersion) ([]entityVersion, []int) {
|
||||
byForm := make(map[string]int, len(versions))
|
||||
out := make([]entityVersion, 0, len(versions))
|
||||
dropped := make([]int, 0, len(versions))
|
||||
|
||||
for _, v := range versions {
|
||||
form := string(v.key)
|
||||
i, seen := byForm[form]
|
||||
if !seen {
|
||||
byForm[form] = len(out)
|
||||
out = append(out, v)
|
||||
dropped = append(dropped, 0)
|
||||
continue
|
||||
}
|
||||
dropped[i]++
|
||||
if bytes.Compare(v.raw, out[i].raw) < 0 {
|
||||
// Байты решают внутри группы ровно так же, как между группами:
|
||||
// порядок элементов на проводе не имеет права выбирать содержимое.
|
||||
// Заголовок едет вместе с байтами — он от них производен.
|
||||
out[i] = v
|
||||
}
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// mergeEntities сливает сущности одной секции с сохранёнными.
|
||||
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
|
||||
for _, v := range versions {
|
||||
stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
|
||||
if !found {
|
||||
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
written++
|
||||
continue
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: совпал, значит содержимое то же, и читать
|
||||
// его не приходится вовсе. Тренировка переприсылается каждой доставкой,
|
||||
// пока не доедет маршрут, — на живом архиве 44 копии дают три различных
|
||||
// содержимых.
|
||||
//
|
||||
// Но провенанс при этом обновить НАДО. Сохранённая позиция журнала
|
||||
// участвует в тай-брейке «содержание равно», и если в ней осталась
|
||||
// первая свёрнутая копия вместо победителя журнала, отложенная доставка
|
||||
// вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
// разойдётся с пересборкой, молча и в содержимом тренировки.
|
||||
//
|
||||
// Предел назван вслух: обновляется провенанс, но НЕ байты. При
|
||||
// совпавшей канонической форме в витрине остаются байты той доставки,
|
||||
// что свернулась первой, — а порядок ключей у HAE нестабилен, значит у
|
||||
// живого приёма и пересборки они могут различаться. Отпечаток этого не
|
||||
// различает (он считает по канонической форме), содержания не теряется
|
||||
// ничего, а переписывать мегабайтный маршрут на каждой из двадцати
|
||||
// шести присылок ради выбора между эквивалентными литералами — цена
|
||||
// несоразмерная.
|
||||
if stored.hash == v.hash {
|
||||
if stored.from.before(v.from) {
|
||||
if err := touchEntityProvenance(ctx, tx, table, v); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
prev := newStoredVersion(storedRaw, stored.hash, stored.from)
|
||||
|
||||
takeIncoming, lost := pickEntity(&prev, &v)
|
||||
if lost {
|
||||
held++
|
||||
if len(heldAt) < maxEntityRefsReported {
|
||||
heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
|
||||
}
|
||||
}
|
||||
if !takeIncoming {
|
||||
continue
|
||||
}
|
||||
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, held, heldAt, nil
|
||||
}
|
||||
|
||||
// touchEntityProvenance поднимает провенанс сущности до более поздней доставки
|
||||
// журнала, не трогая содержимое.
|
||||
//
|
||||
// `updated_at` НЕ двигается, и это отдельное решение, а не экономия. Тренировка
|
||||
// приезжает до двадцати шести раз; бамп метки на каждой сделал бы её меткой
|
||||
// касания строки, а не изменения содержимого, и потребитель запроса «что
|
||||
// изменилось с момента X» получил бы двадцать шесть ложных изменений,
|
||||
// неотличимых от настоящего досчёта. Провенанс несёт собственную метку —
|
||||
// времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||||
//
|
||||
// Счётчик записанных сущностей такое обновление тоже не увеличивает: он считает
|
||||
// СОДЕРЖИМОЕ витрины, и сравнимость его с прежними замерами важнее учёта
|
||||
// обновлённой ссылки.
|
||||
func touchEntityProvenance(ctx context.Context, tx *sql.Tx, table string, v entityVersion) error {
|
||||
q := `UPDATE ` + table + ` SET delivery_id = ?, delivery_received_at = ?` + entityWhere(table)
|
||||
|
||||
args := append([]any{v.from.ID, FormatTime(v.from.ReceivedAt)},
|
||||
entityKeyArgs(table, v.head.Kind, v.head.ID)...)
|
||||
|
||||
res, err := tx.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update %s provenance: %w", table, err)
|
||||
}
|
||||
// Строка гарантированно существует: её заголовок прочитан этой же
|
||||
// транзакцией десятью строками выше. Ноль означал бы, что ключ собран не
|
||||
// теми колонками, — а провенанс в отпечаток витрины не входит, значит
|
||||
// молчаливый промах не поймает ни один оракул сходимости. Соседи по файлу
|
||||
// (FinishParse, MarkSealed) проверяют по той же причине.
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update %s provenance: %w", table, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("update %s provenance: %w", table, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedEntityHead struct {
|
||||
hash string
|
||||
from DeliveryRef
|
||||
}
|
||||
|
||||
func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
|
||||
q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)
|
||||
|
||||
var (
|
||||
head storedEntityHead
|
||||
receivedAt string
|
||||
)
|
||||
row := queryEntity(ctx, tx, q, table, kind, id)
|
||||
err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedEntityHead{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
|
||||
}
|
||||
// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
|
||||
// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
|
||||
// сохранённую версию «самой ранней в журнале», и её затирала бы любая
|
||||
// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
|
||||
head.from.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
return storedEntityHead{}, false, err
|
||||
}
|
||||
return head, true, nil
|
||||
}
|
||||
|
||||
func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
|
||||
q := `SELECT payload FROM ` + table + entityWhere(table)
|
||||
|
||||
var payload []byte
|
||||
if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
|
||||
return nil, fmt.Errorf("select %s payload: %w", table, err)
|
||||
}
|
||||
raw, err := gunzipBytes(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// entityWhere и queryEntity держат разницу между таблицами в одном месте:
|
||||
// у тренировки ключ — `id`, у записи — пара `kind + id`.
|
||||
func entityWhere(table string) string {
|
||||
if table == recordTable {
|
||||
return ` WHERE kind = ? AND id = ?`
|
||||
}
|
||||
return ` WHERE id = ?`
|
||||
}
|
||||
|
||||
// entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число
|
||||
// `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их
|
||||
// соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния,
|
||||
// то есть на пути, который повторяется до пяти раз и оканчивается `failed` у
|
||||
// доставки, а не отказом сборки.
|
||||
func entityKeyArgs(table, kind, id string) []any {
|
||||
if table == recordTable {
|
||||
return []any{kind, id}
|
||||
}
|
||||
return []any{id}
|
||||
}
|
||||
|
||||
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
|
||||
return tx.QueryRowContext(ctx, q, entityKeyArgs(table, kind, id)...)
|
||||
}
|
||||
|
||||
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
|
||||
payload, err := gzipBytes(v.raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := FormatTime(now)
|
||||
received := FormatTime(v.from.ReceivedAt)
|
||||
|
||||
if table == recordTable {
|
||||
const q = `
|
||||
INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
|
||||
delivery_id, delivery_received_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (kind, id) DO UPDATE SET
|
||||
ts_utc = excluded.ts_utc,
|
||||
tz_offset = excluded.tz_offset,
|
||||
payload = excluded.payload,
|
||||
content_hash = excluded.content_hash,
|
||||
delivery_id = excluded.delivery_id,
|
||||
delivery_received_at = excluded.delivery_received_at,
|
||||
updated_at = excluded.updated_at`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
|
||||
payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
|
||||
return fmt.Errorf("upsert record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
|
||||
payload, content_hash, delivery_id, delivery_received_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
start_utc = excluded.start_utc,
|
||||
end_utc = excluded.end_utc,
|
||||
tz_offset = excluded.tz_offset,
|
||||
duration_sec = excluded.duration_sec,
|
||||
payload = excluded.payload,
|
||||
content_hash = excluded.content_hash,
|
||||
delivery_id = excluded.delivery_id,
|
||||
delivery_received_at = excluded.delivery_received_at,
|
||||
updated_at = excluded.updated_at`
|
||||
|
||||
var duration any
|
||||
if v.head.Duration != nil {
|
||||
duration = *v.head.Duration
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
|
||||
v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
|
||||
stamp, stamp); err != nil {
|
||||
return fmt.Errorf("upsert workout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
|
||||
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
|
||||
type Workout struct {
|
||||
ID string
|
||||
Name string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
OffsetSeconds int
|
||||
Duration *float64
|
||||
Raw json.RawMessage
|
||||
Delivery string
|
||||
}
|
||||
|
||||
// Workout читает тренировку по идентификатору.
|
||||
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
|
||||
const q = `
|
||||
SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
|
||||
FROM workout WHERE id = ?`
|
||||
|
||||
var (
|
||||
w Workout
|
||||
start, end string
|
||||
duration sql.NullFloat64
|
||||
payload []byte
|
||||
deliveryFrom string
|
||||
)
|
||||
err := s.db.QueryRowxContext(ctx, q, id).
|
||||
Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Workout{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Workout{}, fmt.Errorf("select workout: %w", err)
|
||||
}
|
||||
|
||||
if w.Start, err = ParseTime(start); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
if w.End, err = ParseTime(end); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
if duration.Valid {
|
||||
v := duration.Float64
|
||||
w.Duration = &v
|
||||
}
|
||||
if w.Raw, err = gunzipBytes(payload); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
w.Delivery = deliveryFrom
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Record — запись секции с собственным идентификатором.
|
||||
type Record struct {
|
||||
Kind string
|
||||
ID string
|
||||
TS time.Time
|
||||
OffsetSeconds int
|
||||
Raw json.RawMessage
|
||||
Delivery string
|
||||
}
|
||||
|
||||
// Record читает запись по роду и идентификатору.
|
||||
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
|
||||
const q = `
|
||||
SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
|
||||
FROM record WHERE kind = ? AND id = ?`
|
||||
|
||||
var (
|
||||
r Record
|
||||
ts string
|
||||
payload []byte
|
||||
deliveryFrom string
|
||||
)
|
||||
err := s.db.QueryRowxContext(ctx, q, kind, id).
|
||||
Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("select record: %w", err)
|
||||
}
|
||||
|
||||
if r.TS, err = ParseTime(ts); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if r.Raw, err = gunzipBytes(payload); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
r.Delivery = deliveryFrom
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
|
||||
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
|
||||
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
|
||||
return 0, fmt.Errorf("count workouts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountRecords возвращает число записей секций с собственным `id`.
|
||||
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
|
||||
return 0, fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Внутренний тест, потому что проверяемое наружу не отдаётся: `updated_at` —
|
||||
// колонка, а не поле модели. Обещание «метка означает изменение содержимого, а
|
||||
// не касание строки» держится только этой проверкой, и внешний тест для неё
|
||||
// потребовал бы публичного метода ради теста.
|
||||
func TestMergeПовторНеДвигаетМеткуИзменения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
raw := json.RawMessage(`{"id":"w1","route":[{"lat":1},{"lat":2}],"totalEnergy":{"qty":20}}`)
|
||||
wo := IncomingEntity{
|
||||
ID: "w1",
|
||||
Kind: "workouts",
|
||||
Start: time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2025, 6, 5, 7, 10, 0, 0, time.UTC),
|
||||
Raw: raw,
|
||||
}
|
||||
merge := func(id string, at time.Time) MergeStats {
|
||||
t.Helper()
|
||||
s, err := st.Merge(ctx, Incoming{Workouts: []IncomingEntity{wo}},
|
||||
DeliveryRef{ID: id, ReceivedAt: at})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
updatedAt := func() string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := st.db.QueryRowContext(ctx,
|
||||
`SELECT updated_at FROM workout WHERE id = 'w1'`).Scan(&v); err != nil {
|
||||
t.Fatalf("чтение updated_at: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
base := time.Date(2025, 6, 5, 8, 0, 0, 0, time.UTC)
|
||||
merge("d1", base)
|
||||
before := updatedAt()
|
||||
|
||||
// Тренировка приезжает до 26 раз, пока источник её досчитывает. Провенанс
|
||||
// при этом обязан подняться, а метка изменения — нет: иначе она становится
|
||||
// меткой касания строки, и запрос «что изменилось с момента X» получает 26
|
||||
// ложных изменений, неотличимых от настоящего досчёта.
|
||||
merge("d2", base.Add(5*time.Minute))
|
||||
if after := updatedAt(); after != before {
|
||||
t.Errorf("метка изменения двинулась без изменения содержимого: %s → %s", before, after)
|
||||
}
|
||||
|
||||
w, err := st.Workout(ctx, "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
if w.Delivery != "d2" {
|
||||
t.Fatal("провенанс не обновился — тест проверяет не то")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
|
||||
// вторичен: правило замены смотрит на содержание, а не на колонки.
|
||||
func workout(t *testing.T, id, raw string) store.IncomingEntity {
|
||||
t.Helper()
|
||||
|
||||
return store.IncomingEntity{
|
||||
ID: id,
|
||||
Kind: "workouts",
|
||||
Name: "На улице Ходьба",
|
||||
Start: ts(t, "2025-06-05T07:00:00Z"),
|
||||
End: ts(t, "2025-06-05T07:10:00Z"),
|
||||
OffsetSeconds: 3 * 3600,
|
||||
Raw: json.RawMessage(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
|
||||
t.Helper()
|
||||
|
||||
return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
|
||||
}
|
||||
|
||||
func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
|
||||
t.Helper()
|
||||
|
||||
stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
|
||||
if err != nil {
|
||||
t.Fatalf("слияние сущностей: %v", err)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const (
|
||||
// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
|
||||
// полей: именно так тренировка и меняется между версиями (досчёт энергии),
|
||||
// и именно на этом ломается правило полноты, написанное для точек.
|
||||
woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||
woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
|
||||
woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woIncomparable = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
|
||||
)
|
||||
|
||||
func storedRaw(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
|
||||
w, err := st.Workout(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
return string(w.Raw)
|
||||
}
|
||||
|
||||
// Единственная причина повторной присылки тренировки — доезжающий маршрут.
|
||||
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 0 {
|
||||
t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("в витрине не версия с маршрутом")
|
||||
}
|
||||
}
|
||||
|
||||
// Тренировка досчитывается задним числом ровно так же, как минутное ведро:
|
||||
// набор полей тот же, значения новые. Тай-брейк по канонической форме
|
||||
// заморозил бы её на произвольной версии навсегда.
|
||||
func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
|
||||
t.Error("досчитанная версия не легла в витрину")
|
||||
}
|
||||
}
|
||||
|
||||
// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
|
||||
// в порядке журнала только среди видимых ему доставок, и доставка с более
|
||||
// ранней меткой может свернуться позже.
|
||||
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
early := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
late := from(t, "d2", "2025-06-05T08:05:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
|
||||
mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))
|
||||
|
||||
a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
|
||||
if a != b {
|
||||
t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
|
||||
}
|
||||
if a != woSameShapeNewValues {
|
||||
t.Error("победила версия не из более поздней доставки журнала")
|
||||
}
|
||||
}
|
||||
|
||||
// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
|
||||
// всего журнала. Событие делается наблюдаемым, а не необратимым.
|
||||
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
|
||||
}
|
||||
if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
|
||||
t.Errorf("координаты удержанной версии %v", stats.HeldAt)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("маршрут пропал из витрины")
|
||||
}
|
||||
}
|
||||
|
||||
// Усечённый маршрут ключа не теряет — множеств ключей мало.
|
||||
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("полный маршрут вытеснен усечённым")
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы: у каждой версии есть содержание, которого нет у другой.
|
||||
// Поля не объединяются, вместо этого — счётчик.
|
||||
func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("несравнимая версия заместила сохранённую")
|
||||
}
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
|
||||
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
|
||||
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
|
||||
// форма обязана совпасть.
|
||||
same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
|
||||
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
|
||||
}
|
||||
}
|
||||
|
||||
// Версии в разных порядках подачи: пункты правила, не зависящие от порядка
|
||||
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
|
||||
// а не пары (попарная свёртка уже давала нетранзитивную победу на точках) и
|
||||
// версии с содержимым, равным одной из присланных, — иначе ветка «содержание
|
||||
// равно» не посещается ни разу.
|
||||
func TestMergeПерестановкаВерсийДаётОдноСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type step struct {
|
||||
d store.DeliveryRef
|
||||
raw string
|
||||
}
|
||||
// Четвёртая версия несёт содержимое, РАВНОЕ одной из уже присланных. Без неё
|
||||
// перебор троек с разными хешами ветку «содержание равно» не посещает ни
|
||||
// разу — а именно на ней провенанс устаревал, и живая витрина расходилась с
|
||||
// пересборкой молча.
|
||||
steps := []step{
|
||||
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
|
||||
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
|
||||
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
|
||||
{from(t, "d4", "2025-06-05T08:15:00Z"), woWithRoute},
|
||||
}
|
||||
orders := [][]int{
|
||||
{0, 1, 2, 3}, {3, 2, 1, 0}, {1, 0, 3, 2},
|
||||
{2, 3, 0, 1}, {1, 3, 0, 2}, {3, 0, 2, 1},
|
||||
}
|
||||
|
||||
var want string
|
||||
for i, order := range orders {
|
||||
st := open(t)
|
||||
for _, idx := range order {
|
||||
mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
|
||||
}
|
||||
got := storedRaw(t, st, "w1")
|
||||
if i == 0 {
|
||||
want = got
|
||||
if want != woRicher {
|
||||
t.Fatalf("победила не самая полная версия")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("порядок %v дал другое состояние", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
|
||||
// внутри одной доставки не имеют права разрешаться «последним в массиве».
|
||||
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
}
|
||||
|
||||
// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
|
||||
// один id.
|
||||
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mind := store.IncomingEntity{
|
||||
ID: "e1", Kind: "stateOfMind",
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
|
||||
}
|
||||
ecg := store.IncomingEntity{
|
||||
ID: "e1", Kind: "ecg",
|
||||
Start: ts(t, "2025-06-05T19:00:00Z"),
|
||||
End: ts(t, "2025-06-05T19:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
|
||||
from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние записей: %v", err)
|
||||
}
|
||||
|
||||
for _, kind := range []string{"stateOfMind", "ecg"} {
|
||||
if _, err := st.Record(ctx, kind, "e1"); err != nil {
|
||||
t.Errorf("запись рода %q не найдена: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
|
||||
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
|
||||
func TestFingerprintРазличаетТренировки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(raw string) string {
|
||||
st := open(t)
|
||||
in := store.Incoming{
|
||||
Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
|
||||
Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
|
||||
}
|
||||
if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
full, changed := build(woWithRoute), build(woSameShapeNewValues)
|
||||
if full == changed {
|
||||
t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
|
||||
}
|
||||
if again := build(woWithRoute); again != full {
|
||||
t.Error("отпечаток не воспроизводится на одном содержимом")
|
||||
}
|
||||
}
|
||||
|
||||
// Длительность: ноль — законное измерение, а «не прислали» обязано быть
|
||||
// отличимо от него.
|
||||
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zero := 0.0
|
||||
withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
|
||||
withZero.Duration = &zero
|
||||
withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
w, err := st.Workout(ctx, "w-zero")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration == nil || *w.Duration != 0 {
|
||||
t.Errorf("нулевая длительность потерялась: %v", w.Duration)
|
||||
}
|
||||
w, err = st.Workout(ctx, "w-none")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration != nil {
|
||||
t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
|
||||
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
// Литералы, которые теряет любая пересборка через разобранные значения:
|
||||
// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
|
||||
// символы, которые json.Marshal экранирует по умолчанию.
|
||||
const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
|
||||
"note":"a<b&c>d","isIndoor":false}`
|
||||
|
||||
if _, err := st.Merge(context.Background(),
|
||||
store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
if got := storedRaw(t, st, "w9"); got != raw {
|
||||
t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него
|
||||
// запись WARN об удержанной версии не связать с телом в архиве.
|
||||
func TestWorkoutНесётПровенанс(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
w, err := st.Workout(context.Background(), "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Delivery != "d7" {
|
||||
t.Errorf("провенанс %q, ожидался d7", w.Delivery)
|
||||
}
|
||||
if w.Start.IsZero() || w.End.Before(w.Start) {
|
||||
t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
|
||||
}
|
||||
if w.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Внутри одной доставки «сохранённой» версии не существует — есть только
|
||||
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
|
||||
// имеют права от него зависеть.
|
||||
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
|
||||
const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`
|
||||
|
||||
прямой := open(t)
|
||||
a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))
|
||||
|
||||
обратный := open(t)
|
||||
b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
if a.EntitiesDiverging != b.EntitiesDiverging {
|
||||
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging)
|
||||
}
|
||||
if a.EntitiesDiverging == 0 {
|
||||
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
|
||||
}
|
||||
// Удержаний тут нет: победитель лёг в витрину целиком, терять нечего.
|
||||
// Счётчики разведены именно ради этого различия.
|
||||
if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 {
|
||||
t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d",
|
||||
a.EntitiesHeld, b.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
|
||||
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
|
||||
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(kind, id string) string {
|
||||
st := open(t)
|
||||
rec := store.IncomingEntity{
|
||||
ID: id, Kind: kind,
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"x","valence":0.5}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
if build("a", "b/c") == build("a/b", "c") {
|
||||
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
|
||||
}
|
||||
}
|
||||
|
||||
// Оракулы враждебного прохода ревью: тело контролирует отправитель целиком, и
|
||||
// «версия той же формы без содержания» проходила все проверки — а маршрут это
|
||||
// 95% тренировки, которого в экспорте Apple нет вовсе. Восстановить его после
|
||||
// затирания не может даже пересборка: журнал проиграет то же поражение.
|
||||
const (
|
||||
woRealWorkout = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
|
||||
"maxHeartRate":{"qty":199,"units":"count/min"},
|
||||
"heartRate":{"max":{"qty":199},"avg":{"qty":47.2},"min":{"qty":41}},
|
||||
"heartRateData":[{"Max":199,"Avg":86.1,"Min":41},{"Max":150,"Avg":80.0,"Min":44}],
|
||||
"activeEnergy":[{"qty":49.4,"units":"kJ"},{"qty":12.1,"units":"kJ"}],
|
||||
"totalEnergy":{"qty":66.4,"units":"kJ"},"duration":11.1}`
|
||||
woSkeleton = `{"id":"w7","name":"x","isIndoor":false,
|
||||
"maxHeartRate":1,"heartRate":1,
|
||||
"heartRateData":[null,null],"activeEnergy":[null,null],
|
||||
"totalEnergy":1,"duration":1}`
|
||||
woNulledRoute = `{"id":"w1","name":"На улице Ходьба","route":[null,null,null],
|
||||
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||
woEmptyRoute = `{"id":"w1","name":"На улице Ходьба","route":[{},{},{}],
|
||||
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||
// Тот же смысл, другой порядок ключей и другая запись числа: каноническая
|
||||
// форма совпадает, байты — нет.
|
||||
woSameFormOtherBytes = `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
|
||||
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||
)
|
||||
|
||||
func TestMergeСкелетНеВытесняетТренировку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w7", woRealWorkout))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w7", woSkeleton))
|
||||
|
||||
if got := storedRaw(t, st, "w7"); !strings.Contains(got, "199") {
|
||||
t.Error("тренировка заменена скелетом из скаляров и null")
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалось 1: событие обязано быть видно", stats.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeМаршрутНеЗатираетсяПустышкамиТойЖеДлины(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, poor := range map[string]string{"null": woNulledRoute, "пустые объекты": woEmptyRoute} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", poor))
|
||||
|
||||
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
|
||||
t.Error("маршрут затёрт рядом той же длины без содержания")
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ключ с пустым значением исчезал бы по жребию тай-брейка. Второй разряд
|
||||
// сравнения записан для точек и здесь применяется к сущностям.
|
||||
func TestMergeКлючСПустымЗначениемНеИсчезает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
withEmpty = `{"id":"w1","qty":10,"context":null}`
|
||||
noEmpty = `{"id":"w1","qty":11}`
|
||||
)
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", withEmpty))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", noEmpty))
|
||||
|
||||
if got := storedRaw(t, st, "w1"); !strings.Contains(got, "context") {
|
||||
t.Error("ключ с пустым значением исчез по жребию")
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
// Две версии одного `id` в ОДНОМ теле с разным содержанием: счётчик не имеет
|
||||
// права молчать. До правки он давал ноль — то есть событие проходило бы как
|
||||
// штатное INFO.
|
||||
func TestMergeДвеВерсииВОдномТелеСчитаются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
st := open(t)
|
||||
stats := mergeWorkouts(t, st, d, workout(t, "w1", woWithRoute), workout(t, "w1", woNulledRoute))
|
||||
|
||||
if stats.EntitiesDiverging == 0 {
|
||||
t.Error("две версии одного id в одном теле — счётчик молчит")
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
|
||||
t.Error("в теле победила версия без содержания")
|
||||
}
|
||||
}
|
||||
|
||||
// Побайтовое различие при совпавшей канонической форме событием не является:
|
||||
// порядок ключей у HAE нестабилен и дребезг последнего разряда тоже. Но байты в
|
||||
// витрине обязаны быть одни при любой перестановке — иначе исход зависит от
|
||||
// порядка элементов на проводе.
|
||||
func TestMergeРавнаяФормаРазныеБайтыНеСобытие(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
s1 := mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameFormOtherBytes))
|
||||
обратный := open(t)
|
||||
s2 := mergeWorkouts(t, обратный, d, workout(t, "w1", woSameFormOtherBytes), workout(t, "w1", woWithRoute))
|
||||
|
||||
if s1.EntitiesDiverging != 0 || s2.EntitiesDiverging != 0 {
|
||||
t.Errorf("счётчик сработал на дребезге записи: %d и %d",
|
||||
s1.EntitiesDiverging, s2.EntitiesDiverging)
|
||||
}
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("в витрине разные байты при одинаковом содержимом: победитель зависит от порядка")
|
||||
}
|
||||
}
|
||||
|
||||
// Победитель внутри доставки — функция МНОЖЕСТВА версий. Попарная свёртка
|
||||
// частичного порядка с тотальным тай-брейком нетранзитивна: [A,B,C] давало C,
|
||||
// [B,C,A] давало A.
|
||||
func TestMergeПерестановкаТрёхВерсийВОдномТеле(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
vA = `{"a":[9,9],"b":2,"id":"k"}`
|
||||
vB = `{"a":[1,2],"id":"k"}`
|
||||
vC = `{"a":[1],"c":3,"id":"k"}`
|
||||
)
|
||||
orders := [][]string{
|
||||
{vA, vB, vC}, {vA, vC, vB}, {vB, vA, vC},
|
||||
{vB, vC, vA}, {vC, vA, vB}, {vC, vB, vA},
|
||||
}
|
||||
|
||||
var want string
|
||||
for i, order := range orders {
|
||||
st := open(t)
|
||||
ws := make([]store.IncomingEntity, 0, len(order))
|
||||
for _, raw := range order {
|
||||
ws = append(ws, workout(t, "k", raw))
|
||||
}
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
|
||||
|
||||
got := storedRaw(t, st, "k")
|
||||
if i == 0 {
|
||||
want = got
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("порядок %d дал другого победителя:\n %s\n %s", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс обязан отражать победителя ЖУРНАЛА, а не первую свёрнутую копию.
|
||||
// Иначе доставка, свёрнутая с опозданием, вернёт витрину к прежнему содержимому,
|
||||
// и живая витрина разойдётся с пересборкой молча — в содержимом тренировки.
|
||||
func TestMergeОтложеннаяДоставкаНеВозвращаетПрежнееСодержимое(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
journal := open(t)
|
||||
mergeWorkouts(t, journal, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, journal, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||
mergeWorkouts(t, journal, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
deferred := open(t)
|
||||
mergeWorkouts(t, deferred, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, deferred, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, deferred, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
if a, b := storedRaw(t, journal, "w1"), storedRaw(t, deferred, "w1"); a != b {
|
||||
t.Errorf("состояние зависит от порядка свёртки:\n %s\n %s", a, b)
|
||||
}
|
||||
if a, b := fingerprint(t, journal), fingerprint(t, deferred); a != b {
|
||||
t.Errorf("отпечатки разошлись: %s против %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс поднимается до более поздней доставки журнала даже при совпавшем
|
||||
// хеше — иначе тай-брейк «содержание равно» решает по устаревшей позиции.
|
||||
func TestMergeПовторОбновляетПровенанс(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
|
||||
}
|
||||
w, err := st.Workout(ctx, "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
if w.Delivery != "d2" {
|
||||
t.Errorf("провенанс %q, ожидался d2: победитель журнала — более поздняя доставка", w.Delivery)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс записи обновляется тем же путём, что и у тренировки, но ключ у неё
|
||||
// СОСТАВНОЙ — а значит текст `WHERE` и хвост аргументов обязаны совпадать. Ветка
|
||||
// не покрывалась ни одним тестом, притом что промах давал бы `UPDATE` в ноль
|
||||
// строк, невидимый ни в отпечатке (провенанса там нет), ни в счётчиках.
|
||||
// Род `record` — единственный, где цена необратима: в экспорте Apple его нет.
|
||||
func TestMergeПровенансЗаписиОбновляетсяПоСоставномуКлючу(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
st := open(t)
|
||||
rec := func() store.IncomingEntity {
|
||||
return store.IncomingEntity{
|
||||
ID: "e1", Kind: "stateOfMind",
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
|
||||
}
|
||||
}
|
||||
merge := func(d store.DeliveryRef) store.MergeStats {
|
||||
t.Helper()
|
||||
s, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec()}}, d)
|
||||
if err != nil {
|
||||
t.Fatalf("слияние записи: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
merge(from(t, "d1", "2025-06-05T20:00:00Z"))
|
||||
stats := merge(from(t, "d2", "2025-06-05T20:05:00Z"))
|
||||
if stats.RecordsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.RecordsWritten)
|
||||
}
|
||||
|
||||
got, err := st.Record(ctx, "stateOfMind", "e1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение записи: %v", err)
|
||||
}
|
||||
if got.Delivery != "d2" {
|
||||
t.Errorf("провенанс %q, ожидался d2", got.Delivery)
|
||||
}
|
||||
}
|
||||
|
||||
// Число версий одного ключа приходит из чужого тела, а выбор победителя по ним
|
||||
// квадратичен. Отмена обязана прерывать отбор: без неё тело с двадцатью
|
||||
// тысячами версий занимает единственного воркера свёртки дольше, чем длится его
|
||||
// собственный дедлайн, и очередь встаёт молча при зелёном `/healthz`.
|
||||
func TestMergeОтменаПрерываетВыборПобедителя(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
ws := make([]store.IncomingEntity, 0, 3)
|
||||
for i := range 3 {
|
||||
raw := fmt.Sprintf(`{"id":"w1","qty":%d,"route":[{"lat":%d}]}`, i, i)
|
||||
ws = append(ws, workout(t, "w1", raw))
|
||||
}
|
||||
_, err := st.Merge(ctx, store.Incoming{Workouts: ws}, from(t, "d1", "2025-06-05T08:00:00Z"))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("слияние дало %v, ожидалась отмена", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Версии с совпавшей канонической формой схлопываются ДО квадратичного отбора:
|
||||
// иначе тело, вмещающее сотни тысяч копий одного `id`, стоит часов работы. При
|
||||
// этом схлопывание не имеет права менять исход — победитель тот же.
|
||||
func TestMergeРавныеФормыСхлопываютсяДоОтбора(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ws := make([]store.IncomingEntity, 0, 2000)
|
||||
for range 2000 {
|
||||
ws = append(ws, workout(t, "w1", woWithRoute))
|
||||
}
|
||||
ws = append(ws, workout(t, "w1", woRicher))
|
||||
|
||||
done := make(chan store.MergeStats, 1)
|
||||
go func() {
|
||||
done <- mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
|
||||
}()
|
||||
select {
|
||||
case stats := <-done:
|
||||
if got := storedRaw(t, st, "w1"); got != woRicher {
|
||||
t.Errorf("схлопывание изменило победителя:\n%s", got)
|
||||
}
|
||||
// Победитель — единственная более полная версия; от неё формой
|
||||
// отличаются две тысячи копий победнее, и схлопывание их не прячет:
|
||||
// счётчик считает версии, а не группы.
|
||||
if stats.EntitiesDiverging != 2000 {
|
||||
t.Errorf("различающихся версий %d, ожидалось 2000", stats.EntitiesDiverging)
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("слияние не уложилось в 20 с: схлопывание не работает")
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия, найденная враждебным проходом ревью: безусловный второй разряд
|
||||
// правила покрытия запирал законный досчёт НАВСЕГДА. Версия с пустым ключом и
|
||||
// без маршрута оказывалась несравнимой с версией, у которой маршрут приехал, а
|
||||
// этого ключа нет, — и маршрут не доезжал ни одной доставкой, причём пересборка
|
||||
// проигрывала то же поражение. Второй разряд разрешает спор равных, а не
|
||||
// отменяет первый.
|
||||
func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
сПустымКлючом = `{"id":"w2","activeEnergy":[{"qty":10}],"totalEnergy":null}`
|
||||
сМаршрутом = `{"id":"w2","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||
)
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w2", сПустымКлючом))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w2", сМаршрутом))
|
||||
|
||||
if got := storedRaw(t, st, "w2"); !strings.Contains(got, `"lat":1`) {
|
||||
t.Errorf("маршрут не доехал — досчёт заперт пустым ключом:\n%s", got)
|
||||
}
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 0 {
|
||||
t.Errorf("удержано %d: законный досчёт принят за потерю содержания", stats.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
// Единственная точка, где витрина НЕ является функцией множества доставок, —
|
||||
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
|
||||
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
|
||||
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
|
||||
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
|
||||
// следующим проходом.
|
||||
//
|
||||
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
|
||||
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
|
||||
// текст в обоих местах пора переписать.
|
||||
func TestMergeНесравнимыеВерсииЗависятОтПорядкаСвёртки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
база = `{"id":"w3","activeEnergy":[{"qty":10}]}`
|
||||
сТреком = `{"id":"w3","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2}]}`
|
||||
сЭтажами = `{"id":"w3","activeEnergy":[{"qty":10}],"flightsClimbed":{"qty":3}}`
|
||||
)
|
||||
d1 := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
d2 := from(t, "d2", "2025-06-05T08:05:00Z")
|
||||
d3 := from(t, "d3", "2025-06-05T08:10:00Z")
|
||||
|
||||
журнальный := open(t)
|
||||
mergeWorkouts(t, журнальный, d1, workout(t, "w3", база))
|
||||
mergeWorkouts(t, журнальный, d2, workout(t, "w3", сТреком))
|
||||
mergeWorkouts(t, журнальный, d3, workout(t, "w3", сЭтажами))
|
||||
|
||||
отложенный := open(t)
|
||||
mergeWorkouts(t, отложенный, d1, workout(t, "w3", база))
|
||||
mergeWorkouts(t, отложенный, d3, workout(t, "w3", сЭтажами))
|
||||
stats := mergeWorkouts(t, отложенный, d2, workout(t, "w3", сТреком))
|
||||
|
||||
a, b := storedRaw(t, журнальный, "w3"), storedRaw(t, отложенный, "w3")
|
||||
if a == b {
|
||||
t.Fatal("предел закрылся — перепиши текст в pickEntity и architecture.md")
|
||||
}
|
||||
// И главное: расхождение не молчит.
|
||||
if stats.EntitiesHeld == 0 {
|
||||
t.Error("расхождение по порядку свёртки не отражено счётчиком удержаний")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,47 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotFound — записи нет. Граничную ошибку драйвера (sql.ErrNoRows)
|
||||
// транслируем в доменную здесь же, у источника, чтобы выше по коду не торчал
|
||||
// database/sql.
|
||||
var ErrNotFound = errors.New("запись не найдена")
|
||||
|
||||
// errNoCandidates — выбор победителя позван на пустом множестве. Нарушенный
|
||||
// инвариант вызывающего, а не свойство данных: множество собирается из карты и
|
||||
// пустым быть не может. Ошибкой, а не паникой, потому что путь проходит внутри
|
||||
// свёртки принятой доставки — отказ обязан быть диагностируемым, а не «index
|
||||
// out of range» в стеке фоновой горутины.
|
||||
var errNoCandidates = errors.New("выбор победителя на пустом множестве версий")
|
||||
|
||||
// ErrBusy — база занята, и повторы транзакции этого не пересидели.
|
||||
//
|
||||
// Доменная ошибка, а не код драйвера: на неё ветвится свёртка. Отказ по
|
||||
// занятости не является свойством доставки — работа просто не сделана, и
|
||||
// доставка обязана остаться в очереди. Без этого различения конкуренция за
|
||||
// базу выводила бы доставку из очереди навсегда.
|
||||
var ErrBusy = errors.New("база занята")
|
||||
|
||||
// Transient отвечает, вызван ли отказ ОБСТОЯТЕЛЬСТВАМИ, а не данными.
|
||||
//
|
||||
// Ровно два случая: работу прекратили снаружи и база оказалась занята дольше,
|
||||
// чем длятся повторы транзакции. Оба означают «не сделано», а не «не выходит»,
|
||||
// поэтому работа обязана остаться к повторению.
|
||||
//
|
||||
// Определение живёт здесь, в одном месте, и его читают двое: тот, кто пишет
|
||||
// исход разбора доставки, и тот, кто классифицирует этот исход в счётчики. Две
|
||||
// копии правила разошлись бы, и доставка одновременно осталась бы в очереди и
|
||||
// числилась отказавшей.
|
||||
//
|
||||
// Дедлайн самой операции сюда НЕ входит: не уложившаяся в бюджет работа не
|
||||
// уложится в него и в следующий раз, а бесконечный повтор заведомо
|
||||
// безнадёжного — это очередь, которая не движется.
|
||||
func Transient(err error) bool {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
|
||||
}
|
||||
|
||||
@@ -88,3 +88,77 @@ func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
|
||||
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
|
||||
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
|
||||
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
|
||||
//
|
||||
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
|
||||
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
|
||||
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("миграции: %v", err)
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
if err != nil {
|
||||
t.Fatalf("провайдер: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := p.UpTo(ctx, 6); err != nil {
|
||||
t.Fatalf("миграция до 6: %v", err)
|
||||
}
|
||||
|
||||
const insert = `
|
||||
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
||||
aggregation, period, session_id, bytes, sha256, raw_path,
|
||||
parse_status, points, uncovered_sections)
|
||||
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
|
||||
cases := []struct {
|
||||
id string
|
||||
status string
|
||||
uncovered string
|
||||
want string
|
||||
}{
|
||||
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
|
||||
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
|
||||
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
|
||||
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
|
||||
// Ничего из ставшего покрытым: трогать нечего.
|
||||
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
|
||||
// Подстрока имени секции — не имя секции: отбор идёт по элементу
|
||||
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
|
||||
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
|
||||
// Статус `failed` возвращает только пересборка, а `parsed` этой
|
||||
// миграцией не трогается: у него пустой список непокрытых.
|
||||
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
|
||||
{"d-parsed", ParseDone, `[]`, ParseDone},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
|
||||
t.Fatalf("вставка %s: %v", c.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := p.UpTo(ctx, 7); err != nil {
|
||||
t.Fatalf("миграция до 7: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
var got string
|
||||
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
|
||||
t.Fatalf("чтение %s: %v", c.id, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- Очередью свёртки служит сама таблица: доставка ждёт разбора в статусе
|
||||
-- `pending`, а фоновый воркер выбирает такие строки в порядке журнала. Запрос
|
||||
-- идёт чаще, чем раз в минуту, а `delivery` растёт примерно на 300 строк в
|
||||
-- сутки — без индекса это скан всей таблицы с сортировкой на каждый проход.
|
||||
--
|
||||
-- Индекс ЧАСТИЧНЫЙ, и это не украшение: в установившемся режиме неразобранных
|
||||
-- доставок ноль или одна, поэтому индекс держит ноль-одну строку. Полный
|
||||
-- индекс по `parse_status` хранил бы всю историю (сто тысяч строк в год) ради
|
||||
-- выборки из одной. SQLite применяет частичный индекс, когда условие запроса
|
||||
-- следует из условия индекса — наш случай.
|
||||
--
|
||||
-- Порядок колонок = порядок журнала, тот же, в котором проигрывает пересборка.
|
||||
-- Второй ключ обязателен: `received_at` хранится с секундной точностью, и
|
||||
-- доставки одной секунды без него шли бы в неопределённом порядке.
|
||||
CREATE INDEX delivery_pending ON delivery (received_at, id)
|
||||
WHERE parse_status = 'pending';
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX delivery_pending;
|
||||
@@ -0,0 +1,123 @@
|
||||
-- +goose Up
|
||||
-- Вторая единица хранения витрины: сущность с собственным идентификатором.
|
||||
-- Часовой объект ей не подходит — у неё есть естественный ключ, она редка (за
|
||||
-- двое суток потока две тренировки и две записи состояния разума при 44 и 52
|
||||
-- доставленных копиях), и группировать её по часам незачем.
|
||||
--
|
||||
-- Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||
-- которому идёт выборка (имя, интервал, длительность), а у записи его нет.
|
||||
-- Общая таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти
|
||||
-- родов из шести.
|
||||
CREATE TABLE workout (
|
||||
-- Идентификатор из HealthKit. Приходит из тела и ограничен по длине
|
||||
-- разбором: уезжает и в ключ, и в записи лога.
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
-- Имя как прислал HAE, локализованное («В помещении Ходьба» — машинная
|
||||
-- калька с Indoor Walk). Хранится дословно; стабильный код HealthKit
|
||||
-- припишет задача словаря категориальных значений.
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Интервал в UTC, RFC 3339. Конец, которого нет или который не читается,
|
||||
-- равен началу: ключ — id, схлопывать координаты нечем, а истина остаётся
|
||||
-- в payload.
|
||||
start_utc TEXT NOT NULL,
|
||||
end_utc TEXT NOT NULL,
|
||||
-- Смещение зоны НАЧАЛА: колонка одна, а тренировка через смену зоны дала
|
||||
-- бы два разных.
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Длительность в секундах, как прислал HAE. NULL означает «источник не
|
||||
-- прислал»: ноль — законная длительность, и потребитель, сложивший
|
||||
-- столбец, иначе не отличил бы одно от другого. Не вычисляется из
|
||||
-- интервала — HAE шлёт 91.746 при интервале в 91 секунду.
|
||||
duration_sec REAL,
|
||||
|
||||
-- Тренировка целиком исходными байтами, сжатая gzip: заголовок, маршрут,
|
||||
-- внутренние ряды и сводки. Маршрут — 95% веса (190 КБ из 199,6 у
|
||||
-- десятиминутной прогулки), а такой JSON жмётся примерно в 25 раз.
|
||||
-- Внутрь средствами SQL не заглянуть — та же плата, что у bucket.payload.
|
||||
payload BLOB NOT NULL,
|
||||
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
|
||||
-- Тренировка переприсылается каждой доставкой, пока не доедет маршрут: 44
|
||||
-- копии на живом архиве дают три различных содержимых.
|
||||
content_hash TEXT NOT NULL,
|
||||
|
||||
-- Провенанс: доставка, ЧЬЯ ВЕРСИЯ лежит сейчас, и её метка приёма. Не
|
||||
-- отчётность: по паре (received_at, id) разрешается тай-брейк между
|
||||
-- версиями равной полноты. «Побеждает приехавшая» было бы функцией порядка
|
||||
-- свёртки, а он порядку журнала не равен — доставка с более ранней меткой,
|
||||
-- свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка
|
||||
-- разошлась бы с живым приёмом молча.
|
||||
-- Без DEFAULT намеренно: единственный писатель заполняет обе колонки
|
||||
-- всегда, а умолчание превратило бы его дефект из отказа вставки в тихо
|
||||
-- неверный исход — строка с пустой меткой оказалась бы «самой ранней в
|
||||
-- журнале», и её затирала бы любая приехавшая версия.
|
||||
delivery_id TEXT NOT NULL,
|
||||
delivery_received_at TEXT NOT NULL,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Основной запрос трекера — «заголовки тренировок за период».
|
||||
CREATE INDEX workout_start_utc ON workout (start_utc);
|
||||
|
||||
CREATE TABLE record (
|
||||
-- Род — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||||
-- `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||||
-- именам секций.
|
||||
kind TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
|
||||
-- Метка события в UTC и смещение исходной зоны. У stateOfMind HAE шлёт
|
||||
-- RFC 3339 в UTC, поэтому смещение там всегда 0 — это значит «источник
|
||||
-- прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в
|
||||
-- потоке нет вовсе.
|
||||
ts_utc TEXT NOT NULL,
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
payload BLOB NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
|
||||
delivery_id TEXT NOT NULL,
|
||||
delivery_received_at TEXT NOT NULL,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
-- Ключ — ПАРА, а не один id. Собственный id наблюдался живьём только у
|
||||
-- stateOfMind, где он UUID HealthKit; форма идентификатора остальных пяти
|
||||
-- секций не наблюдалась никем, и короткий несквозной id в двух разных
|
||||
-- секциях затёр бы одну запись другой молча. Пара стоит ноль: запросы к
|
||||
-- записям всегда идут с родом.
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
|
||||
-- «Записи такого-то рода за период» — единственная форма запроса к таблице.
|
||||
CREATE INDEX record_kind_ts ON record (kind, ts_utc);
|
||||
|
||||
-- Покрыли секцию — пересверните. Список непокрытых ключей это снимок покрытия
|
||||
-- на момент свёртки: доставки, свёрнутые до того, как workouts и stateOfMind
|
||||
-- стали покрытыми, остались бы partial со старым списком, и ретеншен вечно
|
||||
-- щадил бы тела, которые больше ничего не хранят сверх витрины.
|
||||
--
|
||||
-- Отбор по ЭЛЕМЕНТУ массива, а не по подстроке тела: имя секции приходит из
|
||||
-- чужого тела, и LIKE '%workouts%' поймал бы ключ, лишь содержащий эту
|
||||
-- подстроку. Перевод точечный, а не «все partial»: доставка с непокрытой ecg
|
||||
-- пересворачивать нечего.
|
||||
UPDATE delivery
|
||||
SET parse_status = 'pending'
|
||||
WHERE parse_status = 'partial'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
WHERE json_each.value IN ('workouts', 'stateOfMind')
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
-- Строки, переведённые в pending, Down обратно не возвращает: какими они были,
|
||||
-- восстановить неоткуда, а pending консервативен — ретеншен его не трогает.
|
||||
DROP INDEX record_kind_ts;
|
||||
DROP TABLE record;
|
||||
DROP INDEX workout_start_utc;
|
||||
DROP TABLE workout;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- +goose Up
|
||||
-- Сколько сущностей с собственным `id` разбор этой доставки пропустил: без
|
||||
-- `id`, с непомерно длинным `id`, с неразбираемой меткой времени или не
|
||||
-- разобравшихся как объект.
|
||||
--
|
||||
-- Заводится не ради отчётности. Ретеншен сырого архива решает «что потеряется,
|
||||
-- если тело удалить», ПО БАЗЕ, и до этой колонки получал ответ «терять нечего»
|
||||
-- ровно там, где потеряна тренировка с маршрутом: сущность в витрину не попала,
|
||||
-- список непокрытых секций пуст, статус `parsed`. Лог здесь не годится — он
|
||||
-- ротируется, а решение об удалении тела необратимо.
|
||||
--
|
||||
-- БЕЗ DEFAULT намеренно: NULL означает «этот разбор пропусков не считал», и это
|
||||
-- НЕ то же, что ноль. Подстановка нуля объявила бы весь исторический журнал
|
||||
-- проверенным — то самое ложное «терять нечего», ради которого колонка и
|
||||
-- заводится, только теперь с видом измерения. Читатель, принимающий по
|
||||
-- счётчику необратимое решение, обязан трактовать NULL как «не удалять».
|
||||
--
|
||||
-- Data-миграции при этом нет, и это сказано числом: прогон всех 118 тел живого
|
||||
-- архива через разбор даёт `noID=0 noTime=0 malformed=0`, то есть корпус
|
||||
-- пропусков не производил и пересворачивать нечего. «Ничего не потерял по
|
||||
-- замеру» и «проверено этим разбором» — разные утверждения, поэтому колонка
|
||||
-- всё равно остаётся NULL до первой пересвёртки.
|
||||
--
|
||||
-- Статус разбора от пропуска сущности не зависит: `partial` определён списком
|
||||
-- непокрытых секций, и второй источник истины для него завёл бы расхождение
|
||||
-- читателей, которое учёт частичного разбора запрещает явно.
|
||||
ALTER TABLE delivery ADD COLUMN skipped_entities INTEGER;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE delivery DROP COLUMN skipped_entities;
|
||||
@@ -0,0 +1,153 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Очередь свёртки — сама таблица, и порядок её обхода это порядок журнала:
|
||||
// `(received_at, id)`, тот же, в котором проигрывает пересборка.
|
||||
func TestPendingDeliveriesИдётВПорядкеЖурнала(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
// Учёт заполняется в порядке, обратном хронологии, и доставки одной секунды
|
||||
// различаются только идентификатором.
|
||||
seedPending(t, st, "d3", at.Add(time.Second))
|
||||
seedPending(t, st, "d2", at)
|
||||
seedPending(t, st, "d1", at)
|
||||
|
||||
got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
want := []string{"d1", "d2", "d3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("выбрано %d доставок, ожидалось %d", len(got), len(want))
|
||||
}
|
||||
for i, id := range want {
|
||||
if got[i].ID != id {
|
||||
t.Errorf("на месте %d доставка %q, ожидалась %q", i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Курсор строго возрастает, и обход им конечен: без этого доставка, у которой
|
||||
// не удалось записать даже исход разбора, выбиралась бы бесконечно.
|
||||
func TestPendingDeliveriesКурсорСтрогоВозрастает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
seedPending(t, st, "d1", at)
|
||||
seedPending(t, st, "d2", at)
|
||||
|
||||
first, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(first) != 1 || first[0].ID != "d1" {
|
||||
t.Fatalf("первая порция %+v", first)
|
||||
}
|
||||
|
||||
second, err := st.PendingDeliveries(ctx, first[0], 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(second) != 1 || second[0].ID != "d2" {
|
||||
t.Fatalf("вторая порция %+v", second)
|
||||
}
|
||||
|
||||
// Доставка, оставшаяся `pending`, за курсором больше не выбирается — именно
|
||||
// на этом стоит завершимость прохода воркера.
|
||||
third, err := st.PendingDeliveries(ctx, second[0], 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(third) != 0 {
|
||||
t.Errorf("за последней доставкой выбрано %d строк", len(third))
|
||||
}
|
||||
}
|
||||
|
||||
// В очередь попадают только неразобранные: свёрнутая доставка из неё выбывает,
|
||||
// иначе воркер сворачивал бы весь журнал на каждом проходе.
|
||||
func TestPendingDeliveriesБерётТолькоНеразобранные(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
seedPending(t, st, "d1", at)
|
||||
seedPending(t, st, "d2", at.Add(time.Second))
|
||||
if err := st.FinishParse(ctx, "d1", store.ParseOutcome{Status: store.ParseDone}); err != nil {
|
||||
t.Fatalf("FinishParse: %v", err)
|
||||
}
|
||||
|
||||
n, err := st.CountPendingDeliveries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPendingDeliveries: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("задолженность %d, ожидалась 1", n)
|
||||
}
|
||||
|
||||
got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "d2" {
|
||||
t.Errorf("в очереди %+v, ожидалась только d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «отказ обстоятельств, а не данных» живёт в одном месте: его читают и
|
||||
// тот, кто пишет исход разбора, и тот, кто классифицирует этот исход.
|
||||
func TestTransientРазличаетОбстоятельстваИДанные(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
"работу прекратили снаружи": {context.Canceled, true},
|
||||
"база занята": {store.ErrBusy, true},
|
||||
"база занята, обёрнута": {fmt.Errorf("слияние: %w", store.ErrBusy), true},
|
||||
"не уложились в бюджет": {context.DeadlineExceeded, false},
|
||||
"записи нет": {store.ErrNotFound, false},
|
||||
"прочее": {errors.New("диск отвалился"), false},
|
||||
"ошибки нет": {nil, false},
|
||||
}
|
||||
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := store.Transient(c.err); got != c.want {
|
||||
t.Errorf("Transient(%v) = %v, ожидалось %v", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
|
||||
t.Helper()
|
||||
|
||||
err := st.CreateDelivery(context.Background(), store.Delivery{
|
||||
ID: id, ReceivedAt: at, RawPath: id + ".json.gz",
|
||||
SHA256: "-", ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("запись доставки %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Откат бинаря поверх новой схемы обязан отказывать, а не стартовать молча:
|
||||
// старый бинарь незнакомые секции игнорирует и доставки за окно отката помечает
|
||||
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
|
||||
// «молчание», и после ретеншена тел окно становится невосстановимым.
|
||||
func TestOpenОтвергаетСхемуИзБудущего(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "healthlog.db")
|
||||
seed(t, path)
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO goose_db_version (version_id, is_applied, tstamp)
|
||||
VALUES (99, 1, datetime('now'))`); err != nil {
|
||||
t.Fatalf("вставка версии из будущего: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
st, err := store.Open(path)
|
||||
if err == nil {
|
||||
_ = st.Close()
|
||||
t.Fatal("Open молча открыл базу со схемой, которой бинарь не знает")
|
||||
}
|
||||
if !errors.Is(err, store.ErrSchemaMismatch) {
|
||||
t.Errorf("Open дал %v, ожидался ErrSchemaMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Версия базы НИЖЕ версии бинаря отказом быть не должна: ради этого случая
|
||||
// миграции и существуют. Асимметрия только у Open — OpenForRead строг.
|
||||
func TestOpenНоваяБазаМигрирует(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "fresh.db")
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open новой базы: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Повторное открытие уже мигрированной базы тоже проходит: current == target.
|
||||
st2, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("повторный Open: %v", err)
|
||||
}
|
||||
_ = st2.Close()
|
||||
}
|
||||
|
||||
// База без журнала миграций нашей не является, и сказать это надо прямо и
|
||||
// сразу. Через goose такой вопрос стоил бы трёх секунд повторов и ответа
|
||||
// «attempt to write a readonly database» — то есть оператор, спросивший про
|
||||
// версию схемы, получил бы ответ про права на файл.
|
||||
func TestOpenForReadЧужаяБазаОтвергаетсяБыстро(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "alien.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE t (a INTEGER)`); err != nil {
|
||||
t.Fatalf("создание чужой таблицы: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
start := time.Now()
|
||||
st, err := store.OpenForRead(path)
|
||||
if err == nil {
|
||||
_ = st.Close()
|
||||
t.Fatal("чужая база открылась на чтение")
|
||||
}
|
||||
if !errors.Is(err, store.ErrSchemaMismatch) {
|
||||
t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err)
|
||||
}
|
||||
// Проверяется свойство «отказ не идёт через повторы записи», а не
|
||||
// конкретная скорость: повторы у goose — три по секунде.
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Errorf("отказ занял %v — путь идёт через попытки записи", d)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// каждом глубоком проходе, и настоящий отказ правила слияния становился
|
||||
// неотличим от нормы — при том что счётчик перезаписей объявлен единственным
|
||||
// наблюдением за этим правилом.
|
||||
func TestMergePointsДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
||||
func TestMergeДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string][2]string{
|
||||
@@ -41,10 +41,10 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
||||
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0])
|
||||
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1])
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{first}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{second}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
||||
// Настоящее столкновение обязано оставить след с координатами объекта: одно
|
||||
// число `overwrites` не говорит, какая метрика и какой час пострадали, и
|
||||
// расследовать перезапись по нему нечем.
|
||||
func TestMergePointsСтолкновениеОставляетКоординаты(t *testing.T) {
|
||||
func TestMergeСтолкновениеОставляетКоординаты(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -74,10 +74,10 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":61}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
||||
// экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но
|
||||
// обещание перестаёт быть правдой, а сравнение байтов при следующей доставке
|
||||
// той же точки начинает промахиваться навсегда.
|
||||
func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
||||
func TestMergeХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -107,7 +107,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
||||
raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}`
|
||||
in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
||||
// Смена единиц не имеет права молча переподписать уже сохранённые точки:
|
||||
// внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их
|
||||
// единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик.
|
||||
func TestMergePointsСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
||||
func TestMergeСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -148,10 +148,10 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
||||
mi.End = mi.Start
|
||||
mi.Raw = []byte(`{"qty":2}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{km}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{km}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{mi}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{mi}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -173,14 +173,14 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо
|
||||
// ровно тогда, когда точки начнут теряться по-настоящему.
|
||||
func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
func TestMergeСчитаетСохранённые(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{p, p, p}, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p, p, p}}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored)
|
||||
}
|
||||
|
||||
stats, err = st.MergePoints(ctx, []store.IncomingPoint{p}, "d2")
|
||||
stats, err = st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повтор: %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
// оставляет частичного состояния — а значит и не зависит от порядка обхода.
|
||||
// Раньше транзакция была на объект, и восемь прогонов одной доставки давали
|
||||
// семь разных наборов записанных объектов.
|
||||
func TestMergePointsОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
||||
func TestMergeОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -218,7 +218,7 @@ func TestMergePointsОтказНеОставляетЧастиОбъектов(t
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err == nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err == nil {
|
||||
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||
}
|
||||
|
||||
|
||||
+147
-6
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
@@ -24,7 +25,27 @@ type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// Open открывает БД по пути и накатывает миграции.
|
||||
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
||||
//
|
||||
// Версия базы ВЫШЕ версии бинаря — отказ, а не повод мигрировать. Иначе откат
|
||||
// бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно,
|
||||
// незнакомые секции игнорирует и доставки за окно отката помечает
|
||||
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
|
||||
// «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно
|
||||
// становится невосстановимым.
|
||||
//
|
||||
// Цена самого отказа названа вслух, потому что она реальна: сервис не
|
||||
// поднимется, а телефон шлёт непрерывно и молча — доставка, не попавшая в
|
||||
// архив, в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря
|
||||
// это действие оператора, который в этот момент рядом и видит отказ сразу
|
||||
// (контейнер уходит в цикл перезапуска), а дыры плотных метрик за время простоя
|
||||
// закроют широкий и глубокий проходы синхронизации. Не закроют `stateOfMind` —
|
||||
// у него доставки HAE единственный источник; это и есть цена. Она меньше цены
|
||||
// молчания, которое портит витрину за всё окно отката незаметно.
|
||||
//
|
||||
// Версия базы НИЖЕ версии бинаря отказом не является: ради этого случая
|
||||
// миграции и существуют. Асимметрия только здесь — OpenForRead остаётся
|
||||
// строгим.
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
||||
if err != nil {
|
||||
@@ -38,6 +59,97 @@ 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)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// Журнал миграций спрашивается ДО goose и структурно, а не по тексту ошибки
|
||||
// драйвера. Причина не в стиле: `GetVersions` при отсутствии таблицы идёт
|
||||
// её СОЗДАВАТЬ, на соединении `mode=ro` это три секунды повторов и отказ
|
||||
// «attempt to write a readonly database» — оператор, спросивший про версию
|
||||
// схемы, получал бы ответ про права на файл. База без журнала миграций
|
||||
// нашей не является, и сказать это надо прямо.
|
||||
ok, err := hasMigrationLog(ctx, db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: журнала миграций в базе нет", ErrSchemaMismatch)
|
||||
}
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Строгое равенство, в отличие от Open: у чтения нет способа догнать схему,
|
||||
// а база старее бинаря отдала бы колонки, которых в ней ещё нет. Так уже
|
||||
// нормировано пересборкой, и настоящее правило её не ослабляет.
|
||||
if inDB != inBinary {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// hasMigrationLog говорит, есть ли в базе журнал миграций goose. Структурный
|
||||
// вопрос к самой базе, а не разбор текста ошибки драйвера: сообщения драйвера
|
||||
// контрактом не являются — правило записано в isBusy и действует здесь.
|
||||
func hasMigrationLog(ctx context.Context, db *sqlx.DB) (bool, error) {
|
||||
const q = `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'goose_db_version'`
|
||||
|
||||
var n int
|
||||
if err := db.GetContext(ctx, &n, q); err != nil {
|
||||
return false, fmt.Errorf("read migration log presence: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// readSchemaVersion отвечает, какая версия схемы лежит в базе и какую знает
|
||||
// бинарь. Единственное место, где версия ЧИТАЕТСЯ, — сравнивают её два способа
|
||||
// открытия по-разному, а читают одинаково.
|
||||
//
|
||||
// Спрашиваем сам goose, а не собственный `SELECT max(version_id)`: имя таблицы
|
||||
// учёта, имя колонки и правило «максимум = текущая версия» принадлежат ему.
|
||||
// Рукописная копия его приватной схемы разошлась бы при обновлении зависимости,
|
||||
// причём не отказом, а тем, что страж перестал бы ловить, — то есть ровно тем,
|
||||
// что страж и обязан не допускать. Заодно исчезает собственный разбор имён
|
||||
// `NNNNN_*.sql` и вопрос «как отличить пустую таблицу от отсутствующей, не
|
||||
// читая текст ошибки драйвера»: на новой базе goose отдаёт 0 сам.
|
||||
//
|
||||
// Оговорка, без которой обещание непроверяемо: `GetVersions` при ОТСУТСТВИИ
|
||||
// таблицы учёта идёт её создавать. На соединении только для чтения это отказ, и
|
||||
// вызывающий обязан отсеять такую базу раньше (см. hasMigrationLog); на
|
||||
// соединении с записью создание законно — им и начинается новая база.
|
||||
func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64, err error) {
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
inDB, inBinary, err = p.GetVersions(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
return inDB, inBinary, nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
func (s *Store) Close() error {
|
||||
if err := s.db.Close(); err != nil {
|
||||
@@ -63,6 +175,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 и
|
||||
@@ -71,21 +195,38 @@ func dsn(path string) string {
|
||||
// пересборка витрины откроет второе, и хранилище не должно зависеть от того,
|
||||
// что вызывающий этого не сделает.
|
||||
func migrate(db *sqlx.DB) error {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
ctx := context.Background()
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose migrations fs: %w", err)
|
||||
return err
|
||||
}
|
||||
if inDB > inBinary {
|
||||
return fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose provider: %w", err)
|
||||
return err
|
||||
}
|
||||
if _, err := p.Up(context.Background()); err != nil {
|
||||
if _, err := p.Up(ctx); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProvider(db *sqlx.DB) (*goose.Provider, error) {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose migrations fs: %w", err)
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose provider: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Now — единая точка генерации времени: UTC, секундная точность.
|
||||
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
||||
// сортировка TEXT совпадает с хронологией.
|
||||
|
||||
@@ -51,7 +51,10 @@ func (s *Store) inTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return fmt.Errorf("транзакция не прошла за %d попыток: %w", txRetries, lastErr)
|
||||
// Занятость называется доменной ошибкой здесь, у источника: выше по коду
|
||||
// не должно торчать ни `sqlite.Error`, ни его коды, а ветвиться на этот
|
||||
// исход нужно — доставка при нём остаётся в очереди.
|
||||
return fmt.Errorf("%w: транзакция не прошла за %d попыток: %v", ErrBusy, txRetries, lastErr) //nolint:errorlint // раскрываем sentinel, причину — намеренно нет
|
||||
}
|
||||
|
||||
func runTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// pickBest выбирает победителя среди кандидатов на одни координаты.
|
||||
//
|
||||
// **Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.**
|
||||
// Попарная свёртка этого не даёт: полнота (или покрытие) — частичный порядок,
|
||||
// тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы,
|
||||
// то есть цикл. При цикле повторная свёртка одной и той же доставки меняет
|
||||
// содержимое витрины, и она перестаёт быть свёрткой журнала. Проверено дважды:
|
||||
// сперва на точках, где нетранзитивность нашлась перебором троек, потом на
|
||||
// сущностях, где ту же ошибку повторили молча.
|
||||
//
|
||||
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
|
||||
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
|
||||
// род метрики будет измерен, — то есть правка одного экземпляра при живом
|
||||
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
|
||||
// свёртки молча.
|
||||
//
|
||||
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
|
||||
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
|
||||
// множество непревзойдённых окажется пустым.
|
||||
//
|
||||
// less обязан быть ТОТАЛЬНЫМ строгим порядком: при неединственном минимуме
|
||||
// победителем оказывается просто первый в срезе, то есть порядок элементов на
|
||||
// проводе, а он у HAE нестабилен.
|
||||
//
|
||||
// Отбор непревзойдённых КВАДРАТИЧЕН по числу кандидатов, и это названо вслух,
|
||||
// потому что число кандидатов приходит из чужого тела. Отсюда `ctx`: цикл, чья
|
||||
// стоимость определяется размером входа, обязан видеть отмену. Без него тело с
|
||||
// двадцатью тысячами версий одного ключа занимало бы единственного воркера
|
||||
// свёртки дольше, чем длится его же дедлайн, — то есть дедлайн, заведённый
|
||||
// ровно против такого случая, не значил бы ничего. Замерено: n=4000 — 3.9 с,
|
||||
// n=8000 — вчетверо больше.
|
||||
//
|
||||
// Вызывающий обязан сокращать множество до входа сюда: совпавших кандидатов
|
||||
// схлопывать, а число различных — ограничивать. Помощник этого не делает
|
||||
// сам — что считать «тем же» кандидатом, знает только он.
|
||||
//
|
||||
// Возвращает индекс победителя и индексы непревзойдённых — вторые нужны тем,
|
||||
// кто считает несравнимость среди них. Пустой срез кандидатов — нарушенный
|
||||
// инвариант вызывающего: оба сегодняшних вызова собирают множество из карты и
|
||||
// пустого дать не могут.
|
||||
func pickBest[T any](ctx context.Context, cands []T, dominates func(a, b T) bool, less func(a, b T) bool) (winner int, maximal []int, err error) {
|
||||
if len(cands) == 0 {
|
||||
return 0, nil, errNoCandidates
|
||||
}
|
||||
|
||||
maximal = make([]int, 0, len(cands))
|
||||
for i := range cands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
beaten := false
|
||||
for j := range cands {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if dominates(cands[j], cands[i]) {
|
||||
beaten = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !beaten {
|
||||
maximal = append(maximal, i)
|
||||
}
|
||||
}
|
||||
|
||||
winner = maximal[0]
|
||||
for _, i := range maximal[1:] {
|
||||
if less(cands[i], cands[winner]) {
|
||||
winner = i
|
||||
}
|
||||
}
|
||||
return winner, maximal, nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-02
|
||||
@@ -0,0 +1,374 @@
|
||||
## Context
|
||||
|
||||
Семь находок дозапущенных проходов ревью (`tmp/triage-late.md`) по коммиту
|
||||
`f8200f7`. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
|
||||
тестами пакетов, `tmp/` в `.gitignore` и жить в нём им нельзя.
|
||||
|
||||
Ограничения, которые задача не выбирает, а наследует:
|
||||
|
||||
- **Свёртка обязана быть функцией журнала.** Живая витрина и `reindex` обязаны
|
||||
сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка
|
||||
элементов на проводе, — дефект по определению.
|
||||
- **Тренировка приезжает повторно, пока источник её досчитывает** (26 копий,
|
||||
три различных содержимых на живом архиве), и значения между копиями
|
||||
расходятся **всегда**. Поэтому правило полноты точек к сущностям неприменимо,
|
||||
и `Covers` существует отдельно от `Relate`.
|
||||
- **Маршрут — 95% веса тренировки**, и в экспорте Apple его нет вовсе.
|
||||
Затирание маршрута необратимо: `reindex` проиграет журнал и получит то же.
|
||||
- **Цена канонизации измерена**: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
|
||||
блокировка удерживается 5.019 с при `busy_timeout` 5000.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
|
||||
- Победитель — функция множества версий: и внутри доставки, и между доставками.
|
||||
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
|
||||
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
|
||||
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
|
||||
- Каноническая форма сущности считается один раз и вне транзакции.
|
||||
- Диагностика разбора не несёт значений из тела.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Хранение сущности с `id` и неразобранной меткой (NULL-метка) — требует схемы
|
||||
и правил чтения витрины.
|
||||
- Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
|
||||
формы и хеша — новая политика, а не правка.
|
||||
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине,
|
||||
что для точек.
|
||||
- Поэлементная сверка **содержимого** элементов ряда (точка маршрута без
|
||||
`altitude`) — см. «Риски».
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `Covers` получает второй разряд, запрет вырождения формы и счёт содержательных элементов
|
||||
|
||||
Сегодня `Covers(g)` требует от `f` лишь наличия каждого содержательного ключа
|
||||
`g` и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
|
||||
(`{"maxHeartRate":1,"heartRateData":[null,null]}` против настоящей тренировки)
|
||||
признался равным настоящей и выиграл тай-брейк журнала.
|
||||
|
||||
Правило дополняется тремя проверками, все — по верхнему уровню:
|
||||
|
||||
1. **Второй разряд: ключи без содержания.** Каждый ключ `g` (включая пустые)
|
||||
обязан быть у `f`. Тот же стандарт записан для точек в `canon.Relate`:
|
||||
«иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
|
||||
`Max` исчезли бы из витрины по жребию тай-брейка». Отличие от `Relate`:
|
||||
там второй разряд применяется только при равенстве первого, здесь включение
|
||||
всех ключей требуется безусловно. Это эквивалентно при равном первом разряде
|
||||
и строже, когда первый разряд неравен, — а строже здесь и нужно: `Covers`
|
||||
отвечает «не потеряем ли содержания».
|
||||
2. **Запрет вырождения формы.** Покрывающая версия не может подменить объект
|
||||
или массив скаляром: если `g[k]` — объект, `f[k]` обязан быть объектом; если
|
||||
массив — массивом. Обратное (скаляр у `g`, объект у `f`) разрешено: `f`
|
||||
богаче формой. Стоимость — O(ключей), первый байт литерала.
|
||||
3. **Содержательные элементы массива.** Рядом с длиной считается число
|
||||
**непустых** элементов. `[null,null,null]` и `[{},{},{}]` сохраняют длину, но
|
||||
не несут ничего, а именно так выглядит затёртый маршрут.
|
||||
|
||||
Про третью проверку важно, что она **не добавляет обхода**: `arrayLen` уже
|
||||
сегодня декодирует каждый элемент массива в выбрасываемый `json.RawMessage`,
|
||||
чтобы посчитать их количество. Добавляется `isEmpty` по литералу элемента —
|
||||
проверка первого байта и, для чисел, `strconv`; в дерево значений элемент не
|
||||
разворачивается. Поэтому цена, названная триажем («полный обход маршрута на
|
||||
каждое сравнение»), уже уплачена, и решение её не умножает.
|
||||
|
||||
**Отвергнуто:** сверка содержимого *внутри* элемента ряда (набор ключей у
|
||||
каждой точки маршрута). Вот она обход действительно умножила бы — на 768 МиБ
|
||||
пика, измеренных пунктом 4, — и остаётся названным пределом (см. «Риски»).
|
||||
|
||||
**Отношение остаётся частичным порядком** — конъюнкция включений множеств и
|
||||
нестрогих неравенств по каждому общему ключу транзитивна. На это опирается
|
||||
решение 2.
|
||||
|
||||
### 2. Победитель внутри доставки — минимум канонической формы среди непревзойдённых
|
||||
|
||||
Попарная свёртка `pickWithinDelivery` нетранзитивна: полнота — частичный
|
||||
порядок, тай-брейк — тотальный, и вместе они образуют цикл. Стандарт для точек
|
||||
записан в `architecture.md` («победитель — функция множества точек, а не порядка
|
||||
их поступления») и реализован в `store.resolve`; для сущностей он применяется
|
||||
дословно: собрать версии ключа, отбросить строго покрытые, среди оставшихся
|
||||
взять минимум канонической формы.
|
||||
|
||||
Порядок обязан быть тотальным **до конца**. У точек кандидаты с равной
|
||||
канонической формой схлопываются ещё до сравнения, поэтому минимум единственен;
|
||||
у сущностей схлопывания нет, и при двух версиях, различающихся только порядком
|
||||
ключей или дребезгом последнего разряда (измеренная норма HAE), «минимум формы»
|
||||
неединственен — в хранилище лёг бы тот элемент, что стоял в массиве раньше.
|
||||
Поэтому последним разрядом сравнения идут исходные байты, ровно тем же
|
||||
движением и по той же причине, что записана в `canon.Less`.
|
||||
|
||||
«Строго покрыта» определено явно: покрыта другой версией и сама её не покрывает.
|
||||
Покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и наивное
|
||||
«выбросить всё, что кем-то покрыто» опустошило бы множество, потеряв обе.
|
||||
|
||||
Счётчик «в одном теле приехали две версии одного ключа с разным содержанием»
|
||||
становится функцией множества тем же движением: считаются кандидаты, чья
|
||||
каноническая форма отличается от формы победителя.
|
||||
|
||||
Здесь требование постановки исполнено **по канонической форме, а не по байтам**,
|
||||
и это сказано прямо, потому что постановка говорит «при равном содержании и
|
||||
разных байтах обязан считать `differs=true`». Цель постановки — чтобы счётчик
|
||||
перестал молчать на двух настоящих разных версиях — достигается: случай оракула
|
||||
(маршрут против маршрута из `null`) считается. Побайтовый вариант отвергнут
|
||||
записанным инвариантом: порядок ключей в JSON от HAE нестабилен и дребезг
|
||||
последнего разряда тоже, ради чего канонизация и заведена, — счётчик по байтам
|
||||
срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
|
||||
понадобился бы. Детерминизм при этом обеспечен не счётчиком, а тай-брейком по
|
||||
байтам выше.
|
||||
|
||||
`pickWithinDelivery` исчезает: она была парной формой того, что теперь делает
|
||||
множество.
|
||||
|
||||
Отбор «максимальные элементы плюс минимум по тотальному порядку» существует в
|
||||
проекте для точек (`store.resolve`) и объявлен стандартом в `architecture.md`.
|
||||
Второй рукописный экземпляр — ровно то, чем был `pickWithinDelivery`, и он
|
||||
разошёлся со стандартом нетранзитивностью. Поэтому механизм выносится в общего
|
||||
помощника, а точки и сущности становятся двумя его вызовами с разными
|
||||
отношениями: `architecture.md` уже обещает смену тай-брейка точек, когда род
|
||||
метрики будет измерен, то есть правка одного экземпляра при живом втором
|
||||
запланирована заранее.
|
||||
|
||||
### 3. Провенанс обновляется при совпавшем хеше
|
||||
|
||||
Совпал хеш — содержимое то же, писать нечего. Но провенанс
|
||||
(`delivery_id`/`delivery_received_at`) остаётся от первой свёрнутой копии, а не
|
||||
от победителя журнала. Следствия два: провенанс устаревает гарантированно на
|
||||
каждой из ~26 повторных присылок, и при возврате содержимого к прежнему
|
||||
(A→B→A) живая витрина расходится с `reindex` — тай-брейк пункта 4 правила
|
||||
сравнивает позиции, а сохранённая позиция неверна.
|
||||
|
||||
Решение: при совпавшем хеше сравнить позиции журнала и, если сохранённая
|
||||
раньше приехавшей, обновить **только** колонки провенанса. Провенанс становится
|
||||
максимумом по журналу среди версий с этим содержимым, то есть функцией
|
||||
множества.
|
||||
|
||||
`updated_at` при этом не двигается — и это отдельное решение, а не экономия.
|
||||
Тренировка приезжает до двадцати шести раз, и бамп метки на каждой сделал бы её
|
||||
меткой **касания строки**, а не изменения содержимого. Потребитель запроса «что
|
||||
изменилось с момента X» — естественного для коллектора и уже заказанного Read
|
||||
API — получил бы двадцать шесть ложных изменений, неотличимых от настоящего
|
||||
досчёта, и выяснилось бы это после того, как потребитель написан. Провенанс
|
||||
несёт собственную метку (время приёма своей доставки), и для тай-брейка её
|
||||
достаточно.
|
||||
|
||||
Счётчик записанных сущностей такое обновление **не** увеличивает: он считает
|
||||
содержимое витрины, и его сравнимость с прежними замерами важнее, чем учёт
|
||||
обновления. Отпечаток витрины провенанса не включает, поэтому сходимость
|
||||
`reindex` от этого решения не зависит — она зависит от него косвенно, через
|
||||
тай-брейк.
|
||||
|
||||
Слово «провенанс» после этого означает у сущности не то, что у часового
|
||||
объекта: у объекта хранится доставка, **создавшая** его, и она не поднимается.
|
||||
Асимметрия законная — у объекта нет замещения версии целиком, — но записана в
|
||||
спеке явно, иначе читатель перенесёт смысл с одного на другое.
|
||||
|
||||
### 4. Каноническая форма считается один раз, вне транзакции
|
||||
|
||||
Комментарий `bucket.go` утверждает, что канонизация вынесена наружу; фактически
|
||||
`analyze()` зовётся из `compareEntities` **внутри** `inTx`, а его результат
|
||||
пишется в **копию** элемента среза и не переживает даже одной попытки.
|
||||
|
||||
Ключевое наблюдение: `canon.Hash(raw)` уже считает полную каноническую форму и
|
||||
**выбрасывает** её, а `analyze()` считает ту же форму заново. То есть дорогая
|
||||
часть и так платится на каждой версии, в `prepareEntities`, вне транзакции.
|
||||
|
||||
Решение: `canon` отдаёт форму и хеш **одним проходом** (`FormAndHash`, внутри —
|
||||
запись в `io.MultiWriter(буфер, sha256)`, той же формой, какой уже написан
|
||||
`HashAll`), `newEntityVersion` зовёт его один раз и держит результат вместе с
|
||||
`canon.Analyze`. Ленивость, `analyze()` и флаг `parsed` уходят.
|
||||
|
||||
Отдельный вызов `HashForm(form []byte)` отвергнут: он вводит контракт
|
||||
очерёдности («сперва `Form`, потом `HashForm`»), где передача сырых байт вместо
|
||||
формы даёт правдоподобный, но неверный хеш, — а компилятор такую подмену не
|
||||
ловит.
|
||||
|
||||
Баланс работы назван честно, потому что он не односторонний:
|
||||
|
||||
- на пути **разошедшегося хеша** (одна доставка из сорока четырёх) — минус одна
|
||||
полная канонизация: сегодня форма считается дважды;
|
||||
- на пути **совпавшего хеша** (сорок три из сорока четырёх) — плюс один мелкий
|
||||
разбор `json.Unmarshal` в `map[string]json.RawMessage`, то есть проход по телу
|
||||
и копия каждого верхнеуровневого значения. Сегодня на этом пути `analyze()` не
|
||||
зовётся вовсе.
|
||||
|
||||
Плюс оценивается величиной входа, минус — величиной входа с константой развёртки
|
||||
в дерево значений, так что суммарно решение не дороже. Но «строго меньше» было
|
||||
бы неправдой, и приёмка меряет пик кучи до и после, а не верит рассуждению.
|
||||
|
||||
Разбор **сохранённой** версии остаётся внутри транзакции: её содержимое читается
|
||||
оттуда же и только когда хеш разошёлся (одна доставка из сорока четырёх).
|
||||
Убрать это можно лишь оптимистичным чтением до транзакции с перепроверкой
|
||||
внутри — это уже пределы размера и потоковый расчёт, то есть остаток.
|
||||
|
||||
### 5. Страж версии схемы переезжает в `Open`
|
||||
|
||||
`OpenForRead` сверяет версию схемы и отказывает при расхождении; `Open`
|
||||
мигрирует безусловно. Поэтому старый бинарь поверх схемы 7 стартует молча,
|
||||
незнакомые секции игнорирует, а доставки за окно отката помечает разобранными —
|
||||
и ничто не намекает, что для этого окна нужен `reindex`.
|
||||
|
||||
Асимметрия у `Open` законная: версия базы **ниже** версии бинаря — это ровно то,
|
||||
ради чего миграции существуют. Отказ ставится на «версия базы **выше** версии
|
||||
бинаря». Асимметрия относится только к `Open`: `OpenForRead` сохраняет строгое
|
||||
равенство, как требует спека пересборки, — иначе `reindex` начал бы читать
|
||||
рабочую базу схемы старее бинаря по колонкам, которых там нет. В одно место
|
||||
выносится **чтение** версии, а не сравнение.
|
||||
|
||||
Чтение берётся у самого goose (`Provider.GetVersions` отдаёт и текущую версию
|
||||
базы, и целевую), потому что имя таблицы учёта, имя колонки и правило «максимум
|
||||
= текущая версия» принадлежат ему: рукописная копия его приватной схемы
|
||||
разошлась бы при обновлении зависимости — и не отказом, а тем, что страж
|
||||
перестал бы ловить. Если окажется, что на соединении только для чтения этот путь
|
||||
требует записи или отдаёт лишнюю задержку (SQLite-диалект goose не умеет
|
||||
`TableExists`, поэтому на отсутствующей таблице уходит в повторы), копия
|
||||
допустима, но одной функцией и с названной причиной — и тогда «таблицы нет»
|
||||
распознаётся структурно (`sqlite_master`) и `NULL` читается как `NULL`
|
||||
(`sql.NullInt64`), а не по тексту ошибки драйвера: сообщения драйвера не
|
||||
контракт, это уже записанное правило проекта.
|
||||
|
||||
**Цена отказа названа, потому что она реальна.** Страж останавливает сервис
|
||||
целиком, а телефон шлёт непрерывно и молча: доставка, не попавшая в архив, в
|
||||
журнал не попадает вовсе. Взвешено так: откат бинаря — действие оператора,
|
||||
который в этот момент рядом и видит crash-loop сразу; дыры плотных метрик за
|
||||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||||
`stateOfMind` — у него доставки HAE единственный источник, — и это цена решения.
|
||||
Она меньше цены молчания: молчаливый старт портит витрину за всё окно отката, а
|
||||
узнать об этом неоткуда, и после ретеншена тел чинить будет нечем. Отвергнуты:
|
||||
деградированный режим «принимать и архивировать, свёртку не начинать» (сохраняет
|
||||
оба инварианта, но заводит режим, о существовании которого надо помнить, и
|
||||
правила его видимости) и отказ только воркеру свёртки (требует доказать, что
|
||||
старый бинарь корректно пишет учёт в новую схему, — доказательства нет).
|
||||
|
||||
### 6. Мягкий заголовок сущности
|
||||
|
||||
`entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, и
|
||||
сущность теряется целиком при смене типа любого из пяти. Причина названа точно,
|
||||
потому что от неё зависит выбор решения: роняет не `encoding/json`, а строка
|
||||
`entity.go:51-53`, где любая ошибка разбора считается фатальной. Сам
|
||||
`json.Unmarshal` «skips that field and completes the unmarshaling as best it
|
||||
can» и возвращает `*UnmarshalTypeError`.
|
||||
|
||||
Отсюда напрашивается трёхстрочная альтернатива — `errors.As(err, &ute)` и
|
||||
продолжить с уже заполненным заголовком. Она **отвергается**, и по названной
|
||||
причине: та же документация тут же оговаривает — «it's not guaranteed that all
|
||||
the remaining fields following the problematic one will be unmarshaled». Разбор,
|
||||
построенный на дозаполнении, перестал бы быть функцией тела: одна и та же
|
||||
тренировка давала бы разный заголовок в зависимости от порядка ключей на
|
||||
проводе, а он у HAE нестабилен.
|
||||
|
||||
Решение — штатная точка расширения `encoding/json`: тип `softString` с
|
||||
`UnmarshalJSON`, который на нестроковом значении ничего не пишет и возвращает
|
||||
`nil`. Теги остаются декларативными, ручных извлечений нет, гарантия полная.
|
||||
Заодно сохраняется различение счётчиков: элемент, который сам не объект, даёт
|
||||
ошибку **верхнего** уровня и по-прежнему уходит в «не разобралось как объект», а
|
||||
не в «нет `id`».
|
||||
|
||||
`id` при этом остаётся требованием, а не полем: без него сущность не адресуема.
|
||||
Число вместо строки в `id` — это сменившаяся форма идентификатора, и
|
||||
превращать `42` в `"42"` значило бы придумать идентичность за источник. Такая
|
||||
сущность пропускается прежним счётчиком.
|
||||
|
||||
`start`, приехавший не строкой, на `date` **не** откатывается. Мягкое чтение
|
||||
объявляет непонятое значение отсутствующим, а фолбэк `start → date` существует
|
||||
для сущностей, у которых `start` не прислан вовсе; композиция этих двух правил
|
||||
подставила бы метку другого момента времени, неотличимую от настоящей и ничем не
|
||||
считаемую. Поэтому нестроковый `start` — это неразбираемая метка.
|
||||
|
||||
Граница правила названа вслух: оно закрывает смену **типа**, но не смену
|
||||
**формата строки**, а наблюдался именно дрейф формата дат. Тренировка с датой в
|
||||
незнакомом формате по-прежнему теряется целиком — теперь со счётчиком в базе, —
|
||||
и закрыть это может только хранение сущности с неразобранной меткой, вынесенное
|
||||
остатком.
|
||||
|
||||
**Пропуски становятся видны в базе.** Миграция `00008` добавляет доставке
|
||||
колонку `skipped_entities`; свёртка пишет туда сумму трёх счётчиков пропуска
|
||||
сущностей. Причина не в отчётности: ретеншен решает «что потеряется, если тело
|
||||
удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где
|
||||
теряется тренировка с маршрутом.
|
||||
|
||||
Статус доставки от пропуска сущности **не** меняется: `partial` определён
|
||||
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
|
||||
расхождение, которое спека запрещает явно.
|
||||
|
||||
### 7. Диагностика разбора без значений из тела
|
||||
|
||||
`fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт
|
||||
текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`.
|
||||
Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально.
|
||||
|
||||
Ошибка называет **тип токена** и `dec.InputOffset()`. Смещение полезнее
|
||||
значения: по нему место в теле находится в архиве, а значение из тела в логе не
|
||||
имеет права быть в принципе.
|
||||
|
||||
## Три формы решения главного узла и компромисс каждой
|
||||
|
||||
Главный узел — глубина сравнения содержания при слиянии версий сущности.
|
||||
Рассматривались три, и выбор записан не по умолчанию:
|
||||
|
||||
1. **Поэлементная сверка содержимого рядов** (у каждого элемента маршрута
|
||||
сравнивать набор ключей). Ловит всё, включая точку маршрута без `altitude`.
|
||||
Компромисс: полный обход маршрута с материализацией каждого элемента на
|
||||
каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр
|
||||
правила слияния целиком. Отвергнута ценой.
|
||||
2. **Второй разряд + запрет вырождения формы + счёт содержательных элементов**
|
||||
(выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
|
||||
Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
|
||||
счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
|
||||
мера «сходимость `verify:archive`» его не увидит по построению. Стоимость —
|
||||
O(ключей) плюс `isEmpty` на элементах в уже существующем обходе.
|
||||
3. **Принять предел, оставить только наблюдаемость** (счётчик по различию байт,
|
||||
предел записан в `architecture.md`). Компромисс: маршрут продолжает теряться
|
||||
необратимо при обеднённой версии, а восстановить его после ретеншена тел
|
||||
неоткуда — в экспорте Apple маршрута нет. Отвергнута последствием.
|
||||
|
||||
Форма (2) принята владельцем в постановке; здесь она не переоткрывается, а
|
||||
уточняется недостающими определениями (пустота элемента, строгость покрытия,
|
||||
тотальность порядка) и получает контроль, которого у неё не было.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Порча внутри элемента ряда не ловится** (точка маршрута без `altitude`:
|
||||
длина та же, элемент непуст, форма не выродилась) → предел записывается в
|
||||
`architecture.md` рядом с описанием `Covers` так же прямо, как он записан в
|
||||
комментарии кода. Закрыть его может только сверка с телом в архиве, а тело
|
||||
живёт до ретеншена.
|
||||
- **Второй разряд `Covers` строже прежнего правила** и может удержать версию,
|
||||
которая раньше замещала: досчёт, потерявший ключ с пустым значением, теперь
|
||||
проигрывает. Мера контроля — **не** сходимость `verify:archive`: живой приём и
|
||||
пересборка пользуются одним правилом и одинаково сойдутся на одинаково
|
||||
замороженной версии, то есть слишком строгое правило выглядело бы идеальной
|
||||
сходимостью. Контроль — счётчик удержаний, выведенный в отчёт пересборки, и
|
||||
замер его значения на живом архиве.
|
||||
- **`isEmpty` считает ноль пустотой**, и это переносится на элементы ряда: ряд
|
||||
настоящих нулей будет выглядеть опустошённым → ошибка направлена в безопасную
|
||||
сторону (удерживаем, а не затираем) и видна счётчиком; наблюдённые ряды HAE
|
||||
состоят из объектов. Записано в спеку, потому что после мерджа это часть
|
||||
правила слияния навсегда.
|
||||
- **Мягкий заголовок принимает больше входов**, то есть сущности, ранее
|
||||
уходившие в `SkippedEntityMalformed`, начнут попадать в витрину → это
|
||||
изменение разбора, и по правилу «покрыли — пересверните» такие доставки надо
|
||||
пересворачивать. Замер на живом архиве сделан **до** утверждения формулировок:
|
||||
118 тел, пропусков `noID=0 noTime=0 malformed=0`, то есть пересворачивать
|
||||
нечего, и data-миграции нет.
|
||||
- **Мягкий заголовок увеличивает долю тел, доходящих до канонизации**: сущность,
|
||||
раньше отсекавшаяся на разборе заголовка почти бесплатно, теперь канонизуется
|
||||
целиком, и худший случай по памяти становится достижим на входах, которые до
|
||||
него не доходили → предел на размер сущности из задачи-остатка перестаёт быть
|
||||
желательным и становится **обязательным условием**; записано в её теле.
|
||||
- **Обновление провенанса при совпавшем хеше — дополнительная запись** там, где
|
||||
раньше её не было: ~26 повторных присылок на тренировку → запись касается трёх
|
||||
колонок без `payload`, то есть не трогает самое тяжёлое; счётчик записанных
|
||||
сущностей и `updated_at` не двигаются, и сравнимость замеров сохраняется.
|
||||
- **Страж версии схемы отказывает при старте** — сервис не поднимется на базе
|
||||
из будущего, то есть приём останавливается, а телефон не перешлёт → цена
|
||||
взвешена выше, в решении 5, вместе с отвергнутыми альтернативами; в
|
||||
`architecture.md` уезжает эксплуатационный контракт: как это выглядит
|
||||
(crash-loop контейнера) и чем лечится (возврат бинаря).
|
||||
- **Пункт 5 правила (несравнимые наборы) остаётся функцией порядка
|
||||
проигрывания**, и второй разряд `Covers` делает этот исход чаще → приёмочный
|
||||
критерий сходимости сформулирован условно (перестановка даёт один отпечаток
|
||||
при нулевом счётчике несравнимых), а сам предел записан в `architecture.md`
|
||||
как единственная точка, где витрина не является функцией множества доставок.
|
||||
@@ -0,0 +1,107 @@
|
||||
## Why
|
||||
|
||||
Изменение «тренировки и записи с собственным `id`» (`f8200f7`) прошло ревью не
|
||||
полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались.
|
||||
Дозапуск нашёл девять причин, триаж оставил семь — у каждой прогнанный оракул.
|
||||
Две из них необратимы по последствиям: обеднённая версия тренировки затирает
|
||||
маршрут молча (в экспорте Apple маршрута нет, `reindex` проиграет то же
|
||||
поражение), а одно поле не той формы уносит тренировку целиком, причём доставка
|
||||
при этом числится разобранной — то есть ретеншен, решающий «что потеряется,
|
||||
если тело удалить», получит ложное «терять нечего».
|
||||
|
||||
Остальные пять — молчание там, где обещана детерминированность: откат бинаря
|
||||
поверх новой схемы стартует без слова, победитель внутри доставки зависит от
|
||||
порядка элементов на проводе, провенанс устаревает на каждой из ~26 повторных
|
||||
присылок, канонизация идёт внутри транзакции вопреки собственному комментарию
|
||||
(измерено: 768 МиБ пика, 5.019 с удержания блокировки), а тело доставки в 8 МиБ
|
||||
целиком уезжает в текст ошибки и оттуда в `WARN`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Сравнение содержания сущностей получает второй разряд и запрет вырождения
|
||||
формы.** Сегодня `Covers` смотрит только наличие ключа и длину
|
||||
верхнеуровневого массива, поэтому версия-скелет (каждый массив заменён
|
||||
массивом той же длины из `null`, каждый вложенный объект — скаляром)
|
||||
признаётся равной настоящей и выигрывает тай-брейк журнала. Добавляются: ключи
|
||||
без содержания вторым разрядом (как у точек — «иначе ключ с пустым значением
|
||||
исчезает по жребию»), запрет покрывающей версии подменять объект или массив
|
||||
скаляром, и счёт **содержательных элементов** верхнеуровневого массива рядом с
|
||||
его длиной. Предел правила остаётся названным вслух и не закрывается:
|
||||
сокращение **внутри** элемента ряда (точка маршрута без `altitude`) ловится
|
||||
только сверкой с телом в архиве.
|
||||
- **Победитель внутри одной доставки становится функцией множества версий, а не
|
||||
порядка элементов массива.** Стандарт уже записан для точек и для сущностей
|
||||
молча не применён: попарная свёртка частичного порядка с тотальным тай-брейком
|
||||
нетранзитивна — `[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Механизм выносится в
|
||||
одного помощника, общего с точками. Счётчик различающихся версий при этом
|
||||
считает по **канонической форме**, а не по байтам: требование постановки
|
||||
(«differs при разных байтах») исполнено по форме, потому что порядок ключей у
|
||||
HAE нестабилен и побайтовый счётчик срабатывал бы на норме потока;
|
||||
детерминизм обеспечен тай-брейком по байтам, а не счётчиком.
|
||||
- **Провенанс обновляется при совпавшем хеше.** Сегодня совпадение хеша
|
||||
пропускает запись вместе с провенансом, и в сущности остаётся первая
|
||||
свёрнутая копия, а не победитель по журналу; при возврате содержимого к
|
||||
прежнему живая витрина расходится с `reindex`.
|
||||
- **Одно поле не того ТИПА больше не уносит сущность.** Пять полей заголовка
|
||||
читаются мягко — тем же принципом, который уже записан в коде для `duration`.
|
||||
Граница названа вслух: правило закрывает смену типа значения, но **не** смену
|
||||
формата строки, а наблюдался именно дрейф формата дат — тренировка с
|
||||
незнакомой датой по-прежнему теряется целиком, и закрыть это может только
|
||||
хранение сущности с неразобранной меткой (остаток). Пропуски сущностей при
|
||||
этом становятся видны в **учётной записи доставки**, а не только в логе,
|
||||
причём «не измерялось» отличимо от нуля.
|
||||
- **Счётчик удержанных версий выводится в отчёт пересборки.** Новое правило
|
||||
строже прежнего, а сходимость отпечатка его не проверяет по построению: живой
|
||||
приём и пересборка пользуются одним правилом и одинаково сойдутся на
|
||||
одинаково удержанной версии.
|
||||
- **`store.Open` сверяет версию схемы перед миграцией.** Версия базы выше
|
||||
версии бинаря — отказ, а не повод мигрировать; прецедент записан в
|
||||
`OpenForRead`.
|
||||
- **Канонизация сущности уезжает за транзакцию по-настоящему.** Каноническая
|
||||
форма считается один раз на версию, до входа в транзакцию, и из неё же
|
||||
берётся хеш — сегодня форма считается дважды (в хеше и в отложенном разборе),
|
||||
причём второй раз внутри `inTx`, который повторяется до пяти раз.
|
||||
- **Текст ошибки разбора перестаёт нести значения из тела**: называется тип
|
||||
токена и смещение во входе.
|
||||
- Лог `delivery failed` на приёме отличает занятость базы от прочих причин.
|
||||
- **Не входит:** хранение сущности с `id`, но неразобранной меткой (NULL-метка);
|
||||
пределы на размер одной сущности и секции и потоковый расчёт формы и хеша;
|
||||
принцип «data-миграции не отбирают строки по обрезаемым спискам»; длина
|
||||
очереди `pending` в `/stats`. Всё четыре уходят задачами беклога.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет. Все семь пунктов — уточнения уже записанных правил разбора и
|
||||
хранения; новое понятие ввёл бы второй словарь для того же домена.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `parsing`: заголовок сущности читается мягко — поле не той формы пропускает
|
||||
само поле, а не сущность; диагностика разбора не несёт значений из тела.
|
||||
- `storage`: содержание сущностей сравнивается с двумя разрядами, запретом
|
||||
вырождения формы и счётом содержательных элементов массива; победитель внутри
|
||||
доставки объявлен функцией множества с тотальным порядком; провенанс
|
||||
обновляется при совпавшем хеше, а метка изменения — нет; учётная запись
|
||||
доставки несёт число пропущенных сущностей, отличая ноль от «не измерялось»;
|
||||
открытие базы сверяет версию схемы; каноническая форма сущности считается вне
|
||||
транзакции и один раз.
|
||||
- `ingest`: лог отказа приёма отличает занятость базы от прочих причин.
|
||||
- `reindex`: число пропущенных сущностей внесено в перечень производных полей,
|
||||
которые пересборка не переносит; отчёт пересборки называет число удержанных
|
||||
версий сущностей.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/canon` — `Covers`, форма массива, экспорт хеша по готовой форме.
|
||||
- `internal/store` — `entity.go` (версия сущности, слияние, провенанс),
|
||||
`bucket.go` (комментарий о канонизации), `store.go` (страж версии схемы),
|
||||
новая миграция `00008` (колонка `skipped_entities` у доставки).
|
||||
- `internal/hae` — `entity.go` (мягкий заголовок), `hae.go` (текст ошибок).
|
||||
- `internal/fold`, `internal/ingest` — проброс счётчика пропусков в учёт,
|
||||
различение занятости базы в логе.
|
||||
- `docs/architecture.md`, `docs/database.md`, `docs/conventions.md`,
|
||||
`docs/review-journal.md`.
|
||||
- Оракулы из `tmp/adv/` переезжают обычными тестами в `internal/canon`,
|
||||
`internal/store`, `internal/hae`.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отказ учёта доставки называет класс причины
|
||||
|
||||
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
|
||||
|
||||
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
|
||||
|
||||
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
|
||||
учётной записи, то есть осиротело, и вернуть его в журнал может только
|
||||
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
|
||||
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
|
||||
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
|
||||
или испорченная база.
|
||||
|
||||
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
|
||||
|
||||
- **WHEN** запись учёта доставки не проходит из-за занятости базы
|
||||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||||
- **AND** запись отличает занятость базы от прочих причин отказа
|
||||
- **AND** тот же отказ по другой причине этого признака не несёт
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разбор секций с собственными идентификаторами
|
||||
|
||||
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||
|
||||
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||
с ними остаётся `partial`.
|
||||
|
||||
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||
|
||||
**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
|
||||
Каждое поле заголовка читается мягко: строка берётся, когда значение является
|
||||
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
|
||||
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
|
||||
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
|
||||
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
|
||||
разобранной.
|
||||
|
||||
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
|
||||
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
|
||||
«skips that field and completes the unmarshaling as best it can», но тут же
|
||||
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
|
||||
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
|
||||
функцией тела: одна и та же тренировка давала бы разный заголовок.
|
||||
|
||||
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
|
||||
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
|
||||
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
|
||||
сущность целиком; закрыть это может только хранение сущности с неразобранной
|
||||
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
|
||||
доходит до учётной записи доставки.
|
||||
|
||||
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
|
||||
не адресуема, и приведение чужого нестрокового значения к строке было бы
|
||||
выдумыванием идентичности за источник. Такая сущность пропускается тем же
|
||||
счётчиком, что и сущность без `id`.
|
||||
|
||||
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
|
||||
`date` MUST NOT — включая `start: null`.
|
||||
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
|
||||
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
|
||||
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
|
||||
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
|
||||
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
|
||||
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
|
||||
строки, и без этого различения он молча уводил бы тренировку на другой момент
|
||||
времени.
|
||||
|
||||
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
|
||||
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
|
||||
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
|
||||
заголовок функцией истории вызовов, а не тела.
|
||||
|
||||
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
|
||||
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
|
||||
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
|
||||
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
|
||||
|
||||
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||
два разных.
|
||||
|
||||
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||
Apple не транслируется» относится и к именам секций.
|
||||
|
||||
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||
секций.
|
||||
|
||||
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||
внутри тренировки, — и смешение задвоило бы ряд.
|
||||
|
||||
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
|
||||
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
|
||||
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
|
||||
|
||||
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||
метрик — большинство потока.
|
||||
|
||||
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||
|
||||
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||
офсетом и длительностью
|
||||
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||
|
||||
- **WHEN** тренировка содержит `heartRateData`
|
||||
- **THEN** точки этого ряда не попадают в точки метрик
|
||||
- **AND** остаются внутри содержимого сущности
|
||||
|
||||
#### Scenario: Запись состояния разума разбирается
|
||||
|
||||
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||
времени и содержимым исходными байтами
|
||||
|
||||
#### Scenario: Имя не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
|
||||
числом
|
||||
- **THEN** тренировка попадает в результат разбора с пустым именем
|
||||
- **AND** её содержимое сохраняется дословно, включая маршрут
|
||||
|
||||
#### Scenario: Конец не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
|
||||
- **THEN** тренировка попадает в результат разбора, а конец равен началу
|
||||
|
||||
#### Scenario: Сущность без идентификатора пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
|
||||
приехал не строкой
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||
|
||||
#### Scenario: Элемент секции не является объектом
|
||||
|
||||
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||
разбираются как обычно
|
||||
|
||||
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||
|
||||
#### Scenario: Элемент секции не объект — свой счётчик
|
||||
|
||||
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
|
||||
- **THEN** факт учитывается счётчиком «не разобралось как объект»
|
||||
- **AND** счётчик «нет `id`» не растёт
|
||||
|
||||
#### Scenario: Повтор ключа метки решается последним значением
|
||||
|
||||
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
|
||||
последней
|
||||
- **THEN** сущность попадает в результат разбора с этой меткой
|
||||
|
||||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
|
||||
(включая `null`)
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком
|
||||
- **AND** поле `date` вместо непонятого `start` не подставляется
|
||||
|
||||
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||
|
||||
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||
|
||||
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||
`start`/`end` в 91 секунду
|
||||
- **THEN** длительность сущности равна `91.746`
|
||||
|
||||
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||
|
||||
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||
- **AND** нулём она MUST NOT становиться
|
||||
|
||||
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||
|
||||
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||
- **THEN** конец сущности равен её началу
|
||||
- **AND** исходное значение остаётся в содержимом дословно
|
||||
|
||||
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||
|
||||
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||
- **AND** разбор не завершается ошибкой
|
||||
|
||||
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||
|
||||
- **WHEN** тело содержит `data.ecg`
|
||||
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||
- **AND** сущностей из неё разбор не отдаёт
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Диагностика разбора не несёт значений из тела
|
||||
|
||||
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
|
||||
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
|
||||
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
|
||||
права быть в принципе.
|
||||
|
||||
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
|
||||
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
|
||||
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
|
||||
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
|
||||
поэтому печатается значением.
|
||||
|
||||
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
|
||||
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
|
||||
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
|
||||
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
|
||||
случае, ради которого требование написано, и обещание «место находится в теле»
|
||||
не выполнялось бы.
|
||||
|
||||
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
|
||||
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
|
||||
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
|
||||
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
|
||||
доставки оказывается в логе полностью и без обрезки.
|
||||
|
||||
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
|
||||
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
|
||||
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
|
||||
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
|
||||
названной длине на границе. Тот же предел SHALL действовать на проверке формы
|
||||
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
|
||||
`DEBUG`, где инвариант тоже требует обрезки.
|
||||
|
||||
#### Scenario: Огромное значение не доезжает до текста ошибки
|
||||
|
||||
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
|
||||
мегабайт
|
||||
- **THEN** разбор завершается ошибкой
|
||||
- **AND** длина текста ошибки не зависит от длины этого значения
|
||||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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,
|
||||
skipped_entities ← начинаются пустыми
|
||||
```
|
||||
|
||||
Перечень производных полей SHALL пополняться **тем же изменением**, которое
|
||||
заводит новое поле: он единственное место, где сказано, чему нельзя пережить
|
||||
пересборку, и следующий автор решает по нему. Поле, не внесённое в перечень,
|
||||
однажды перенесут «для полноты учёта».
|
||||
|
||||
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
|
||||
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
|
||||
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
|
||||
для **всех** доставок, не только подобранных. Запись без тела при этом не
|
||||
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
|
||||
|
||||
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
|
||||
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
|
||||
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
|
||||
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
|
||||
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
|
||||
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
|
||||
ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит
|
||||
«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за
|
||||
измерение текущего — а по нему принимается необратимое решение об удалении тела.
|
||||
|
||||
#### Scenario: Учёт переносится полностью
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
|
||||
плюс число подобранных тел
|
||||
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
|
||||
|
||||
#### Scenario: Слой прошлого разбора в наследование не попадает
|
||||
|
||||
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
|
||||
того же журнала из учёта без проставленных слоёв
|
||||
|
||||
#### Scenario: Число пропущенных сущностей не переносится из журнала
|
||||
|
||||
- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а
|
||||
тела этой доставки в архиве уже нет
|
||||
- **THEN** в базе назначения её число пропущенных сущностей отсутствует
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отчёт пересборки показывает удержанные версии сущностей
|
||||
|
||||
Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не
|
||||
теряем содержания», — тем же счётчиком, что ведёт свёртка.
|
||||
|
||||
Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не
|
||||
проверяет **по построению**: живой приём и пересборка пользуются одним правилом
|
||||
и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое
|
||||
правило — например, замораживающее тренировку на старой версии из-за исчезнувшего
|
||||
ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик
|
||||
несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же
|
||||
рассуждением.
|
||||
|
||||
Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь
|
||||
утверждение, а не отсутствие новостей.
|
||||
|
||||
#### Scenario: Удержанная версия видна в отчёте пересборки
|
||||
|
||||
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
|
||||
теряет содержание сохранённой
|
||||
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Замена версии сущности не теряет содержания
|
||||
|
||||
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||||
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||||
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||||
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||||
`basalEnergy`.
|
||||
|
||||
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||||
содержания** сохранённой. Порядок разбора:
|
||||
|
||||
```
|
||||
1. хеш канонического содержимого совпал → содержимое не пишется,
|
||||
провенанс поднимается до
|
||||
более поздней позиции журнала
|
||||
2. содержание приехавшей покрывает сохранённую
|
||||
и сверх того → приехавшая замещает целиком
|
||||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
4. содержание сравнимо, наборы равны → версия из более поздней
|
||||
доставки журнала
|
||||
5. наборы несравнимы → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
```
|
||||
|
||||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||||
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
|
||||
гасит отношение включения, когда значения общих содержательных ключей
|
||||
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
|
||||
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
|
||||
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
|
||||
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
|
||||
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
|
||||
совпали» здесь быть MUST NOT.
|
||||
|
||||
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
|
||||
содержимого:
|
||||
|
||||
1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
|
||||
непуст;
|
||||
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
|
||||
включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
|
||||
с тем же условием: иначе ключ с пустым значением исчезает по жребию
|
||||
тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
|
||||
пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
|
||||
которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
|
||||
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
|
||||
быть объект; где массив — массив. Версия, подменившая объект или массив
|
||||
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
|
||||
`null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
|
||||
тай-брейк журнала;
|
||||
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
|
||||
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
|
||||
`[null,null,null]` не теряет и длины — притом что маршрут это 95%
|
||||
содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
|
||||
опустошение элементов — законные признаки «приехало меньше».
|
||||
|
||||
Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
|
||||
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
|
||||
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
|
||||
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
|
||||
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
|
||||
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||||
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
|
||||
HAE состоят из объектов, а не из чисел.
|
||||
|
||||
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
|
||||
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
|
||||
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
|
||||
там, где ни то, ни другое ничего не несёт.
|
||||
|
||||
Предел правила называется вслух и не закрывается: сокращение **внутри**
|
||||
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
|
||||
длине) не ловится ничем, кроме сверки с телом в архиве.
|
||||
|
||||
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
|
||||
множества ключей — то же правило, что для точки: такая версия проигрывает любой
|
||||
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
|
||||
|
||||
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||||
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||||
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||||
необратимым.
|
||||
|
||||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||
а не от того, кто раньше добрался до базы.
|
||||
|
||||
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||||
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||||
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
|
||||
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
|
||||
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
|
||||
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
|
||||
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
|
||||
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
|
||||
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
|
||||
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||||
|
||||
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
|
||||
доставка, свёрнутая повторно) ничего не меняют.
|
||||
|
||||
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
|
||||
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||||
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
|
||||
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
|
||||
|
||||
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
|
||||
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
|
||||
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
|
||||
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
|
||||
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
|
||||
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||||
журнала, причём молча.
|
||||
|
||||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||
версий навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
||||
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
|
||||
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
|
||||
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
|
||||
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
|
||||
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
|
||||
до конца**: при совпавших канонических формах решает минимум исходных байтов —
|
||||
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
|
||||
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
|
||||
содержимом. Попарная свёртка здесь
|
||||
неверна ровно так же, как она была неверна для точек: покрытие — частичный
|
||||
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
|
||||
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
|
||||
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
|
||||
SHALL до сравнения с сохранённой.
|
||||
|
||||
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
|
||||
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
|
||||
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
|
||||
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
|
||||
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
|
||||
ни на одно. На счётчик удержаний опирается единственный контроль того, что
|
||||
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
|
||||
шума.
|
||||
|
||||
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
|
||||
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
|
||||
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
|
||||
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
|
||||
значит ничего. Побайтовое различие при
|
||||
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
|
||||
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
|
||||
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
|
||||
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
|
||||
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
|
||||
|
||||
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||||
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||||
наблюдение.
|
||||
|
||||
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
|
||||
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
|
||||
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
|
||||
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
|
||||
счётчиком и `WARN`.
|
||||
|
||||
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно, добавив `route`
|
||||
- **THEN** в хранилище лежит версия с маршрутом
|
||||
|
||||
#### Scenario: Досчитанные значения при том же наборе полей побеждают
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
|
||||
изменившимися значениями, доставкой с более поздней позицией журнала
|
||||
- **THEN** в хранилище лежит приехавшая версия
|
||||
|
||||
#### Scenario: Версия из более ранней доставки не откатывает витрину
|
||||
|
||||
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
|
||||
свёрнута сперва более поздняя по журналу, затем более ранняя
|
||||
- **THEN** в хранилище лежит версия из более поздней доставки
|
||||
- **AND** тот же исход даёт свёртка в обратном порядке
|
||||
|
||||
#### Scenario: Обеднённая версия сохранённую не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
|
||||
сохранённой, **и** с изменившимися значениями общих полей
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
|
||||
тренировки
|
||||
|
||||
#### Scenario: Усечённый маршрут сохранённый не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
|
||||
`route` короче сохранённого
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Маршрут из пустых элементов сохранённый не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
|
||||
элементы которого пусты (`null` либо пустой объект)
|
||||
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Скелет из скаляров сохранённую тренировку не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
|
||||
заменён числом, а каждый массив — массивом той же длины из `null`
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Ключ с пустым значением не исчезает по жребию
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
|
||||
сохранённой было пустым, при совпадающих содержательных ключах
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком удержаний
|
||||
|
||||
#### Scenario: Пустой ключ не запирает законный досчёт
|
||||
|
||||
- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
|
||||
не несёт, но приносит содержательный ключ, которого у сохранённой не было
|
||||
- **THEN** приехавшая замещает сохранённую
|
||||
- **AND** счётчик удержаний не растёт
|
||||
|
||||
#### Scenario: Две версии одной сущности в одном теле
|
||||
|
||||
- **WHEN** тело содержит два элемента секции с одним `id`
|
||||
- **THEN** исход не зависит от их порядка в массиве
|
||||
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||||
|
||||
#### Scenario: Три версии одной сущности в одном теле
|
||||
|
||||
- **WHEN** тело содержит три элемента секции с одним `id`, из которых один
|
||||
покрывает второй, а третий несравним с обоими
|
||||
- **THEN** победитель одинаков при любой перестановке этих трёх элементов
|
||||
|
||||
#### Scenario: Две версии разного содержания при равной длине массивов
|
||||
|
||||
- **WHEN** тело содержит два элемента секции с одним `id`, содержимое которых
|
||||
различается, но множества ключей и длины верхнеуровневых массивов совпадают
|
||||
- **THEN** факт учитывается счётчиком различающихся версий
|
||||
|
||||
#### Scenario: Разные байты при совпавшей канонической форме событием не считаются
|
||||
|
||||
- **WHEN** тело содержит два элемента секции с одним `id`, различающихся только
|
||||
порядком ключей либо записью числа
|
||||
- **THEN** счётчик различающихся версий не растёт
|
||||
- **AND** в хранилище лежат одни и те же байты при любой перестановке элементов
|
||||
|
||||
#### Scenario: Повторная присылка обновляет провенанс
|
||||
|
||||
- **WHEN** та же сущность приезжает повторно с тем же содержимым доставкой,
|
||||
стоящей в журнале позже сохранённой
|
||||
- **THEN** содержимое не переписывается
|
||||
- **AND** провенанс сущности указывает на более позднюю доставку
|
||||
|
||||
#### Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому
|
||||
|
||||
- **WHEN** журнал несёт содержимое A, затем B, затем снова A, и доставка с B
|
||||
свёрнута последней
|
||||
- **THEN** содержимое сущности и отпечаток витрины совпадают со свёрткой того
|
||||
же журнала в его порядке
|
||||
|
||||
#### Scenario: Составной ключ не даёт коллизии отпечатка
|
||||
|
||||
- **WHEN** две витрины различаются только тем, где проходит граница между родом
|
||||
и идентификатором записи
|
||||
- **THEN** отпечатки не совпадают
|
||||
|
||||
#### Scenario: Несравнимые наборы полей не объединяются
|
||||
|
||||
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
|
||||
сохранённой, и теряет содержательный ключ, который у сохранённой есть
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Повторная свёртка того же журнала состояния не меняет
|
||||
|
||||
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||
- **THEN** содержимое сущностей не меняется
|
||||
|
||||
### Requirement: Хранение сущностей с собственным идентификатором
|
||||
|
||||
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
|
||||
часовыми объектами, а по одной строке на сущность: у них есть естественный
|
||||
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
|
||||
разума), и группировать их по часам незачем.
|
||||
|
||||
Единиц хранения две:
|
||||
|
||||
```
|
||||
тренировка ключ id
|
||||
заголовок колонками: имя, начало, конец, офсет зоны, длительность
|
||||
запись ключ род секции + id
|
||||
заголовок колонками: род, метка времени, офсет зоны
|
||||
```
|
||||
|
||||
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
|
||||
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
|
||||
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
|
||||
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
|
||||
телом в архиве.
|
||||
|
||||
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
|
||||
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
|
||||
бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
|
||||
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
|
||||
идентификатора остальных пяти секций не наблюдалась никем, и короткий
|
||||
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
|
||||
стоит ноль: запросы к записям всегда идут с родом.
|
||||
|
||||
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
|
||||
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
|
||||
существует ради выборки по времени и не является разбором содержимого: любая
|
||||
следующая колонка была бы решением за Apple о том, что в тренировке главное.
|
||||
|
||||
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
|
||||
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
|
||||
|
||||
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
|
||||
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
|
||||
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
|
||||
за двое суток и основанием для второй транзакции не является.
|
||||
|
||||
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
|
||||
пропускать запись содержимого, если хеш не изменился. Тренировка
|
||||
переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на
|
||||
живом архиве 44 доставленные копии дают три различных содержимых.
|
||||
|
||||
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
|
||||
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
|
||||
из 44 копий не за чем.
|
||||
|
||||
Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и
|
||||
до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри
|
||||
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
|
||||
`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости
|
||||
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
|
||||
блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка
|
||||
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
|
||||
NOT: это ровно та же работа над теми же байтами.
|
||||
|
||||
Остаточный предел называется вслух: разбор **сохранённой** версии остаётся
|
||||
внутри транзакции — её содержимое читается оттуда же и только когда хеш
|
||||
разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру
|
||||
сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500
|
||||
по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а
|
||||
лишь становится различимым в логе. Закрыть его может только предел на размер
|
||||
сущности вместе с потоковым расчётом — отдельная задача.
|
||||
|
||||
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||||
|
||||
- **WHEN** приезжает тренировка с маршрутом
|
||||
- **THEN** она хранится одной строкой, адресуемой своим `id`
|
||||
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не попадает в метрику
|
||||
|
||||
- **WHEN** тренировка несёт `heartRateData`
|
||||
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
|
||||
|
||||
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
|
||||
|
||||
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
|
||||
- **THEN** в хранилище лежат обе
|
||||
|
||||
#### Scenario: Повторная присылка той же тренировки не пишет в базу
|
||||
|
||||
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и
|
||||
её доставка стоит в журнале не позже сохранённой
|
||||
- **THEN** хеш совпадает и запись не выполняется
|
||||
|
||||
#### Scenario: Отказ посреди доставки не оставляет части сущностей
|
||||
|
||||
- **WHEN** свёртка доставки прерывается на середине
|
||||
- **THEN** не записывается ни одна сущность этой доставки
|
||||
|
||||
#### Scenario: Каноническая форма сущности считается один раз
|
||||
|
||||
- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за
|
||||
занятости базы
|
||||
- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на
|
||||
повторе, ни отдельно от хеша
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Открытие базы отказывает при схеме из будущего
|
||||
|
||||
Открытие витрины SHALL сверять версию схемы базы с версией, вшитой в бинарь, до
|
||||
наката миграций. Версия базы **выше** версии бинаря MUST быть отказом с
|
||||
указанием обеих, а не поводом мигрировать: прецедент уже записан для открытия
|
||||
только на чтение — «расхождение версий — отказ, а не повод мигрировать».
|
||||
|
||||
Без этого откат бинаря проходит молча: старый бинарь поверх новой схемы
|
||||
стартует успешно, незнакомые секции игнорирует и доставки за окно отката
|
||||
помечает разобранными — то есть ничто не намекает, что для этого окна нужна
|
||||
пересборка. Класс «молчание», и цена его растёт вместе с ретеншеном: после
|
||||
удаления тел окно становится невосстановимым.
|
||||
|
||||
Асимметрия относится **только к открытию с накатом миграций**: там версия базы
|
||||
ниже версии бинаря отказом быть MUST NOT — ради этого случая миграции и
|
||||
существуют. Открытие **только на чтение** сохраняет строгое равенство версий,
|
||||
как уже нормировано пересборкой: утилита, которой достаточно прочитать учёт, на
|
||||
базе старее бинаря читала бы колонки, которых там ещё нет. Ослабление этого
|
||||
отказа настоящим требованием запрещено.
|
||||
|
||||
В одно место SHALL выноситься **чтение** версии, а не сравнение: сравнивают эти
|
||||
два способа открытия по-разному, а читают одинаково. Отсутствие журнала
|
||||
миграций (новая база) SHALL означать версию 0, и распознаваться это MUST по
|
||||
структуре базы, а не по тексту ошибки драйвера — сообщения драйвера контрактом
|
||||
не являются, и это уже записанное правило проекта.
|
||||
|
||||
Читать версию система SHALL средствами того же инструмента миграций, которым их
|
||||
накатывает, если он это умеет: имя таблицы учёта, имя колонки и правило
|
||||
«максимум = текущая версия» принадлежат ему, и рукописная копия его приватной
|
||||
схемы разошлась бы при обновлении зависимости — причём не отказом, а тем, что
|
||||
страж перестал бы ловить. Если цена такого чтения неприемлема (например, оно
|
||||
требует записи на соединении только для чтения), копия допустима, но SHALL жить
|
||||
одной функцией с названной вслух причиной.
|
||||
|
||||
Эксплуатационная цена отказа называется вслух, потому что она реальна: сервис не
|
||||
поднимется, а телефон шлёт непрерывно и молча, и доставка, не попавшая в архив,
|
||||
в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря — действие
|
||||
оператора, который в этот момент рядом и видит отказ сразу, а дыры плотных
|
||||
метрик закрывают широкий и глубокий проходы синхронизации. Не закрывается ими
|
||||
`stateOfMind`: у него доставки HAE единственный источник, и окно простоя для
|
||||
него — потеря без возврата. Молчаливый старт при этом стоит дороже: он портит
|
||||
витрину за всё окно отката, и узнать об этом неоткуда.
|
||||
|
||||
#### Scenario: Старый бинарь не открывает базу из будущего
|
||||
|
||||
- **WHEN** в журнале миграций базы стоит версия выше последней, вшитой в бинарь
|
||||
- **THEN** открытие завершается отказом с указанием обеих версий
|
||||
- **AND** миграции не накатываются
|
||||
|
||||
#### Scenario: Новая база открывается и мигрирует
|
||||
|
||||
- **WHEN** базы ещё нет либо журнал миграций пуст
|
||||
- **THEN** открытие проходит и накатывает миграции до версии бинаря
|
||||
|
||||
#### Scenario: Открытие только на чтение остаётся строгим
|
||||
|
||||
- **WHEN** версия схемы базы ниже последней, вшитой в бинарь, и база
|
||||
открывается только на чтение
|
||||
- **THEN** открытие завершается отказом с указанием обеих версий
|
||||
|
||||
### Requirement: Пропущенные сущности видны в учётной записи доставки
|
||||
|
||||
Учётная запись доставки SHALL нести число сущностей, которые разбор пропустил:
|
||||
без `id`, с непомерно длинным `id`, с неразбираемой меткой времени или не
|
||||
разобравшихся как объект.
|
||||
|
||||
Причина не в отчётности. Ретеншен сырого архива решает «что потеряется, если
|
||||
тело удалить», **по базе**, и сегодня получает ответ «терять нечего» ровно там,
|
||||
где потеряна тренировка с маршрутом: сущность в витрину не попала, список
|
||||
непокрытых секций пуст, статус `parsed`. Лог здесь не годится — он ротируется,
|
||||
а решение об удалении тела необратимо.
|
||||
|
||||
Число SHALL замещаться целиком при каждой свёртке доставки, включая замещение
|
||||
нулём: иначе доставка, пропуски которой исчезли вместе с поумневшим разбором,
|
||||
осталась бы помеченной навсегда. Записываться оно SHALL в обоих исходах свёртки
|
||||
— и при успехе, и при отказе, если разбор успел досчитать, — тем же правилом,
|
||||
каким уже записывается список непокрытых секций.
|
||||
|
||||
**«Не измерялось» SHALL быть отличимо от нуля, и на пути отказа тоже.** Разбор,
|
||||
вернувший ошибку, отдаёт нулевые счётчики по построению, а не по измерению;
|
||||
записать этот ноль значило бы объявить проверенной доставку, содержимое которой
|
||||
никто не смотрел. Число SHALL записываться только когда разбор досчитал; во всех
|
||||
прочих исходах колонка MUST оставаться нетронутой — той же идиомой, какой уже
|
||||
сохраняется выведенный слой. Доставки, свёрнутые разбором,
|
||||
который пропусков не считал, значения не имеют, и подстановка нуля объявила бы
|
||||
их проверенными: ретеншен получил бы то самое ложное «терять нечего», ради
|
||||
которого счётчик и заводится, — только теперь с видом измерения. Поэтому
|
||||
колонка допускает отсутствие значения, миграция его не подставляет, а читатель,
|
||||
принимающий по счётчику необратимое решение, SHALL трактовать отсутствие как
|
||||
«не удалять». Замер на живом архиве (118 тел) даёт ноль пропусков всех классов,
|
||||
то есть исторический корпус ничего не потерял, — но «ничего не потерял по
|
||||
замеру» и «проверено этим разбором» это разные утверждения, и колонка обязана
|
||||
их различать.
|
||||
|
||||
Счётчик — производное от разбора поле: пересборка витрины SHALL начинать его
|
||||
пустым и переносить из журнала MUST NOT, иначе свежая витрина унаследует
|
||||
измерение прежнего разбора.
|
||||
|
||||
Статус разбора от пропуска сущности меняться MUST NOT: `partial` определён
|
||||
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
|
||||
расхождение читателей, которое учёт частичного разбора запрещает явно.
|
||||
|
||||
#### Scenario: Пропущенная сущность видна в учёте доставки
|
||||
|
||||
- **WHEN** тело несёт покрытую секцию, один элемент которой не разобрался
|
||||
- **THEN** число пропущенных сущностей у доставки больше нуля
|
||||
- **AND** соседние сущности той же секции сохранены
|
||||
|
||||
#### Scenario: Пересвёртка без пропусков обнуляет счётчик
|
||||
|
||||
- **WHEN** доставка с ненулевым числом пропущенных сущностей сворачивается
|
||||
повторно разбором, который эти элементы понимает
|
||||
- **THEN** число пропущенных сущностей у доставки равно нулю
|
||||
|
||||
#### Scenario: Доставка, свёрнутая до появления счётчика, отличима от нулевой
|
||||
|
||||
- **WHEN** доставка была свёрнута разбором, который пропусков не считал, и с тех
|
||||
пор не пересворачивалась
|
||||
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user