Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
+167 -21
View File
@@ -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 руками / плейбук).