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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+8 -2
View File
@@ -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 {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
+25
View File
@@ -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)
}
}
}
+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 руками / плейбук).
+4 -1
View File
@@ -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 — следующая покрытая секция унаследует слепую зону
+12
View File
@@ -15,3 +15,15 @@
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
перезапуск хоста или откат деплоя.
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
`negative`).
@@ -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` (пропуск проходов на чекпоинте — отклонение процесса,
ему там место).
@@ -0,0 +1,38 @@
# Сущность с id, но неразобранной меткой
**Приоритет:** средний
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком.
## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
счётчик `SkippedEntityNoTime` растёт.
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
и ретеншен получает честный ответ «терять есть что». То есть событие больше
не молчит — но содержимое всё ещё не хранится.
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
задокументирован (`docs/local-research.md`), то есть вход не выдуман.
## Что решить
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
что делает с такими строками, иначе они молча исчезнут из любого ответа.
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
когда разбор научится читать формат.
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
вернёт `reindex`. После включения ретеншена окно становится необратимым.
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
измерение в колонке, по которой идёт выборка.
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
чтения без читателя проектируется вслепую.
@@ -0,0 +1,45 @@
# Data-миграции не отбирают строки по обрезаемым спискам
**Приоритет:** низкий
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`).
## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
секции стоит в `uncovered_sections`:
```sql
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
WHERE json_each.value IN ('workouts', 'stateOfMind'))
```
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
секцией `ecg` за ними даёт список без `ecg`.
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
(`docs/local-research.md`, находка 50), секций восемь, тела с 32 незнакомыми
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
слепую зону, а к тому времени причину никто не вспомнит.
## Что делать
Записать принцип и выбрать форму отбора:
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
например «эту доставку смотрел разбор старше версии N».
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
означать безусловное пересворачивание — доставка, у которой список обрезан,
про своё покрытие ничего достоверного не говорит.
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора
окажется общей.
## Связано
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) —
именно она следующей сделает секцию покрытой и напишет такую миграцию.
@@ -0,0 +1,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` (раздел «Деплой»).
@@ -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`.
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
отброшено как предел по конструкции, но условием ложится сюда.
+17
View File
@@ -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` именно ради этого
различия.
+8
View File
@@ -22,5 +22,13 @@
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
десятков тысяч доставок.
Активное уведомление — отдельная задача, здесь только факт.
+32
View File
@@ -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 @@
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза.
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
равной формой ловит другое: неединственный минимум, при котором победителем
оказывается просто первый в срезе, то есть порядок элементов на проводе.
- **Изменение правила разбора или слияния сопровождается замером на живом
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
+2
View File
@@ -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` (учёт
+37
View File
@@ -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 чек-листа
равен длине чек-листа, а этих пунктов в чек-листах нет и быть не может.
- **Что меняем:** отчёт ревью обязан перечислять запущенные проходы **поимённо
и с исходом**, а оркестратор задачи — сверять этот перечень с составом
профиля до того, как коммитить; непущенный проход идёт в границы покрытия
строкой «не запускался», а не отсутствует. Правилом линтера это не
выражается, автоматической проверки нет — но пропуск, названный в отчёте,
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
сработали ровно так, как задуманы, — их просто не позвали.
+179 -41
View File
@@ -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 говорит, совпадают ли значения ключей, содержательных у обеих
+158
View File
@@ -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 принят за корректный")
}
}
+63 -12
View File
@@ -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)
+128
View File
@@ -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)
}
}
+95 -15
View File
@@ -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
+109 -7
View File
@@ -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)
}
}
+77 -6
View File
@@ -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
}
+84
View File
@@ -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()))
}
}
+24
View File
@@ -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": "Незнакомое поле и дословные литералы",
+17 -2
View File
@@ -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)
+70
View File
@@ -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("литерал из тела виден в логе")
}
}
+8 -1
View File
@@ -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)
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
+16 -1
View File
@@ -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
}
+7 -2
View File
@@ -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` — результат ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало
// вместе с доставкой. У последнего пустота означает «не измерялось», так что
// перенос выдал бы измерение прежнего разбора за измерение текущего — а по нему
// решают, можно ли удалить тело. Перенести их
// значило бы сделать пересобранную витрину функцией прошлого прогона: доставка,
// чей повторный разбор отказал (штатный исход, когда слой не выводится),
// сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и
+52
View File
@@ -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)
}
}
+80 -54
View File
@@ -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
}
}
+58 -9
View File
@@ -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)
}
+259 -93
View File
@@ -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 {
+72
View File
@@ -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("провенанс не обновился — тест проверяет не то")
}
}
+409 -7
View File
@@ -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("расхождение по порядку свёртки не отражено счётчиком удержаний")
}
}
+7
View File
@@ -10,6 +10,13 @@ import (
// database/sql.
var ErrNotFound = errors.New("запись не найдена")
// errNoCandidates — выбор победителя позван на пустом множестве. Нарушенный
// инвариант вызывающего, а не свойство данных: множество собирается из карты и
// пустым быть не может. Ошибкой, а не паникой, потому что путь проходит внутри
// свёртки принятой доставки — отказ обязан быть диагностируемым, а не «index
// out of range» в стеке фоновой горутины.
var errNoCandidates = errors.New("выбор победителя на пустом множестве версий")
// ErrBusy — база занята, и повторы транзакции этого не пересидели.
//
// Доменная ошибка, а не код драйвера: на неё ветвится свёртка. Отказ по
@@ -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;
+85
View File
@@ -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)
}
}
+100 -38
View File
@@ -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 совпадает с хронологией.
+77
View File
@@ -0,0 +1,77 @@
package store
import "context"
// pickBest выбирает победителя среди кандидатов на одни координаты.
//
// **Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.**
// Попарная свёртка этого не даёт: полнота (или покрытие) — частичный порядок,
// тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы,
// то есть цикл. При цикле повторная свёртка одной и той же доставки меняет
// содержимое витрины, и она перестаёт быть свёрткой журнала. Проверено дважды:
// сперва на точках, где нетранзитивность нашлась перебором троек, потом на
// сущностях, где ту же ошибку повторили молча.
//
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
// род метрики будет измерен, — то есть правка одного экземпляра при живом
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
// свёртки молча.
//
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
// множество непревзойдённых окажется пустым.
//
// less обязан быть ТОТАЛЬНЫМ строгим порядком: при неединственном минимуме
// победителем оказывается просто первый в срезе, то есть порядок элементов на
// проводе, а он у HAE нестабилен.
//
// Отбор непревзойдённых КВАДРАТИЧЕН по числу кандидатов, и это названо вслух,
// потому что число кандидатов приходит из чужого тела. Отсюда `ctx`: цикл, чья
// стоимость определяется размером входа, обязан видеть отмену. Без него тело с
// двадцатью тысячами версий одного ключа занимало бы единственного воркера
// свёртки дольше, чем длится его же дедлайн, — то есть дедлайн, заведённый
// ровно против такого случая, не значил бы ничего. Замерено: n=4000 — 3.9 с,
// n=8000 — вчетверо больше.
//
// Вызывающий обязан сокращать множество до входа сюда: совпавших кандидатов
// схлопывать, а число различных — ограничивать. Помощник этого не делает
// сам — что считать «тем же» кандидатом, знает только он.
//
// Возвращает индекс победителя и индексы непревзойдённых — вторые нужны тем,
// кто считает несравнимость среди них. Пустой срез кандидатов — нарушенный
// инвариант вызывающего: оба сегодняшних вызова собирают множество из карты и
// пустого дать не могут.
func pickBest[T any](ctx context.Context, cands []T, dominates func(a, b T) bool, less func(a, b T) bool) (winner int, maximal []int, err error) {
if len(cands) == 0 {
return 0, nil, errNoCandidates
}
maximal = make([]int, 0, len(cands))
for i := range cands {
if err := ctx.Err(); err != nil {
return 0, nil, err
}
beaten := false
for j := range cands {
if i == j {
continue
}
if dominates(cands[j], cands[i]) {
beaten = true
break
}
}
if !beaten {
maximal = append(maximal, i)
}
}
winner = maximal[0]
for _, i := range maximal[1:] {
if less(cands[i], cands[winner]) {
winner = i
}
}
return winner, maximal, nil
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,374 @@
## Context
Семь находок дозапущенных проходов ревью (`tmp/triage-late.md`) по коммиту
`f8200f7`. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
тестами пакетов, `tmp/` в `.gitignore` и жить в нём им нельзя.
Ограничения, которые задача не выбирает, а наследует:
- **Свёртка обязана быть функцией журнала.** Живая витрина и `reindex` обязаны
сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка
элементов на проводе, — дефект по определению.
- **Тренировка приезжает повторно, пока источник её досчитывает** (26 копий,
три различных содержимых на живом архиве), и значения между копиями
расходятся **всегда**. Поэтому правило полноты точек к сущностям неприменимо,
и `Covers` существует отдельно от `Relate`.
- **Маршрут — 95% веса тренировки**, и в экспорте Apple его нет вовсе.
Затирание маршрута необратимо: `reindex` проиграет журнал и получит то же.
- **Цена канонизации измерена**: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
блокировка удерживается 5.019 с при `busy_timeout` 5000.
## Goals / Non-Goals
**Goals:**
- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
- Победитель — функция множества версий: и внутри доставки, и между доставками.
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
- Каноническая форма сущности считается один раз и вне транзакции.
- Диагностика разбора не несёт значений из тела.
**Non-Goals:**
- Хранение сущности с `id` и неразобранной меткой (NULL-метка) — требует схемы
и правил чтения витрины.
- Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
формы и хеша — новая политика, а не правка.
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине,
что для точек.
- Поэлементная сверка **содержимого** элементов ряда (точка маршрута без
`altitude`) — см. «Риски».
## Decisions
### 1. `Covers` получает второй разряд, запрет вырождения формы и счёт содержательных элементов
Сегодня `Covers(g)` требует от `f` лишь наличия каждого содержательного ключа
`g` и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
(`{"maxHeartRate":1,"heartRateData":[null,null]}` против настоящей тренировки)
признался равным настоящей и выиграл тай-брейк журнала.
Правило дополняется тремя проверками, все — по верхнему уровню:
1. **Второй разряд: ключи без содержания.** Каждый ключ `g` (включая пустые)
обязан быть у `f`. Тот же стандарт записан для точек в `canon.Relate`:
«иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
`Max` исчезли бы из витрины по жребию тай-брейка». Отличие от `Relate`:
там второй разряд применяется только при равенстве первого, здесь включение
всех ключей требуется безусловно. Это эквивалентно при равном первом разряде
и строже, когда первый разряд неравен, — а строже здесь и нужно: `Covers`
отвечает «не потеряем ли содержания».
2. **Запрет вырождения формы.** Покрывающая версия не может подменить объект
или массив скаляром: если `g[k]` — объект, `f[k]` обязан быть объектом; если
массив — массивом. Обратное (скаляр у `g`, объект у `f`) разрешено: `f`
богаче формой. Стоимость — O(ключей), первый байт литерала.
3. **Содержательные элементы массива.** Рядом с длиной считается число
**непустых** элементов. `[null,null,null]` и `[{},{},{}]` сохраняют длину, но
не несут ничего, а именно так выглядит затёртый маршрут.
Про третью проверку важно, что она **не добавляет обхода**: `arrayLen` уже
сегодня декодирует каждый элемент массива в выбрасываемый `json.RawMessage`,
чтобы посчитать их количество. Добавляется `isEmpty` по литералу элемента —
проверка первого байта и, для чисел, `strconv`; в дерево значений элемент не
разворачивается. Поэтому цена, названная триажем («полный обход маршрута на
каждое сравнение»), уже уплачена, и решение её не умножает.
**Отвергнуто:** сверка содержимого *внутри* элемента ряда (набор ключей у
каждой точки маршрута). Вот она обход действительно умножила бы — на 768 МиБ
пика, измеренных пунктом 4, — и остаётся названным пределом (см. «Риски»).
**Отношение остаётся частичным порядком** — конъюнкция включений множеств и
нестрогих неравенств по каждому общему ключу транзитивна. На это опирается
решение 2.
### 2. Победитель внутри доставки — минимум канонической формы среди непревзойдённых
Попарная свёртка `pickWithinDelivery` нетранзитивна: полнота — частичный
порядок, тай-брейк — тотальный, и вместе они образуют цикл. Стандарт для точек
записан в `architecture.md` («победитель — функция множества точек, а не порядка
их поступления») и реализован в `store.resolve`; для сущностей он применяется
дословно: собрать версии ключа, отбросить строго покрытые, среди оставшихся
взять минимум канонической формы.
Порядок обязан быть тотальным **до конца**. У точек кандидаты с равной
канонической формой схлопываются ещё до сравнения, поэтому минимум единственен;
у сущностей схлопывания нет, и при двух версиях, различающихся только порядком
ключей или дребезгом последнего разряда (измеренная норма HAE), «минимум формы»
неединственен — в хранилище лёг бы тот элемент, что стоял в массиве раньше.
Поэтому последним разрядом сравнения идут исходные байты, ровно тем же
движением и по той же причине, что записана в `canon.Less`.
«Строго покрыта» определено явно: покрыта другой версией и сама её не покрывает.
Покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и наивное
«выбросить всё, что кем-то покрыто» опустошило бы множество, потеряв обе.
Счётчик «в одном теле приехали две версии одного ключа с разным содержанием»
становится функцией множества тем же движением: считаются кандидаты, чья
каноническая форма отличается от формы победителя.
Здесь требование постановки исполнено **по канонической форме, а не по байтам**,
и это сказано прямо, потому что постановка говорит «при равном содержании и
разных байтах обязан считать `differs=true`». Цель постановки — чтобы счётчик
перестал молчать на двух настоящих разных версиях — достигается: случай оракула
(маршрут против маршрута из `null`) считается. Побайтовый вариант отвергнут
записанным инвариантом: порядок ключей в JSON от HAE нестабилен и дребезг
последнего разряда тоже, ради чего канонизация и заведена, — счётчик по байтам
срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
понадобился бы. Детерминизм при этом обеспечен не счётчиком, а тай-брейком по
байтам выше.
`pickWithinDelivery` исчезает: она была парной формой того, что теперь делает
множество.
Отбор «максимальные элементы плюс минимум по тотальному порядку» существует в
проекте для точек (`store.resolve`) и объявлен стандартом в `architecture.md`.
Второй рукописный экземпляр — ровно то, чем был `pickWithinDelivery`, и он
разошёлся со стандартом нетранзитивностью. Поэтому механизм выносится в общего
помощника, а точки и сущности становятся двумя его вызовами с разными
отношениями: `architecture.md` уже обещает смену тай-брейка точек, когда род
метрики будет измерен, то есть правка одного экземпляра при живом втором
запланирована заранее.
### 3. Провенанс обновляется при совпавшем хеше
Совпал хеш — содержимое то же, писать нечего. Но провенанс
(`delivery_id`/`delivery_received_at`) остаётся от первой свёрнутой копии, а не
от победителя журнала. Следствия два: провенанс устаревает гарантированно на
каждой из ~26 повторных присылок, и при возврате содержимого к прежнему
(A→B→A) живая витрина расходится с `reindex` — тай-брейк пункта 4 правила
сравнивает позиции, а сохранённая позиция неверна.
Решение: при совпавшем хеше сравнить позиции журнала и, если сохранённая
раньше приехавшей, обновить **только** колонки провенанса. Провенанс становится
максимумом по журналу среди версий с этим содержимым, то есть функцией
множества.
`updated_at` при этом не двигается — и это отдельное решение, а не экономия.
Тренировка приезжает до двадцати шести раз, и бамп метки на каждой сделал бы её
меткой **касания строки**, а не изменения содержимого. Потребитель запроса «что
изменилось с момента X» — естественного для коллектора и уже заказанного Read
API — получил бы двадцать шесть ложных изменений, неотличимых от настоящего
досчёта, и выяснилось бы это после того, как потребитель написан. Провенанс
несёт собственную метку (время приёма своей доставки), и для тай-брейка её
достаточно.
Счётчик записанных сущностей такое обновление **не** увеличивает: он считает
содержимое витрины, и его сравнимость с прежними замерами важнее, чем учёт
обновления. Отпечаток витрины провенанса не включает, поэтому сходимость
`reindex` от этого решения не зависит — она зависит от него косвенно, через
тай-брейк.
Слово «провенанс» после этого означает у сущности не то, что у часового
объекта: у объекта хранится доставка, **создавшая** его, и она не поднимается.
Асимметрия законная — у объекта нет замещения версии целиком, — но записана в
спеке явно, иначе читатель перенесёт смысл с одного на другое.
### 4. Каноническая форма считается один раз, вне транзакции
Комментарий `bucket.go` утверждает, что канонизация вынесена наружу; фактически
`analyze()` зовётся из `compareEntities` **внутри** `inTx`, а его результат
пишется в **копию** элемента среза и не переживает даже одной попытки.
Ключевое наблюдение: `canon.Hash(raw)` уже считает полную каноническую форму и
**выбрасывает** её, а `analyze()` считает ту же форму заново. То есть дорогая
часть и так платится на каждой версии, в `prepareEntities`, вне транзакции.
Решение: `canon` отдаёт форму и хеш **одним проходом** (`FormAndHash`, внутри —
запись в `io.MultiWriter(буфер, sha256)`, той же формой, какой уже написан
`HashAll`), `newEntityVersion` зовёт его один раз и держит результат вместе с
`canon.Analyze`. Ленивость, `analyze()` и флаг `parsed` уходят.
Отдельный вызов `HashForm(form []byte)` отвергнут: он вводит контракт
очерёдности («сперва `Form`, потом `HashForm`»), где передача сырых байт вместо
формы даёт правдоподобный, но неверный хеш, — а компилятор такую подмену не
ловит.
Баланс работы назван честно, потому что он не односторонний:
- на пути **разошедшегося хеша** (одна доставка из сорока четырёх) — минус одна
полная канонизация: сегодня форма считается дважды;
- на пути **совпавшего хеша** (сорок три из сорока четырёх) — плюс один мелкий
разбор `json.Unmarshal` в `map[string]json.RawMessage`, то есть проход по телу
и копия каждого верхнеуровневого значения. Сегодня на этом пути `analyze()` не
зовётся вовсе.
Плюс оценивается величиной входа, минус — величиной входа с константой развёртки
в дерево значений, так что суммарно решение не дороже. Но «строго меньше» было
бы неправдой, и приёмка меряет пик кучи до и после, а не верит рассуждению.
Разбор **сохранённой** версии остаётся внутри транзакции: её содержимое читается
оттуда же и только когда хеш разошёлся (одна доставка из сорока четырёх).
Убрать это можно лишь оптимистичным чтением до транзакции с перепроверкой
внутри — это уже пределы размера и потоковый расчёт, то есть остаток.
### 5. Страж версии схемы переезжает в `Open`
`OpenForRead` сверяет версию схемы и отказывает при расхождении; `Open`
мигрирует безусловно. Поэтому старый бинарь поверх схемы 7 стартует молча,
незнакомые секции игнорирует, а доставки за окно отката помечает разобранными —
и ничто не намекает, что для этого окна нужен `reindex`.
Асимметрия у `Open` законная: версия базы **ниже** версии бинаря — это ровно то,
ради чего миграции существуют. Отказ ставится на «версия базы **выше** версии
бинаря». Асимметрия относится только к `Open`: `OpenForRead` сохраняет строгое
равенство, как требует спека пересборки, — иначе `reindex` начал бы читать
рабочую базу схемы старее бинаря по колонкам, которых там нет. В одно место
выносится **чтение** версии, а не сравнение.
Чтение берётся у самого goose (`Provider.GetVersions` отдаёт и текущую версию
базы, и целевую), потому что имя таблицы учёта, имя колонки и правило «максимум
= текущая версия» принадлежат ему: рукописная копия его приватной схемы
разошлась бы при обновлении зависимости — и не отказом, а тем, что страж
перестал бы ловить. Если окажется, что на соединении только для чтения этот путь
требует записи или отдаёт лишнюю задержку (SQLite-диалект goose не умеет
`TableExists`, поэтому на отсутствующей таблице уходит в повторы), копия
допустима, но одной функцией и с названной причиной — и тогда «таблицы нет»
распознаётся структурно (`sqlite_master`) и `NULL` читается как `NULL`
(`sql.NullInt64`), а не по тексту ошибки драйвера: сообщения драйвера не
контракт, это уже записанное правило проекта.
**Цена отказа названа, потому что она реальна.** Страж останавливает сервис
целиком, а телефон шлёт непрерывно и молча: доставка, не попавшая в архив, в
журнал не попадает вовсе. Взвешено так: откат бинаря — действие оператора,
который в этот момент рядом и видит crash-loop сразу; дыры плотных метрик за
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
`stateOfMind` — у него доставки HAE единственный источник, — и это цена решения.
Она меньше цены молчания: молчаливый старт портит витрину за всё окно отката, а
узнать об этом неоткуда, и после ретеншена тел чинить будет нечем. Отвергнуты:
деградированный режим «принимать и архивировать, свёртку не начинать» (сохраняет
оба инварианта, но заводит режим, о существовании которого надо помнить, и
правила его видимости) и отказ только воркеру свёртки (требует доказать, что
старый бинарь корректно пишет учёт в новую схему, — доказательства нет).
### 6. Мягкий заголовок сущности
`entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, и
сущность теряется целиком при смене типа любого из пяти. Причина названа точно,
потому что от неё зависит выбор решения: роняет не `encoding/json`, а строка
`entity.go:51-53`, где любая ошибка разбора считается фатальной. Сам
`json.Unmarshal` «skips that field and completes the unmarshaling as best it
can» и возвращает `*UnmarshalTypeError`.
Отсюда напрашивается трёхстрочная альтернатива — `errors.As(err, &ute)` и
продолжить с уже заполненным заголовком. Она **отвергается**, и по названной
причине: та же документация тут же оговаривает — «it's not guaranteed that all
the remaining fields following the problematic one will be unmarshaled». Разбор,
построенный на дозаполнении, перестал бы быть функцией тела: одна и та же
тренировка давала бы разный заголовок в зависимости от порядка ключей на
проводе, а он у HAE нестабилен.
Решение — штатная точка расширения `encoding/json`: тип `softString` с
`UnmarshalJSON`, который на нестроковом значении ничего не пишет и возвращает
`nil`. Теги остаются декларативными, ручных извлечений нет, гарантия полная.
Заодно сохраняется различение счётчиков: элемент, который сам не объект, даёт
ошибку **верхнего** уровня и по-прежнему уходит в «не разобралось как объект», а
не в «нет `id`».
`id` при этом остаётся требованием, а не полем: без него сущность не адресуема.
Число вместо строки в `id` — это сменившаяся форма идентификатора, и
превращать `42` в `"42"` значило бы придумать идентичность за источник. Такая
сущность пропускается прежним счётчиком.
`start`, приехавший не строкой, на `date` **не** откатывается. Мягкое чтение
объявляет непонятое значение отсутствующим, а фолбэк `start → date` существует
для сущностей, у которых `start` не прислан вовсе; композиция этих двух правил
подставила бы метку другого момента времени, неотличимую от настоящей и ничем не
считаемую. Поэтому нестроковый `start` — это неразбираемая метка.
Граница правила названа вслух: оно закрывает смену **типа**, но не смену
**формата строки**, а наблюдался именно дрейф формата дат. Тренировка с датой в
незнакомом формате по-прежнему теряется целиком — теперь со счётчиком в базе, —
и закрыть это может только хранение сущности с неразобранной меткой, вынесенное
остатком.
**Пропуски становятся видны в базе.** Миграция `00008` добавляет доставке
колонку `skipped_entities`; свёртка пишет туда сумму трёх счётчиков пропуска
сущностей. Причина не в отчётности: ретеншен решает «что потеряется, если тело
удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где
теряется тренировка с маршрутом.
Статус доставки от пропуска сущности **не** меняется: `partial` определён
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
расхождение, которое спека запрещает явно.
### 7. Диагностика разбора без значений из тела
`fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт
текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`.
Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально.
Ошибка называет **тип токена** и `dec.InputOffset()`. Смещение полезнее
значения: по нему место в теле находится в архиве, а значение из тела в логе не
имеет права быть в принципе.
## Три формы решения главного узла и компромисс каждой
Главный узел — глубина сравнения содержания при слиянии версий сущности.
Рассматривались три, и выбор записан не по умолчанию:
1. **Поэлементная сверка содержимого рядов** (у каждого элемента маршрута
сравнивать набор ключей). Ловит всё, включая точку маршрута без `altitude`.
Компромисс: полный обход маршрута с материализацией каждого элемента на
каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр
правила слияния целиком. Отвергнута ценой.
2. **Второй разряд + запрет вырождения формы + счёт содержательных элементов**
(выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
мера «сходимость `verify:archive`» его не увидит по построению. Стоимость —
O(ключей) плюс `isEmpty` на элементах в уже существующем обходе.
3. **Принять предел, оставить только наблюдаемость** (счётчик по различию байт,
предел записан в `architecture.md`). Компромисс: маршрут продолжает теряться
необратимо при обеднённой версии, а восстановить его после ретеншена тел
неоткуда — в экспорте Apple маршрута нет. Отвергнута последствием.
Форма (2) принята владельцем в постановке; здесь она не переоткрывается, а
уточняется недостающими определениями (пустота элемента, строгость покрытия,
тотальность порядка) и получает контроль, которого у неё не было.
## Risks / Trade-offs
- **Порча внутри элемента ряда не ловится** (точка маршрута без `altitude`:
длина та же, элемент непуст, форма не выродилась) → предел записывается в
`architecture.md` рядом с описанием `Covers` так же прямо, как он записан в
комментарии кода. Закрыть его может только сверка с телом в архиве, а тело
живёт до ретеншена.
- **Второй разряд `Covers` строже прежнего правила** и может удержать версию,
которая раньше замещала: досчёт, потерявший ключ с пустым значением, теперь
проигрывает. Мера контроля — **не** сходимость `verify:archive`: живой приём и
пересборка пользуются одним правилом и одинаково сойдутся на одинаково
замороженной версии, то есть слишком строгое правило выглядело бы идеальной
сходимостью. Контроль — счётчик удержаний, выведенный в отчёт пересборки, и
замер его значения на живом архиве.
- **`isEmpty` считает ноль пустотой**, и это переносится на элементы ряда: ряд
настоящих нулей будет выглядеть опустошённым → ошибка направлена в безопасную
сторону (удерживаем, а не затираем) и видна счётчиком; наблюдённые ряды HAE
состоят из объектов. Записано в спеку, потому что после мерджа это часть
правила слияния навсегда.
- **Мягкий заголовок принимает больше входов**, то есть сущности, ранее
уходившие в `SkippedEntityMalformed`, начнут попадать в витрину → это
изменение разбора, и по правилу «покрыли — пересверните» такие доставки надо
пересворачивать. Замер на живом архиве сделан **до** утверждения формулировок:
118 тел, пропусков `noID=0 noTime=0 malformed=0`, то есть пересворачивать
нечего, и data-миграции нет.
- **Мягкий заголовок увеличивает долю тел, доходящих до канонизации**: сущность,
раньше отсекавшаяся на разборе заголовка почти бесплатно, теперь канонизуется
целиком, и худший случай по памяти становится достижим на входах, которые до
него не доходили → предел на размер сущности из задачи-остатка перестаёт быть
желательным и становится **обязательным условием**; записано в её теле.
- **Обновление провенанса при совпавшем хеше — дополнительная запись** там, где
раньше её не было: ~26 повторных присылок на тренировку → запись касается трёх
колонок без `payload`, то есть не трогает самое тяжёлое; счётчик записанных
сущностей и `updated_at` не двигаются, и сравнимость замеров сохраняется.
- **Страж версии схемы отказывает при старте** — сервис не поднимется на базе
из будущего, то есть приём останавливается, а телефон не перешлёт → цена
взвешена выше, в решении 5, вместе с отвергнутыми альтернативами; в
`architecture.md` уезжает эксплуатационный контракт: как это выглядит
(crash-loop контейнера) и чем лечится (возврат бинаря).
- **Пункт 5 правила (несравнимые наборы) остаётся функцией порядка
проигрывания**, и второй разряд `Covers` делает этот исход чаще → приёмочный
критерий сходимости сформулирован условно (перестановка даёт один отпечаток
при нулевом счётчике несравнимых), а сам предел записан в `architecture.md`
как единственная точка, где витрина не является функцией множества доставок.
@@ -0,0 +1,107 @@
## Why
Изменение «тренировки и записи с собственным `id`» (`f8200f7`) прошло ревью не
полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались.
Дозапуск нашёл девять причин, триаж оставил семь — у каждой прогнанный оракул.
Две из них необратимы по последствиям: обеднённая версия тренировки затирает
маршрут молча (в экспорте Apple маршрута нет, `reindex` проиграет то же
поражение), а одно поле не той формы уносит тренировку целиком, причём доставка
при этом числится разобранной — то есть ретеншен, решающий «что потеряется,
если тело удалить», получит ложное «терять нечего».
Остальные пять — молчание там, где обещана детерминированность: откат бинаря
поверх новой схемы стартует без слова, победитель внутри доставки зависит от
порядка элементов на проводе, провенанс устаревает на каждой из ~26 повторных
присылок, канонизация идёт внутри транзакции вопреки собственному комментарию
(измерено: 768 МиБ пика, 5.019 с удержания блокировки), а тело доставки в 8 МиБ
целиком уезжает в текст ошибки и оттуда в `WARN`.
## What Changes
- **Сравнение содержания сущностей получает второй разряд и запрет вырождения
формы.** Сегодня `Covers` смотрит только наличие ключа и длину
верхнеуровневого массива, поэтому версия-скелет (каждый массив заменён
массивом той же длины из `null`, каждый вложенный объект — скаляром)
признаётся равной настоящей и выигрывает тай-брейк журнала. Добавляются: ключи
без содержания вторым разрядом (как у точек — «иначе ключ с пустым значением
исчезает по жребию»), запрет покрывающей версии подменять объект или массив
скаляром, и счёт **содержательных элементов** верхнеуровневого массива рядом с
его длиной. Предел правила остаётся названным вслух и не закрывается:
сокращение **внутри** элемента ряда (точка маршрута без `altitude`) ловится
только сверкой с телом в архиве.
- **Победитель внутри одной доставки становится функцией множества версий, а не
порядка элементов массива.** Стандарт уже записан для точек и для сущностей
молча не применён: попарная свёртка частичного порядка с тотальным тай-брейком
нетранзитивна — `[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Механизм выносится в
одного помощника, общего с точками. Счётчик различающихся версий при этом
считает по **канонической форме**, а не по байтам: требование постановки
(«differs при разных байтах») исполнено по форме, потому что порядок ключей у
HAE нестабилен и побайтовый счётчик срабатывал бы на норме потока;
детерминизм обеспечен тай-брейком по байтам, а не счётчиком.
- **Провенанс обновляется при совпавшем хеше.** Сегодня совпадение хеша
пропускает запись вместе с провенансом, и в сущности остаётся первая
свёрнутая копия, а не победитель по журналу; при возврате содержимого к
прежнему живая витрина расходится с `reindex`.
- **Одно поле не того ТИПА больше не уносит сущность.** Пять полей заголовка
читаются мягко — тем же принципом, который уже записан в коде для `duration`.
Граница названа вслух: правило закрывает смену типа значения, но **не** смену
формата строки, а наблюдался именно дрейф формата дат — тренировка с
незнакомой датой по-прежнему теряется целиком, и закрыть это может только
хранение сущности с неразобранной меткой (остаток). Пропуски сущностей при
этом становятся видны в **учётной записи доставки**, а не только в логе,
причём «не измерялось» отличимо от нуля.
- **Счётчик удержанных версий выводится в отчёт пересборки.** Новое правило
строже прежнего, а сходимость отпечатка его не проверяет по построению: живой
приём и пересборка пользуются одним правилом и одинаково сойдутся на
одинаково удержанной версии.
- **`store.Open` сверяет версию схемы перед миграцией.** Версия базы выше
версии бинаря — отказ, а не повод мигрировать; прецедент записан в
`OpenForRead`.
- **Канонизация сущности уезжает за транзакцию по-настоящему.** Каноническая
форма считается один раз на версию, до входа в транзакцию, и из неё же
берётся хеш — сегодня форма считается дважды (в хеше и в отложенном разборе),
причём второй раз внутри `inTx`, который повторяется до пяти раз.
- **Текст ошибки разбора перестаёт нести значения из тела**: называется тип
токена и смещение во входе.
- Лог `delivery failed` на приёме отличает занятость базы от прочих причин.
- **Не входит:** хранение сущности с `id`, но неразобранной меткой (NULL-метка);
пределы на размер одной сущности и секции и потоковый расчёт формы и хеша;
принцип «data-миграции не отбирают строки по обрезаемым спискам»; длина
очереди `pending` в `/stats`. Всё четыре уходят задачами беклога.
## Capabilities
### New Capabilities
Новых нет. Все семь пунктов — уточнения уже записанных правил разбора и
хранения; новое понятие ввёл бы второй словарь для того же домена.
### Modified Capabilities
- `parsing`: заголовок сущности читается мягко — поле не той формы пропускает
само поле, а не сущность; диагностика разбора не несёт значений из тела.
- `storage`: содержание сущностей сравнивается с двумя разрядами, запретом
вырождения формы и счётом содержательных элементов массива; победитель внутри
доставки объявлен функцией множества с тотальным порядком; провенанс
обновляется при совпавшем хеше, а метка изменения — нет; учётная запись
доставки несёт число пропущенных сущностей, отличая ноль от «не измерялось»;
открытие базы сверяет версию схемы; каноническая форма сущности считается вне
транзакции и один раз.
- `ingest`: лог отказа приёма отличает занятость базы от прочих причин.
- `reindex`: число пропущенных сущностей внесено в перечень производных полей,
которые пересборка не переносит; отчёт пересборки называет число удержанных
версий сущностей.
## Impact
- `internal/canon``Covers`, форма массива, экспорт хеша по готовой форме.
- `internal/store``entity.go` (версия сущности, слияние, провенанс),
`bucket.go` (комментарий о канонизации), `store.go` (страж версии схемы),
новая миграция `00008` (колонка `skipped_entities` у доставки).
- `internal/hae``entity.go` (мягкий заголовок), `hae.go` (текст ошибок).
- `internal/fold`, `internal/ingest` — проброс счётчика пропусков в учёт,
различение занятости базы в логе.
- `docs/architecture.md`, `docs/database.md`, `docs/conventions.md`,
`docs/review-journal.md`.
- Оракулы из `tmp/adv/` переезжают обычными тестами в `internal/canon`,
`internal/store`, `internal/hae`.
@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: Отказ учёта доставки называет класс причины
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
учётной записи, то есть осиротело, и вернуть его в журнал может только
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
или испорченная база.
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
- **WHEN** запись учёта доставки не проходит из-за занятости базы
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
- **AND** запись отличает занятость базы от прочих причин отказа
- **AND** тот же отказ по другой причине этого признака не несёт
@@ -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** смещение не меняется, если то же значение сделать длиннее
@@ -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** отчёт пересборки называет число удержанных версий больше нуля
@@ -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** её число пропущенных сущностей отсутствует, а не равно нулю
@@ -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` при разных байтах» исполнено **по
канонической форме**: побайтовый счётчик срабатывал бы на измеренной норме
потока (нестабильный порядок ключей, дребезг последнего разряда).
- Тренировка с датой в **незнакомом формате** по-прежнему теряется целиком —
мягкое чтение закрывает смену типа, а не формата строки. Пропуск виден в
учётной записи; хранение сущности с неразобранной меткой вынесено остатком.
+21 -2
View File
@@ -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** тот же отказ по другой причине этого признака не несёт
+125 -3
View File
@@ -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** смещение не меняется, если то же значение сделать длиннее
+38 -3
View File
@@ -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** отчёт пересборки называет число удержанных версий больше нуля
+337 -36
View File
@@ -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** её число пропущенных сущностей отсутствует, а не равно нулю