- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
219 lines
20 KiB
Markdown
219 lines
20 KiB
Markdown
## 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](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||
у неё своё решение владельца и своя очередь.
|
||
- Пересборка витрины и подмена файла базы — необратимое действие человека.
|
||
|
||
## 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](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||
и переигрывать её здесь нельзя.
|
||
|
||
**Названо прямо, потому что иначе прочтётся как «закрыто»:** решение владельца
|
||
в той задаче — вариант (в), повторы при `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](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||
чьё нынешнее решение его не закрывает; вопрос владельцу записан туда же.
|
||
- **Цена изменения сосредоточена в одной метрике одного слоя** → из 75 494
|
||
координат, где меняется исход, 71 773 (95%) — `basal_energy_burned` слоя
|
||
`raw`. Ошибка была массовой по координатам и узкой по метрикам, и число
|
||
переписываемых объектов растёт соответственно.
|
||
- **Правило стало функцией порядка, а значит хрупче** → это записывается в
|
||
спеку хранения нормативно (было «функция множества», стало «функция множества
|
||
и позиции в журнале»), чтобы следующий автор не восстановил коммутативность
|
||
«для чистоты» и не откатил починку молча.
|
||
- **Больше записей в витрину** → повторная доставка той же координаты с другим
|
||
значением теперь всегда переписывает объект, тогда как байтовый порядок часто
|
||
давал `unchanged`. Хеш-детектор продолжает гасить точные повторы; рост
|
||
ограничен долей спорных координат.
|
||
- **`WARN` о свёртке вне порядка журнала может шуметь при подборе
|
||
задолженности** → строка пишется одна на доставку и только когда позже неё
|
||
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
|
||
доставки нет.
|