- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
20 KiB
Context
Правило слияния точек стоит на двух отношениях: полнота — частичный порядок, тай-брейк — тотальный. Победитель сегодня есть функция множества кандидатов координаты, и это записано в спеке хранения отдельным требованием: витрина обязана быть свёрткой журнала, а функция множества коммутативна и потому от порядка свёртки не зависит.
Решение владельца 2026-08-04 ломает ровно это свойство: «пришедшая побеждает» — не полурешётка. Правило перестаёт быть коммутативным, и исход слияния становится функцией порядка свёртки. Задача обязана внести плату целиком: привести порядок свёртки к порядку журнала и доказать равенство «пересборка = приём» оракулом, а не рассуждением.
Осложняющий факт назван в постановке: replay идёт по (received_at, id),
живая свёртка — по факту свёртки, и доставка, отложенная занятостью базы, их
разводит.
Схему трогать нельзя: колонок провенанса на точку не заводим, формат payload
не меняем, миграции нет.
Goals / Non-Goals
Goals:
- При равной полноте побеждает точка, пришедшая этой доставкой.
- Внутри одной доставки исход по-прежнему решает порядок канонических форм.
- Порядок свёртки равен порядку журнала везде, где это достижимо без изменения приёма; недостижимый остаток — наблюдаемый, а не молчащий.
- «Пришедшая проиграла сохранённой» считается отдельным счётчиком.
Non-Goals:
- Провенанс на точку, смена формата
payload, миграция. - Тай-брейк, зависящий от значения точки (вариант «б») или от измеренного рода
метрики — род есть функция витрины, и правило слияния, читающее собственную
выдачу, повторяет дефект наследования слоя «из будущего»
(
docs/review.md, 2026-08-01). - Удержание порядка на самом приёме — это journal-order-on-ingest, у неё своё решение владельца и своя очередь.
- Пересборка витрины и подмена файла базы — необратимое действие человека.
Decisions
Прежде своего — prior art
CRDT-литература отвечает на вопрос прямо. Регистр «последняя запись побеждает» (LWW-Register, Shapiro et al., «A comprehensive study of Convergent and Commutative Replicated Data Types», INRIA RR-7506) сходится только потому, что метка времени хранится вместе со значением: слияние сравнивает две метки, а не «кто пришёл вторым». Без хранимой метки то же правило вырождается в last-writer-wins по порядку применения — а он у реплик разный, и сходимости нет. Ровно это и означает «не полурешётка».
Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на точку,
то есть смена формата payload и миграция, которые постановка запрещает.
Отвергнуто с названной причиной, и причина не «нам не подходит», а «цена
выше разрешённой рамки».
Что из литературы берётся: её же вывод о том, чем платят за отказ от метки.
Если состояние не решётка, сходимость обеспечивается единственным
детерминированным порядком применения операций — это уже не CRDT, а
конвейер репликации с журналом (state machine replication: Schneider,
«Implementing fault-tolerant services using the state machine approach»,
и то же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
потребители применяют журнал в одном порядке. Наш журнал есть, порядок в нём
определён ((received_at, id)), и задача сводится к тому, чтобы живая свёртка
применяла его в том же порядке, что и пересборка.
Прецедент внутри проекта — сильнее внешнего. Слияние сущностей
(internal/store/entity.go, openspec/specs/storage/spec.md) ту же развилку
уже прошло и выбрало позицию доставки в журнале, явно отвергнув «побеждает
приехавшая» такими словами: «"Побеждает приехавшая" было бы функцией порядка
свёртки, а он порядку журнала не равен». Сущность могла себе это позволить —
у неё есть колонка провенанса. У точки её нет и не будет, значит равенство
порядков обязано быть обеспечено, а не предположено.
Решение 1: тай-брейк по происхождению кандидата, затем по канонической форме
candidate получает флаг incoming — «эта точка приехала разбираемой
доставкой, а не лежала в объекте». Тотальный порядок pointLess становится
лексикографическим по паре (не incoming, каноническая форма): пришедшая
предшествует сохранённой, при равном происхождении решают байты канонической
формы.
Отношение полноты (pointDominates) не трогается: более полная точка
побеждает по-прежнему, и происхождение на это не влияет. Флаг участвует только
в тотальном порядке, то есть ровно там, где полнота ответа не дала.
Механизм pickBest остаётся общим с сущностями: он требует от less тотального
строгого порядка, и пара (ранг, ключ) его даёт — ключи различны, потому что
совпавшие канонические формы схлопываются до выбора.
Схлопывание поднимает флаг. Если приехавшая точка канонически совпала с
сохранённой, выживает сохранённый кандидат (его байты дословны), но флаг
incoming он получает. Без этого «внутри доставки решают байты» нарушалось бы
ровно в том случае, когда одна из точек доставки совпала с сохранённой:
сохранённый кандидат проигрывал бы соседу по доставке, которого сам обязан был
обойти по байтам. С поднятием флага доставка сравнивается с доставкой, и
сохранённые байты остаются в витрине — то есть хеш не двигается и объект не
переписывается.
Альтернатива — двухстадийная схема (сперва победитель внутри доставки, потом он же против сохранённой) — отвергнута: она гасит полноту. Точка, не превзойдённая никем, вылетала бы на внутридоставочном байтовом тай-брейке против точки, которую сохранённая заведомо превосходит. Одна стадия над объединением кандидатов сохраняет инвариант «выигрывает более полная».
Решение 2: проход воркера прекращается на первой отложенной доставке
Сегодня Worker.Pass двигает курсор и после отложенной доставки, то есть
сворачивает её преемниц раньше неё. При коммутативном правиле это было
безразлично; теперь это молчаливое расхождение с пересборкой.
Проход прекращается на первой доставке с исходом Deferred. Следующий проход
(сигнал приёма или тик раз в минуту) начинает с начала очереди и берёт её же.
Опасности «очередь встала навсегда» здесь нет, и это следствие уже принятого
определения: store.Transient — это только занятость базы и отмена
снаружи; собственный дедлайн свёртки в него намеренно не входит, и доставка,
не уложившаяся в бюджет, получает failed и очередь освобождает. Занятость же
блокирует запись всем одинаково — проход, перешагнувший занятую доставку, всё
равно упёрся бы в ту же занятость на следующей.
Плата называется вслух: голова очереди блокирует хвост. Раньше проход при занятости базы двигался дальше и мог свернуть хоть что-то. Это осознанный размен «полнота витрины сейчас» на «витрина есть свёртка журнала», и он соответствует тому, как проект уже разрешил тот же спор у сущностей.
Решение 3: остаточное окно делается наблюдаемым
Строка учёта доставки становится видимой воркеру после записи тела (измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более ранней меткой может появиться после того, как её преемница уже свёрнута. Барьер этого не ловит: отставшей доставки в момент прохода просто не существует.
Закрыть окно можно только на приёме — резервированием строки учёта до записи тела либо выдержкой перед свёрткой. И то и другое — предмет отдельной задачи journal-order-on-ingest, и переигрывать её здесь нельзя.
Названо прямо, потому что иначе прочтётся как «закрыто»: решение владельца
в той задаче — вариант (в), повторы при ErrLayerUnknown, — порядок журнала
не восстанавливает. Он лечит невыводимый слой, а доставка всё равно
сворачивается после своей преемницы. Значит после его реализации равенство
«пересборка = приём» останется условным. Это записано вопросом владельцу в
раздел «Вопросы» той задачи вместе с тегом question, четвёртым вариантом
(сериализовать выпуск ULID с записью тела и вставкой строки) и рекомендацией.
Что делается вместо: перед свёрткой воркер спрашивает журнал, есть ли доставка
позже этой, которая уже вышла из очереди. Есть — пишется WARN с
идентификатором доставки: порядок свёртки разошёлся с порядком журнала, и
витрина в этом месте не равна пересборке. Событие перестаёт быть невидимым,
а лечится оно healthlog reindex.
Запрос — тот же кортежный предикат (received_at, id) > (?, ?), что уже
используется в PendingDeliveries и LastDerivedLayer, но не по тому же
индексу, и это названо, потому что первая редакция утверждала обратное.
delivery_pending — индекс частичный, построен для parse_status = 'pending',
а предикат просит IN ('parsed','partial'); планировщик берёт
delivery_received_at (EXPLAIN QUERY PLAN: SEARCH delivery USING INDEX delivery_received_at).
Цена измерена, а не объявлена нулевой. В установившемся режиме (очередь пуста
или почти пуста) — единицы микросекунд. На чистой задолженности, где
преемниц в статусе parsed нет вовсе, поиск доходит до конца хвоста, и
суммарная цена прохода квадратична по длине задолженности: 0,26 с при 2 000
доставок, 3,12 с при 8 000, 19,8 с при 20 000. Это меньше 2% от цены самой
свёртки (порядка 0,4 с на доставку по прогону живого архива), поэтому индекс не
заводится: миграция ради двух процентов — плохой размен, а числа названы, чтобы
следующий читатель не принимал решение по слову «дёшево».
Проверка живёт в воркере, а не в свёртке: пересборка зовёт Player.Play
напрямую и по построению идёт в порядке журнала, а её тишина здесь
содержательна.
Решение 4: счётчик удержаний
MergeStats.PointsHeld — сколько пришедших точек проиграло сохранённой, плюс
PointsHeldAt с координатами первых (Collision, тот же потолок
maxCollisionsReported). Считается координата, где победил кандидат без
флага incoming, а кандидаты с флагом были: под новым правилом это возможно
ровно тогда, когда сохранённая строго полнее.
Отдельно от Overwrites — потому что Overwrites считает столкновение в обе
стороны (на корпусе он сработал бы ~84 000 раз) и отличить «оставили новое» от
«выбросили новое» по нему нельзя. Ровно тот же довод уже записан для
EntitiesHeld у сущностей, и именование берётся оттуда же.
Risks / Trade-offs
- Витрина в
./dataрасходится с новым правилом → отпечаток обязан измениться, иначе правило не сработало. Приведение в соответствие —healthlog reindexс остановкой сервиса и подменой файла базы, необратимое действие человека. Этим изменением не выполняется. - Голова очереди блокирует хвост при занятой базе → занятость и раньше блокировала запись всем; проход возобновляется сигналом приёма или тиком раз в минуту, тела всё это время лежат в архиве и не теряются.
- Остаточное окно конкурентного приёма → не закрывается, но перестаёт быть
молчащим (
WARN). Закрытие — за journal-order-on-ingest, чьё нынешнее решение его не закрывает; вопрос владельцу записан туда же. - Цена изменения сосредоточена в одной метрике одного слоя → из 75 494
координат, где меняется исход, 71 773 (95%) —
basal_energy_burnedслояraw. Ошибка была массовой по координатам и узкой по метрикам, и число переписываемых объектов растёт соответственно. - Правило стало функцией порядка, а значит хрупче → это записывается в спеку хранения нормативно (было «функция множества», стало «функция множества и позиции в журнале»), чтобы следующий автор не восстановил коммутативность «для чистоты» и не откатил починку молча.
- Больше записей в витрину → повторная доставка той же координаты с другим
значением теперь всегда переписывает объект, тогда как байтовый порядок часто
давал
unchanged. Хеш-детектор продолжает гасить точные повторы; рост ограничен долей спорных координат. WARNо свёртке вне порядка журнала может шуметь при подборе задолженности → строка пишется одна на доставку и только когда позже неё уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой доставки нет.