Files
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

36 KiB
Raw Permalink Blame History

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 как единственная точка, где витрина не является функцией множества доставок.