Files
healthlog/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md

120 lines
11 KiB
Markdown
Raw Permalink 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.
# Тай-брейк точек — порядок журнала, а не хранимая метка
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md
## Решение
При равной полноте побеждает точка, пришедшая разбираемой доставкой. Правило
слияния точек тем самым перестаёт быть функцией множества и становится **явной
функцией порядка журнала**; за это платится приведением порядка живой свёртки к
журнальному. Хранимая метка провенанса у точки — очевидный ответ на тот же
вопрос — отвергнута по цене.
## Почему
Байтовый порядок канонических форм, стоявший тай-брейком прежде, оказался не
крайним разрядом правила, а главным: перемер на живом корпусе дал 80 129 спорных
координат, из которых полнота отбрасывает кого-то лишь в 981 (1,2%), а 79 148
(98,8%) решает тай-брейк. И решает измеримо неверно — берёт меньшее значение в
1 847 случаях из 1 912, то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого час `2026-08-03T07:00Z` метрики `step_count` остался
недосчитанным, сверка слоёв объявила метрику мгновенной против 23 согласных
часов, и род ушёл в `unknown`.
Готовое решение известно и рассмотрено первым. Цитата из источника:
> Регистр «последняя запись побеждает» (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)`, прямо
отвергнув «побеждает приехавшая». Разница не в намерении, а в том, что у
сущности колонка провенанса есть, а у точки нет. Критерий выбора между двумя
механизмами записан в `docs/architecture.md`, раздел «Разрешение столкновений».
Значение точки и род метрики в правило не входят намеренно: «брать бо́льшее»
неверно для мгновенных метрик, которые источник досчитывает вниз, а род есть
функция витрины — правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала (тот же дефект уже ловили на наследовании слоя «из будущего»).
## Последствия
- `+` `step_count` вернул род (`cumulative`, ноль противоречащих часов), заодно
вернулся `headphone_audio_exposure` (`instant`); общий станок
`task verify:archive` из красного стал зелёным.
- `+` Систематический недосчёт на 75 494 координатах прекращён (95% из них —
`basal_energy_burned` слоя `raw`).
- `` Правило больше не коммутативно: содержимое витрины стало функцией порядка
свёртки. Живой порядок приведён к журнальному барьером — проход воркера
прекращается на первой отложенной занятостью доставке, — но голова очереди
теперь блокирует хвост.
- `` Остаточное окно конкурентного приёма (строка учёта видна позже метки)
закрыть без изменения приёма нельзя; оно сделано наблюдаемым (`WARN`) и
оставлено вопросом владельца в `docs/tasks/items/journal-order-on-ingest.md`.
- `` Появилось направление, в котором правило теряет содержание: разряд полноты
гаснет при разошедшихся значениях общих ключей, и пришедшая точка может унести
ключ сохранённой. Замерено — 2 координаты из 80 129 спорных; вместо запрета
заведён счётчик и `WARN`, тем же решением и по той же причине, по какой
отложено объединение полей.
- `` Восстановление коммутативности «для чистоты» молча откатит починку.
Поэтому запрет записан нормативно в спеке хранения, а формулировки во всех
документах приведены к «функция множества **и позиции в журнале**».
- `` Живая витрина в `./data` расходится с новым правилом до пересборки:
подмена файла базы — необратимое действие человека и этим изменением не
выполняется.
## Открыто, решает владелец
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется, а эти два
решения переживают её.
**1. Пересобирать ли живую витрину сейчас.** Правило действует только вперёд:
уже сохранённые часы держат значение прежнего, измеримо смещённого правила, пока
витрину не пересоберут, — это 75 494 координаты (95% — `basal_energy_burned`
слоя `raw`). До пересборки сверка отпечатков с `healthlog reindex` не сойдётся и
будет выглядеть отказом.
- **(а)** `reindex` с остановкой сервиса и подменой базы сразу после выкладки.
Цена: минута простоя приёма на нынешнем архиве плюс необратимое действие
руками. **Рекомендация.**
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
- **(в)** не пересобирать: витрина сойдётся только по координатам, которые
переприедут доставками, — смещение останется в истории навсегда.
**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
гаснет при разошедшихся значениях общих ключей, и тогда пришедшая точка
побеждает, даже если унесёт ключ, которого сама не несёт. Замер: 2 координаты из
80 129 спорных, обе — те же, что дают несравнимые наборы.
- **(а, сделано)** оставить правило, завести счётчик `PointsErased` с `WARN`.
Событие наблюдается, но не предотвращается; обратимо пересборкой, пока жив
архив.
- **(б)** сузить «побеждает пришедшая» до случая совпавших множеств
содержательных ключей, а при строгом включении имён оставлять более полную
независимо от происхождения. Цена: правило перестаёт быть чисто структурным на
этом разряде, дельта хранения переписывается, прогон живого архива снимается
заново. Проверить обязательно: сохраняется ли починка `step_count` — по замеру
его столкновения идут с одинаковыми наборами `{date, qty}`, то есть должна.
Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.