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

219 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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` о свёртке вне порядка журнала может шуметь при подборе
задолженности** → строка пишется одна на доставку и только когда позже неё
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
доставки нет.