Files
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

20 KiB
Raw Permalink Blame History

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 о свёртке вне порядка журнала может шуметь при подборе задолженности → строка пишется одна на доставку и только когда позже неё уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой доставки нет.