store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
## 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` о свёртке вне порядка журнала может шуметь при подборе
|
||||
задолженности** → строка пишется одна на доставку и только когда позже неё
|
||||
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
|
||||
доставки нет.
|
||||
Reference in New Issue
Block a user