- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
36 KiB
Context
Семь находок дозапущенных проходов ревью (tmp/triage-late.md) по коммиту
f8200f7. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
тестами пакетов, tmp/ в .gitignore и жить в нём им нельзя.
Ограничения, которые задача не выбирает, а наследует:
- Свёртка обязана быть функцией журнала. Живая витрина и
reindexобязаны сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка элементов на проводе, — дефект по определению. - Тренировка приезжает повторно, пока источник её досчитывает (26 копий,
три различных содержимых на живом архиве), и значения между копиями
расходятся всегда. Поэтому правило полноты точек к сущностям неприменимо,
и
Coversсуществует отдельно отRelate. - Маршрут — 95% веса тренировки, и в экспорте Apple его нет вовсе.
Затирание маршрута необратимо:
reindexпроиграет журнал и получит то же. - Цена канонизации измерена: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
блокировка удерживается 5.019 с при
busy_timeout5000.
Goals / Non-Goals
Goals:
- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
- Победитель — функция множества версий: и внутри доставки, и между доставками.
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
- Каноническая форма сущности считается один раз и вне транзакции.
- Диагностика разбора не несёт значений из тела.
Non-Goals:
- Хранение сущности с
idи неразобранной меткой (NULL-метка) — требует схемы и правил чтения витрины. - Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт формы и хеша — новая политика, а не правка.
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине, что для точек.
- Поэлементная сверка содержимого элементов ряда (точка маршрута без
altitude) — см. «Риски».
Decisions
1. Covers получает второй разряд, запрет вырождения формы и счёт содержательных элементов
Сегодня Covers(g) требует от f лишь наличия каждого содержательного ключа
g и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
({"maxHeartRate":1,"heartRateData":[null,null]} против настоящей тренировки)
признался равным настоящей и выиграл тай-брейк журнала.
Правило дополняется тремя проверками, все — по верхнему уровню:
- Второй разряд: ключи без содержания. Каждый ключ
g(включая пустые) обязан быть уf. Тот же стандарт записан для точек вcanon.Relate: «иначе{date, qty, Min:0, Max:0}и{date, qty}неразличимы, иMinсMaxисчезли бы из витрины по жребию тай-брейка». Отличие отRelate: там второй разряд применяется только при равенстве первого, здесь включение всех ключей требуется безусловно. Это эквивалентно при равном первом разряде и строже, когда первый разряд неравен, — а строже здесь и нужно:Coversотвечает «не потеряем ли содержания». - Запрет вырождения формы. Покрывающая версия не может подменить объект
или массив скаляром: если
g[k]— объект,f[k]обязан быть объектом; если массив — массивом. Обратное (скаляр уg, объект уf) разрешено:fбогаче формой. Стоимость — O(ключей), первый байт литерала. - Содержательные элементы массива. Рядом с длиной считается число
непустых элементов.
[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(). Смещение полезнее
значения: по нему место в теле находится в архиве, а значение из тела в логе не
имеет права быть в принципе.
Три формы решения главного узла и компромисс каждой
Главный узел — глубина сравнения содержания при слиянии версий сущности. Рассматривались три, и выбор записан не по умолчанию:
- Поэлементная сверка содержимого рядов (у каждого элемента маршрута
сравнивать набор ключей). Ловит всё, включая точку маршрута без
altitude. Компромисс: полный обход маршрута с материализацией каждого элемента на каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр правила слияния целиком. Отвергнута ценой. - Второй разряд + запрет вырождения формы + счёт содержательных элементов
(выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
мера «сходимость
verify:archive» его не увидит по построению. Стоимость — O(ключей) плюсisEmptyна элементах в уже существующем обходе. - Принять предел, оставить только наблюдаемость (счётчик по различию байт,
предел записан в
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как единственная точка, где витрина не является функцией множества доставок.