From 833132813413bcca2069711a718858ced3958ed0 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 16:38:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BF=D0=BE=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B8=D1=8F=D0=BD=D0=B8=D1=8E=20=D1=81=D1=83=D1=89=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа. --- cmd/healthlog/reindex_report.go | 10 +- cmd/healthlog/reindex_test.go | 25 + docs/architecture.md | 188 ++++++- docs/backlog/README.md | 5 +- docs/backlog/alert-tishina-potoka.md | 12 + docs/backlog/dozakryt-nahodki-sushchnostej.md | 125 ----- .../backlog/hranenie-sushchnosti-bez-metki.md | 38 ++ docs/backlog/otbor-strok-data-migraciyami.md | 45 ++ docs/backlog/otkat-reliza-posle-migracii.md | 56 ++ docs/backlog/predely-razmera-sushchnosti.md | 70 +++ docs/backlog/retenshen-syrogo-arhiva.md | 17 + docs/backlog/stats-nablyudaemost.md | 8 + docs/conventions.md | 32 ++ docs/database.md | 2 + docs/review-journal.md | 37 ++ internal/canon/canon.go | 220 ++++++-- internal/canon/canon_test.go | 158 ++++++ internal/fold/fold.go | 75 ++- internal/fold/fold_test.go | 128 +++++ internal/hae/entity.go | 110 +++- internal/hae/entity_test.go | 116 +++- internal/hae/hae.go | 83 ++- internal/hae/hae_test.go | 84 +++ internal/hae/testdata/handmade_entities.json | 24 + internal/ingest/ingest.go | 19 +- internal/ingest/ingest_test.go | 70 +++ internal/replay/archive_test.go | 9 +- internal/replay/player.go | 17 +- internal/replay/replay.go | 9 +- internal/replay/replay_test.go | 52 ++ internal/store/bucket.go | 134 +++-- internal/store/delivery.go | 67 ++- internal/store/entity.go | 352 ++++++++---- internal/store/entity_internal_test.go | 72 +++ internal/store/entity_test.go | 416 +++++++++++++- internal/store/errors.go | 7 + .../00008_delivery_skipped_entities.sql | 30 + internal/store/readonly_test.go | 85 +++ internal/store/store.go | 138 +++-- internal/store/winner.go | 77 +++ .../.openspec.yaml | 2 + .../design.md | 374 +++++++++++++ .../proposal.md | 107 ++++ .../specs/ingest/spec.md | 21 + .../specs/parsing/spec.md | 265 +++++++++ .../specs/reindex/spec.md | 81 +++ .../specs/storage/spec.md | 527 ++++++++++++++++++ .../tasks.md | 238 ++++++++ openspec/specs/ingest/spec.md | 23 +- openspec/specs/parsing/spec.md | 128 ++++- openspec/specs/reindex/spec.md | 41 +- openspec/specs/storage/spec.md | 373 +++++++++++-- 52 files changed, 4921 insertions(+), 481 deletions(-) delete mode 100644 docs/backlog/dozakryt-nahodki-sushchnostej.md create mode 100644 docs/backlog/hranenie-sushchnosti-bez-metki.md create mode 100644 docs/backlog/otbor-strok-data-migraciyami.md create mode 100644 docs/backlog/otkat-reliza-posle-migracii.md create mode 100644 docs/backlog/predely-razmera-sushchnosti.md create mode 100644 internal/store/entity_internal_test.go create mode 100644 internal/store/migrations/00008_delivery_skipped_entities.sql create mode 100644 internal/store/winner.go create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/design.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/parsing/spec.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/reindex/spec.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md diff --git a/cmd/healthlog/reindex_report.go b/cmd/healthlog/reindex_report.go index 1a25c37..6e56dc8 100644 --- a/cmd/healthlog/reindex_report.go +++ b/cmd/healthlog/reindex_report.go @@ -28,8 +28,14 @@ func writeReport(w io.Writer, r report) { p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d, отложено %d", r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther, r.replay.Deferred) - p(" слияние: частично разобрано %d, несравнимых наборов %d", - r.replay.Partial, r.replay.Incomparable) + // Удержанные версии сущностей печатаются ВСЕГДА, а не только при ненулевом + // значении: ноль здесь утверждение, а не отсутствие новостей. Отпечаток это + // правило не проверяет по построению — живой приём и пересборка пользуются + // одним правилом и одинаково сойдутся на одинаково удержанной версии, — так + // что счётчик и есть единственный способ увидеть, что правило слияния + // сущностей стало слишком строгим. + p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d", + r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging) if r.replay.Canceled { // Ни отпечаток пересобранной витрины, ни число доставок после прогона при diff --git a/cmd/healthlog/reindex_test.go b/cmd/healthlog/reindex_test.go index 614ca31..c87a414 100644 --- a/cmd/healthlog/reindex_test.go +++ b/cmd/healthlog/reindex_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "os" "path/filepath" "strings" @@ -312,3 +313,27 @@ func TestФайлНазначенияНеМожетБытьПутёмОтсут 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) + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 7922b1e..2ea06d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -486,9 +486,16 @@ task up Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё нет, переносить нечего) и производные от разбора поля учёта — `parse_status`, -`points`, `derived_layer`, `uncovered_sections`. Последнее не косметика: -доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего -разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала. +`points`, `derived_layer`, `uncovered_sections`, `skipped_entities`. Перечень +пополняется **тем же изменением**, которое заводит новое поле: он единственное +место, где сказано, чему нельзя пережить пересборку. + +Это не косметика. Доставка, чей повторный разбор отказал, отдала бы в +наследование слой прежнего разбора, и витрина снова стала бы функцией +предыдущего прогона, а не журнала. У числа пропущенных сущностей цена та же и +хуже: пустота у него означает «не измерялось», и перенесённое число выдавало бы +измерение прежнего разбора за измерение текущего — а по нему принимается +необратимое решение об удалении тела. #### Что не восстанавливается, и это сказано вслух @@ -555,7 +562,7 @@ 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) @@ -858,6 +865,29 @@ data-миграции, переводящей уже принятые `partial`- объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не смешиваются. +**Значение заголовка не того типа стоит одного поля, а не сущности.** Пять полей +(`id`, `name`, `date`, `start`, `end`) читаются мягко: нестроковое значение +считается неприсланным. Иначе `name`, приехавшее числом, уносит тренировку +вместе с маршрутом, а доставка при этом числится разобранной. Мягкость сделана +через `json.Unmarshaler`, а не через разбор ошибки типа постфактум: библиотека +дозаполняет поля «как может», но не обязуется дозаполнить те, что стоят **после** +проблемного, — то есть исход перестал бы быть функцией тела. + +Исключений два, и оба названы. `id`: без строкового идентификатора сущность не +адресуема, а приведение чужого значения к строке было бы выдумыванием +идентичности за источник. `start`: непонятое значение не откатывается на `date` — +подстановка другого поля дала бы метку **другого момента времени**, неотличимую +от настоящей и ничем не считаемую. + +**Граница правила: оно закрывает смену типа, но не смену формата строки.** А +наблюдался именно дрейф формата дат. Тренировка с датой в незнакомом формате +по-прежнему теряется целиком; закрыть это может только хранение сущности с +неразобранной меткой, и это отдельная задача. Пропуск при этом перестал быть +невидимым: число пропущенных сущностей лежит в учётной записи доставки, и +ретеншен, решающий «что потеряется, если тело удалить», больше не получает +ложное «терять нечего». Отсутствие значения в этой колонке означает «не +измерялось» и нулю не равно. + #### Замена версии сущности «Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой @@ -882,16 +912,67 @@ data-миграции, переводящей уже принятые `partial`- счётчик + WARN ``` -**Содержание сравнивается множеством ключей с непустым значением и длиной -верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для -точек, здесь неприменимо, и это проверено выполненной командой: оно гасит -отношение включения до «равенства», когда значения общих ключей разошлись, — а -у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и -заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с -неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому, -что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса -тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не -ловится ничем, кроме сверки с телом в архиве. +**Содержание сравнивается множествами ключей и формой их значений — но не +значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это +проверено выполненной командой: оно гасит отношение включения до «равенства», +когда значения общих ключей разошлись, — а у сущности они расходятся всегда. +Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с +маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы +зелёным. + +Условий покрытия четыре, все по **верхнему уровню**: + +``` +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)`, а не порядок свёртки.** Напрашивавшееся «побеждает @@ -904,9 +985,32 @@ data-миграции, переводящей уже принятые `partial`- точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной из версий навсегда, вместе с недосчитанной энергией. -Две версии одного ключа **внутри одной доставки** позициями не различаются и -разрешаются минимумом канонической формы: порядок элементов в JSON-массиве -нестабилен. +Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же, +писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и +если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная +доставка вернёт витрину к прежнему содержимому — то есть живая витрина +разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения +содержимого не двигается, иначе она становится меткой касания строки и дребезжит +двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с +момента X» получает шум, неотличимый от настоящего досчёта. + +Слово «провенанс» у сущности и у часового объекта значит **разное**, и это +сказано вслух: у объекта хранится доставка, **создавшая** его, и она не +поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она +поднимается до максимума по журналу среди версий с этим содержимым. У объекта +нет замещения версии целиком, у сущности только оно и есть. + +Версии одного ключа **внутри одной доставки** позициями не различаются, и +победитель среди них — **функция множества**, а не порядка элементов массива: +отбрасываются строго покрытые (покрыта другой и сама её не покрывает — +покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы +множество), среди оставшихся берётся минимум канонической формы, а при равных +формах — минимум исходных байтов. Последний разряд не украшение: у сущностей +версии с равной формой не схлопываются, а порядок ключей в JSON от HAE +нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом. +Механизм тот же, что у точек, и живёт он одним помощником на обе единицы +хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой +`[A,B,C]` и `[B,C,A]` выбирали разных победителей. Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый @@ -914,10 +1018,19 @@ MongoDB, и так просилось из слова «перезаписыва восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения множеств и делает событие наблюдаемым вместо необратимого. -Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых -наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у -часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты -истории. +Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно — +в витрине лежит победитель прошлых слияний, а не все кандидаты истории, — +поэтому при несравнимых наборах (пункт 5) исход зависит от порядка +проигрывания. Тот же предел есть у часового объекта. **Это единственная точка, +где витрина не является функцией множества доставок**, и потому утверждение +«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом +счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти +вместе с этим счётчиком. + +Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая +новые содержательные ключи и потерявшая пустой, теперь несравнима вместо +«полнее». Плата принята сознательно — она направлена в сторону удержания, а не +затирания, — и её величину показывает счётчик удержаний в отчёте пересборки. #### Отпечаток и отчёт пересборки идут за витриной @@ -1116,6 +1229,39 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг (с токенами) — отдельно, `0600`. +**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше +версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это +выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает +сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и +правило «максимум = текущая версия» принадлежат ему, и рукописная копия +разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж +перестал бы ловить. + +Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не +работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон +её не перешлёт. Выбор сделан так потому, что откат это действие оператора, +который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за +время простоя закроют широкий и глубокий проходы синхронизации. Не закроют +`stateOfMind`: у него доставки HAE единственный источник — это и есть цена +решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал +бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал +разобранными, а узнать об этом было бы неоткуда. + +Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим: +там отказ даёт любое расхождение версий, включая базу старее бинаря — читать +колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и +структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии +таблицы идёт её создавать, и на соединении «только чтение» это три секунды +повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у +открытия с накатом. + +**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды +миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в +миграциях существует для локальной разработки и на рабочей базе не исполнялся ни +разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит — +чинит только выкатка вперёд. Это цена стража, названная целиком; чем её +смягчать, решает отдельная задача беклога. + ## Открытые вопросы - Механизм доставки образа и запуска на rivendell (compose руками / плейбук). diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c37cc92..22c507b 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -19,9 +19,9 @@ ## блокеры - [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex +- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем — аварийный путь придётся изобретать при остановленном приёме ## высокий -- [Дозакрыть находки ревью по слиянию сущностей](dozakryt-nahodki-sushchnostej.md) — Скелет из null затирает маршрут необратимо, а откат бинаря поверх новой схемы проходит молча: семь находок с прогнанными оракулами - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате @@ -45,6 +45,8 @@ - [Заголовки доставки в архиве рядом с телом](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 он избыточен @@ -58,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 — следующая покрытая секция унаследует слепую зону diff --git a/docs/backlog/alert-tishina-potoka.md b/docs/backlog/alert-tishina-potoka.md index 7fff7a7..951626a 100644 --- a/docs/backlog/alert-tishina-potoka.md +++ b/docs/backlog/alert-tishina-potoka.md @@ -15,3 +15,15 @@ Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день. После деплоя на rivendell поднимется. + +## Источник алерта не может жить внутри `serve` + +Отказ стража версии схемы (база новее бинаря) останавливает процесс, а +`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N +часов», живущий внутри сервиса, на эту причину остановки не сработает **по +построению** — он не поднимется вместе с ним. Обоснование стража («откат делает +оператор, он в этот момент рядом») верно для ручного отката и не покрывает +перезапуск хоста или откат деплоя. + +Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`, +`negative`). diff --git a/docs/backlog/dozakryt-nahodki-sushchnostej.md b/docs/backlog/dozakryt-nahodki-sushchnostej.md deleted file mode 100644 index ac9eb7b..0000000 --- a/docs/backlog/dozakryt-nahodki-sushchnostej.md +++ /dev/null @@ -1,125 +0,0 @@ -# Дозакрыть находки ревью по слиянию сущностей - -**Приоритет:** высокий - -Задача «Тренировки и секции с собственными id» (`f8200f7`) прошла ревью не -полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались. -Дозапуск принёс девять причин, триаж оставил семь. Оба заявленных `critical` -понижены до `major` с названной причиной — вход недостижим из штатного потока -HAE (корпус в 118 доставок такого не производил), — но остались в работе: -потеря маршрута необратима, а `reindex` проигрывает то же поражение. - -Отчёт триажа с прогнанными оракулами — `tmp/triage-late.md`, оракулы — -`tmp/adv/*.go`. Каждый пункт ниже имеет падающий тест; правка считается -сделанной, когда соответствующий оракул зеленеет **и** переезжает из `tmp/` в -обычные тесты пакета. - -## Что делать - -**1. Скелет не затирает маршрут.** `canon.Fields.Covers` проверяет только -наличие ключа и длину верхнеуровневого массива, поэтому версия, где каждый -массив заменён массивом той же длины из `null`, а каждое не-массивное значение -— скаляром, признаётся равной настоящей и по тай-брейку журнала замещает её. -Оракул: `-run 'Скелет|Маршрут|ДвеВерсии'`. - -Решение принято, новое не проектируем: применяем к сущностям **тот же -стандарт, что записан для точек** — второй разряд сравнения, как у -`canon.Relate` («иначе ключ с пустым значением исчезает по жребию»). Плюс -запрет вырождения формы: покрывающая версия не может подменить объект или -массив скаляром — проверка по верхнему уровню, стоимость O(ключей). -Поэлементная содержательность массивов **отвергнута ценой**: пункт 4 измерил -768 МиБ пика на канонизации, полный обход маршрута на каждое сравнение эту цену -умножит. Остающийся предел — порча *внутри* элемента ряда (точка маршрута без -`altitude`) — не закрывается ничем, кроме сверки с телом в архиве, и должен -быть записан в `architecture.md` рядом с описанием `Covers` так же прямо, как -он записан в комментарии кода. - -Отдельно: `pickWithinDelivery` при равном содержании и **разных байтах** обязан -считать `differs=true` — сейчас две версии одного `id` в одном теле дают -`удержано=0` и молчащий счётчик. - -**2. Одно поле не той формы не уносит сущность.** `entityHead` держит -`ID`/`Name`/`Date`/`Start`/`End` типизированными строками, поэтому смена типа -любого из пяти роняет `json.Unmarshal` целиком, а доставка при этом получает -`parsed` с пустым списком непокрытого. Достижимо из реального потока: дрейф -формата дат у HAE задокументирован. Оракул: `-run ОдноПоле`. - -Читать пять полей через `json.RawMessage` и извлекать мягко — это буквально -принцип, уже записанный в коде для `Duration` («нечисловое значение — это -пропуск ОДНОГО поля, а не сломанная сущность»). Плюс пропуски обязаны быть -видны в **учётной записи** доставки, а не только в логе: ретеншен решает по -базе, и сегодня он получит ответ «терять нечего». Хранение сущности с -неразобранной меткой (NULL) в эту задачу не входит — см. остаток ниже. - -**3. Откат бинаря не проходит молча.** `store.Open` мигрирует безусловно и не -сверяет версию схемы, поэтому старый бинарь успешно стартует поверх схемы 7, -молча игнорирует незнакомые секции и помечает доставки разобранными. Оракул -прогнан живьём: `-run СтарыйБинарь`. Перенести в `Open` страж из -`OpenForRead` — прецедент записан там же: «расхождение версий — отказ, а не -повод мигрировать». - -**4. Канонизация — за транзакцию, по-настоящему.** Комментарий -`bucket.go:143-146` утверждает, что канонизация вынесена наружу; фактически -`analyze()` вызывается из `compareEntities` **внутри** `inTx`, который открывает -`immediate` и повторяет до пяти раз, а кеш `analyze()` пишется в **копию** -элемента среза и не переживает даже одной попытки. Измерено: тело 40 МиБ → пик -768.3 МиБ; 63 МиБ → блокировка удерживается 5.019 с при `busy_timeout` 5000, то -есть конкурентный `CreateDelivery` исчерпывает повторы и приём отвечает 500 по -доставке, тело которой уже на диске. - -В этой задаче: вынести `analyze()` наружу по-настоящему, кешировать в срезе, а -не в копии, и различать в логе `delivery failed` занятость базы (`store.ErrBusy` -уже выделен доменной ошибкой) от прочих причин. Пределы на размер сущности и -потоковый расчёт хеша — остатком. - -**5. Значения из тела не попадают в текст ошибки.** `fmt.Errorf("… встречено -%v", tok)` подставляет токен целиком: тело 8 МиБ даёт текст ошибки 8 МиБ, -который уходит атрибутом `error` на уровень `WARN`. Инвариант «тела запросов -только на `DEBUG` и с обрезкой» нарушен буквально. Называть тип токена и -`dec.InputOffset()`. Оракул: `-run Тело`. Дефект в базе диффа, не внесён -разбором сущностей. - -**6. Победитель внутри доставки — функция множества, а не порядка.** -Попарная свёртка частичного порядка с тотальным тай-брейком нетранзитивна: -`[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Стандарт «победитель — функция множества -точек, а не порядка» записан в `architecture.md` для точек и для сущностей -молча не применён. Собрать версии ключа, отбросить строго покрытые, среди -оставшихся взять минимум канонической формы. Оракул: `-run ПорядокВнутри`. - -**7. Провенанс обновляется при равных хешах.** Совпал хеш — запись -пропускается вместе с провенансом, и в `delivery_id`/`delivery_received_at` -остаётся первая свёрнутая копия, а не победитель по журналу. Провенанс -устаревает на каждой из ~26 повторных присылок гарантированно; расхождение -живой витрины с `reindex` латентно (требует возврата содержимого к прежнему — -корпус такого не производил), но нарушает записанный инвариант детерминизма. -Сравнивать позиции в журнале и обновлять провенанс. Оракул: `-run Порядок`. - -## Что уходит остатком - -- хранение сущности с `id`, но неразобранной меткой (NULL-метка): требует схемы - и правил чтения, а после пункта 2 случай становится редким; -- пределы на размер одной сущности и суммарный размер секции, потоковый расчёт - канонической формы и хеша — заводится задачей вместе с условием из пункта 4; -- принцип «data-миграции не отбирают строки по спискам, которые где-то - обрезаются» (миграция `00007` отбирает по обрезаемому на 32 - `uncovered_sections`; для неё дефект пустой — HAE шлёт одну секцию за - доставку, — но следующая покрытая секция унаследует слепую зону); -- длина очереди `pending` в `/stats` **и без WARN**: после миграции, переводящей - доставки в `pending`, отставание по конструкции не WARN-ится (`startupDone`), - и бэклог идёт молча при зелёном `/healthz` — строка уходит в - [наблюдаемость](stats-nablyudaemost.md). - -## Кандидаты в конвенции - -- текст ошибки разбора не содержит значений из тела — только тип токена и - смещение; -- тест перестановок правила слияния обязан включать версию с содержимым, равным - одной из уже присланных: тест трёх версий с разными хешами ветку равенства не - посещает ни разу. - -Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а -`task verify:archive` сходится. - -Связано: `internal/canon`, `internal/store/entity.go`, `internal/hae/entity.go`, -`docs/review-journal.md` (пропуск проходов на чекпоинте — отклонение процесса, -ему там место). diff --git a/docs/backlog/hranenie-sushchnosti-bez-metki.md b/docs/backlog/hranenie-sushchnosti-bez-metki.md new file mode 100644 index 0000000..5bd2cdb --- /dev/null +++ b/docs/backlog/hranenie-sushchnosti-bez-metki.md @@ -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 по сущностям: правило +чтения без читателя проектируется вслепую. diff --git a/docs/backlog/otbor-strok-data-migraciyami.md b/docs/backlog/otbor-strok-data-migraciyami.md new file mode 100644 index 0000000..e232759 --- /dev/null +++ b/docs/backlog/otbor-strok-data-migraciyami.md @@ -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) — + именно она следующей сделает секцию покрытой и напишет такую миграцию. diff --git a/docs/backlog/otkat-reliza-posle-migracii.md b/docs/backlog/otkat-reliza-posle-migracii.md new file mode 100644 index 0000000..28b20de --- /dev/null +++ b/docs/backlog/otkat-reliza-posle-migracii.md @@ -0,0 +1,56 @@ +# Чем откатывать релиз после наката миграции + +**Приоритет:** блокеры + +Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей» +(проходы `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` (раздел «Деплой»). diff --git a/docs/backlog/predely-razmera-sushchnosti.md b/docs/backlog/predely-razmera-sushchnosti.md new file mode 100644 index 0000000..db14b60 --- /dev/null +++ b/docs/backlog/predely-razmera-sushchnosti.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`. +- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» — + отброшено как предел по конструкции, но условием ложится сюда. diff --git a/docs/backlog/retenshen-syrogo-arhiva.md b/docs/backlog/retenshen-syrogo-arhiva.md index ac290c7..7df8476 100644 --- a/docs/backlog/retenshen-syrogo-arhiva.md +++ b/docs/backlog/retenshen-syrogo-arhiva.md @@ -61,3 +61,20 @@ Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбор же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило соблюдается. + +## Что читать перед удалением тела + +Две колонки учётной записи, и обе обязательны: + +- `uncovered_sections` — непустой список означает, что в теле есть секции, + которых разбор не покрывает; удалять нельзя; +- `skipped_entities` — число сущностей с собственным `id`, которые разбор не + понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят + доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не + досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело + тренировки, маршрута которой нет больше нигде: в экспорте Apple его не + существует. + +Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» +(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого +различия. diff --git a/docs/backlog/stats-nablyudaemost.md b/docs/backlog/stats-nablyudaemost.md index 3a8861c..6c58601 100644 --- a/docs/backlog/stats-nablyudaemost.md +++ b/docs/backlog/stats-nablyudaemost.md @@ -22,5 +22,13 @@ Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи. +Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей +доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими +доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то +есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально, +и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью +по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы +десятков тысяч доставок. + Активное уведомление — отдельная задача, здесь только факт. diff --git a/docs/conventions.md b/docs/conventions.md index febc338..22bcee0 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -66,6 +66,12 @@ При сомнении логируем факт наличия, не значение. - Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG` и с обрезкой по длине. +- **Текст ошибки разбора не содержит значений из входа** — только род токена + (словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится + одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он + уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не + обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке + разбора не узнает. ## Конфигурация @@ -105,6 +111,22 @@ пересборкой молча. - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет названный предел длины (имена непокрытых секций, `id` сущности). +- **Колонка, по которой принимается необратимое решение, отличает ноль от «не + измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль + историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и + подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример: + `delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить + тело. +- **Метка изменения строки меняется только при изменении содержимого.** Апдейт, + трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе + она становится меткой касания, и запрос «что изменилось с момента X» получает + столько ложных изменений, сколько раз источник переприслал то же самое (у + тренировки — двадцать шесть). +- **Новая производная от разбора колонка в момент появления вносится в перечень + того, что пересборка не переносит.** Перечень — единственное место, где это + сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды + перенесут «для полноты учёта», и витрина снова станет функцией предыдущего + прогона. - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная ширина сохраняет лексикографическую сортировку = хронологию. Единая точка генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна @@ -129,3 +151,13 @@ и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого не увидело, ревью кода увидело только перебором троек. Правилом линтера не выражается — отсюда проза. +- **В тот же перебор обязана входить версия с содержимым, равным одной из уже + присланных, и пара «равная каноническая форма, разные байты».** Три версии с + разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней + устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с + равной формой ловит другое: неединственный минимум, при котором победителем + оказывается просто первый в срезе, то есть порядок элементов на проводе. +- **Изменение правила разбора или слияния сопровождается замером на живом + архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение + без числа не отличается от предположения, а цена ошибки здесь — необратимое + решение о судьбе тел. diff --git a/docs/database.md b/docs/database.md index e91232a..0f31f9c 100644 --- a/docs/database.md +++ b/docs/database.md @@ -28,6 +28,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа │ headers TEXT │ │ updated_at TEXT │ │ derived_layer TEXT │ └──────────────────────────────┘ │ uncovered_sections TEXT │ +│ skipped_entities INTEGER? │ └────────────────────────────┘ ┊ ┌──────────────────────────┐ ┌──────────────────────────┐ ┊ │ workout │ │ record │ @@ -69,6 +70,7 @@ 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` (учёт diff --git a/docs/review-journal.md b/docs/review-journal.md index 61ecf11..3a5c03e 100644 --- a/docs/review-journal.md +++ b/docs/review-journal.md @@ -73,3 +73,40 @@ печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой протухшей константы, а после этой задачи прогон стал ещё и единственным, кто проверяет настоящий проигрыватель журнала. + +## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё + +- **Где:** конвейер, а не код: коммит `f8200f7` («тренировки и записи с + собственным `id`»), шаг 7 скилла `healthlog-task-pipeline`, профиль `deep`. +- **Симптом:** изменение было закоммичено и заархивировано как прошедшее ревью. + Дозапуск трёх пропущенных проходов на **уже закоммиченном** коде дал девять + причин, семь из которых пошли в работу с прогнанными оракулами: скелет из + `null` затирает маршрут молча и необратимо; одно поле не той формы уносит + тренировку, а доставка при этом числится разобранной; откат бинаря поверх + новой схемы стартует без слова; победитель внутри доставки зависит от порядка + элементов на проводе; провенанс устаревает на каждой повторной присылке; + канонизация идёт внутри транзакции вопреки собственному комментарию (768 МиБ + пика, 5.019 с удержания блокировки); тело в 8 МиБ целиком уезжает в текст + ошибки и оттуда в `WARN`. +- **Причина:** сабагент, проводивший задачу, на чекпоинте кода запустил не все + проходы профиля `deep` — не отработали `adversary`, `ops` и архитектурный. + Отчёт триажа при этом был выпущен и выглядел полным: он агрегирует то, что + ему подали, и о непоступивших проходах не знает. Секция границ покрытия + обязана была это назвать, но она заполняется тем же триажем — то есть + единственный, кто мог заметить пропуск, узнаёт о нём из того же источника, + который его допустил. +- **Почему не поймали:** пропуск прохода **не отличим от прохода без находок**. + Гейт зелёный, спеки сошлись, applicative-проходы отработали — снаружи это + выглядит как чистое ревью. Все семь находок принадлежат ровно тем классам, + которые applicative-проходы не достают по построению: враждебно + сконструированный вход (`adversary`), поведение под откатом и конкуренцией + (`ops`), второй способ делать уже сделанное (архитектура). Recall чек-листа + равен длине чек-листа, а этих пунктов в чек-листах нет и быть не может. +- **Что меняем:** отчёт ревью обязан перечислять запущенные проходы **поимённо + и с исходом**, а оркестратор задачи — сверять этот перечень с составом + профиля до того, как коммитить; непущенный проход идёт в границы покрытия + строкой «не запускался», а не отсутствует. Правилом линтера это не + выражается, автоматической проверки нет — но пропуск, названный в отчёте, + стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи + на их дозакрытие. Состав проходов и профилей при этом не трогаем: они + сработали ровно так, как задуманы, — их просто не позвали. diff --git a/internal/canon/canon.go b/internal/canon/canon.go index 5896219..bd2fbe4 100644 --- a/internal/canon/canon.go +++ b/internal/canon/canon.go @@ -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,8 +282,7 @@ func (f Fields) Relate(g Fields) Fullness { return FullnessEqual } -// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g -// есть у f, и ни один верхнеуровневый массив не стал короче. +// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g. // // Отдельно от Relate, и это не дубль. Relate гасит отношение включения до // FullnessEqual, когда значения общих содержательных ключей разошлись, — верно @@ -259,57 +294,160 @@ func (f Fields) Relate(g Fields) Fullness { // (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы // зелёным. // -// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут -// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому -// укорачивание — законный признак «приехало меньше». Предел правила назван -// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не -// ловится ничем, кроме сверки с телом в архиве. +// Условий четыре, все по ВЕРХНЕМУ уровню: // -// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат -// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже -// самого сравнения. +// 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 } - gn, gok := arrayLen(gv) - if !gok { - continue + if !shapeKept(fv, gv) { + return false } - fn, fok := arrayLen(fv) - if !fok || fn < gn { + } + // Первый разряд пройден. Второй включается ТОЛЬКО при равенстве множеств + // содержательных ключей: если 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 } -// arrayLen возвращает число элементов верхнеуровневого массива. Второй возврат -// — является ли значение массивом вообще. +// 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 arrayLen(raw json.RawMessage) (int, bool) { - if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' { - return 0, false +// Элементы проглатываются в выбрасываемый 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, false + return 0, 0, false } - n := 0 + // Буфер объявлен НАД циклом: RawMessage.UnmarshalJSON делает + // `append((*m)[0:0], data...)`, то есть переиспользует ёмкость. Объявление + // внутри цикла обнуляло бы срез каждый виток и давало аллокацию на элемент — + // маршрут в 593 точки стоил бы 593 аллокаций на каждую проверку покрытия, + // притом что комментарий выше обещает обратное. + var elem json.RawMessage for dec.More() { - var skip json.RawMessage - if err := dec.Decode(&skip); err != nil { - return 0, false + if err := dec.Decode(&elem); err != nil { + return 0, 0, false + } + total++ + if !isEmpty(elem) { + contentful++ } - n++ } - return n, true + return total, contentful, true } // agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих diff --git a/internal/canon/canon_test.go b/internal/canon/canon_test.go index 47cc255..a837f0f 100644 --- a/internal/canon/canon_test.go +++ b/internal/canon/canon_test.go @@ -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 принят за корректный") + } +} diff --git a/internal/fold/fold.go b/internal/fold/fold.go index cc5c347..d9f2971 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -117,7 +117,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err } stats = Stats{} err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления - s.fail(ctx, deliveryID, err, nil) + s.fail(ctx, deliveryID, err, parseResidue{}) }() d, err := s.store.DeliveryForParse(ctx, deliveryID) @@ -131,7 +131,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err body, err := s.readBody(d.RawPath) if err != nil { - s.fail(ctx, deliveryID, err, nil) + s.fail(ctx, deliveryID, err, parseResidue{}) return stats, err } @@ -149,7 +149,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err // Список непокрытых секций переживает отказ: доставка, у которой не // определился слой, обязана остаться записью о том, что в теле есть // невосстановимая секция. - s.fail(ctx, deliveryID, err, parsed.Uncovered) + // + // А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший + // ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и + // записать этот ноль значило бы объявить доставку проверенной. + s.fail(ctx, deliveryID, err, residueOf(parsed)) return stats, err } @@ -171,7 +175,12 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err 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.MergeStats = merge @@ -184,10 +193,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err 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) @@ -237,6 +247,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { "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, @@ -255,6 +266,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { 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)) + } // Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не // штатная работа. Без этого условия смена формата метки выглядела бы как @@ -270,6 +284,14 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { // за отказ объединять поля: событие обязано быть видно, потому что на // живом потоке оно не наступало ни разу и правило держится на этом. 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: @@ -349,7 +371,35 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse // большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их // бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не // затрагивается: сохранили значит приняли. -func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) { +// 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: пройдёт само, разбирать нечего. Строка нужна, чтобы // повтор не выглядел беспричинным. @@ -375,9 +425,10 @@ 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) diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index 58743b0..8e204f5 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -386,3 +386,131 @@ func TestFoldНепонятоеСодержимоеВыводитИзОчере 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) + } +} diff --git a/internal/hae/entity.go b/internal/hae/entity.go index ae77cf9..ae39593 100644 --- a/internal/hae/entity.go +++ b/internal/hae/entity.go @@ -19,18 +19,67 @@ const maxEntityID = 128 // (находка 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 string `json:"id"` - Name string `json:"name"` - Date string `json:"date"` - Start string `json:"start"` - End string `json:"end"` + 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"` } @@ -47,17 +96,41 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity { 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 } - if head.ID == "" || len(head.ID) > maxEntityID { + // Идентификатор исключение из мягкости: без строкового `id` сущность не + // адресуема, а приведение чужого нестрокового значения к строке было бы + // выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение + // уже превратило в пустую строку — исход тот же, что у отсутствующего. + id := head.ID.value + if id == "" || len(id) > maxEntityID { res.SkippedNoID++ continue } - start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date)) + // Фолбэк `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 @@ -68,17 +141,17 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity { // координату, — а сущность адресуется своим `id`, и схлопывать нечего. // Истина при этом остаётся в Raw дословно. end := start - if head.End != "" { - if e, ok := parseEntityTime(head.End); ok { + if head.End.value != "" { + if e, ok := parseEntityTime(head.End.value); ok { end = e } } _, offset := start.Zone() e := Entity{ - ID: head.ID, + ID: id, Kind: kind, - Name: head.Name, + Name: head.Name.value, Start: start.UTC(), End: end.UTC(), OffsetSeconds: offset, @@ -136,6 +209,13 @@ func parseDuration(raw json.RawMessage) *float64 { 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 diff --git a/internal/hae/entity_test.go b/internal/hae/entity_test.go index dd16d5f..0bee45f 100644 --- a/internal/hae/entity_test.go +++ b/internal/hae/entity_test.go @@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) { byID[w.ID] = w } - // Пустой id, отсутствующий id и id длиннее предела — один счётчик на три - // случая: исход у них общий. - if res.SkippedNoID != 3 { - t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID) + // Пустой id, отсутствующий id, id длиннее предела и id не строкой — один + // счётчик на четыре случая: исход у них общий, сущность не адресуема. + if res.SkippedNoID != 4 { + t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID) } - // Метка не разбирается и метки нет вовсе. - if res.SkippedEntityNoTime != 2 { - t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime) + // Метка не разбирается, метки нет вовсе и начало приехало не строкой. + if res.SkippedEntityNoTime != 3 { + t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime) } // Элемент, не являющийся объектом. if res.SkippedEntityMalformed != 1 { @@ -176,6 +176,50 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) { } }) + // Значение не того ТИПА стоит одного поля, а не сущности: иначе `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 { @@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te 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) + } +} diff --git a/internal/hae/hae.go b/internal/hae/hae.go index 07c16fb..2c04d41 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -220,7 +220,7 @@ 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))) } }() @@ -422,7 +422,7 @@ func decodeEnvelope(body []byte) (envelope, error) { var env envelope fail := func(e error) (envelope, error) { - return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается + return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e)) } dec := json.NewDecoder(bytes.NewReader(body)) @@ -430,6 +430,7 @@ func decodeEnvelope(body []byte) (envelope, error) { // Верхний уровень тела: интересует только data. Прочие ключи конверта в // список не идут — иначе в одном списке смешались бы имена секций и мусор // конверта, а форму `{"data": …}` проверяет приём. + at := dec.InputOffset() tok, err := dec.Token() if err != nil { return fail(err) @@ -441,7 +442,7 @@ func decodeEnvelope(body []byte) (envelope, error) { 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() { @@ -486,13 +487,14 @@ type envelope struct { // 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 err } // data не объект — прежнее поведение: ошибка ровно там, где была. if d, ok := tok.(json.Delim); !ok || d != '{' { - return fmt.Errorf("data: ожидался объект, встречено %v", tok) + return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at)) } for dec.More() { @@ -544,17 +546,85 @@ func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) { // 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 @@ -562,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 } diff --git a/internal/hae/hae_test.go b/internal/hae/hae_test.go index f9a8b8f..11ed436 100644 --- a/internal/hae/hae_test.go +++ b/internal/hae/hae_test.go @@ -702,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())) + } +} diff --git a/internal/hae/testdata/handmade_entities.json b/internal/hae/testdata/handmade_entities.json index 5d13b1a..057c112 100644 --- a/internal/hae/testdata/handmade_entities.json +++ b/internal/hae/testdata/handmade_entities.json @@ -74,6 +74,30 @@ "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": "Незнакомое поле и дословные литералы", diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index d1fc1a6..4a76cb9 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -14,6 +14,7 @@ import ( "time" "git.vakhrushev.me/av/healthlog/internal/archive" + "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/ident" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -158,7 +159,17 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e if err != nil { // Тело уже на диске — данные не потеряны, но учёта нет. Такое тело // подберёт пересборка (`healthlog reindex`), заведя запись заново. - s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath) + // + // Занятость базы называется отдельно. Уровень от этого не меняется: + // тело осиротело в любом случае, и вернуть его в журнал может только + // пересборка. Но лечится занятость не тем, чем сбой диска или испорченная + // база, — это конкуренция за запись, и она будет повторяться. Признак + // снимается с доменной ошибки, а не с предиката «обстоятельства вообще»: + // тот включает ещё и отмену снаружи, а здесь она невозможна по + // построению — учёт ведётся на контексте, переживающем обрыв соединения. + 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) } @@ -227,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) diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index c078efe..338268e 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -1,14 +1,17 @@ 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" @@ -261,3 +264,70 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) 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("литерал из тела виден в логе") + } +} diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index 00b31bb..80333b9 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -97,7 +97,14 @@ func TestReplayЖивогоАрхива(t *testing.T) { if first.Records == 0 { t.Error("записей в витрине нет — секция stateOfMind не разбирается") } - t.Logf("тренировок %d, записей %d", first.Workouts, first.Records) + // Удержанные версии сущностей печатаются рядом с их числом. Число — это + // «сколько лежит», а удержания — «сколько правило слияния не пустило», и + // второе отпечатком не проверяется по построению: живой приём и пересборка + // пользуются одним правилом и одинаково сойдутся на одинаково удержанной + // версии. Печатается, а не утверждается: удержание — событие для разбора, + // а не отказ сходимости. + t.Logf("тренировок %d, записей %d, удержано версий сущностей %d", + first.Workouts, first.Records, first.EntitiesHeld) // Повторное проигрывание того же журнала даёт то же состояние: свёртка // детерминирована, и пересборка даёт то же, что живой приём. diff --git a/internal/replay/player.go b/internal/replay/player.go index 9c51315..19be835 100644 --- a/internal/replay/player.go +++ b/internal/replay/player.go @@ -32,6 +32,17 @@ type Outcome struct { // Incomparable — столкновений с несравнимыми наборами полей. На живом потоке // их не было ни разу, и на этом стоит отказ от объединения полей. Incomparable int + // EntitiesHeld — версий сущностей, удержанных правилом «не теряем + // содержания». Без него правило слияния сущностей проверить нечем: + // сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и + // пересборка пользуются одним правилом и одинаково сойдутся на одинаково + // удержанной версии. То есть слишком строгое правило (замораживающее + // тренировку на старой версии) выглядело бы идеальной сходимостью. + EntitiesHeld int + // EntitiesDiverging — версии одного ключа, приехавшие в одном теле с разным + // содержанием. Событие другого рода, чем удержание, и считается отдельно: + // смешанное число не отвечало бы ни на один из двух вопросов. + EntitiesDiverging int } // Add накапливает исход одной доставки в общий. @@ -43,6 +54,8 @@ func (o *Outcome) Add(other Outcome) { o.FailedOther += other.FailedOther o.Partial += other.Partial o.Incomparable += other.Incomparable + o.EntitiesHeld += other.EntitiesHeld + o.EntitiesDiverging += other.EntitiesDiverging } // classify раскладывает ошибку свёртки по классам исхода. @@ -52,7 +65,7 @@ func (o *Outcome) Add(other Outcome) { // проверяется она перебором классов, без базы и без архива. // // Неэкспортируемая намеренно: её результат содержит поля `Partial` и -// `Incomparable`, которые дописывает только Play, — вторая публичная дверь +// `Incomparable`, `EntitiesHeld` и `EntitiesDiverging`, которые дописывает только Play, — вторая публичная дверь // молча занижала бы именно тот счётчик, по которому принимается решение о // судьбе тела в архиве. func classify(err error) Outcome { @@ -105,6 +118,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) { out.Partial++ } out.Incomparable += st.Incomparable + out.EntitiesHeld += st.EntitiesHeld + out.EntitiesDiverging += st.EntitiesDiverging } return out, err } diff --git a/internal/replay/replay.go b/internal/replay/replay.go index a47f8b7..7358c39 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -203,6 +203,8 @@ func Run(ctx context.Context, o Options) (Report, error) { "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) @@ -333,8 +335,11 @@ func stopOr(rep Report, err error) (Report, error) { // prepare оставляет от учётной записи ФАКТЫ ЖУРНАЛА и сбрасывает производные от // разбора поля. // -// `parse_status`, `points`, `derived_layer` и `uncovered_sections` — результат -// ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало вместе с доставкой. Перенести их +// `parse_status`, `points`, `derived_layer`, `uncovered_sections` и +// `skipped_entities` — результат ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало +// вместе с доставкой. У последнего пустота означает «не измерялось», так что +// перенос выдал бы измерение прежнего разбора за измерение текущего — а по нему +// решают, можно ли удалить тело. Перенести их // значило бы сделать пересобранную витрину функцией прошлого прогона: доставка, // чей повторный разбор отказал (штатный исход, когда слой не выводится), // сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и diff --git a/internal/replay/replay_test.go b/internal/replay/replay_test.go index 1316992..c6493b8 100644 --- a/internal/replay/replay_test.go +++ b/internal/replay/replay_test.go @@ -625,3 +625,55 @@ func TestИмяТелаОбязаноБытьКаноническим(t *testing 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) + } +} diff --git a/internal/store/bucket.go b/internal/store/bucket.go index b67f663..35be2c7 100644 --- a/internal/store/bucket.go +++ b/internal/store/bucket.go @@ -84,9 +84,23 @@ type MergeStats struct { // EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание // сохранённой (включая несравнимые наборы). Это и есть плата за отказ // объединять поля: событие считается, а не предотвращается молча. + // + // На него опирается ЕДИНСТВЕННЫЙ контроль того, что правило покрытия не + // стало слишком строгим: сходимость отпечатка этого не проверяет по + // построению — живой приём и пересборка пользуются одним правилом и + // одинаково сойдутся на одинаково удержанной версии. Поэтому счётчик + // обязан считать ровно удержания и ничего сверх. EntitiesHeld int // HeldAt — координаты первых таких сущностей, для записи в лог. HeldAt []EntityRef + // EntitiesDiverging — версии одного ключа, приехавшие в ОДНОМ теле с разным + // содержанием. Событие другого рода: победитель ложится в витрину целиком, + // терять нечего, лечится оно не тем же. Считается отдельно от удержаний, + // иначе одно число отвечало бы на два вопроса — и число удержаний, по + // которому судят о строгости правила, стало бы неотличимо от шума. + EntitiesDiverging int + // DivergingAt — координаты первых таких сущностей. + DivergingAt []EntityRef } // Collision — координаты объекта, где столкновение разрешилось перезаписью @@ -140,10 +154,22 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge 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 @@ -152,18 +178,25 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge if err != nil { return MergeStats{}, err } - workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts) - records, recordsHeld, recordsHeldAt := dedupeEntities(records) + 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 { // Счётчики обнуляются на каждой попытке: повтор транзакции начинает // слияние заново, и накопленное от прошлой попытки посчиталось бы дважды. stats = MergeStats{ - Workouts: len(in.Workouts), - Records: len(in.Records), - EntitiesHeld: workoutsHeld + recordsHeld, - HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)), + Workouts: len(in.Workouts), + Records: len(in.Records), + EntitiesDiverging: workoutsDiverging + recordsDiverging, + DivergingAt: clipRefs(append(append([]EntityRef{}, + workoutsDivergingAt...), recordsDivergingAt...)), } for _, key := range keys { @@ -300,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 // Считаем сохранённые точки, а не присланные: точные повторы внутри @@ -356,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 @@ -404,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 + } // Перезаписей столько, сколько точек уступило: при двух кандидатах // одна, при трёх две. Так счёт остаётся сравнимым с прежним, где // столкновение считалось на каждую приехавшую точку. @@ -425,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 — точка вместе с тем, что о ней нужно знать при выборе @@ -444,14 +483,11 @@ func newCandidate(p Point) candidate { // resolve выбирает победителя среди кандидатов одной координаты. // -// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления. -// Сперва отбрасываются те, кого превосходит по полноте кто-то другой -// (полнота — частичный порядок, поэтому «непревзойдённые» определены -// однозначно), затем среди оставшихся берётся минимум по каноническому -// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят -// только от состава множества, поэтому пересборка журнала даёт то же -// состояние, что живой приём, а повторная свёртка той же доставки не меняет -// ничего. +// Механизм общий с выбором версии сущности — pickBest: отбрасываем +// превзойдённых по частичному порядку, среди оставшихся берём минимум по +// тотальному. Отношения разные (полнота у точек, покрытие у сущностей), а +// рассуждение одно, и второй его экземпляр однажды уже разошёлся со стандартом +// нетранзитивностью. // // Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а // не конструирует. Каноническая форма существует только в момент сравнения, и @@ -461,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 } } diff --git a/internal/store/delivery.go b/internal/store/delivery.go index 4ae12b9..828ee77 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -51,6 +51,20 @@ type Delivery struct { // имён. Ответ на вопрос «что останется потерянным, если тело удалить»: // ретеншен обязан спрашивать его прежде, чем срезать тело. UncoveredSections string + // SkippedEntities — сколько сущностей с собственным `id` разбор пропустил. + // Второй половина ответа на тот же вопрос: сущность, которую разбор не + // понял, в витрину не попала, а список непокрытых секций про неё молчит. + // + // Отсутствие значения означает «не измерялось» и НЕ равно нулю: так + // выглядят доставки, свёрнутые разбором, который пропусков не считал, и те, + // чей разбор не досчитал. Читатель, принимающий по счётчику необратимое + // решение, обязан трактовать отсутствие как «не удалять». + // + // Указателем, а не sql.NullInt64: поле уедет в JSON `/stats` и в MCP, а + // NullInt64 сериализуется формой драйвера (`{"Int64":0,"Valid":false}`) — + // первый, кто про это забудет, опубликует её наружу, и она станет + // контрактом. Указатель даёт `null` бесплатно и означает ровно то же. + SkippedEntities *int64 } // CreateDelivery записывает факт приёма пакета. @@ -92,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 { @@ -120,11 +139,19 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) { // // Отдаются только **факты журнала**: то, что пришло вместе с доставкой. // Производные от разбора поля (`parse_status`, `points`, `derived_layer`, -// `uncovered_sections`) сюда не попадают намеренно — перенос их в пересобранную -// базу сделал бы витрину функцией предыдущего прогона. Особенно `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, @@ -255,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 { @@ -262,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 сериализуется как @@ -276,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) } diff --git a/internal/store/entity.go b/internal/store/entity.go index c4c0373..eaa06c8 100644 --- a/internal/store/entity.go +++ b/internal/store/entity.go @@ -106,21 +106,23 @@ func clipRefs(refs []EntityRef) []EntityRef { // entityVersion — версия сущности вместе с тем, что нужно знать при выборе // победителя. // -// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с -// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу -// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево -// значений на каждой копии — ровно та форма, от которой разбор тела отказался -// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается -// сразу и один раз на доставку: он и есть быстрый путь. +// Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость +// здесь была мнимой: хеш всё равно требует полной канонической формы, то есть +// самая дорогая работа платилась на каждой копии и так, а отложенный разбор +// считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая +// открыта `immediate` и повторяется до пяти раз при занятости базы. +// +// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока +// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша +// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без +// разворачивания значений. Внутри транзакции для приехавших версий не остаётся +// ничего. type entityVersion struct { - raw json.RawMessage - hash string - from DeliveryRef - - // key и fields заполняются лениво, методом analyze(). + raw json.RawMessage + hash string + from DeliveryRef key []byte fields canon.Fields - parsed bool // head — заголовок, который пишется колонками. У сохранённой версии он не // нужен: она либо побеждает и остаётся как есть, либо замещается целиком. @@ -128,21 +130,56 @@ type entityVersion struct { } func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) { - h, err := canon.Hash(e.Raw) + v, err := analyzeVersion(e.Raw, from) if err != nil { - return entityVersion{}, fmt.Errorf("хеш сущности: %w", err) + return entityVersion{}, err } - return entityVersion{raw: e.Raw, hash: h, from: from, head: e}, nil + v.head = e + return v, nil } -// analyze разбирает версию, если этого ещё не делали. -func (v *entityVersion) analyze() { - if v.parsed { - return +// 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), } - v.key = canon.SortKey(v.raw) - v.fields = canon.Analyze(v.raw) - v.parsed = true +} + +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 выбирает между сохранённой и приехавшей версией. @@ -177,35 +214,23 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) { // Объединение полей отвергнуто там же и по той же причине, что для // точек, — на живом потоке событие не наступало ни разу, — а из двух // версий остаётся сохранённая: правило называется «не теряет - // содержания», и приехавшая его теряет. Исход при этом остаётся - // функцией журнала: доставки проигрываются в его порядке. + // содержания», и приехавшая его теряет. + // + // ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от + // журнала: в витрине лежит победитель прошлых слияний, а не все + // кандидаты истории, и «сохранённая выигрывает» означает разный итог + // при разном порядке. Порядок свёртки журналу не равен — доставка, + // получившая ErrBusy, остаётся `pending` и сворачивается следующим + // проходом, — так что живой приём и пересборка на несравнимых версиях + // законно расходятся. Это единственная точка, где витрина не является + // функцией множества доставок; она названа вслух в architecture.md, и + // счётчик удержаний ниже — единственное, что о ней сообщает. return false, true default: return laterInJournal(stored, incoming), false } } -// pickWithinDelivery выбирает между двумя версиями одного ключа ВНУТРИ одной -// доставки. Второй возврат — различается ли их содержание вообще. -// -// Отдельно от pickEntity, и не ради симметрии: «сохранённой» версии здесь нет, -// есть только порядок элементов в JSON-массиве, а он нестабилен. Правило -// «остаётся первая встреченная» сделало бы исход функцией порядка на проводе, -// поэтому при равном и при несравнимом содержании решает тотальный порядок -// канонических форм. -func pickWithinDelivery(a, b *entityVersion) (takeB, differs bool) { - switch v := compareEntities(a, b); v { - case entityIncomingRicher: - return true, true - case entityStoredRicher: - return false, true - case entityIncomparable: - return laterInJournal(a, b), true - default: - return laterInJournal(a, b), false - } -} - // entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности. // Нумерация с единицы: нулевое значение не должно выглядеть как «равны». type entityVerdict int @@ -221,9 +246,6 @@ const ( ) func compareEntities(stored, incoming *entityVersion) entityVerdict { - stored.analyze() - incoming.analyze() - storedCovers := stored.fields.Covers(incoming.fields) incomingCovers := incoming.fields.Covers(stored.fields) @@ -250,58 +272,135 @@ func laterInJournal(stored, incoming *entityVersion) bool { if incoming.from.before(stored.from) { return false } - return bytes.Compare(incoming.key, stored.key) < 0 + return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0 } -// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же -// правилом — до сравнения с сохранённой. +// entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и +// не покрывается в ответ. // -// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы -// победу последнему элементу массива мимо правила полноты, а порядок элементов -// в JSON-массиве нестабилен. -// -// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного -// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки -// «сохранённой» версии не существует, есть только порядок элементов массива, и -// счётчик, зависящий от него, наблюдал бы событие через раз. -func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) { - type slot struct { - v entityVersion - pos int - } +// Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии +// могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и +// отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе. +func entityDominates(a, b entityVersion) bool { + return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields) +} - byKey := make(map[EntityRef]slot, len(versions)) +// 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)) - held := 0 - var heldAt []EntityRef for _, v := range versions { ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID} - prev, seen := byKey[ref] - if !seen { - byKey[ref] = slot{v: v, pos: len(order)} + if _, seen := byKey[ref]; !seen { order = append(order, ref) - continue } - takeB, differs := pickWithinDelivery(&prev.v, &v) - if differs { - held++ - if len(heldAt) < maxEntityRefsReported { - heldAt = append(heldAt, ref) - } - } - winner := prev.v - if takeB { - winner = v - } - byKey[ref] = slot{v: winner, pos: prev.pos} + byKey[ref] = append(byKey[ref], v) } out := make([]entityVersion, 0, len(order)) + diverging := 0 + var divergingAt []EntityRef + for _, ref := range order { - out = append(out, byKey[ref].v) + 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, held, heldAt + 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 сливает сущности одной секции с сохранёнными. @@ -320,11 +419,31 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent continue } - // Хеш — детектор изменений: совпал, значит писать нечего, и содержимое - // сохранённой сущности читать не приходится вовсе. Тренировка - // переприсылается каждой доставкой, пока не доедет маршрут, — на живом - // архиве 44 копии дают три различных содержимых. + // Хеш — детектор изменений: совпал, значит содержимое то же, и читать + // его не приходится вовсе. Тренировка переприсылается каждой доставкой, + // пока не доедет маршрут, — на живом архиве 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 } @@ -332,7 +451,7 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent if err != nil { return 0, 0, nil, err } - prev := entityVersion{raw: storedRaw, hash: stored.hash, from: stored.from} + prev := newStoredVersion(storedRaw, stored.hash, stored.from) takeIncoming, lost := pickEntity(&prev, &v) if lost { @@ -352,6 +471,44 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent 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 @@ -406,11 +563,20 @@ func entityWhere(table string) string { return ` WHERE id = ?` } -func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row { +// entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число +// `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их +// соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния, +// то есть на пути, который повторяется до пяти раз и оканчивается `failed` у +// доставки, а не отказом сборки. +func entityKeyArgs(table, kind, id string) []any { if table == recordTable { - return tx.QueryRowContext(ctx, q, kind, id) + return []any{kind, id} } - return tx.QueryRowContext(ctx, q, 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 { diff --git a/internal/store/entity_internal_test.go b/internal/store/entity_internal_test.go new file mode 100644 index 0000000..f8f5241 --- /dev/null +++ b/internal/store/entity_internal_test.go @@ -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("провенанс не обновился — тест проверяет не то") + } +} diff --git a/internal/store/entity_test.go b/internal/store/entity_test.go index 4d0bb29..3b1b0ce 100644 --- a/internal/store/entity_test.go +++ b/internal/store/entity_test.go @@ -3,11 +3,25 @@ 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 { @@ -206,22 +220,32 @@ func TestMergeПовторТойЖеТренировкиНеПишет(t *testin } } -// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка +// Версии в разных порядках подачи: пункты правила, не зависящие от порядка // свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх, -// а не пары: попарная свёртка уже давала нетранзитивную победу на точках. -func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) { +// а не пары (попарная свёртка уже давала нетранзитивную победу на точках) и +// версии с содержимым, равным одной из присланных, — иначе ветка «содержание +// равно» не посещается ни разу. +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}, } - orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}} var want string for i, order := range orders { @@ -421,12 +445,18 @@ func TestMergeНесравнимыеВерсииВОдномТелеНеЗави if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("исход зависит от порядка элементов в массиве секции") } - if a.EntitiesHeld != b.EntitiesHeld { - t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld) + if a.EntitiesDiverging != b.EntitiesDiverging { + t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging) } - if a.EntitiesHeld == 0 { + if a.EntitiesDiverging == 0 { t.Error("две версии с разным содержанием в одном теле остались незамеченными") } + // Удержаний тут нет: победитель лёг в витрину целиком, терять нечего. + // Счётчики разведены именно ради этого различия. + if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 { + t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d", + a.EntitiesHeld, b.EntitiesHeld) + } } // Отпечаток обязан различать состояния, а не только содержимое: составной ключ @@ -458,3 +488,375 @@ func TestFingerprintРазличаетСоставнойКлючЗаписи(t * 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("расхождение по порядку свёртки не отражено счётчиком удержаний") + } +} diff --git a/internal/store/errors.go b/internal/store/errors.go index 1a0844a..4b7d3c1 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -10,6 +10,13 @@ import ( // database/sql. var ErrNotFound = errors.New("запись не найдена") +// errNoCandidates — выбор победителя позван на пустом множестве. Нарушенный +// инвариант вызывающего, а не свойство данных: множество собирается из карты и +// пустым быть не может. Ошибкой, а не паникой, потому что путь проходит внутри +// свёртки принятой доставки — отказ обязан быть диагностируемым, а не «index +// out of range» в стеке фоновой горутины. +var errNoCandidates = errors.New("выбор победителя на пустом множестве версий") + // ErrBusy — база занята, и повторы транзакции этого не пересидели. // // Доменная ошибка, а не код драйвера: на неё ветвится свёртка. Отказ по diff --git a/internal/store/migrations/00008_delivery_skipped_entities.sql b/internal/store/migrations/00008_delivery_skipped_entities.sql new file mode 100644 index 0000000..83a08bc --- /dev/null +++ b/internal/store/migrations/00008_delivery_skipped_entities.sql @@ -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; diff --git a/internal/store/readonly_test.go b/internal/store/readonly_test.go index baabf59..a39bbc8 100644 --- a/internal/store/readonly_test.go +++ b/internal/store/readonly_test.go @@ -108,3 +108,88 @@ func seed(t *testing.T, path string) { } } } + +// Откат бинаря поверх новой схемы обязан отказывать, а не стартовать молча: +// старый бинарь незнакомые секции игнорирует и доставки за окно отката помечает +// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс +// «молчание», и после ретеншена тел окно становится невосстановимым. +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) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 373d8b9..1f8047d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,8 +9,6 @@ import ( "fmt" "io/fs" "net/url" - "strconv" - "strings" "time" "github.com/jmoiron/sqlx" @@ -27,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 { @@ -59,50 +77,77 @@ func OpenForRead(dbPath string) (*Store, error) { return nil, fmt.Errorf("open sqlite %q read-only: %w", dbPath, err) } - want, err := latestMigration() + 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 } - var got int64 - if err := db.Get(&got, `SELECT max(version_id) FROM goose_db_version`); err != nil { + if !ok { _ = db.Close() - return nil, fmt.Errorf("read schema version: %w", err) + return nil, fmt.Errorf("%w: журнала миграций в базе нет", ErrSchemaMismatch) } - if got != want { + + inDB, inBinary, err := readSchemaVersion(ctx, db) + if err != nil { _ = db.Close() - return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, got, want) + return nil, err + } + // Строгое равенство, в отличие от Open: у чтения нет способа догнать схему, + // а база старее бинаря отдала бы колонки, которых в ней ещё нет. Так уже + // нормировано пересборкой, и настоящее правило её не ослабляет. + if inDB != inBinary { + _ = db.Close() + return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary) } return &Store{db: db}, nil } -// latestMigration — номер последней миграции, вшитой в бинарь. -func latestMigration() (int64, error) { - entries, err := fs.ReadDir(migrationsFS, "migrations") +// 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, fmt.Errorf("read migrations dir: %w", err) + return 0, 0, err } - var top int64 - for _, e := range entries { - name := e.Name() - // Неразобранное имя — отказ, а не пропуск: страж «версия схемы не та», - // молча не заметивший миграцию, перестаёт страховать, не сказав об этом. - idx := strings.IndexByte(name, '_') - if idx <= 0 { - return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name) - } - v, err := strconv.ParseInt(name[:idx], 10, 64) - if err != nil { - return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name) - } - if v > top { - top = v - } + inDB, inBinary, err = p.GetVersions(ctx) + if err != nil { + return 0, 0, fmt.Errorf("read schema version: %w", err) } - if top == 0 { - return 0, errors.New("миграций не найдено") - } - return top, nil + return inDB, inBinary, nil } // Close закрывает соединение с БД. @@ -150,21 +195,38 @@ func readOnlyDSN(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 совпадает с хронологией. diff --git a/internal/store/winner.go b/internal/store/winner.go new file mode 100644 index 0000000..0dd014e --- /dev/null +++ b/internal/store/winner.go @@ -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 +} diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/.openspec.yaml b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/design.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/design.md new file mode 100644 index 0000000..2a9fd16 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/design.md @@ -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` + как единственная точка, где витрина не является функцией множества доставок. diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/proposal.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/proposal.md new file mode 100644 index 0000000..a2f169e --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/proposal.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`. diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/ingest/spec.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/ingest/spec.md new file mode 100644 index 0000000..bc0af5d --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/ingest/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Отказ учёта доставки называет класс причины + +Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться. + +Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет. + +Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без +учётной записи, то есть осиротело, и вернуть его в журнал может только +пересборка. Занятость базы этого не отменяет — она объясняет причину, а не +снимает работу. Смысл различения в другом: занятость означает конкуренцию за +запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска +или испорченная база. + +#### Scenario: Занятая база при учёте доставки видна как отдельный класс + +- **WHEN** запись учёта доставки не проходит из-за занятости базы +- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве +- **AND** запись отличает занятость базы от прочих причин отказа +- **AND** тот же отказ по другой причине этого признака не несёт diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/parsing/spec.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/parsing/spec.md new file mode 100644 index 0000000..1aa20f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/parsing/spec.md @@ -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** смещение не меняется, если то же значение сделать длиннее diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/reindex/spec.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/reindex/spec.md new file mode 100644 index 0000000..ff92c8c --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/reindex/spec.md @@ -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** отчёт пересборки называет число удержанных версий больше нуля diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/storage/spec.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/storage/spec.md new file mode 100644 index 0000000..fd7f7e9 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/storage/spec.md @@ -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** её число пропущенных сущностей отсутствует, а не равно нулю diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md new file mode 100644 index 0000000..50e5212 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md @@ -0,0 +1,238 @@ +## 1. Сравнение содержания сущностей (`internal/canon`) + +- [x] 1.1 `arrayLen` → `arrayShape`: рядом с числом элементов считается число + **содержательных** (по `isEmpty` литерала элемента, без разворачивания в + дерево). Обход тот же, что и сегодня. +- [x] 1.2 `Covers` получает второй разряд (все ключи `g` есть у `f`), запрет + вырождения формы (объект/массив нельзя подменить скаляром) и сравнение + содержательных элементов массива. Условия 3 и 4 — только для ключей, + содержательных у `g`. +- [x] 1.3 Комментарий `Covers` называет предел вслух: порча **внутри** элемента + ряда не ловится ничем, кроме сверки с телом в архиве; и цену `isEmpty` на + элементах (ряд настоящих нулей выглядит опустошённым). +- [x] 1.4 `FormAndHash(raw) (form []byte, hash string, err error)` — форма и хеш + одним проходом через `io.MultiWriter`; `Form`, `Hash`, `HashAll` + переписаны через общего писателя, чтобы реализация осталась одна. +- [x] 1.5 Тесты в `internal/canon/canon_test.go`: скелет не покрывает настоящую; + массив из `null`/`{}` той же длины не покрывает содержательный; ключ с + пустым значением не исчезает; форма не вырождается скаляром; покрытие + остаётся транзитивным; `FormAndHash` совпадает с `Form`+`Hash`. + +## 2. Каноническая форма один раз и вне транзакции (`internal/store`) + +- [x] 2.1 `entityVersion` без ленивости: `newEntityVersion` зовёт `FormAndHash` + один раз и держит `canon.Fields`. `analyze()` и флаг `parsed` удаляются. +- [x] 2.2 Сохранённая версия строится своим конструктором (её содержимое + читается внутри транзакции и только при разошедшемся хеше). +- [x] 2.3 Комментарий `bucket.go` приводится в соответствие с тем, что код + делает, — сегодня он утверждает обратное; остаточный предел (разбор + сохранённой остаётся в транзакции) назван там же. + +## 3. Победитель внутри доставки — функция множества (`internal/store`) + +- [x] 3.1 Общий помощник выбора победителя: максимальные элементы частичного + порядка плюс минимум по тотальному. `store.resolve` (точки) и + `dedupeEntities` (сущности) становятся двумя его вызовами. + `pickWithinDelivery` удаляется. +- [x] 3.2 Порядок тотален до конца: при совпавших канонических формах решает + минимум исходных байтов. «Строго покрыта» = покрыта другой и сама её не + покрывает. +- [x] 3.3 Счётчик различающихся версий считает кандидатов, чья каноническая + форма отличается от формы победителя. +- [x] 3.4 Тесты: перестановки трёх версий одного `id` в одном теле; две версии + разного содержания при равных множествах ключей и длинах (счётчик не + молчит); две версии с равной канонической формой и разными байтами + (счётчик молчит, байты в витрине одни при любой перестановке). + +## 4. Провенанс при совпавшем хеше (`internal/store`) + +- [x] 4.1 При совпавшем хеше сравниваются позиции журнала; сохранённая раньше + приехавшей — обновляются **только** колонки провенанса. `updated_at` не + двигается: иначе он становится меткой касания строки. +- [x] 4.2 Счётчик записанных сущностей от такого обновления не растёт; + причина — комментарием. Обновление идемпотентно при равных позициях. +- [x] 4.3 Тест сходимости журнала `A → B → A` с отложенной доставкой: содержимое + и отпечаток совпадают со свёрткой в порядке журнала. +- [x] 4.4 Тест: повторная присылка того же содержимого не двигает `updated_at`. + +## 5. Страж версии схемы (`internal/store`) + +- [x] 5.1 Чтение версии схемы — одной функцией и средствами goose + (`Provider.GetVersions`), если это работает на соединении только для + чтения; иначе ручной запрос с `sqlite_master` + `sql.NullInt64` и + названной вслух причиной копии. Разбора текста ошибки драйвера быть не + должно. +- [x] 5.2 `Open` отказывает, если версия базы **выше** версии бинаря, и не + мигрирует. `OpenForRead` сохраняет строгое равенство — не ослабляется. +- [x] 5.3 Тесты: `Open` отказывает на базе с версией из будущего; новая база + открывается и мигрирует; `OpenForRead` по-прежнему отказывает на базе + старее бинаря. + +## 6. Мягкий заголовок сущности (`internal/hae`) + +- [x] 6.1 Тип `softString` с `UnmarshalJSON`, игнорирующим нестроковое значение; + пять полей заголовка получают его. Теги остаются декларативными. +- [x] 6.2 Нестроковый `start` не откатывается на `date` — он считается + неразбираемой меткой. +- [x] 6.3 Тесты: `name`/`end` не той формы не уносят тренировку; `id` не строкой + и `id` длиннее предела пропускают сущность со счётчиком; метка не того + формата и нестроковый `start` пропускают со счётчиком; элемент, который + сам не объект, остаётся в **своём** счётчике. + +## 7. Пропуски сущностей в учёте доставки + +- [x] 7.1 Миграция `00008`: колонка `skipped_entities INTEGER` **без + умолчания** — отсутствие значения означает «не измерялось» и отличимо от + нуля. +- [x] 7.2 `ParseOutcome` несёт число пропущенных сущностей; свёртка заполняет + его в обоих исходах (успех и отказ, если разбор успел досчитать), замещая + прежнее значение целиком. +- [x] 7.3 `docs/database.md` обновлён тем же изменением. +- [x] 7.4 Колонка внесена в реестр производных полей: комментарий + `ListDeliveries` и дельта `specs/reindex/`. Пересборка её не переносит. +- [x] 7.5 Тесты: доставка с пропущенной сущностью получает ненулевой счётчик; + пересвёртка без пропусков его обнуляет; доставка, свёрнутая до появления + счётчика, отличима от нулевой. +- [x] 7.6 **Замер сделан до утверждения формулировок**: живой архив, 118 тел, + пропусков `noID=0 noTime=0 malformed=0` — data-миграции не нужно, и это + сказано числом в комментарии миграции. + +## 8. Диагностика разбора без значений из тела (`internal/hae`) + +- [x] 8.1 Четыре места `fmt.Errorf("… встречено %v", tok)` называют тип токена + словарём JSON (`object`/`array`/`string`/`number`/`bool`/`null`, + делимитер — значением) и смещение **начала** токена: `InputOffset()` + снимается ДО `Token()`. +- [x] 8.2 Тест: тело в мегабайты даёт текст ошибки постоянной длины. + +## 9. Занятость базы в логе приёма (`internal/ingest`) + +- [x] 9.1 Отказ учёта доставки различает именно `store.ErrBusy`, а не + «обстоятельства вообще»: отмена на этом пути невозможна по построению. + Уровень остаётся `ERROR`. +- [x] 9.2 Тест на форму записи лога. + +## 10. Наблюдаемость нового правила + +- [x] 10.1 Счётчик удержанных версий сущностей выведен в отчёт пересборки + (`internal/replay`) и печатается в `task verify:archive` — сходимость + отпечатка это правило не проверяет по построению. +- [x] 10.2 Замер: сколько удержаний даёт живой архив с новым правилом. + +## 11. Документация и беклог + +- [x] 11.1 `docs/architecture.md`: правило покрытия (четыре условия + пустота + элемента), предел «порча внутри элемента ряда», провенанс при совпавшем + хеше и его асимметрия с провенансом объекта, победитель внутри доставки + как функция множества, страж версии схемы с эксплуатационной ценой, + счётчик пропущенных сущностей, пункт 5 как единственная точка, где витрина + не функция множества доставок. +- [x] 11.2 `docs/conventions.md`: текст ошибки разбора без значений из тела; + тест перестановок обязан включать версию с содержимым, равным одной из + присланных, и пару «равная форма, разные байты»; колонка необратимого + решения отличает ноль от «не измерялось»; метка изменения меняется только + при изменении содержимого. +- [x] 11.3 `docs/review-journal.md`: запись о чекпоинте кода без трёх проходов. +- [x] 11.4 Остатки заведены задачами беклога: NULL-метка; пределы размера + сущности и секции с потоковым расчётом; принцип отбора data-миграций; + строка про очередь `pending` — в `stats-nablyudaemost.md`. + +## 12. Приёмка + +- [x] 12.1 Оракулы `tmp/adv/` переписаны под нормированные ожидания и живут + обычными тестами пакетов. Из семи подслучаев `ОдноПоле` зеленеют два + (`name` числом, `end` числом); пять (`id` числом, `id` длиннее предела, + метка иного формата, метка Unix-эпохой, метки нет) остаются пропусками со + счётчиком **по замыслу** и проверяются как пропуски. +- [x] 12.2 `task gate` зелёный. +- [x] 12.3 `task verify:archive` сходится на живом архиве (база: 2049 объектов, + отпечаток `799dc2b7…`, тренировок 2, записей 2). + +## Приёмочные критерии (рубрика ревью предложения) + +- [x] A1 Любая перестановка порядка свёртки в пределах одного журнала даёт один + отпечаток витрины **при нулевом счётчике несравнимых версий**; при + ненулевом расхождение допустимо и обязано сопровождаться этим счётчиком. +- [x] A2 Ни одно правило слияния не зависит от порядка элементов в JSON-массиве, + включая случай равных канонических форм. +- [x] A3 Внутри транзакции не считается ни одна каноническая форма приехавшей + версии; пик кучи на большом теле измерен до и после. +- [x] A4 Правило слияния тотально: для каждой пары версий назван ровно один + исход, ветки «по умолчанию побеждает приехавшая» нет. +- [x] A5 Каждый исход, при котором содержимое отброшено или сохранённая + удержана, даёт счётчик; счётчик удержаний виден в отчёте пересборки. +- [x] A6 Одно поле не того типа стоит одного поля; исключения (`id`, метка) + названы поимённо и обоснованы. +- [x] A7 Ни одно сообщение об ошибке и ни одна запись лога выше `DEBUG` не несут + значений из тела доставки; длина текста ошибки от длины значения не + зависит. +- [x] A8 Повторная присылка того же содержимого не меняет ни витрину, ни + отпечаток, ни счётчики записи, ни метку изменения содержимого. +- [x] A9 Чтение хеша и провенанса сохранённой версии идёт в той же транзакции, + в которой пишется результат. +- [x] A10 Всё, по чему принимается необратимое решение (число пропусков), + отличает «ноль» от «не измерялось». + +## 13. Дозакрыто по ревью кода (профиль `deep`, девять проходов) + +Обе регрессии, найденные враждебным проходом, подтверждены триажем прогоном +против базы и исправлены. + +- [x] 13.1 **Второй разряд `Covers` сделан условным** — как у `Relate` и как + велела постановка. Безусловный вариант был регрессией: версия с + `totalEnergy: null` и без маршрута запирала законный досчёт навсегда + (оракул: база писала маршрут, новый код удерживал пустышку). +- [x] 13.2 **Выбор победителя внутри доставки перестал быть квадратичным по + числу присланных версий**: совпавшие канонические формы схлопываются до + отбора, отбор видит отмену. Было: n=4000 — 3.9 с против 31 мс базы; тело в + 1.6 МБ перекрывало дедлайн свёртки, 64 МиБ — порядка 59 часов работы + единственного воркера при зелёном `/healthz`. Стало: 2000 копий — 0.06 с. +- [x] 13.3 **Отказ разбора больше не пишет «измеренный ноль» пропусков.** + `ParseOutcome.SkippedEntities` стал указателем, колонка не трогается той + же идиомой, что и выведенный слой. +- [x] 13.4 Счётчики разведены: `EntitiesHeld` (удержания против сохранённой) и + `EntitiesDiverging` (версии одного ключа в одном теле). Отдельные атрибуты + лога, отдельные ветви WARN, две цифры в отчёте пересборки. +- [x] 13.5 `canon.Form` перестал считать и выбрасывать SHA-256 на каждой точке + (общий низ без хеша); `arrayShape` перестал аллоцировать копию каждого + элемента; `shapeKept` смотрит признак «это массив» у обеих сторон. +- [x] 13.6 Разбор сохранённой версии внутри транзакции удешевлён: только + множества ключей, форма — лениво (замер: 4.5 мс / 2.3 МБ против 1.3 мс / + 174 КБ). +- [x] 13.7 `softString` задаёт поле целиком и различает «ключа не было» от + «ключ был»: `start: null` больше не уводит тренировку на момент из `date`, + повтор ключа решается последним значением. +- [x] 13.8 Элемент секции, не являющийся объектом (включая `null`), уходит в + свой счётчик, а не в «нет `id`». +- [x] 13.9 Чужая причина ошибки обрезается на границе `hae` и на проверке формы + конверта в приёме: `UnmarshalTypeError` кладёт в текст литерал, и тело из + миллиона цифр давало мегабайт в логе. +- [x] 13.10 `OpenForRead` отвергает базу без журнала миграций сразу и + структурно, а не тремя секундами попыток записи через goose. +- [x] 13.11 `touchEntityProvenance` проверяет `RowsAffected`; ключевые аргументы + живут одной функцией рядом с текстом `WHERE`; ветка составного ключа + покрыта тестом. +- [x] 13.12 Названы вслух: предел «байты от первой свёрнутой доставки при + совпавшей форме», зависимость исхода несравнимости от порядка свёртки, + провенанс вне отпечатка, исключение `source`, удержание памяти на всё + время транзакции, отсутствие пути понижения схемы. +- [x] 13.13 Заведён блокер «Чем откатывать релиз после наката миграции»; + пополнены задачи-остатки (потолок числа версий, чтение `NULL` ретеншеном, + источник алерта тишины). + +## 14. Границы, оставленные сознательно + +- Исход при **несравнимых** версиях остаётся функцией порядка свёртки, а не + журнала: в витрине лежит победитель прошлых слияний, а не все кандидаты + истории. Единственная точка, где витрина не функция множества доставок; + названа в коде и в `architecture.md`, наблюдается счётчиком удержаний. +- При совпавшей канонической форме в витрине остаются **байты** первой + свёрнутой доставки. Содержания не теряется, отпечаток не различает, + переписывать мегабайтный маршрут ради выбора между эквивалентными литералами + не стали. +- Требование постановки «`differs=true` при разных байтах» исполнено **по + канонической форме**: побайтовый счётчик срабатывал бы на измеренной норме + потока (нестабильный порядок ключей, дребезг последнего разряда). +- Тренировка с датой в **незнакомом формате** по-прежнему теряется целиком — + мягкое чтение закрывает смену типа, а не формата строки. Пропуск виден в + учётной записи; хранение сущности с неразобранной меткой вынесено остатком. diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index a2147ea..07bdff2 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -7,9 +7,7 @@ принятое, что происходит с несвёрнутым при остановке и рестарте. Цена ошибки здесь наивысшая в проекте — доставка, не попавшая в архив и в журнал, не восстанавливается: телефон её не перешлёт. - ## Requirements - ### Requirement: Ответ приёма отражает сохранность, а не разбор Приём SHALL отвечать `200` после того, как тело записано в сырой архив и @@ -302,3 +300,24 @@ NOT: факт уходит в `DEBUG`, приём продолжается. - **WHEN** запрос идёт не на приём - **THEN** его бюджет записи ответа остаётся общим `write_timeout` + +### Requirement: Отказ учёта доставки называет класс причины + +Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться. + +Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет. + +Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без +учётной записи, то есть осиротело, и вернуть его в журнал может только +пересборка. Занятость базы этого не отменяет — она объясняет причину, а не +снимает работу. Смысл различения в другом: занятость означает конкуренцию за +запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска +или испорченная база. + +#### Scenario: Занятая база при учёте доставки видна как отдельный класс + +- **WHEN** запись учёта доставки не проходит из-за занятости базы +- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве +- **AND** запись отличает занятость базы от прочих причин отказа +- **AND** тот же отказ по другой причине этого признака не несёт + diff --git a/openspec/specs/parsing/spec.md b/openspec/specs/parsing/spec.md index 728b601..80cb179 100644 --- a/openspec/specs/parsing/spec.md +++ b/openspec/specs/parsing/spec.md @@ -465,6 +465,55 @@ JSON от HAE нестабилен, а значение уезжает в баз полей зависит от типа тренировки (у уличной есть `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 секунду, и вычисленное значение молча разошлось бы с присланным. Отсутствие или нечисловое значение длительности @@ -496,7 +545,9 @@ SHALL пропускаться тем же счётчиком, что и сущ Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку -подберёт пересборка, когда разбор научится её понимать. +подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL +доходить до учётной записи доставки, а не только до лога, — иначе ретеншен, +решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка. Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних метрик — большинство потока. @@ -520,9 +571,22 @@ SHALL пропускаться тем же счётчиком, что и сущ - **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой времени и содержимым исходными байтами +#### Scenario: Имя не той формы не уносит тренировку + +- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало + числом +- **THEN** тренировка попадает в результат разбора с пустым именем +- **AND** её содержимое сохраняется дословно, включая маршрут + +#### Scenario: Конец не той формы не уносит тренировку + +- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом +- **THEN** тренировка попадает в результат разбора, а конец равен началу + #### Scenario: Сущность без идентификатора пропускается -- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст +- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id` + приехал не строкой - **THEN** сущность в результат разбора не попадает - **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается @@ -539,12 +603,26 @@ SHALL пропускаться тем же счётчиком, что и сущ `failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой тренировкой в него уехали бы записи `stateOfMind` той же доставки. +#### Scenario: Элемент секции не объект — свой счётчик + +- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив +- **THEN** факт учитывается счётчиком «не разобралось как объект» +- **AND** счётчик «нет `id`» не растёт + +#### Scenario: Повтор ключа метки решается последним значением + +- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит + последней +- **THEN** сущность попадает в результат разбора с этой меткой + #### Scenario: Сущность без разбираемой метки времени пропускается - **WHEN** элемент покрытой секции несёт `id`, но его метка времени не - разбирается ни одним из поддерживаемых форматов + разбирается ни одним из поддерживаемых форматов либо пришла не строкой + (включая `null`) - **THEN** сущность в результат разбора не попадает - **AND** факт учитывается счётчиком +- **AND** поле `date` вместо непонятого `start` не подставляется #### Scenario: Сущность со слишком длинным идентификатором пропускается @@ -582,3 +660,47 @@ SHALL пропускаться тем же счётчиком, что и сущ - **THEN** `ecg` попадает в список непокрытых ключей - **AND** сущностей из неё разбор не отдаёт +### 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** смещение не меняется, если то же значение сделать длиннее + diff --git a/openspec/specs/reindex/spec.md b/openspec/specs/reindex/spec.md index 4d207f3..0a2ec1e 100644 --- a/openspec/specs/reindex/spec.md +++ b/openspec/specs/reindex/spec.md @@ -249,10 +249,15 @@ факты журнала id, received_at, automation_name, automation_id, aggregation, period, session_id, bytes, sha256, raw_path, headers ← переносятся дословно -производные parse_status, points, derived_layer, uncovered_sections - ← начинаются пустыми +производные parse_status, points, derived_layer, uncovered_sections, + skipped_entities ← начинаются пустыми ``` +Перечень производных полей SHALL пополняться **тем же изменением**, которое +заводит новое поле: он единственное место, где сказано, чему нельзя пережить +пересборку, и следующий автор решает по нему. Поле, не внесённое в перечень, +однажды перенесут «для полноты учёта». + Факты журнала SHALL переноситься дословно, включая записи, тела которых в архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, — и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя @@ -265,7 +270,9 @@ следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы самосогласованы — проверка «повторная пересборка ничего не меняет» этого не -ловит. +ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит +«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за +измерение текущего — а по нему принимается необратимое решение об удалении тела. #### Scenario: Учёт переносится полностью @@ -280,6 +287,12 @@ - **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки того же журнала из учёта без проставленных слоёв +#### Scenario: Число пропущенных сущностей не переносится из журнала + +- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а + тела этой доставки в архиве уже нет +- **THEN** в базе назначения её число пропущенных сущностей отсутствует + ### Requirement: Подмену рабочей базы делает человек Система SHALL оставлять замену рабочей базы пересобранной человеку и @@ -463,3 +476,25 @@ SHALL идти в поток ошибок, а не смешиваться с о - **AND** команда завершается ненулевым кодом - **AND** файла по пути назначения не остаётся +### Requirement: Отчёт пересборки показывает удержанные версии сущностей + +Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не +теряем содержания», — тем же счётчиком, что ведёт свёртка. + +Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не +проверяет **по построению**: живой приём и пересборка пользуются одним правилом +и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое +правило — например, замораживающее тренировку на старой версии из-за исчезнувшего +ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик +несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же +рассуждением. + +Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь +утверждение, а не отсутствие новостей. + +#### Scenario: Удержанная версия видна в отчёте пересборки + +- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой + теряет содержание сохранённой +- **THEN** отчёт пересборки называет число удержанных версий больше нуля + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 79bfeca..fc9c6ec 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -486,15 +486,30 @@ Apple его нет (находка 46). Поэтому список MUST сох за двое суток и основанием для второй транзакции не является. Система SHALL хранить рядом с сущностью хеш её канонического содержимого и -пропускать запись, если хеш не изменился. Тренировка переприсылается каждой -доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные -копии дают три различных содержимых. +пропускать запись содержимого, если хеш не изменился. Тренировка +переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на +живом архиве 44 доставленные копии дают три различных содержимых. Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой -из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на -доставку, а не на каждой попытке повтора транзакции при занятости базы: -канонизация материализует значение целиком, и повтор умножал бы пик кучи. +из 44 копий не за чем. + +Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и +до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри +транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается +`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости +базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает +блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка +исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST +NOT: это ровно та же работа над теми же байтами. + +Остаточный предел называется вслух: разбор **сохранённой** версии остаётся +внутри транзакции — её содержимое читается оттуда же и только когда хеш +разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру +сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500 +по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а +лишь становится различимым в логе. Закрыть его может только предел на размер +сущности вместе с потоковым расчётом — отдельная задача. #### Scenario: Тренировка хранится одной строкой с маршрутом @@ -514,7 +529,8 @@ Apple его нет (находка 46). Поэтому список MUST сох #### Scenario: Повторная присылка той же тренировки не пишет в базу -- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым +- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и + её доставка стоит в журнале не позже сохранённой - **THEN** хеш совпадает и запись не выполняется #### Scenario: Отказ посреди доставки не оставляет части сущностей @@ -522,6 +538,13 @@ Apple его нет (находка 46). Поэтому список MUST сох - **WHEN** свёртка доставки прерывается на середине - **THEN** не записывается ни одна сущность этой доставки +#### Scenario: Каноническая форма сущности считается один раз + +- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за + занятости базы +- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на + повторе, ни отдельно от хеша + ### Requirement: Замена версии сущности не теряет содержания Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по @@ -535,7 +558,9 @@ Apple его нет (находка 46). Поэтому список MUST сох содержания** сохранённой. Порядок разбора: ``` -1. хеш канонического содержимого совпал → записи нет +1. хеш канонического содержимого совпал → содержимое не пишется, + провенанс поднимается до + более поздней позиции журнала 2. содержание приехавшей покрывает сохранённую и сверх того → приехавшая замещает целиком 3. приехавшая теряет содержание сохранённой → остаётся сохранённая, @@ -546,21 +571,59 @@ Apple его нет (находка 46). Поэтому список MUST сох счётчик + WARN ``` -**Содержание сравнивается множеством ключей с непустым значением — и только им.** -Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение -включения, когда значения общих содержательных ключей разошлись, а у сущности -они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая -тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при -неизменных значениях и «равенство» при изменившихся, то есть на живых данных -защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями -остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT. +**Содержание сравнивается множествами ключей и формой их значений — но не +значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно +гасит отношение включения, когда значения общих содержательных ключей +разошлись, а у сущности они расходятся **всегда** — источник её досчитывает. +Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута +даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то +есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с +неизменёнными значениями остался бы зелёным. Условия «значения общих ключей +совпали» здесь быть MUST NOT. -Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых -массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет -95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание — -законный признак «приехало меньше». Предел правила называется вслух: сокращение -**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме -сверки с телом в архиве. +Покрытие 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 доставленные копии не случилось ни разу. Но восстановление @@ -576,6 +639,36 @@ Apple его нет (находка 46). Поэтому список MUST сох в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала, а не от того, кто раньше добрался до базы. +Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш +совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если +в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная +доставка вернёт витрину к прежнему содержимому — то есть живая витрина +разойдётся с пересборкой. Обновление MUST касаться **только** провенанса; +содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей +не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами +важнее учёта обновления), и метка изменения содержимого не двигается тоже: +иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на +неизменившейся тренировке, а потребитель запроса «что изменилось с момента X» +получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную +метку — времени приёма своей доставки, — и для тай-брейка её достаточно. + +Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же +доставка, свёрнутая повторно) ничего не меняют. + +Слово «провенанс» у сущности и у часового объекта означает **разное**, и это +называется вслух: у объекта хранится доставка, **создавшая** его, и она не +поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она +поднимается до максимума по журналу среди версий с этим содержимым. Причина в +том, что у объекта нет замещения версии целиком, а у сущности только оно и есть. + +Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной +транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются +там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только +с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности +прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что +закоммитила последней: исход снова станет функцией порядка коммитов, а не +журнала, причём молча. + Отличие от точки здесь содержательное: у точки на одних координатах законно встречаются два разных измерения, и предпочитать позднее нет оснований — там исход решает порядок канонических форм. У сущности `id` — идентичность одного @@ -583,15 +676,42 @@ Apple его нет (находка 46). Поэтому список MUST сох тай-брейк по канонической форме заморозил бы тренировку на произвольной из версий навсегда, вместе с недосчитанной энергией. -Две версии одного ключа **внутри одной доставки** позициями не различаются и -SHALL разрешаться минимумом канонической формы — включая случай несравнимых -наборов. Внутри доставки «сохранённой» версии не существует, есть только -порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая -встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между -собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле -приехали две версии одного ключа с разным содержанием» SHALL считаться -**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие -через раз. +Версии одного ключа **внутри одной доставки** позициями не различаются, и +победитель среди них SHALL быть **функцией множества версий, а не порядка +элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных, +среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает +«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две +версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого +опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным +до конца**: при совпавших канонических формах решает минимум исходных байтов — +иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей +в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом +содержимом. Попарная свёртка здесь +неверна ровно так же, как она была неверна для точек: покрытие — частичный +порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение +победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок +элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии +SHALL до сравнения с сохранённой. + +Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL +считаться **симметрично** и тоже быть функцией множества: считаются кандидаты, +чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть +ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют — +победитель ложится в витрину целиком, — и одно число на два события отвечало бы +ни на одно. На счётчик удержаний опирается единственный контроль того, что +правило покрытия не стало слишком строгим; примесь делает его неотличимым от +шума. + +Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя. +Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без +схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть +отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не +значит ничего. Побайтовое различие при +совпавшей канонической форме событием MUST NOT считаться — порядок ключей в +JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что +счётчик по байтам срабатывал бы на измеренной норме потока. Различие +**содержимого** при совпадающих множествах ключей и длинах массивов считаться +SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик. Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются @@ -600,11 +720,11 @@ SHALL разрешаться минимумом канонической фор наблюдение. Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется -вслух: слияние попарное — сохранённая против приехавшей, — поэтому при -несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же -предел есть у часового объекта, где хранится победитель прошлых слияний, а не -все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5 -сопровождается счётчиком и `WARN`. +вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель +прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах +(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового +объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается +счётчиком и `WARN`. #### Scenario: Доехавший маршрут замещает тренировку без маршрута @@ -639,12 +759,73 @@ SHALL разрешаться минимумом канонической фор - **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** две витрины различаются только тем, где проходит граница между родом @@ -708,3 +889,123 @@ SHALL разрешаться минимумом канонической фор - **WHEN** отпечаток снимается, а параллельно коммитится свёртка - **THEN** отпечаток отражает одно состояние базы, а не смесь снимков +### 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** её число пропущенных сущностей отсутствует, а не равно нулю +