Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+167
-21
@@ -486,9 +486,16 @@ task up
|
||||
|
||||
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
|
||||
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
|
||||
`points`, `derived_layer`, `uncovered_sections`. Последнее не косметика:
|
||||
доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего
|
||||
разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала.
|
||||
`points`, `derived_layer`, `uncovered_sections`, `skipped_entities`. Перечень
|
||||
пополняется **тем же изменением**, которое заводит новое поле: он единственное
|
||||
место, где сказано, чему нельзя пережить пересборку.
|
||||
|
||||
Это не косметика. Доставка, чей повторный разбор отказал, отдала бы в
|
||||
наследование слой прежнего разбора, и витрина снова стала бы функцией
|
||||
предыдущего прогона, а не журнала. У числа пропущенных сущностей цена та же и
|
||||
хуже: пустота у него означает «не измерялось», и перенесённое число выдавало бы
|
||||
измерение прежнего разбора за измерение текущего — а по нему принимается
|
||||
необратимое решение об удалении тела.
|
||||
|
||||
#### Что не восстанавливается, и это сказано вслух
|
||||
|
||||
@@ -555,7 +562,7 @@ HAE. Значит для него доставки не хвост журнал
|
||||
```
|
||||
delivery(id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status, points,
|
||||
headers, derived_layer)
|
||||
headers, derived_layer, uncovered_sections, skipped_entities NULL)
|
||||
|
||||
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
|
||||
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
|
||||
@@ -858,6 +865,29 @@ data-миграции, переводящей уже принятые `partial`-
|
||||
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
||||
смешиваются.
|
||||
|
||||
**Значение заголовка не того типа стоит одного поля, а не сущности.** Пять полей
|
||||
(`id`, `name`, `date`, `start`, `end`) читаются мягко: нестроковое значение
|
||||
считается неприсланным. Иначе `name`, приехавшее числом, уносит тренировку
|
||||
вместе с маршрутом, а доставка при этом числится разобранной. Мягкость сделана
|
||||
через `json.Unmarshaler`, а не через разбор ошибки типа постфактум: библиотека
|
||||
дозаполняет поля «как может», но не обязуется дозаполнить те, что стоят **после**
|
||||
проблемного, — то есть исход перестал бы быть функцией тела.
|
||||
|
||||
Исключений два, и оба названы. `id`: без строкового идентификатора сущность не
|
||||
адресуема, а приведение чужого значения к строке было бы выдумыванием
|
||||
идентичности за источник. `start`: непонятое значение не откатывается на `date` —
|
||||
подстановка другого поля дала бы метку **другого момента времени**, неотличимую
|
||||
от настоящей и ничем не считаемую.
|
||||
|
||||
**Граница правила: оно закрывает смену типа, но не смену формата строки.** А
|
||||
наблюдался именно дрейф формата дат. Тренировка с датой в незнакомом формате
|
||||
по-прежнему теряется целиком; закрыть это может только хранение сущности с
|
||||
неразобранной меткой, и это отдельная задача. Пропуск при этом перестал быть
|
||||
невидимым: число пропущенных сущностей лежит в учётной записи доставки, и
|
||||
ретеншен, решающий «что потеряется, если тело удалить», больше не получает
|
||||
ложное «терять нечего». Отсутствие значения в этой колонке означает «не
|
||||
измерялось» и нулю не равно.
|
||||
|
||||
#### Замена версии сущности
|
||||
|
||||
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
|
||||
@@ -882,16 +912,67 @@ data-миграции, переводящей уже принятые `partial`-
|
||||
счётчик + WARN
|
||||
```
|
||||
|
||||
**Содержание сравнивается множеством ключей с непустым значением и длиной
|
||||
верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для
|
||||
точек, здесь неприменимо, и это проверено выполненной командой: оно гасит
|
||||
отношение включения до «равенства», когда значения общих ключей разошлись, — а
|
||||
у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и
|
||||
заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с
|
||||
неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому,
|
||||
что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса
|
||||
тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не
|
||||
ловится ничем, кроме сверки с телом в архиве.
|
||||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||||
значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это
|
||||
проверено выполненной командой: оно гасит отношение включения до «равенства»,
|
||||
когда значения общих ключей разошлись, — а у сущности они расходятся всегда.
|
||||
Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с
|
||||
маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы
|
||||
зелёным.
|
||||
|
||||
Условий покрытия четыре, все по **верхнему уровню**:
|
||||
|
||||
```
|
||||
1. каждый содержательный ключ сохранённой есть у приехавшей и содержателен
|
||||
2. каждый ключ сохранённой, даже пустой, есть у приехавшей
|
||||
3. форма не вырождается: объект остаётся объектом, массив — массивом
|
||||
4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов
|
||||
```
|
||||
|
||||
Условие 2 — тот же второй разряд, что у точек, и с тем же **условием**: оно
|
||||
включается только при равенстве множеств содержательных ключей. Иначе ключ с
|
||||
пустым значением исчезает по жребию тай-брейка — но и обратная крайность
|
||||
проверена оракулом и отвергнута: безусловный второй разряд запирал законный
|
||||
досчёт навсегда. Версия с `totalEnergy: null` и без маршрута оказывалась
|
||||
несравнимой с версией, у которой маршрут приехал, а этого ключа нет, — и
|
||||
маршрут не доезжал **никогда**, причём пересборка проигрывала то же поражение.
|
||||
Второй разряд разрешает спор равных, а не отменяет первый.
|
||||
|
||||
Условие 3 закрывает «скелет»: тело, где каждый вложенный объект заменён числом,
|
||||
а каждый массив — массивом той же длины из `null`, проходило все прежние
|
||||
проверки и по тай-брейку журнала замещало настоящую тренировку целиком.
|
||||
|
||||
Условие 4 добавлено потому, что усечённый маршрут (три точки вместо 593) ключа
|
||||
не теряет, а маршрут из `[null,null,null]` не теряет и длины — притом что
|
||||
маршрут это 95% веса тренировки. Содержательность элемента — **та же пустота**,
|
||||
что у поля точки; второй словарь пустоты дал бы два ответа на один вопрос. Цена
|
||||
названа вслух: ряд настоящих нулей (`[0,0,0]`) считается лишённым содержания, и
|
||||
версия с ним сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||||
правило удерживает, а не затирает, — и видна счётчиком.
|
||||
|
||||
Условия 3 и 4 применяются к ключам, содержательным у сохранённой: у пустоты
|
||||
формы нет, и требовать её сохранения значило бы отличать `[]` от `0` там, где ни
|
||||
то, ни другое ничего не несёт.
|
||||
|
||||
Поле `source` в множества не входит — ни у точки, ни у сущности. Для точки
|
||||
причина измерена (оно нестабильно и переписывается задним числом, находка 36);
|
||||
для сущности она наследуется, и это сказано вслух, потому что список исключений
|
||||
живёт в общем разборе: правка ради точек молча изменит правило удержания
|
||||
сущностей. Верхнеуровневого `source` ни у тренировки, ни у `stateOfMind` живьём
|
||||
не наблюдалось.
|
||||
|
||||
**Предел правила назван вслух и не закрывается: сокращение внутри элемента ряда
|
||||
(точка маршрута без `altitude` при непустом элементе и той же длине) не ловится
|
||||
ничем, кроме сверки с телом в архиве.** Поэлементная сверка содержимого
|
||||
отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
|
||||
значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
|
||||
|
||||
**Проверить это правило отпечатком нельзя.** Живой приём и пересборка пользуются
|
||||
одним правилом и одинаково сойдутся на одинаково удержанной версии — то есть
|
||||
слишком строгое правило, замораживающее тренировку на старой версии, выглядело
|
||||
бы идеальной сходимостью. Поэтому число удержаний идёт в отчёт пересборки и
|
||||
печатается всегда, включая ноль: здесь ноль это утверждение, а не отсутствие
|
||||
новостей.
|
||||
|
||||
**Тай-брейк при равном содержании — позиция доставки в журнале
|
||||
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
|
||||
@@ -904,9 +985,32 @@ data-миграции, переводящей уже принятые `partial`-
|
||||
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
|
||||
из версий навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
|
||||
нестабилен.
|
||||
Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же,
|
||||
писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и
|
||||
если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения
|
||||
содержимого не двигается, иначе она становится меткой касания строки и дребезжит
|
||||
двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с
|
||||
момента X» получает шум, неотличимый от настоящего досчёта.
|
||||
|
||||
Слово «провенанс» у сущности и у часового объекта значит **разное**, и это
|
||||
сказано вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||||
поднимается до максимума по журналу среди версий с этим содержимым. У объекта
|
||||
нет замещения версии целиком, у сущности только оно и есть.
|
||||
|
||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||
победитель среди них — **функция множества**, а не порядка элементов массива:
|
||||
отбрасываются строго покрытые (покрыта другой и сама её не покрывает —
|
||||
покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы
|
||||
множество), среди оставшихся берётся минимум канонической формы, а при равных
|
||||
формах — минимум исходных байтов. Последний разряд не украшение: у сущностей
|
||||
версии с равной формой не схлопываются, а порядок ключей в JSON от HAE
|
||||
нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом.
|
||||
Механизм тот же, что у точек, и живёт он одним помощником на обе единицы
|
||||
хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой
|
||||
`[A,B,C]` и `[B,C,A]` выбирали разных победителей.
|
||||
|
||||
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
|
||||
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
|
||||
@@ -914,10 +1018,19 @@ MongoDB, и так просилось из слова «перезаписыва
|
||||
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
|
||||
сравнения множеств и делает событие наблюдаемым вместо необратимого.
|
||||
|
||||
Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых
|
||||
наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у
|
||||
часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты
|
||||
истории.
|
||||
Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно —
|
||||
в витрине лежит победитель прошлых слияний, а не все кандидаты истории, —
|
||||
поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
|
||||
проигрывания. Тот же предел есть у часового объекта. **Это единственная точка,
|
||||
где витрина не является функцией множества доставок**, и потому утверждение
|
||||
«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом
|
||||
счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти
|
||||
вместе с этим счётчиком.
|
||||
|
||||
Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая
|
||||
новые содержательные ключи и потерявшая пустой, теперь несравнима вместо
|
||||
«полнее». Плата принята сознательно — она направлена в сторону удержания, а не
|
||||
затирания, — и её величину показывает счётчик удержаний в отчёте пересборки.
|
||||
|
||||
#### Отпечаток и отчёт пересборки идут за витриной
|
||||
|
||||
@@ -1116,6 +1229,39 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
|
||||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||||
(с токенами) — отдельно, `0600`.
|
||||
|
||||
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
|
||||
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
|
||||
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
|
||||
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
|
||||
правило «максимум = текущая версия» принадлежат ему, и рукописная копия
|
||||
разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж
|
||||
перестал бы ловить.
|
||||
|
||||
Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не
|
||||
работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон
|
||||
её не перешлёт. Выбор сделан так потому, что откат это действие оператора,
|
||||
который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за
|
||||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||||
`stateOfMind`: у него доставки HAE единственный источник — это и есть цена
|
||||
решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал
|
||||
бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал
|
||||
разобранными, а узнать об этом было бы неоткуда.
|
||||
|
||||
Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим:
|
||||
там отказ даёт любое расхождение версий, включая базу старее бинаря — читать
|
||||
колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и
|
||||
структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии
|
||||
таблицы идёт её создавать, и на соединении «только чтение» это три секунды
|
||||
повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у
|
||||
открытия с накатом.
|
||||
|
||||
**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды
|
||||
миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в
|
||||
миграциях существует для локальной разработки и на рабочей базе не исполнялся ни
|
||||
разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит —
|
||||
чинит только выкатка вперёд. Это цена стража, названная целиком; чем её
|
||||
смягчать, решает отдельная задача беклога.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
## блокеры
|
||||
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
|
||||
- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем — аварийный путь придётся изобретать при остановленном приёме
|
||||
|
||||
## высокий
|
||||
- [Дозакрыть находки ревью по слиянию сущностей](dozakryt-nahodki-sushchnostej.md) — Скелет из null затирает маршрут необратимо, а откат бинаря поверх новой схемы проходит молча: семь находок с прогнанными оракулами
|
||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
@@ -45,6 +45,8 @@
|
||||
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
- [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
||||
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
|
||||
## низкий
|
||||
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
@@ -58,4 +60,5 @@
|
||||
- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- [Data-миграции не отбирают строки по обрезаемым спискам](otbor-strok-data-migraciyami.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
|
||||
|
||||
@@ -15,3 +15,15 @@
|
||||
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
|
||||
После деплоя на rivendell поднимется.
|
||||
|
||||
|
||||
## Источник алерта не может жить внутри `serve`
|
||||
|
||||
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
|
||||
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
|
||||
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
|
||||
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
|
||||
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
|
||||
перезапуск хоста или откат деплоя.
|
||||
|
||||
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
|
||||
`negative`).
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# Дозакрыть находки ревью по слиянию сущностей
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Задача «Тренировки и секции с собственными id» (`f8200f7`) прошла ревью не
|
||||
полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались.
|
||||
Дозапуск принёс девять причин, триаж оставил семь. Оба заявленных `critical`
|
||||
понижены до `major` с названной причиной — вход недостижим из штатного потока
|
||||
HAE (корпус в 118 доставок такого не производил), — но остались в работе:
|
||||
потеря маршрута необратима, а `reindex` проигрывает то же поражение.
|
||||
|
||||
Отчёт триажа с прогнанными оракулами — `tmp/triage-late.md`, оракулы —
|
||||
`tmp/adv/*.go`. Каждый пункт ниже имеет падающий тест; правка считается
|
||||
сделанной, когда соответствующий оракул зеленеет **и** переезжает из `tmp/` в
|
||||
обычные тесты пакета.
|
||||
|
||||
## Что делать
|
||||
|
||||
**1. Скелет не затирает маршрут.** `canon.Fields.Covers` проверяет только
|
||||
наличие ключа и длину верхнеуровневого массива, поэтому версия, где каждый
|
||||
массив заменён массивом той же длины из `null`, а каждое не-массивное значение
|
||||
— скаляром, признаётся равной настоящей и по тай-брейку журнала замещает её.
|
||||
Оракул: `-run 'Скелет|Маршрут|ДвеВерсии'`.
|
||||
|
||||
Решение принято, новое не проектируем: применяем к сущностям **тот же
|
||||
стандарт, что записан для точек** — второй разряд сравнения, как у
|
||||
`canon.Relate` («иначе ключ с пустым значением исчезает по жребию»). Плюс
|
||||
запрет вырождения формы: покрывающая версия не может подменить объект или
|
||||
массив скаляром — проверка по верхнему уровню, стоимость O(ключей).
|
||||
Поэлементная содержательность массивов **отвергнута ценой**: пункт 4 измерил
|
||||
768 МиБ пика на канонизации, полный обход маршрута на каждое сравнение эту цену
|
||||
умножит. Остающийся предел — порча *внутри* элемента ряда (точка маршрута без
|
||||
`altitude`) — не закрывается ничем, кроме сверки с телом в архиве, и должен
|
||||
быть записан в `architecture.md` рядом с описанием `Covers` так же прямо, как
|
||||
он записан в комментарии кода.
|
||||
|
||||
Отдельно: `pickWithinDelivery` при равном содержании и **разных байтах** обязан
|
||||
считать `differs=true` — сейчас две версии одного `id` в одном теле дают
|
||||
`удержано=0` и молчащий счётчик.
|
||||
|
||||
**2. Одно поле не той формы не уносит сущность.** `entityHead` держит
|
||||
`ID`/`Name`/`Date`/`Start`/`End` типизированными строками, поэтому смена типа
|
||||
любого из пяти роняет `json.Unmarshal` целиком, а доставка при этом получает
|
||||
`parsed` с пустым списком непокрытого. Достижимо из реального потока: дрейф
|
||||
формата дат у HAE задокументирован. Оракул: `-run ОдноПоле`.
|
||||
|
||||
Читать пять полей через `json.RawMessage` и извлекать мягко — это буквально
|
||||
принцип, уже записанный в коде для `Duration` («нечисловое значение — это
|
||||
пропуск ОДНОГО поля, а не сломанная сущность»). Плюс пропуски обязаны быть
|
||||
видны в **учётной записи** доставки, а не только в логе: ретеншен решает по
|
||||
базе, и сегодня он получит ответ «терять нечего». Хранение сущности с
|
||||
неразобранной меткой (NULL) в эту задачу не входит — см. остаток ниже.
|
||||
|
||||
**3. Откат бинаря не проходит молча.** `store.Open` мигрирует безусловно и не
|
||||
сверяет версию схемы, поэтому старый бинарь успешно стартует поверх схемы 7,
|
||||
молча игнорирует незнакомые секции и помечает доставки разобранными. Оракул
|
||||
прогнан живьём: `-run СтарыйБинарь`. Перенести в `Open` страж из
|
||||
`OpenForRead` — прецедент записан там же: «расхождение версий — отказ, а не
|
||||
повод мигрировать».
|
||||
|
||||
**4. Канонизация — за транзакцию, по-настоящему.** Комментарий
|
||||
`bucket.go:143-146` утверждает, что канонизация вынесена наружу; фактически
|
||||
`analyze()` вызывается из `compareEntities` **внутри** `inTx`, который открывает
|
||||
`immediate` и повторяет до пяти раз, а кеш `analyze()` пишется в **копию**
|
||||
элемента среза и не переживает даже одной попытки. Измерено: тело 40 МиБ → пик
|
||||
768.3 МиБ; 63 МиБ → блокировка удерживается 5.019 с при `busy_timeout` 5000, то
|
||||
есть конкурентный `CreateDelivery` исчерпывает повторы и приём отвечает 500 по
|
||||
доставке, тело которой уже на диске.
|
||||
|
||||
В этой задаче: вынести `analyze()` наружу по-настоящему, кешировать в срезе, а
|
||||
не в копии, и различать в логе `delivery failed` занятость базы (`store.ErrBusy`
|
||||
уже выделен доменной ошибкой) от прочих причин. Пределы на размер сущности и
|
||||
потоковый расчёт хеша — остатком.
|
||||
|
||||
**5. Значения из тела не попадают в текст ошибки.** `fmt.Errorf("… встречено
|
||||
%v", tok)` подставляет токен целиком: тело 8 МиБ даёт текст ошибки 8 МиБ,
|
||||
который уходит атрибутом `error` на уровень `WARN`. Инвариант «тела запросов
|
||||
только на `DEBUG` и с обрезкой» нарушен буквально. Называть тип токена и
|
||||
`dec.InputOffset()`. Оракул: `-run Тело`. Дефект в базе диффа, не внесён
|
||||
разбором сущностей.
|
||||
|
||||
**6. Победитель внутри доставки — функция множества, а не порядка.**
|
||||
Попарная свёртка частичного порядка с тотальным тай-брейком нетранзитивна:
|
||||
`[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Стандарт «победитель — функция множества
|
||||
точек, а не порядка» записан в `architecture.md` для точек и для сущностей
|
||||
молча не применён. Собрать версии ключа, отбросить строго покрытые, среди
|
||||
оставшихся взять минимум канонической формы. Оракул: `-run ПорядокВнутри`.
|
||||
|
||||
**7. Провенанс обновляется при равных хешах.** Совпал хеш — запись
|
||||
пропускается вместе с провенансом, и в `delivery_id`/`delivery_received_at`
|
||||
остаётся первая свёрнутая копия, а не победитель по журналу. Провенанс
|
||||
устаревает на каждой из ~26 повторных присылок гарантированно; расхождение
|
||||
живой витрины с `reindex` латентно (требует возврата содержимого к прежнему —
|
||||
корпус такого не производил), но нарушает записанный инвариант детерминизма.
|
||||
Сравнивать позиции в журнале и обновлять провенанс. Оракул: `-run Порядок`.
|
||||
|
||||
## Что уходит остатком
|
||||
|
||||
- хранение сущности с `id`, но неразобранной меткой (NULL-метка): требует схемы
|
||||
и правил чтения, а после пункта 2 случай становится редким;
|
||||
- пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
|
||||
канонической формы и хеша — заводится задачей вместе с условием из пункта 4;
|
||||
- принцип «data-миграции не отбирают строки по спискам, которые где-то
|
||||
обрезаются» (миграция `00007` отбирает по обрезаемому на 32
|
||||
`uncovered_sections`; для неё дефект пустой — HAE шлёт одну секцию за
|
||||
доставку, — но следующая покрытая секция унаследует слепую зону);
|
||||
- длина очереди `pending` в `/stats` **и без WARN**: после миграции, переводящей
|
||||
доставки в `pending`, отставание по конструкции не WARN-ится (`startupDone`),
|
||||
и бэклог идёт молча при зелёном `/healthz` — строка уходит в
|
||||
[наблюдаемость](stats-nablyudaemost.md).
|
||||
|
||||
## Кандидаты в конвенции
|
||||
|
||||
- текст ошибки разбора не содержит значений из тела — только тип токена и
|
||||
смещение;
|
||||
- тест перестановок правила слияния обязан включать версию с содержимым, равным
|
||||
одной из уже присланных: тест трёх версий с разными хешами ветку равенства не
|
||||
посещает ни разу.
|
||||
|
||||
Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а
|
||||
`task verify:archive` сходится.
|
||||
|
||||
Связано: `internal/canon`, `internal/store/entity.go`, `internal/hae/entity.go`,
|
||||
`docs/review-journal.md` (пропуск проходов на чекпоинте — отклонение процесса,
|
||||
ему там место).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Сущность с id, но неразобранной меткой
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
|
||||
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
|
||||
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
|
||||
с неразбираемой меткой по-прежнему пропускается целиком.
|
||||
|
||||
## Что известно
|
||||
|
||||
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
|
||||
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
|
||||
счётчик `SkippedEntityNoTime` растёт.
|
||||
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
|
||||
и ретеншен получает честный ответ «терять есть что». То есть событие больше
|
||||
не молчит — но содержимое всё ещё не хранится.
|
||||
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
|
||||
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
|
||||
задокументирован (`docs/local-research.md`), то есть вход не выдуман.
|
||||
|
||||
## Что решить
|
||||
|
||||
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
|
||||
|
||||
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
|
||||
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
|
||||
что делает с такими строками, иначе они молча исчезнут из любого ответа.
|
||||
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
|
||||
когда разбор научится читать формат.
|
||||
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
|
||||
вернёт `reindex`. После включения ретеншена окно становится необратимым.
|
||||
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
|
||||
измерение в колонке, по которой идёт выборка.
|
||||
|
||||
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
|
||||
чтения без читателя проектируется вслепую.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Data-миграции не отбирают строки по обрезаемым спискам
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`).
|
||||
|
||||
## Оракул: механизм доказан, дефект пока пустой
|
||||
|
||||
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
|
||||
секции стоит в `uncovered_sections`:
|
||||
|
||||
```sql
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
WHERE json_each.value IN ('workouts', 'stateOfMind'))
|
||||
```
|
||||
|
||||
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
|
||||
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
|
||||
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
|
||||
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
|
||||
секцией `ecg` за ними даёт список без `ecg`.
|
||||
|
||||
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
|
||||
(`docs/local-research.md`, находка 50), секций восемь, тела с 32 незнакомыми
|
||||
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
|
||||
слепую зону, а к тому времени причину никто не вспомнит.
|
||||
|
||||
## Что делать
|
||||
|
||||
Записать принцип и выбрать форму отбора:
|
||||
|
||||
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
|
||||
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
|
||||
например «эту доставку смотрел разбор старше версии N».
|
||||
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
|
||||
означать безусловное пересворачивание — доставка, у которой список обрезан,
|
||||
про своё покрытие ничего достоверного не говорит.
|
||||
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора
|
||||
окажется общей.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) —
|
||||
именно она следующей сделает секцию покрытой и напишет такую миграцию.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Чем откатывать релиз после наката миграции
|
||||
|
||||
**Приоритет:** блокеры
|
||||
|
||||
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(проходы `ops` и `negative`, профиль `deep`).
|
||||
|
||||
## Что именно решить
|
||||
|
||||
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
|
||||
отказ на старте. Решение принято владельцем и здесь не пересматривается. Но у
|
||||
него есть следствие, которое до сих пор нигде не было записано:
|
||||
|
||||
**после того как новый бинарь накатил миграцию, возврат старого бинаря приёма
|
||||
не чинит.** Он теперь отказывается стартовать, а понизить схему нечем:
|
||||
|
||||
- подкоманды миграции у бинаря нет (`serve`, `reindex`, `healthcheck`);
|
||||
- `goose` CLI в образ не кладётся;
|
||||
- блоки `-- +goose Down` в миграциях написаны, но ни один тест их не исполняет,
|
||||
и на рабочей базе они не выполнялись ни разу (`DROP COLUMN` в SQLite через
|
||||
`modernc.org/sqlite` не проверялся вовсе);
|
||||
- `restart: unless-stopped` превращает отказ в цикл перезапуска, а телефон всё
|
||||
это время шлёт в закрытый порт и **не перешлёт** потом.
|
||||
|
||||
То есть аварийный путь придётся изобретать в момент аварии, при остановленном
|
||||
приёме. Цена простоя для метрик закрывается широким и глубоким проходами
|
||||
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
|
||||
единственный источник.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
|
||||
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
|
||||
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
|
||||
импровизацией.
|
||||
2. **Копия файла базы перед накатом** — entrypoint контейнера делает `cp` рядом,
|
||||
откат = подмена файла. Цена: место (база растёт), плюс правило «сколько копий
|
||||
держим». Зато не требует ни кода, ни доверия к `Down`, а база производна от
|
||||
архива — потеря копии не смертельна.
|
||||
3. **`goose` CLI в образ.** Цена: образ перестаёт быть одним статическим
|
||||
бинарём, появляется вторая точка, знающая про схему.
|
||||
4. **Ничего, но записать вслух**: «понижение схемы не поддерживается, лечение —
|
||||
только выкатка вперёд». Цена: в аварии выбора нет.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
(2) плюс уже сделанная запись из (4). Копия файла — единственный вариант,
|
||||
который не требует доверять непроверенному коду ровно в тот момент, когда
|
||||
проверять некогда; а `Down`-блоки при этом честно называются декорацией для
|
||||
локальной разработки.
|
||||
|
||||
## Что стоит, пока решения нет
|
||||
|
||||
Ничего: страж работает, и это правильно. Стоит только аварийный сценарий —
|
||||
он существует ровно в том виде, в каком описан выше. Строка «понижение схемы не
|
||||
поддерживается» уже записана в `docs/architecture.md` (раздел «Деплой»).
|
||||
@@ -0,0 +1,70 @@
|
||||
# Пределы на размер сущности и потоковый расчёт формы
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
|
||||
сущности из транзакции и перестала считать каноническую форму дважды. Осталось
|
||||
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
|
||||
считаются материализацией значения целиком.
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
|
||||
на теле в пределах приёма (64 МиБ):
|
||||
|
||||
```
|
||||
тело 40 МиБ → пик HeapAlloc 768.3 МиБ
|
||||
тело 63 МиБ → повторная доставка держит блокировку 5.019 с при busy_timeout 5000
|
||||
```
|
||||
|
||||
При `_txlock=immediate` конкурентный `CreateDelivery` получает `SQLITE_BUSY`,
|
||||
`inTx` повторяет до пяти раз и на исчерпании отдаёт `store.ErrBusy` — приём
|
||||
отвечает 500 по доставке, тело которой уже в архиве. Осиротевшее тело подберёт
|
||||
`reindex`, но узнать о нём можно только из лога.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Предел на размер **одной сущности** и на суммарный размер секции, отдельно
|
||||
от предела тела (64 МиБ). Сегодня одна тренировка законно может занять всё
|
||||
тело целиком. Вход, превышающий предел, обязан отклоняться **до**
|
||||
канонизации, а не после.
|
||||
2. Потоковый расчёт канонической формы и хеша: `canon.Form` разворачивает
|
||||
значение в дерево `any`, из-за чего пик кучи кратен размеру входа (замер даёт
|
||||
множитель около 19×). Хеш считается по потоку; форма нужна целиком только для
|
||||
сравнения, и только когда хеш разошёлся.
|
||||
3. Разбор **сохранённой** версии всё ещё идёт внутри транзакции: её содержимое
|
||||
читается оттуда же. Убрать это можно оптимистичным чтением до транзакции —
|
||||
но только с перепроверкой хеша и провенанса **внутри** транзакции, иначе две
|
||||
конкурентные свёртки одного `id` дадут потерянное обновление и исход снова
|
||||
станет функцией порядка коммитов, а не журнала.
|
||||
|
||||
## Условия, пришедшие из закрывающей задачи
|
||||
|
||||
1. Мягкое чтение заголовка сущности увеличило долю тел, доходящих до
|
||||
канонизации: сущность, которая раньше отсекалась на `json.Unmarshal`
|
||||
заголовка почти бесплатно, теперь разбирается и канонизируется целиком. То
|
||||
есть худший случай по памяти стал достижим на входах, которые до него не
|
||||
доходили, — предел из пункта 1 после этого **обязателен**, а не желателен.
|
||||
|
||||
2. Каноническая форма и множества ключей всех версий доставки теперь
|
||||
**удерживаются** до конца транзакции слияния (раньше считались лениво и на
|
||||
одной доставке из сорока четырёх). Расход стал пропорционален размеру
|
||||
ДОСТАВКИ, а не самой большой её сущности; предел обязан считать суммарный
|
||||
размер секции, а не только одной сущности.
|
||||
|
||||
3. **Потолок на число версий одного ключа в одной доставке.** Выбор победителя
|
||||
квадратичен по числу кандидатов; версии с совпавшей канонической формой
|
||||
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
|
||||
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
|
||||
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
|
||||
вопрос открыт для точек на одной координате: `cena-sliyaniya-na-shirokoj-dostavke.md`,
|
||||
пункт 4.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) —
|
||||
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
|
||||
часа). Задачи делать вместе: половина решения общая — `canon`.
|
||||
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
|
||||
отброшено как предел по конструкции, но условием ложится сюда.
|
||||
@@ -61,3 +61,20 @@ Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбор
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
|
||||
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
|
||||
соблюдается.
|
||||
|
||||
## Что читать перед удалением тела
|
||||
|
||||
Две колонки учётной записи, и обе обязательны:
|
||||
|
||||
- `uncovered_sections` — непустой список означает, что в теле есть секции,
|
||||
которых разбор не покрывает; удалять нельзя;
|
||||
- `skipped_entities` — число сущностей с собственным `id`, которые разбор не
|
||||
понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят
|
||||
доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не
|
||||
досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело
|
||||
тренировки, маршрута которой нет больше нигде: в экспорте Apple его не
|
||||
существует.
|
||||
|
||||
Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого
|
||||
различия.
|
||||
|
||||
@@ -22,5 +22,13 @@
|
||||
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
|
||||
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
|
||||
|
||||
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
|
||||
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
|
||||
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
|
||||
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
|
||||
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
|
||||
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
|
||||
десятков тысяч доставок.
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
При сомнении логируем факт наличия, не значение.
|
||||
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
|
||||
и с обрезкой по длине.
|
||||
- **Текст ошибки разбора не содержит значений из входа** — только род токена
|
||||
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
|
||||
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
|
||||
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
|
||||
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
|
||||
разбора не узнает.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
@@ -105,6 +111,22 @@
|
||||
пересборкой молча.
|
||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
||||
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
|
||||
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
|
||||
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
|
||||
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
|
||||
тело.
|
||||
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
|
||||
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
|
||||
она становится меткой касания, и запрос «что изменилось с момента X» получает
|
||||
столько ложных изменений, сколько раз источник переприслал то же самое (у
|
||||
тренировки — двадцать шесть).
|
||||
- **Новая производная от разбора колонка в момент появления вносится в перечень
|
||||
того, что пересборка не переносит.** Перечень — единственное место, где это
|
||||
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
|
||||
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
|
||||
прогона.
|
||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||
@@ -129,3 +151,13 @@
|
||||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||||
выражается — отсюда проза.
|
||||
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
|
||||
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
|
||||
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
|
||||
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
|
||||
равной формой ловит другое: неединственный минимум, при котором победителем
|
||||
оказывается просто первый в срезе, то есть порядок элементов на проводе.
|
||||
- **Изменение правила разбора или слияния сопровождается замером на живом
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
|
||||
@@ -28,6 +28,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
│ headers TEXT │ │ updated_at TEXT │
|
||||
│ derived_layer TEXT │ └──────────────────────────────┘
|
||||
│ uncovered_sections TEXT │
|
||||
│ skipped_entities INTEGER? │
|
||||
└────────────────────────────┘
|
||||
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
┊ │ workout │ │ record │
|
||||
@@ -69,6 +70,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
| `points` | сколько точек дал разбор |
|
||||
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
|
||||
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
|
||||
| `skipped_entities` | сколько сущностей с собственным `id` разбор пропустил (нет `id`, `id` длиннее предела, метка не разбирается, элемент не объект). Вторая половина ответа на «что потеряется, если тело удалить»: список непокрытых секций про пропущенную сущность молчит. **NULL означает «не измерялось»** и нулю не равен — так выглядят доставки, свёрнутые разбором, который пропусков не считал; читатель, принимающий по счётчику необратимое решение, обязан трактовать NULL как «не удалять» |
|
||||
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
|
||||
|
||||
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
|
||||
|
||||
@@ -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 чек-листа
|
||||
равен длине чек-листа, а этих пунктов в чек-листах нет и быть не может.
|
||||
- **Что меняем:** отчёт ревью обязан перечислять запущенные проходы **поимённо
|
||||
и с исходом**, а оркестратор задачи — сверять этот перечень с составом
|
||||
профиля до того, как коммитить; непущенный проход идёт в границы покрытия
|
||||
строкой «не запускался», а не отсутствует. Правилом линтера это не
|
||||
выражается, автоматической проверки нет — но пропуск, названный в отчёте,
|
||||
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
|
||||
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
|
||||
сработали ровно так, как задуманы, — их просто не позвали.
|
||||
|
||||
Reference in New Issue
Block a user