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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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", p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d, отложено %d",
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther, r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther,
r.replay.Deferred) 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 { if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при // Ни отпечаток пересобранной витрины, ни число доставок после прогона при
+25
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -312,3 +313,27 @@ func TestФайлНазначенияНеМожетБытьПутёмОтсут
t.Error("--force позволил собрать витрину прямо на место рабочей базы") 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` (правила его выставления ещё Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`, нет, переносить нечего) и производные от разбора поля учёта — `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, delivery(id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, points, 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, bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at) 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 счётчик + 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)`, а не порядок свёртки.** Напрашивавшееся «побеждает `(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
@@ -904,9 +985,32 @@ data-миграции, переводящей уже принятые `partial`-
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
из версий навсегда, вместе с недосчитанной энергией. из версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же,
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и
нестабилен. если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения
содержимого не двигается, иначе она становится меткой касания строки и дребезжит
двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с
момента X» получает шум, неотличимый от настоящего досчёта.
Слово «провенанс» у сущности и у часового объекта значит **разное**, и это
сказано вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. У объекта
нет замещения версии целиком, у сущности только оно и есть.
Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них — **функция множества**, а не порядка элементов массива:
отбрасываются строго покрытые (покрыта другой и сама её не покрывает —
покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы
множество), среди оставшихся берётся минимум канонической формы, а при равных
формах — минимум исходных байтов. Последний разряд не украшение: у сущностей
версии с равной формой не схлопываются, а порядок ключей в JSON от HAE
нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом.
Механизм тот же, что у точек, и живёт он одним помощником на обе единицы
хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой
`[A,B,C]` и `[B,C,A]` выбирали разных победителей.
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
@@ -914,10 +1018,19 @@ MongoDB, и так просилось из слова «перезаписыва
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
сравнения множеств и делает событие наблюдаемым вместо необратимого. сравнения множеств и делает событие наблюдаемым вместо необратимого.
Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно —
наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у в витрине лежит победитель прошлых слияний, а не все кандидаты истории, —
часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
истории. проигрывания. Тот же предел есть у часового объекта. **Это единственная точка,
где витрина не является функцией множества доставок**, и потому утверждение
«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом
счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти
вместе с этим счётчиком.
Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая
новые содержательные ключи и потерявшая пустой, теперь несравнима вместо
«полнее». Плата принята сознательно — она направлена в сторону удержания, а не
затирания, — и её величину показывает счётчик удержаний в отчёте пересборки.
#### Отпечаток и отчёт пересборки идут за витриной #### Отпечаток и отчёт пересборки идут за витриной
@@ -1116,6 +1229,39 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`. (с токенами) — отдельно, `0600`.
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
правило «максимум = текущая версия» принадлежат ему, и рукописная копия
разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж
перестал бы ловить.
Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не
работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон
её не перешлёт. Выбор сделан так потому, что откат это действие оператора,
который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
`stateOfMind`: у него доставки HAE единственный источник — это и есть цена
решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал
бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал
разобранными, а узнать об этом было бы неоткуда.
Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим:
там отказ даёт любое расхождение версий, включая базу старее бинаря — читать
колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и
структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии
таблицы идёт её создавать, и на соединении «только чтение» это три секунды
повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у
открытия с накатом.
**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды
миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в
миграциях существует для локальной разработки и на рабочей базе не исполнялся ни
разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит —
чинит только выкатка вперёд. Это цена стража, названная целиком; чем её
смягчать, решает отдельная задача беклога.
## Открытые вопросы ## Открытые вопросы
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук). - Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
+4 -1
View File
@@ -19,9 +19,9 @@
## блокеры ## блокеры
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex - [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем — аварийный путь придётся изобретать при остановленном приёме
## высокий ## высокий
- [Дозакрыть находки ревью по слиянию сущностей](dozakryt-nahodki-sushchnostej.md) — Скелет из null затирает маршрут необратимо, а откат бинаря поверх новой схемы проходит молча: семь находок с прогнанными оракулами
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
@@ -45,6 +45,8 @@
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке - [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним - [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит - [Сверка живой витрины с пересборкой](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 он избыточен - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -58,4 +60,5 @@
- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно - [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация - [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом - [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Data-миграции не отбирают строки по обрезаемым спискам](otbor-strok-data-migraciyami.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
+12
View File
@@ -15,3 +15,15 @@
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день. Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
После деплоя на rivendell поднимется. После деплоя на 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` (так сделала же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило миграция `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` - Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
и с обрезкой по длине. и с обрезкой по длине.
- **Текст ошибки разбора не содержит значений из входа** — только род токена
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
разбора не узнает.
## Конфигурация ## Конфигурация
@@ -105,6 +111,22 @@
пересборкой молча. пересборкой молча.
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности). названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
тело.
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
она становится меткой касания, и запрос «что изменилось с момента X» получает
столько ложных изменений, сколько раз источник переприслал то же самое (у
тренировки — двадцать шесть).
- **Новая производная от разбора колонка в момент появления вносится в перечень
того, что пересборка не переносит.** Перечень — единственное место, где это
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
прогона.
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
@@ -129,3 +151,13 @@
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза. выражается — отсюда проза.
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
равной формой ловит другое: неединственный минимум, при котором победителем
оказывается просто первый в срезе, то есть порядок элементов на проводе.
- **Изменение правила разбора или слияния сопровождается замером на живом
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
+2
View File
@@ -28,6 +28,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ headers TEXT │ │ updated_at TEXT │ │ headers TEXT │ │ updated_at TEXT │
│ derived_layer TEXT │ └──────────────────────────────┘ │ derived_layer TEXT │ └──────────────────────────────┘
│ uncovered_sections TEXT │ │ uncovered_sections TEXT │
│ skipped_entities INTEGER? │
└────────────────────────────┘ └────────────────────────────┘
┊ ┌──────────────────────────┐ ┌──────────────────────────┐ ┊ ┌──────────────────────────┐ ┌──────────────────────────┐
┊ │ workout │ │ record │ ┊ │ workout │ │ record │
@@ -69,6 +70,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
| `points` | сколько точек дал разбор | | `points` | сколько точек дал разбор |
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты | | `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело | | `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
| `skipped_entities` | сколько сущностей с собственным `id` разбор пропустил (нет `id`, `id` длиннее предела, метка не разбирается, элемент не объект). Вторая половина ответа на «что потеряется, если тело удалить»: список непокрытых секций про пропущенную сущность молчит. **NULL означает «не измерялось»** и нулю не равен — так выглядят доставки, свёрнутые разбором, который пропусков не считал; читатель, принимающий по счётчику необратимое решение, обязан трактовать NULL как «не удалять» |
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя | | `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт Индексы: `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 значащих цифр. // числа округлены до SignificantDigits значащих цифр.
// //
// Форма предназначена для сравнения и хеширования, а не для хранения. // Форма предназначена для сравнения и хеширования, а не для хранения.
//
// Возвращаемый срез принадлежит вызывающему целиком: буфер, в котором форма
// собрана, наружу больше не показывается.
func Form(raw []byte) ([]byte, error) { 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) v, err := decode(raw)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -54,18 +99,9 @@ func Form(raw []byte) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// Hash возвращает шестнадцатеричный SHA-256 канонической формы. func hashOf(form []byte) string {
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
form, err := Form(raw)
if err != nil {
return "", err
}
sum := sha256.Sum256(form) sum := sha256.Sum256(form)
return hex.EncodeToString(sum[:]), nil return hex.EncodeToString(sum[:])
} }
// HashAll возвращает хеш канонической формы последовательности значений — // HashAll возвращает хеш канонической формы последовательности значений —
@@ -246,8 +282,7 @@ func (f Fields) Relate(g Fields) Fullness {
return FullnessEqual return FullnessEqual
} }
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g // Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g.
// есть у f, и ни один верхнеуровневый массив не стал короче.
// //
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до // Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно // FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
@@ -259,57 +294,160 @@ func (f Fields) Relate(g Fields) Fullness {
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы // (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
// зелёным. // зелёным.
// //
// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут // Условий четыре, все по ВЕРХНЕМУ уровню:
// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому
// укорачивание — законный признак «приехало меньше». Предел правила назван
// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не
// ловится ничем, кроме сверки с телом в архиве.
// //
// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат // 1. каждый содержательный ключ g есть у f и содержателен;
// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже //
// самого сравнения. // 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 { func (f Fields) Covers(g Fields) bool {
for k, gv := range g.full { for k, gv := range g.full {
fv, ok := f.full[k] fv, ok := f.full[k]
if !ok { if !ok {
return false return false
} }
gn, gok := arrayLen(gv) if !shapeKept(fv, gv) {
if !gok { return false
continue
} }
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 false
} }
} }
return true 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: считать нужно только // Элементы проглатываются в выбрасываемый RawMessage: материализация маршрута в
// количество, а материализация маршрута в дерево значений стоила бы того же, // дерево значений стоила бы того же, от чего отказался разбор тела. Проверка
// от чего отказался разбор тела. // пустоты идёт по литералу элемента и обхода не добавляет — он уже здесь был
func arrayLen(raw json.RawMessage) (int, bool) { // ради счёта.
if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' { func arrayShape(raw json.RawMessage) (total, contentful int, ok bool) {
return 0, false if literalKind(raw) != kindArray {
return 0, 0, false
} }
dec := json.NewDecoder(bytes.NewReader(raw)) dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // открывающая скобка 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() { for dec.More() {
var skip json.RawMessage if err := dec.Decode(&elem); err != nil {
if err := dec.Decode(&skip); err != nil { return 0, 0, false
return 0, false }
total++
if !isEmpty(elem) {
contentful++
} }
n++
} }
return n, true return total, contentful, true
} }
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих // 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{} stats = Stats{}
err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления 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) 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) body, err := s.readBody(d.RawPath)
if err != nil { if err != nil {
s.fail(ctx, deliveryID, err, nil) s.fail(ctx, deliveryID, err, parseResidue{})
return stats, err 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 return stats, err
} }
@@ -171,7 +175,12 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
ReceivedAt: d.ReceivedAt, ReceivedAt: d.ReceivedAt,
}) })
if err != nil { 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 return stats, err
} }
stats.MergeStats = merge stats.MergeStats = merge
@@ -184,10 +193,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
status = store.ParsePartial status = store.ParsePartial
} }
out := store.ParseOutcome{ out := store.ParseOutcome{
Status: status, Status: status,
Points: int64(stats.Points), Points: int64(stats.Points),
Layer: stats.Layer, Layer: stats.Layer,
Uncovered: parsed.Uncovered, Uncovered: parsed.Uncovered,
SkippedEntities: skippedEntities(parsed),
} }
if err := s.finish(ctx, deliveryID, out); err != nil { if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID) 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", st.Records,
"records_written", st.RecordsWritten, "records_written", st.RecordsWritten,
"entities_held", st.EntitiesHeld, "entities_held", st.EntitiesHeld,
"entities_diverging", st.EntitiesDiverging,
"skipped_entities", skippedEntities, "skipped_entities", skippedEntities,
"layer", st.Layer, "layer", st.Layer,
"layer_mismatch", st.LayerMismatch, "layer_mismatch", st.LayerMismatch,
@@ -255,6 +266,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.HeldAt) > 0 { if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt)) 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...) 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: case allEntitiesSkipped:
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...) s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
case st.UncoveredDropped > 0: case st.UncoveredDropped > 0:
@@ -349,7 +371,35 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их // большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не // бесполезно: статус `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) { if store.Transient(cause) {
// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы // WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
// повтор не выглядел беспричинным. // повтор не выглядел беспричинным.
@@ -375,9 +425,10 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
// строка здесь оборвала бы цепочку наследования, то есть изменила бы // строка здесь оборвала бы цепочку наследования, то есть изменила бы
// результат пересборки журнала. // результат пересборки журнала.
out := store.ParseOutcome{ out := store.ParseOutcome{
Status: store.ParseFailed, Status: store.ParseFailed,
Layer: keepLayer, Layer: keepLayer,
Uncovered: uncovered, Uncovered: residue.uncovered,
SkippedEntities: residue.skipped,
} }
if err := s.finish(ctx, deliveryID, out); err != nil { if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID) 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) 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`. // (находка 16). Метрики и тренировки идут первым, `timeLayout`.
const rfc3339Layout = time.RFC3339 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 и // entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно. // хранится дословно.
//
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
// уводило бы всю тренировку в счётчик «не разобралась как объект».
type entityHead struct { type entityHead struct {
ID string `json:"id"` ID softString `json:"id"`
Name string `json:"name"` Name softString `json:"name"`
Date string `json:"date"` Date softString `json:"date"`
Start string `json:"start"` Start softString `json:"start"`
End string `json:"end"` End softString `json:"end"`
Duration json.RawMessage `json:"duration"` 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)) out := make([]Entity, 0, len(raws))
for _, raw := range raws { for _, raw := range raws {
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
if !isJSONObject(raw) {
res.SkippedEntityMalformed++
continue
}
var head entityHead var head entityHead
if err := json.Unmarshal(raw, &head); err != nil { if err := json.Unmarshal(raw, &head); err != nil {
res.SkippedEntityMalformed++ res.SkippedEntityMalformed++
continue continue
} }
if head.ID == "" || len(head.ID) > maxEntityID { // Идентификатор исключение из мягкости: без строкового `id` сущность не
// адресуема, а приведение чужого нестрокового значения к строке было бы
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
id := head.ID.value
if id == "" || len(id) > maxEntityID {
res.SkippedNoID++ res.SkippedNoID++
continue 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 { if !ok {
res.SkippedEntityNoTime++ res.SkippedEntityNoTime++
continue continue
@@ -68,17 +141,17 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
// координату, — а сущность адресуется своим `id`, и схлопывать нечего. // координату, — а сущность адресуется своим `id`, и схлопывать нечего.
// Истина при этом остаётся в Raw дословно. // Истина при этом остаётся в Raw дословно.
end := start end := start
if head.End != "" { if head.End.value != "" {
if e, ok := parseEntityTime(head.End); ok { if e, ok := parseEntityTime(head.End.value); ok {
end = e end = e
} }
} }
_, offset := start.Zone() _, offset := start.Zone()
e := Entity{ e := Entity{
ID: head.ID, ID: id,
Kind: kind, Kind: kind,
Name: head.Name, Name: head.Name.value,
Start: start.UTC(), Start: start.UTC(),
End: end.UTC(), End: end.UTC(),
OffsetSeconds: offset, OffsetSeconds: offset,
@@ -136,6 +209,13 @@ func parseDuration(raw json.RawMessage) *float64 {
return &v 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 { func firstNonEmpty(a, b string) string {
if a != "" { if a != "" {
return a return a
+109 -7
View File
@@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
byID[w.ID] = w byID[w.ID] = w
} }
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три // Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
// случая: исход у них общий. // счётчик на четыре случая: исход у них общий, сущность не адресуема.
if res.SkippedNoID != 3 { if res.SkippedNoID != 4 {
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID) t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
} }
// Метка не разбирается и метки нет вовсе. // Метка не разбирается, метки нет вовсе и начало приехало не строкой.
if res.SkippedEntityNoTime != 2 { if res.SkippedEntityNoTime != 3 {
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime) t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
} }
// Элемент, не являющийся объектом. // Элемент, не являющийся объектом.
if res.SkippedEntityMalformed != 1 { 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) { t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
w := byID["00000000-0000-4000-8000-000000000004"] w := byID["00000000-0000-4000-8000-000000000004"]
if w.Duration != nil { if w.Duration != nil {
@@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te
t.Errorf("непокрытые %v", res.Uncovered) 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() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
res = Result{} 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 var env envelope
fail := func(e error) (envelope, error) { 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)) dec := json.NewDecoder(bytes.NewReader(body))
@@ -430,6 +430,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
// Верхний уровень тела: интересует только data. Прочие ключи конверта в // Верхний уровень тела: интересует только data. Прочие ключи конверта в
// список не идут — иначе в одном списке смешались бы имена секций и мусор // список не идут — иначе в одном списке смешались бы имена секций и мусор
// конверта, а форму `{"data": …}` проверяет приём. // конверта, а форму `{"data": …}` проверяет приём.
at := dec.InputOffset()
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return fail(err) return fail(err)
@@ -441,7 +442,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
return envelope{}, nil return envelope{}, nil
} }
if d, ok := tok.(json.Delim); !ok || d != '{' { 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{}) seen := make(map[string]struct{})
for dec.More() { for dec.More() {
@@ -486,13 +487,14 @@ type envelope struct {
// decodeData разбирает объект data, дописывая в конверт покрытые секции и // decodeData разбирает объект data, дописывая в конверт покрытые секции и
// имена непокрытых. // имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error { func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
at := dec.InputOffset()
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return err return err
} }
// data не объект — прежнее поведение: ошибка ровно там, где была. // data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' { 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() { for dec.More() {
@@ -544,17 +546,85 @@ func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора // memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному. // escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) { func memberName(dec *json.Decoder) (string, error) {
at := dec.InputOffset()
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return "", err return "", err
} }
name, ok := tok.(string) name, ok := tok.(string)
if !ok { if !ok {
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok) return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
} }
return name, nil 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 проглатывает значение целиком, ничего не удерживая. // swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error { func swallow(dec *json.Decoder) error {
var skip json.RawMessage var skip json.RawMessage
@@ -562,12 +632,13 @@ func swallow(dec *json.Decoder) error {
} }
func expectDelim(dec *json.Decoder, want json.Delim) error { func expectDelim(dec *json.Decoder, want json.Delim) error {
at := dec.InputOffset()
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return err return err
} }
if d, ok := tok.(json.Delim); !ok || d != want { 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 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" "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", "id": "00000000-0000-4000-8000-000000000009",
"name": "Незнакомое поле и дословные литералы", "name": "Незнакомое поле и дословные литералы",
+17 -2
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"git.vakhrushev.me/av/healthlog/internal/archive" "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/ident"
"git.vakhrushev.me/av/healthlog/internal/store" "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 { if err != nil {
// Тело уже на диске — данные не потеряны, но учёта нет. Такое тело // Тело уже на диске — данные не потеряны, но учёта нет. Такое тело
// подберёт пересборка (`healthlog reindex`), заведя запись заново. // подберёт пересборка (`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) return Result{}, fmt.Errorf("record delivery: %w", err)
} }
@@ -227,7 +238,11 @@ func checkEnvelope(body []byte) error {
var env envelope var env envelope
if err := json.Unmarshal(body, &env); err != nil { 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 { if len(env.Data) == 0 {
return fmt.Errorf("%w: нет объекта data", ErrMalformed) return fmt.Errorf("%w: нет объекта data", ErrMalformed)
+70
View File
@@ -1,14 +1,17 @@
package ingest_test package ingest_test
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.vakhrushev.me/av/healthlog/internal/archive" "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()) st, arch := newDeps(t, t.TempDir())
return ingest.New(arch, st, nil, slog.New(slog.DiscardHandler)), arch, st 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 { if first.Records == 0 {
t.Error("записей в витрине нет — секция stateOfMind не разбирается") 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 — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей. // их не было ни разу, и на этом стоит отказ от объединения полей.
Incomparable int Incomparable int
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
// содержания». Без него правило слияния сущностей проверить нечем:
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
// пересборка пользуются одним правилом и одинаково сойдутся на одинаково
// удержанной версии. То есть слишком строгое правило (замораживающее
// тренировку на старой версии) выглядело бы идеальной сходимостью.
EntitiesHeld int
// EntitiesDiverging — версии одного ключа, приехавшие в одном теле с разным
// содержанием. Событие другого рода, чем удержание, и считается отдельно:
// смешанное число не отвечало бы ни на один из двух вопросов.
EntitiesDiverging int
} }
// Add накапливает исход одной доставки в общий. // Add накапливает исход одной доставки в общий.
@@ -43,6 +54,8 @@ func (o *Outcome) Add(other Outcome) {
o.FailedOther += other.FailedOther o.FailedOther += other.FailedOther
o.Partial += other.Partial o.Partial += other.Partial
o.Incomparable += other.Incomparable o.Incomparable += other.Incomparable
o.EntitiesHeld += other.EntitiesHeld
o.EntitiesDiverging += other.EntitiesDiverging
} }
// classify раскладывает ошибку свёртки по классам исхода. // classify раскладывает ошибку свёртки по классам исхода.
@@ -52,7 +65,7 @@ func (o *Outcome) Add(other Outcome) {
// проверяется она перебором классов, без базы и без архива. // проверяется она перебором классов, без базы и без архива.
// //
// Неэкспортируемая намеренно: её результат содержит поля `Partial` и // Неэкспортируемая намеренно: её результат содержит поля `Partial` и
// `Incomparable`, которые дописывает только Play, — вторая публичная дверь // `Incomparable`, `EntitiesHeld` и `EntitiesDiverging`, которые дописывает только Play, — вторая публичная дверь
// молча занижала бы именно тот счётчик, по которому принимается решение о // молча занижала бы именно тот счётчик, по которому принимается решение о
// судьбе тела в архиве. // судьбе тела в архиве.
func classify(err error) Outcome { func classify(err error) Outcome {
@@ -105,6 +118,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
out.Partial++ out.Partial++
} }
out.Incomparable += st.Incomparable out.Incomparable += st.Incomparable
out.EntitiesHeld += st.EntitiesHeld
out.EntitiesDiverging += st.EntitiesDiverging
} }
return out, err return out, err
} }
+7 -2
View File
@@ -203,6 +203,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
"failed_other", rep.FailedOther, "failed_other", rep.FailedOther,
"partial", rep.Partial, "partial", rep.Partial,
"incomparable", rep.Incomparable, "incomparable", rep.Incomparable,
"entities_held", rep.EntitiesHeld,
"entities_diverging", rep.EntitiesDiverging,
"buckets", rep.Buckets, "buckets", rep.Buckets,
"workouts", rep.Workouts, "workouts", rep.Workouts,
"records", rep.Records) "records", rep.Records)
@@ -333,8 +335,11 @@ func stopOr(rep Report, err error) (Report, error) {
// prepare оставляет от учётной записи ФАКТЫ ЖУРНАЛА и сбрасывает производные от // 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) 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 — приехавшие версии, отклонённые как теряющие содержание
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ // сохранённой (включая несравнимые наборы). Это и есть плата за отказ
// объединять поля: событие считается, а не предотвращается молча. // объединять поля: событие считается, а не предотвращается молча.
//
// На него опирается ЕДИНСТВЕННЫЙ контроль того, что правило покрытия не
// стало слишком строгим: сходимость отпечатка этого не проверяет по
// построению — живой приём и пересборка пользуются одним правилом и
// одинаково сойдутся на одинаково удержанной версии. Поэтому счётчик
// обязан считать ровно удержания и ничего сверх.
EntitiesHeld int EntitiesHeld int
// HeldAt — координаты первых таких сущностей, для записи в лог. // HeldAt — координаты первых таких сущностей, для записи в лог.
HeldAt []EntityRef HeldAt []EntityRef
// EntitiesDiverging — версии одного ключа, приехавшие в ОДНОМ теле с разным
// содержанием. Событие другого рода: победитель ложится в витрину целиком,
// терять нечего, лечится оно не тем же. Считается отдельно от удержаний,
// иначе одно число отвечало бы на два вопроса — и число удержаний, по
// которому судят о строгости правила, стало бы неотличимо от шума.
EntitiesDiverging int
// DivergingAt — координаты первых таких сущностей.
DivergingAt []EntityRef
} }
// Collision — координаты объекта, где столкновение разрешилось перезаписью // Collision — координаты объекта, где столкновение разрешилось перезаписью
@@ -140,10 +154,22 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
groups := groupByHour(in.Points) groups := groupByHour(in.Points)
keys := sortedKeys(groups) keys := sortedKeys(groups)
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до // Каноническая форма сущности и её хеш считаются ОДИН раз на версию и здесь
// входа в транзакцию: канонизация материализует значение целиком, а // — до входа в транзакцию. Транзакция открыта `immediate`, то есть блокирует
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик // запись, и повторяется до пяти раз при занятости базы: канонизация внутри
// кучи умножился бы на число попыток. // неё умножала бы и пик кучи, и время удержания блокировки. Измерено: тело
// 40 МиБ даёт 768 МиБ пика, 63 МиБ удерживают блокировку 5.019 с при
// busy_timeout 5000, после чего конкурентный CreateDelivery исчерпывает
// повторы.
//
// Пределов остаётся два, и оба названы вслух. Первый: разбор СОХРАНЁННОЙ
// версии остаётся внутри транзакции — её содержимое читается оттуда же и
// только когда хеш разошёлся; удержание блокировки пропорционально её
// размеру. Второй: форма и множества ключей всех версий доставки
// УДЕРЖИВАЮТСЯ в памяти до конца транзакции, то есть расход пропорционален
// размеру доставки, а не самой большой её сущности. Про процессор здесь
// стало лучше, про память — хуже, и закрыть оба может лишь предел на размер
// сущности вместе с потоковым расчётом.
workouts, err := prepareEntities(in.Workouts, from) workouts, err := prepareEntities(in.Workouts, from)
if err != nil { if err != nil {
return MergeStats{}, err return MergeStats{}, err
@@ -152,18 +178,25 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
if err != nil { if err != nil {
return MergeStats{}, err return MergeStats{}, err
} }
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts) workouts, workoutsDiverging, workoutsDivergingAt, err := dedupeEntities(ctx, workouts)
records, recordsHeld, recordsHeldAt := dedupeEntities(records) if err != nil {
return MergeStats{}, err
}
records, recordsDiverging, recordsDivergingAt, err := dedupeEntities(ctx, records)
if err != nil {
return MergeStats{}, err
}
var stats MergeStats var stats MergeStats
err = s.inTx(ctx, func(tx *sql.Tx) error { err = s.inTx(ctx, func(tx *sql.Tx) error {
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает // Счётчики обнуляются на каждой попытке: повтор транзакции начинает
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды. // слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
stats = MergeStats{ stats = MergeStats{
Workouts: len(in.Workouts), Workouts: len(in.Workouts),
Records: len(in.Records), Records: len(in.Records),
EntitiesHeld: workoutsHeld + recordsHeld, EntitiesDiverging: workoutsDiverging + recordsDiverging,
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)), DivergingAt: clipRefs(append(append([]EntityRef{},
workoutsDivergingAt...), recordsDivergingAt...)),
} }
for _, key := range keys { for _, key := range keys {
@@ -300,7 +333,10 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
return res, err 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.overwrites = overwrites
res.incomparable = incomparable 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 { type coord struct {
start int64 start int64
end int64 end int64
@@ -404,7 +440,10 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
out := make([]Point, 0, len(order)) out := make([]Point, 0, len(order))
for _, c := range order { for _, c := range order {
cands := byCoord[c] 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[i].End.Before(out[j].End)
}) })
return out, overwrites, incomparable return out, overwrites, incomparable, nil
} }
// candidate — точка вместе с тем, что о ней нужно знать при выборе // candidate — точка вместе с тем, что о ней нужно знать при выборе
@@ -444,14 +483,11 @@ func newCandidate(p Point) candidate {
// resolve выбирает победителя среди кандидатов одной координаты. // resolve выбирает победителя среди кандидатов одной координаты.
// //
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления. // Механизм общий с выбором версии сущности — pickBest: отбрасываем
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой // превзойдённых по частичному порядку, среди оставшихся берём минимум по
// (полнота — частичный порядок, поэтому «непревзойдённые» определены // тотальному. Отношения разные (полнота у точек, покрытие у сущностей), а
// однозначно), затем среди оставшихся берётся минимум по каноническому // рассуждение одно, и второй его экземпляр однажды уже разошёлся со стандартом
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят // нетранзитивностью.
// только от состава множества, поэтому пересборка журнала даёт то же
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
// ничего.
// //
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а // Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
// не конструирует. Каноническая форма существует только в момент сравнения, и // не конструирует. Каноническая форма существует только в момент сравнения, и
@@ -461,46 +497,36 @@ func newCandidate(p Point) candidate {
// несравнимыми наборами содержательных полей. На живом потоке этого не // несравнимыми наборами содержательных полей. На живом потоке этого не
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не // случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если событие наступит. // реализовано: вместо него счётчик, который скажет, если событие наступит.
func resolve(cands []candidate) (Point, bool) { func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
if len(cands) == 1 { winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
return cands[0].pt, false if err != nil {
} return Point{}, false, err
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
}
} }
// Несравнимость — не «осталось больше одного»: точки с одинаковыми // Несравнимость — не «осталось больше одного»: точки с одинаковыми
// наборами полей и разными значениями тоже остаются обе, и это рядовой // наборами полей и разными значениями тоже остаются обе, и это рядовой
// тай-брейк. Считается только то, ради чего отложено объединение полей: // тай-брейк. Считается только то, ради чего отложено объединение полей:
// у каждой из двух есть содержательный ключ, которого нет у другой. // у каждой из двух есть содержательный ключ, которого нет у другой.
return best.pt, hasIncomparablePair(maximal) return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
} }
func hasIncomparablePair(cands []candidate) bool { // pointDominates — строгое превосходство по полноте. Relate возвращает
for i := range cands { // FullnessSuperset только когда a несёт всё, что b, и сверх того, поэтому
for j := i + 1; j < len(cands); j++ { // отношение уже строгое.
if cands[i].fields.Relate(cands[j].fields) == canon.FullnessIncomparable { 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 return true
} }
} }
+58 -9
View File
@@ -51,6 +51,20 @@ type Delivery struct {
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»: // имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
// ретеншен обязан спрашивать его прежде, чем срезать тело. // ретеншен обязан спрашивать его прежде, чем срезать тело.
UncoveredSections string UncoveredSections string
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
// Второй половина ответа на тот же вопрос: сущность, которую разбор не
// понял, в витрину не попала, а список непокрытых секций про неё молчит.
//
// Отсутствие значения означает «не измерялось» и НЕ равно нулю: так
// выглядят доставки, свёрнутые разбором, который пропусков не считал, и те,
// чей разбор не досчитал. Читатель, принимающий по счётчику необратимое
// решение, обязан трактовать отсутствие как «не удалять».
//
// Указателем, а не sql.NullInt64: поле уедет в JSON `/stats` и в MCP, а
// NullInt64 сериализуется формой драйвера (`{"Int64":0,"Valid":false}`) —
// первый, кто про это забудет, опубликует её наружу, и она станет
// контрактом. Указатель даёт `null` бесплатно и означает ровно то же.
SkippedEntities *int64
} }
// CreateDelivery записывает факт приёма пакета. // CreateDelivery записывает факт приёма пакета.
@@ -92,21 +106,26 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
const q = ` const q = `
SELECT id, received_at, automation_name, automation_id, aggregation, SELECT id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, 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` FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
var d Delivery var d Delivery
var receivedAt string var receivedAt string
// sql.NullInt64 живёт ровно на границе сканирования и наружу не выходит.
var skipped sql.NullInt64
err := s.db.QueryRowxContext(ctx, q).Scan( err := s.db.QueryRowxContext(ctx, q).Scan(
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation, &d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath, &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) { if errors.Is(err, sql.ErrNoRows) {
return Delivery{}, ErrNotFound return Delivery{}, ErrNotFound
} }
if err != nil { if err != nil {
return Delivery{}, fmt.Errorf("select last delivery: %w", err) return Delivery{}, fmt.Errorf("select last delivery: %w", err)
} }
if skipped.Valid {
d.SkippedEntities = &skipped.Int64
}
d.ReceivedAt, err = ParseTime(receivedAt) d.ReceivedAt, err = ParseTime(receivedAt)
if err != nil { if err != nil {
@@ -120,11 +139,19 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
// //
// Отдаются только **факты журнала**: то, что пришло вместе с доставкой. // Отдаются только **факты журнала**: то, что пришло вместе с доставкой.
// Производные от разбора поля (`parse_status`, `points`, `derived_layer`, // Производные от разбора поля (`parse_status`, `points`, `derived_layer`,
// `uncovered_sections`) сюда не попадают намеренно — перенос их в пересобранную // `uncovered_sections`, `skipped_entities`) сюда не попадают намеренно —
// базу сделал бы витрину функцией предыдущего прогона. Особенно `derived_layer`: // перенос их в пересобранную базу сделал бы витрину функцией предыдущего
// доставка, чей повторный разбор отказал, отдала бы в наследование слой // прогона. Особенно `derived_layer`: доставка, чей повторный разбор отказал,
// прежнего разбора, и следующая доставка той же автоматизации унаследовала бы // отдала бы в наследование слой прежнего разбора, и следующая доставка той же
// его молча. // автоматизации унаследовала бы его молча. У `skipped_entities` цена та же и
// хуже: пустота у него значит «не измерялось», и перенесённое число выдавало бы
// измерение прежнего разбора за измерение текущего — а по нему принимается
// необратимое решение об удалении тела.
//
// Перечень пополняется ТЕМ ЖЕ изменением, которое заводит новое поле: он
// единственное место, где сказано, чему нельзя пережить пересборку, и следующий
// автор решает по нему. Поле, не внесённое сюда, однажды перенесут «для полноты
// учёта».
func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) { func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) {
const q = ` const q = `
SELECT id, received_at, automation_name, automation_id, aggregation, SELECT id, received_at, automation_name, automation_id, aggregation,
@@ -255,6 +282,17 @@ type ParseOutcome struct {
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии. // у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить. // Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
Uncovered []string Uncovered []string
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
// Пишется, когда разбор ДОСЧИТАЛ, включая ноль: доставка, пропуски которой
// исчезли вместе с поумневшим разбором, не должна остаться помеченной
// навсегда.
//
// nil означает «не измерялось» и колонку НЕ ТРОГАЕТ — та же идиома, что у
// пустого Layer. Без неё отказ на чтении тела или паника разбора писали бы
// ноль, то есть «проверено, терять нечего», в доставку, содержимое которой
// никто не смотрел: ровно та подстановка, ради отказа от которой колонка
// заведена без DEFAULT.
SkippedEntities *int64
} }
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error { 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 UPDATE delivery
SET parse_status = ?, points = ?, SET parse_status = ?, points = ?,
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END, derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
uncovered_sections = ? uncovered_sections = ?,
skipped_entities = CASE WHEN ? THEN ? ELSE skipped_entities END
WHERE id = ?` WHERE id = ?`
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как // Ровно одно представление пустоты — `[]`: 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) 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 { if err != nil {
return fmt.Errorf("update parse status: %w", err) return fmt.Errorf("update parse status: %w", err)
} }
+259 -93
View File
@@ -106,21 +106,23 @@ func clipRefs(refs []EntityRef) []EntityRef {
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе // entityVersion — версия сущности вместе с тем, что нужно знать при выборе
// победителя. // победителя.
// //
// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с // Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость
// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу // здесь была мнимой: хеш всё равно требует полной канонической формы, то есть
// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево // самая дорогая работа платилась на каждой копии и так, а отложенный разбор
// значений на каждой копии — ровно та форма, от которой разбор тела отказался // считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая
// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается // открыта `immediate` и повторяется до пяти раз при занятости базы.
// сразу и один раз на доставку: он и есть быстрый путь. //
// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока
// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша
// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без
// разворачивания значений. Внутри транзакции для приехавших версий не остаётся
// ничего.
type entityVersion struct { type entityVersion struct {
raw json.RawMessage raw json.RawMessage
hash string hash string
from DeliveryRef from DeliveryRef
// key и fields заполняются лениво, методом analyze().
key []byte key []byte
fields canon.Fields fields canon.Fields
parsed bool
// head — заголовок, который пишется колонками. У сохранённой версии он не // head — заголовок, который пишется колонками. У сохранённой версии он не
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком. // нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
@@ -128,21 +130,56 @@ type entityVersion struct {
} }
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) { func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
h, err := canon.Hash(e.Raw) v, err := analyzeVersion(e.Raw, from)
if err != nil { 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 разбирает версию, если этого ещё не делали. // newStoredVersion собирает версию, прочитанную из витрины.
func (v *entityVersion) analyze() { //
if v.parsed { // Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой
return // версии — единственная работа, которая осталась внутри транзакции, открытой
// `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 выбирает между сохранённой и приехавшей версией. // pickEntity выбирает между сохранённой и приехавшей версией.
@@ -177,35 +214,23 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
// Объединение полей отвергнуто там же и по той же причине, что для // Объединение полей отвергнуто там же и по той же причине, что для
// точек, — на живом потоке событие не наступало ни разу, — а из двух // точек, — на живом потоке событие не наступало ни разу, — а из двух
// версий остаётся сохранённая: правило называется «не теряет // версий остаётся сохранённая: правило называется «не теряет
// содержания», и приехавшая его теряет. Исход при этом остаётся // содержания», и приехавшая его теряет.
// функцией журнала: доставки проигрываются в его порядке. //
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
// журнала: в витрине лежит победитель прошлых слияний, а не все
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
// при разном порядке. Порядок свёртки журналу не равен — доставка,
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
// проходом, — так что живой приём и пересборка на несравнимых версиях
// законно расходятся. Это единственная точка, где витрина не является
// функцией множества доставок; она названа вслух в architecture.md, и
// счётчик удержаний ниже — единственное, что о ней сообщает.
return false, true return false, true
default: default:
return laterInJournal(stored, incoming), false 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 — как соотносится СОДЕРЖАНИЕ двух версий одной сущности. // entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны». // Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
type entityVerdict int type entityVerdict int
@@ -221,9 +246,6 @@ const (
) )
func compareEntities(stored, incoming *entityVersion) entityVerdict { func compareEntities(stored, incoming *entityVersion) entityVerdict {
stored.analyze()
incoming.analyze()
storedCovers := stored.fields.Covers(incoming.fields) storedCovers := stored.fields.Covers(incoming.fields)
incomingCovers := incoming.fields.Covers(stored.fields) incomingCovers := incoming.fields.Covers(stored.fields)
@@ -250,58 +272,135 @@ func laterInJournal(stored, incoming *entityVersion) bool {
if incoming.from.before(stored.from) { if incoming.from.before(stored.from) {
return false return false
} }
return bytes.Compare(incoming.key, stored.key) < 0 return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0
} }
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же // entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и
// правилом — до сравнения с сохранённой. // не покрывается в ответ.
// //
// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы // Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии
// победу последнему элементу массива мимо правила полноты, а порядок элементов // могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и
// в JSON-массиве нестабилен. // отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе.
// func entityDominates(a, b entityVersion) bool {
// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields)
// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки }
// «сохранённой» версии не существует, есть только порядок элементов массива, и
// счётчик, зависящий от него, наблюдал бы событие через раз.
func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) {
type slot struct {
v entityVersion
pos int
}
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)) order := make([]EntityRef, 0, len(versions))
held := 0
var heldAt []EntityRef
for _, v := range versions { for _, v := range versions {
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID} ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
prev, seen := byKey[ref] if _, seen := byKey[ref]; !seen {
if !seen {
byKey[ref] = slot{v: v, pos: len(order)}
order = append(order, ref) order = append(order, ref)
continue
} }
takeB, differs := pickWithinDelivery(&prev.v, &v) byKey[ref] = append(byKey[ref], 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}
} }
out := make([]entityVersion, 0, len(order)) out := make([]entityVersion, 0, len(order))
diverging := 0
var divergingAt []EntityRef
for _, ref := range order { 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 сливает сущности одной секции с сохранёнными. // mergeEntities сливает сущности одной секции с сохранёнными.
@@ -320,11 +419,31 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
continue continue
} }
// Хеш — детектор изменений: совпал, значит писать нечего, и содержимое // Хеш — детектор изменений: совпал, значит содержимое то же, и читать
// сохранённой сущности читать не приходится вовсе. Тренировка // его не приходится вовсе. Тренировка переприсылается каждой доставкой,
// переприсылается каждой доставкой, пока не доедет маршрут, — на живом // пока не доедет маршрут, — на живом архиве 44 копии дают три различных
// архиве 44 копии дают три различных содержимых. // содержимых.
//
// Но провенанс при этом обновить НАДО. Сохранённая позиция журнала
// участвует в тай-брейке «содержание равно», и если в ней осталась
// первая свёрнутая копия вместо победителя журнала, отложенная доставка
// вернёт витрину к прежнему содержимому — то есть живая витрина
// разойдётся с пересборкой, молча и в содержимом тренировки.
//
// Предел назван вслух: обновляется провенанс, но НЕ байты. При
// совпавшей канонической форме в витрине остаются байты той доставки,
// что свернулась первой, — а порядок ключей у HAE нестабилен, значит у
// живого приёма и пересборки они могут различаться. Отпечаток этого не
// различает (он считает по канонической форме), содержания не теряется
// ничего, а переписывать мегабайтный маршрут на каждой из двадцати
// шести присылок ради выбора между эквивалентными литералами — цена
// несоразмерная.
if stored.hash == v.hash { 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 continue
} }
@@ -332,7 +451,7 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
if err != nil { if err != nil {
return 0, 0, nil, err 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) takeIncoming, lost := pickEntity(&prev, &v)
if lost { if lost {
@@ -352,6 +471,44 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
return written, held, heldAt, nil 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 { type storedEntityHead struct {
hash string hash string
from DeliveryRef from DeliveryRef
@@ -406,11 +563,20 @@ func entityWhere(table string) string {
return ` WHERE id = ?` 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 { 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 { 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 ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"strings"
"testing" "testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store" "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 собирает тренировку с заданным содержимым. Заголовок в этих тестах // workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
// вторичен: правило замены смотрит на содержание, а не на колонки. // вторичен: правило замены смотрит на содержание, а не на колонки.
func workout(t *testing.T, id, raw string) store.IncomingEntity { 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() t.Parallel()
type step struct { type step struct {
d store.DeliveryRef d store.DeliveryRef
raw string raw string
} }
// Четвёртая версия несёт содержимое, РАВНОЕ одной из уже присланных. Без неё
// перебор троек с разными хешами ветку «содержание равно» не посещает ни
// разу — а именно на ней провенанс устаревал, и живая витрина расходилась с
// пересборкой молча.
steps := []step{ steps := []step{
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues}, {from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute}, {from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher}, {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 var want string
for i, order := range orders { for i, order := range orders {
@@ -421,12 +445,18 @@ func TestMergeНесравнимыеВерсииВОдномТелеНеЗави
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("исход зависит от порядка элементов в массиве секции") t.Error("исход зависит от порядка элементов в массиве секции")
} }
if a.EntitiesHeld != b.EntitiesHeld { if a.EntitiesDiverging != b.EntitiesDiverging {
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld) t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging)
} }
if a.EntitiesHeld == 0 { if a.EntitiesDiverging == 0 {
t.Error("две версии с разным содержанием в одном теле остались незамеченными") 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("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины") 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. // database/sql.
var ErrNotFound = errors.New("запись не найдена") var ErrNotFound = errors.New("запись не найдена")
// errNoCandidates — выбор победителя позван на пустом множестве. Нарушенный
// инвариант вызывающего, а не свойство данных: множество собирается из карты и
// пустым быть не может. Ошибкой, а не паникой, потому что путь проходит внутри
// свёртки принятой доставки — отказ обязан быть диагностируемым, а не «index
// out of range» в стеке фоновой горутины.
var errNoCandidates = errors.New("выбор победителя на пустом множестве версий")
// ErrBusy — база занята, и повторы транзакции этого не пересидели. // 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" "fmt"
"io/fs" "io/fs"
"net/url" "net/url"
"strconv"
"strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -27,7 +25,27 @@ type Store struct {
db *sqlx.DB db *sqlx.DB
} }
// Open открывает БД по пути и накатывает миграции. // Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
//
// Версия базы ВЫШЕ версии бинаря — отказ, а не повод мигрировать. Иначе откат
// бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно,
// незнакомые секции игнорирует и доставки за окно отката помечает
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
// «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно
// становится невосстановимым.
//
// Цена самого отказа названа вслух, потому что она реальна: сервис не
// поднимется, а телефон шлёт непрерывно и молча — доставка, не попавшая в
// архив, в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря
// это действие оператора, который в этот момент рядом и видит отказ сразу
// (контейнер уходит в цикл перезапуска), а дыры плотных метрик за время простоя
// закроют широкий и глубокий проходы синхронизации. Не закроют `stateOfMind` —
// у него доставки HAE единственный источник; это и есть цена. Она меньше цены
// молчания, которое портит витрину за всё окно отката незаметно.
//
// Версия базы НИЖЕ версии бинаря отказом не является: ради этого случая
// миграции и существуют. Асимметрия только здесь — OpenForRead остаётся
// строгим.
func Open(dbPath string) (*Store, error) { func Open(dbPath string) (*Store, error) {
db, err := sqlx.Connect("sqlite", dsn(dbPath)) db, err := sqlx.Connect("sqlite", dsn(dbPath))
if err != nil { 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) 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 { if err != nil {
_ = db.Close() _ = db.Close()
return nil, err return nil, err
} }
var got int64 if !ok {
if err := db.Get(&got, `SELECT max(version_id) FROM goose_db_version`); err != nil {
_ = db.Close() _ = 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() _ = 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 return &Store{db: db}, nil
} }
// latestMigration — номер последней миграции, вшитой в бинарь. // hasMigrationLog говорит, есть ли в базе журнал миграций goose. Структурный
func latestMigration() (int64, error) { // вопрос к самой базе, а не разбор текста ошибки драйвера: сообщения драйвера
entries, err := fs.ReadDir(migrationsFS, "migrations") // контрактом не являются — правило записано в 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 { if err != nil {
return 0, fmt.Errorf("read migrations dir: %w", err) return 0, 0, err
} }
var top int64 inDB, inBinary, err = p.GetVersions(ctx)
for _, e := range entries { if err != nil {
name := e.Name() return 0, 0, fmt.Errorf("read schema version: %w", err)
// Неразобранное имя — отказ, а не пропуск: страж «версия схемы не та»,
// молча не заметивший миграцию, перестаёт страховать, не сказав об этом.
idx := strings.IndexByte(name, '_')
if idx <= 0 {
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
}
v, err := strconv.ParseInt(name[:idx], 10, 64)
if err != nil {
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
}
if v > top {
top = v
}
} }
if top == 0 { return inDB, inBinary, nil
return 0, errors.New("миграций не найдено")
}
return top, nil
} }
// Close закрывает соединение с БД. // Close закрывает соединение с БД.
@@ -150,21 +195,38 @@ func readOnlyDSN(path string) string {
// пересборка витрины откроет второе, и хранилище не должно зависеть от того, // пересборка витрины откроет второе, и хранилище не должно зависеть от того,
// что вызывающий этого не сделает. // что вызывающий этого не сделает.
func migrate(db *sqlx.DB) error { func migrate(db *sqlx.DB) error {
sub, err := fs.Sub(migrationsFS, "migrations") ctx := context.Background()
inDB, inBinary, err := readSchemaVersion(ctx, db)
if err != nil { 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 { 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 fmt.Errorf("goose up: %w", err)
} }
return nil 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, секундная точность. // Now — единая точка генерации времени: UTC, секундная точность.
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая // Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
// сортировка TEXT совпадает с хронологией. // сортировка 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 ## Requirements
### Requirement: Ответ приёма отражает сохранность, а не разбор ### Requirement: Ответ приёма отражает сохранность, а не разбор
Приём SHALL отвечать `200` после того, как тело записано в сырой архив и Приём SHALL отвечать `200` после того, как тело записано в сырой архив и
@@ -302,3 +300,24 @@ NOT: факт уходит в `DEBUG`, приём продолжается.
- **WHEN** запрос идёт не на приём - **WHEN** запрос идёт не на приём
- **THEN** его бюджет записи ответа остаётся общим `write_timeout` - **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`, полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`). `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 Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности разошлось бы с присланным. Отсутствие или нечисловое значение длительности
@@ -496,7 +545,9 @@ SHALL пропускаться тем же счётчиком, что и сущ
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать. подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
метрик — большинство потока. метрик — большинство потока.
@@ -520,9 +571,22 @@ SHALL пропускаться тем же счётчиком, что и сущ
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой - **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
времени и содержимым исходными байтами времени и содержимым исходными байтами
#### Scenario: Имя не той формы не уносит тренировку
- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
числом
- **THEN** тренировка попадает в результат разбора с пустым именем
- **AND** её содержимое сохраняется дословно, включая маршрут
#### Scenario: Конец не той формы не уносит тренировку
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
- **THEN** тренировка попадает в результат разбора, а конец равен началу
#### Scenario: Сущность без идентификатора пропускается #### Scenario: Сущность без идентификатора пропускается
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст - **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
приехал не строкой
- **THEN** сущность в результат разбора не попадает - **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается - **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
@@ -539,12 +603,26 @@ SHALL пропускаться тем же счётчиком, что и сущ
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой `failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки. тренировкой в него уехали бы записи `stateOfMind` той же доставки.
#### Scenario: Элемент секции не объект — свой счётчик
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
- **THEN** факт учитывается счётчиком «не разобралось как объект»
- **AND** счётчик «нет `id`» не растёт
#### Scenario: Повтор ключа метки решается последним значением
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
последней
- **THEN** сущность попадает в результат разбора с этой меткой
#### Scenario: Сущность без разбираемой метки времени пропускается #### Scenario: Сущность без разбираемой метки времени пропускается
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не - **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
разбирается ни одним из поддерживаемых форматов разбирается ни одним из поддерживаемых форматов либо пришла не строкой
(включая `null`)
- **THEN** сущность в результат разбора не попадает - **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком - **AND** факт учитывается счётчиком
- **AND** поле `date` вместо непонятого `start` не подставляется
#### Scenario: Сущность со слишком длинным идентификатором пропускается #### Scenario: Сущность со слишком длинным идентификатором пропускается
@@ -582,3 +660,47 @@ SHALL пропускаться тем же счётчиком, что и сущ
- **THEN** `ecg` попадает в список непокрытых ключей - **THEN** `ecg` попадает в список непокрытых ключей
- **AND** сущностей из неё разбор не отдаёт - **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, факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers 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 переноситься дословно, включая записи, тела которых в Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, — архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
@@ -265,7 +270,9 @@
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит. ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит
«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за
измерение текущего — а по нему принимается необратимое решение об удалении тела.
#### Scenario: Учёт переносится полностью #### Scenario: Учёт переносится полностью
@@ -280,6 +287,12 @@
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки - **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв того же журнала из учёта без проставленных слоёв
#### Scenario: Число пропущенных сущностей не переносится из журнала
- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а
тела этой доставки в архиве уже нет
- **THEN** в базе назначения её число пропущенных сущностей отсутствует
### Requirement: Подмену рабочей базы делает человек ### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и Система SHALL оставлять замену рабочей базы пересобранной человеку и
@@ -463,3 +476,25 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **AND** команда завершается ненулевым кодом - **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся - **AND** файла по пути назначения не остаётся
### Requirement: Отчёт пересборки показывает удержанные версии сущностей
Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не
теряем содержания», — тем же счётчиком, что ведёт свёртка.
Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не
проверяет **по построению**: живой приём и пересборка пользуются одним правилом
и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое
правило — например, замораживающее тренировку на старой версии из-за исчезнувшего
ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик
несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же
рассуждением.
Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь
утверждение, а не отсутствие новостей.
#### Scenario: Удержанная версия видна в отчёте пересборки
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
теряет содержание сохранённой
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
+337 -36
View File
@@ -486,15 +486,30 @@ Apple его нет (находка 46). Поэтому список MUST сох
за двое суток и основанием для второй транзакции не является. за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой пропускать запись содержимого, если хеш не изменился. Тренировка
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на
копии дают три различных содержимых. живом архиве 44 доставленные копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на из 44 копий не за чем.
доставку, а не на каждой попытке повтора транзакции при занятости базы:
канонизация материализует значение целиком, и повтор умножал бы пик кучи. Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и
до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
NOT: это ровно та же работа над теми же байтами.
Остаточный предел называется вслух: разбор **сохранённой** версии остаётся
внутри транзакции — её содержимое читается оттуда же и только когда хеш
разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру
сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500
по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а
лишь становится различимым в логе. Закрыть его может только предел на размер
сущности вместе с потоковым расчётом — отдельная задача.
#### Scenario: Тренировка хранится одной строкой с маршрутом #### Scenario: Тренировка хранится одной строкой с маршрутом
@@ -514,7 +529,8 @@ Apple его нет (находка 46). Поэтому список MUST сох
#### Scenario: Повторная присылка той же тренировки не пишет в базу #### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым - **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и
её доставка стоит в журнале не позже сохранённой
- **THEN** хеш совпадает и запись не выполняется - **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей #### Scenario: Отказ посреди доставки не оставляет части сущностей
@@ -522,6 +538,13 @@ Apple его нет (находка 46). Поэтому список MUST сох
- **WHEN** свёртка доставки прерывается на середине - **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки - **THEN** не записывается ни одна сущность этой доставки
#### Scenario: Каноническая форма сущности считается один раз
- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за
занятости базы
- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на
повторе, ни отдельно от хеша
### Requirement: Замена версии сущности не теряет содержания ### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
@@ -535,7 +558,9 @@ Apple его нет (находка 46). Поэтому список MUST сох
содержания** сохранённой. Порядок разбора: содержания** сохранённой. Порядок разбора:
``` ```
1. хеш канонического содержимого совпал → записи нет 1. хеш канонического содержимого совпал → содержимое не пишется,
провенанс поднимается до
более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую 2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая, 3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
@@ -546,21 +571,59 @@ Apple его нет (находка 46). Поэтому список MUST сох
счётчик + WARN счётчик + WARN
``` ```
**Содержание сравнивается множеством ключей с непустым значениеми только им.** **Содержание сравнивается множествами ключей и формой их значенийно не
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
включения, когда значения общих содержательных ключей разошлись, а у сущности гасит отношение включения, когда значения общих содержательных ключей
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
неизменных значениях и «равенство» при изменившихся, то есть на живых данных даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT. неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
совпали» здесь быть MUST NOT.
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет содержимого:
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
законный признак «приехало меньше». Предел правила называется вслух: сокращение 1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме непуст;
сверки с телом в архиве. 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 доставленные копии не случилось ни разу. Но восстановление обратного за 44 доставленные копии не случилось ни разу. Но восстановление
@@ -576,6 +639,36 @@ Apple его нет (находка 46). Поэтому список MUST сох
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала, в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы. а не от того, кто раньше добрался до базы.
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
доставка, свёрнутая повторно) ничего не меняют.
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
закоммитила последней: исход снова станет функцией порядка коммитов, а не
журнала, причём молча.
Отличие от точки здесь содержательное: у точки на одних координатах законно Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного исход решает порядок канонических форм. У сущности `id` — идентичность одного
@@ -583,15 +676,42 @@ Apple его нет (находка 46). Поэтому список MUST сох
тай-брейк по канонической форме заморозил бы тренировку на произвольной из тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией. версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и Версии одного ключа **внутри одной доставки** позициями не различаются, и
SHALL разрешаться минимумом канонической формы — включая случай несравнимых победитель среди них SHALL быть **функцией множества версий, а не порядка
наборов. Внутри доставки «сохранённой» версии не существует, есть только элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между «покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
приехали две версии одного ключа с разным содержанием» SHALL считаться опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие до конца**: при совпавших канонических формах решает минимум исходных байтов —
через раз. иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
содержимом. Попарная свёртка здесь
неверна ровно так же, как она была неверна для точек: покрытие — частичный
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
SHALL до сравнения с сохранённой.
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
ни на одно. На счётчик удержаний опирается единственный контроль того, что
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
шума.
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
значит ничего. Побайтовое различие при
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
@@ -600,11 +720,11 @@ SHALL разрешаться минимумом канонической фор
наблюдение. наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
предел есть у часового объекта, где хранится победитель прошлых слияний, а не (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5 объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
сопровождается счётчиком и `WARN`. счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута #### Scenario: Доехавший маршрут замещает тренировку без маршрута
@@ -639,12 +759,73 @@ SHALL разрешаться минимумом канонической фор
- **THEN** в хранилище остаётся сохранённая версия - **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком - **AND** факт учитывается тем же счётчиком
#### Scenario: Маршрут из пустых элементов сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
элементы которого пусты (`null` либо пустой объект)
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
- **AND** факт учитывается тем же счётчиком
#### Scenario: Скелет из скаляров сохранённую тренировку не затирает
- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
заменён числом, а каждый массив — массивом той же длины из `null`
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Ключ с пустым значением не исчезает по жребию
- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
сохранённой было пустым, при совпадающих содержательных ключах
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком удержаний
#### Scenario: Пустой ключ не запирает законный досчёт
- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
не несёт, но приносит содержательный ключ, которого у сохранённой не было
- **THEN** приехавшая замещает сохранённую
- **AND** счётчик удержаний не растёт
#### Scenario: Две версии одной сущности в одном теле #### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id` - **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве - **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка - **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: Составной ключ не даёт коллизии отпечатка #### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом - **WHEN** две витрины различаются только тем, где проходит граница между родом
@@ -708,3 +889,123 @@ SHALL разрешаться минимумом канонической фор
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка - **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков - **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** её число пропущенных сущностей отсутствует, а не равно нулю