store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
@@ -0,0 +1,84 @@
# Тай-брейк точек — порядок журнала, а не хранимая метка
- **Дата:** 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` расходится с новым правилом до пересборки:
подмена файла базы — необратимое действие человека и этим изменением не
выполняется.
+4
View File
@@ -33,6 +33,10 @@
| Дата | Запись | Статус |
| --- | --- | --- |
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
становится явной функцией порядка журнала; хранимая метка провенанса
(LWW-Register) отвергнута по цене формата и миграции.
- [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
+55 -23
View File
@@ -33,10 +33,11 @@ healthlog принимает выгрузки Apple Health из приложен
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
`source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
чтобы не писать зря. При столкновении выигрывает более полная точка, а при
равной полноте — стоящая **позже в журнале**: бедная доставка не должна
стирать поля у богатой, но и устаревшее значение не должно пережить свой
досчёт. Полнота — **множество** ключей с непустым значением, а не их число
(см. «Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
@@ -203,10 +204,11 @@ HRV); у накопительных — только `date`. Поэтому то
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Правило слияния одинаково для всех проходов, и порядок прихода значения не
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
**имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
позже по журналу. «Последние данные всегда актуализируют картину» остаётся
неверным ровно в одном разряде — более полная точка бедную не пропускает
(см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
@@ -417,10 +419,14 @@ capability**, и здесь стоит ссылка, а не пересказ т
пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
строго по `received_at`, а не по порядку файлов в каталоге.
состояние, что и приём в реальном времени. Разряд полноты коммутативен и
порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
можно только на приёме.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
@@ -928,24 +934,50 @@ hour метки выровнены на час heart_rate 00:00:00
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
**Победитель — функция множества кандидатов вместе с их происхождением, а не
порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава
множества.
минимум тотального порядка — сперва происхождение (пришедшая раньше
сохранённой), затем каноническая форма. Антицикловое свойство от этого не
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
отдельно (см. ниже).
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
стоил `step_count` его рода. Значение точки в правило не входит («брать
бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
доставки**, где провенанс общий.
Цена названа вслух: правило перестало быть функцией множества и стало явной
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
детерминированной»).
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
открывала спор заново:
| | точка | сущность (`workout`, `record`) |
| --- | --- | --- |
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
| внутри одной доставки | порядок канонических форм | он же |
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
### Измерение рода агрегации
+9 -3
View File
@@ -36,9 +36,15 @@
элементов на проводе решает состав витрины.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является:
порядок свёртки сам по себе порядку журнала не равен, и живая витрина
расходится с пересборкой молча. Объявив правило функцией порядка журнала,
изменение обязано **внести плату целиком**: привести порядок свёртки к
журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать
его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с
отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос
отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий
выбора в `architecture.md`, «Разрешение столкновений».
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
+23
View File
@@ -23,6 +23,29 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **В проверке на живом корпусе утверждается инвариант, а число печатается.**
Корпус растёт с каждой доставкой, а прогон живого архива в гейт не входит —
значит константа, производная от его размера, протухает по расписанию
телефона и краснеет у того, кто мимо проходил. Правило шире, чем «не
сравнивай с числом»: протухает и **оценка области действия**, снятая на
прежнем корпусе. «Тай-брейк — крайний разряд после полноты» было верно на
2 897 столкновениях и неверно на 80 129, где полнота решает 1,2%; на этой
оценке стоял нормативный текст спеки. Число, попавшее в спеку или в довод
решения, обязано нести рядом **метод замера** — иначе следующий замер
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
2026-08-03 и 2026-08-04 в [review.md](../review.md).
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
путь провалил, а пересборка проведёт, и за время прогона новых доставок не
приезжало. Оракул, чья посылка не названа, краснеет по причине, к правилу
отношения не имеющей, и краснота становится неотличимой от дефекта — то есть
с ней начинают жить.
- **Проверка правила, зависящего от порядка, несёт отрицательный контроль.**
Тест «два пути дали один отпечаток» зеленеет и на правиле, которое к порядку
безразлично, — то есть не проверяет ничего. Рядом обязан стоять прогон в
заведомо другом порядке с утверждением, что отпечаток **отличается**.
- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
+4 -1
View File
@@ -127,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
**Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
столкновении выигрывает более полная точка, а не последняя пришедшая.
столкновении выигрывает более полная точка, а при равной полноте — стоящая
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
порядок свёртки обязан равняться журнальному.
## `workout` и `record` — сущности с собственным `id`
+67
View File
@@ -1774,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.
## 54. Перемер тай-брейка: 98,8% спорных координат решает не полнота, а порядок форм
Замер 2026-08-04, повод — `task verify:archive` покраснел на `master` без
единого коммита, с ростом корпуса. Метод назван целиком, потому что прежняя
оценка (находка 49) и эта расходятся в 29 раз, и расхождение объясняется
методом, а не данными.
**Метод.** 155 тел архива, разбор настоящий (`hae.Parse` с наследованием слоя по
цепочке), ключ координаты **настоящий**`метрика + слой + начало + конец`.
Кандидаты схлопываются по канонической форме (`canon.SortKey`, округление до 12
значащих цифр, находка 30); полнота — `canon.Fields.Relate`, то есть с условием
«значения общих содержательных ключей совпали». Программа лежала в `tmp/`
(вне репозитория: она ходит в рабочий архив).
Прежний замер того же дня давал «84 978 спорных из 453 171» — он считал ключ
**без слоя**, а без слоя часовая точка сталкивается с минутной, и это не
столкновение, а два разных ряда (та же ошибка названа в находке 49 первой
строкой её таблицы).
| что мерялось | сколько |
| --- | --- |
| координат всего | 460 995 |
| спорных (больше одной канонической формы) | 80 129 (17,4%) |
| из них полнота кого-то отбрасывает | 981 (1,2%) |
| из них все кандидаты непревзойдённые — решает тай-брейк | **79 148 (98,8%)** |
| несравнимых пар среди непревзойдённых | 2 |
| координат, где смена тай-брейка меняет исход | 75 494 |
**Соотношение 1,2% / 98,8% устойчиво** — оно совпало у обоих методов, и именно
оно, а не абсолютное число, было основанием решения: инвариант «выигрывает
более полная точка» на живом потоке отвечает в одном случае из восьмидесяти.
**Изменение сосредоточено в одной метрике одного слоя.** Из 75 494 изменившихся
координат 71 773 (95%) — `basal_energy_burned` слоя `raw`, то есть посекундная
развёртка HAE, которую Read API суммировать и так не имеет права. Следом
`basal_energy_burned/minute` (1 833), `walking_running_distance/raw` (777),
`step_count/raw` (746). Ошибка «системно храним меньшее» была массовой по
координатам и узкой по метрикам.
**Несравнимых наборов больше не ноль.** Находка 49 фиксировала 0 из 2 897; на
155 доставках их 2. Порог «объединять поля не будем, пока счётчик молчит»
поэтому подтверждается, но уже не абсолютен: событие наступило, просто редко.
**Направление, в котором новое правило теряет содержание, замерено отдельно.**
Разряд полноты гаснет, когда значения общих содержательных ключей разошлись, —
и тогда пришедшая точка побеждает, даже если у проигравшей был содержательный
ключ, которого у неё нет. Таких координат на корпусе **2**, обе
`sleep_analysis_summary/day`, и обе — ровно те же, что дают несравнимые наборы.
То есть случай «сохранённая беднее по именам, но значения разошлись» на живом
потоке не наблюдался вовсе. Прежний байтовый порядок давал ту же потерю по
жребию и так же молча; теперь она детерминирована и считается
(`MergeStats.PointsErased`, `WARN`).
**Исход починки, тем же прогоном.** Смена тай-брейка на «побеждает пришедшая»
вернула род двум метрикам: `step_count` (`unknown``cumulative`, ноль
противоречащих часов вместо одного) и `headphone_audio_exposure`
(`unknown``instant`). Итог каталога: накопительных 6 → 7, мгновенных 9 → 10,
неизвестных 16 → 14. Отпечаток витрины сменился, как и требовалось: 3 194
объекта, `bf36b477…``03aace91…`. Удержаний правилом полноты на весь
корпус — 1 247, потерь содержания — 2.
**Проверено ещё раз на выросшем корпусе.** Пока шла работа, телефон прислал ещё
три доставки; прогон на 158 телах остался зелёным (3 255 объектов, отпечаток
`c4fbb1c7…`, ноль противоречащих часов, `step_count` по-прежнему
`cumulative`). Это и есть ответ на то, чем дефект был найден: прежнее правило
покраснело именно от роста корпуса, новое рост пережило.
## Открытые вопросы
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
+39 -1
View File
@@ -143,7 +143,7 @@
**Перестали проверять сознательно.**
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
прогон живого архива был красным и об этом никто не знал).
@@ -436,3 +436,41 @@
файлов каталога получили одну дату при переезде на канон (коммит `d79189b`),
и храповик на давно неподвижных задачах включится только с накоплением
собственной истории правок. Порция этой сессии отобрана по цели.
## 2026-08-04 — оракул `verify:archive` покраснел от роста корпуса второй раз за два дня [проскочил]
- **Где:** `internal/store/bucket.go`, `pointLess` — тай-брейк равной полноты
- **Симптом:** `task verify:archive` красный на `master` без единого коммита:
`step_count: противоречащих часов 1 при 22 согласных`. Разбор довёл до
причины: на час `2026-08-03T07:00Z` приехало четыре точки с двумя значениями,
победило меньшее — оно же приехавшее первым, — потому что его каноническая
форма сортируется раньше. Сверка слоёв объявила метрику мгновенной против 22
согласных часов, и `step_count` ушёл в `unknown`.
- **Причина:** байтовый тай-брейк выбран как «детерминированный и ни на что не
опирающийся», и это было верно. Неверной оказалась оценка его области:
считалось, что он крайний разряд после полноты. Перемер (находка 54) на
настоящем ключе: полнота решает 1,2% спорных координат, тай-брейк — 98,8%.
То есть «выигрывает более полная точка» — не главное правило слияния, а
редкий частный случай, и главным всё это время был лексикографический
порядок JSON.
- **Чем воспроизведён:** `task verify:archive` до и после. До — FAIL,
`step_count unknown`, отпечаток `bf36b477…`; после — PASS, `step_count
cumulative`, отпечаток `03aace91…`, ноль противоречащих часов, и заодно
`headphone_audio_exposure` вернулся из `unknown` в `instant`.
- **Почему не поймали:** та же причина, что 2026-08-02 и 2026-08-03, третий раз
подряд. Прогон живого архива в гейт не входит, значит его краснота видна
только следующей задаче, которая до него дотянется. Но добавилось новое:
здесь протухла не константа, а **оценка области действия правила**, снятая на
корпусе, где спорных координат было 2 897. Ни один проход ревью не
перепроверяет числа, на которых стоит нормативный текст спеки, — они читаются
как факт. Поймал это проход `specs` на профиле `design`: он сверил число в
дельте с находкой 49, увидел расхождение в 29 раз и потребовал назвать метод.
Метод оказался неверным (ключ без слоя), число — завышенным, а соотношение —
верным.
- **Что меняем:** ничего в составе конвейера — он сработал. Два правила
промоутятся в конвенции (см. `conventions/testing.md`): «в проверке на живом
корпусе утверждается инвариант, число печатается» — оно было записано здесь
2026-08-02 со словами «годится в конвенции» и не доехало, после чего класс
повторился дважды; и «оракул сходимости называет свою посылку рядом с собой».
Третий случай одного класса за три дня — это уже не совпадение, и в
`docs/conventions/testing.md` он теперь правило, а не запись в журнале.
+65 -1
View File
@@ -2,7 +2,7 @@
- **Секция:** ядро
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- **Теги:** goal:journal-and-rebuild
- **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
@@ -13,6 +13,70 @@
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
## Вопросы
**Решение (в) порядок журнала не восстанавливает, а цена окна выросла.**
Записано 2026-08-04 задачей `tie-break-equal-completeness`.
Что случилось. Тай-брейк точек при равной полноте сменён на «побеждает
пришедшая»: байтовый порядок системно хранил меньшее значение и стоил
`step_count` его рода. Плата за это названа и внесена — порядок свёртки
приведён к порядку журнала: проход воркера прекращается на первой отложенной
занятостью доставке, а не перешагивает её. Это закрыло ту половину окна,
которая была во власти воркера.
Вторая половина осталась и закрывается только на приёме: `received_at`
фиксируется при выпуске ULID, строка учёта становится видимой после записи тела
(184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более ранней
меткой сворачивается позже своей преемницы.
**Что изменилось по сравнению с постановкой ниже.** Прежде цена окна была узкой:
доставка без плотных метрик не выводила слой и уходила в `failed` — класс редкий
(только автоматизации без плотных метрик). Теперь та же перестановка оставляет в
витрине значение не той доставки, что стоит в журнале последней, — **у любой
метрики**. Расхождение живой витрины с пересборкой перестало быть свойством
редкого класса и стало свойством любого столкновения равной полноты, то есть
98,8% спорных координат (находка 54).
**Почему это вопрос, а не работа.** Выбранный вариант **(в)** — повторы при
`ErrLayerUnknown` — лечит невыводимый слой, но порядок журнала не
восстанавливает: доставка всё равно сворачивается после своей преемницы, просто
не уходит в `failed`. Порядок восстанавливают только **(а)** (резервировать
строку учёта в начале `Accept`) и **(б)** (выдержка перед свёрткой). То есть
после реализации (в) заявленное равенство «пересборка = приём» останется
недостижимым, а спека хранения будет обещать его условно.
**Что сделано вместо, чтобы не молчать.** Воркер перед свёрткой спрашивает
журнал, есть ли доставка позже этой в статусе `parsed` или `partial`; есть —
пишется `WARN` с идентификатором. Расхождение стало наблюдаемым и лечится
`healthlog reindex`. Это страж окна, и его сносят вместе с окном.
**Варианты и цена — те же, что ниже, плюс четвёртый.**
- **(в), как решено** — окно живёт, наблюдается `WARN`, лечится пересборкой.
Дёшево; цена — «витрина есть свёртка журнала» держится на прогоне, который в
гейт не входит.
- **(а)** — резервировать строку учёта до записи тела. Закрывает окно совсем.
Цена: ломается инвариант «тело на диск раньше строки учёта», появляется
состояние «строка есть, тела нет», которое обязаны понимать пересборка и
ретеншен.
- **(а′)** — не резервировать, а **сериализовать** выпуск ULID вместе с записью
тела и вставкой строки: тогда видимость строк монотонна вместе с метками, а
инвариант «тело раньше строки» сохраняется. Цена: приём становится
последовательным, и батч-доставки HAE выстраиваются в очередь (184 мс на
62 МиБ на доставку).
- **Провенанс на объект** (не на точку) — колонка с позицией журнала у часового
объекта, тай-брейк по ней, как у сущностей. Правило снова становится
коммутативным, окно перестаёт быть дефектом, барьер и `WARN` не нужны. Цена:
миграция и смена формата, которую решение владельца 2026-08-04 запретило по
бюджету, — но запрет там назван бюджетным, а не принципиальным.
**Рекомендация.** Пересмотреть (в) в пользу **(а′)**: он единственный закрывает
окно, не трогая ни схему, ни инвариант «тело раньше строки». Если
последовательный приём неприемлем по задержке — тогда провенанс на объект, а не
жизнь с условным равенством: сегодня его проверяет один прогон, который гоняют
руками.
## Что происходит
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
@@ -31,6 +31,52 @@
слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из
будущего» (`docs/review.md`, 2026-08-01).
## Вопросы
Записаны 2026-08-04 по итогам ревью (профиль `deep`). Работа доведена до
коммита в объявленных границах; эти два решения не мои.
**1. Пересобирать ли живую витрину сейчас.** Новое правило меняет исход на
75 494 координатах (95% — `basal_energy_burned` слоя `raw`), но действует
только вперёд: уже сохранённые часы держат значение, выбранное прежним,
измеримо смещённым правилом, пока витрину не пересоберут. Отпечаток живого
`./data` с новым правилом **не сойдётся** — это ожидаемо и названо в дизайне.
- **(а)** `healthlog reindex` с остановкой сервиса и подменой файла базы сразу
после выкладки. Цена: простой приёма на время прогона (минута на нынешнем
архиве) плюс необратимое действие руками.
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
Цена: до пересборки Read API отдаёт по историческим часам прежние значения, а
сверка отпечатков с пересборкой не сойдётся и будет выглядеть отказом.
- **(в)** не пересобирать вовсе — витрина сойдётся только по тем координатам,
которые переприедут доставками. Цена: смещение остаётся в истории навсегда,
обнаружится сверкой с родным экспортом Apple, то есть месяцами позже.
Рекомендация: **(а)**. Подмена файла базы — необратимое действие человека
(`CLAUDE.md`), выполнить его я не вправе; этим изменением оно и не выполняется.
**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
гаснет, когда значения общих содержательных ключей разошлись, — и тогда
пришедшая точка побеждает, даже если унесёт ключ, которого сама не несёт.
Замер: 2 координаты из 80 129 спорных на живом корпусе, обе — те же, что дают
несравнимые наборы.
- **(а, сделано)** оставить правило и завести счётчик `PointsErased` с `WARN` и
координатами. Цена: событие наблюдается, но не предотвращается; обратимо
пересборкой, пока жив архив.
- **(б)** сузить «побеждает пришедшая» до случая, когда множества
содержательных ключей совпали, а при строгом включении имён оставлять более
полную независимо от происхождения. Цена: правило перестаёт быть чисто
структурным на этом разряде, дельта хранения переписывается, прогон живого
архива надо снимать заново. Проверить обязательно: сохраняется ли при этом
починка `step_count` — по замеру его столкновения идут с одинаковыми
наборами `{date, qty}`, то есть должна сохраниться.
Рекомендация: **(а)** — она уже реализована, потому что не меняет принятого
владельцем правила и восстанавливает наблюдаемость. Переход к (б) остаётся
дешёвым: счётчик скажет, если событие станет массовым.
## Критерии приёмки
- ни одна метрика не теряет род из-за столкновения равной полноты; `step_count`