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
+7 -3
View File
@@ -51,7 +51,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение
столкновении выигрывает **более полная** точка, а при равной полноте —
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
форм). Второе означает, что содержимое витрины есть функция **порядка**
свёртки, и порядок этот обязан равняться журнальному. Изменение
запечатанного часа — `WARN`, но данные всё равно пишутся.
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
@@ -90,7 +93,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
минута прогона и данные, которых нет ни на какой другой машине
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон
обязана оставить доставку в очереди, а отложенная доставка не должна развести
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
- `task tidy``go mod tidy`
- `task setup` — установка golangci-lint
@@ -111,7 +115,7 @@ Module path — `git.vakhrushev.me/av/healthlog`.
необратимо.
- **Чего в гейте намеренно нет и кто обязан это гонять:**
`task verify:archive` (минута прогона, данные есть только на этой машине) и
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи**
`task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
перед любым изменением правила разбора, идентичности или слияния — а не «когда
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
[docs/review.md](docs/review.md).
+7 -1
View File
@@ -45,13 +45,19 @@ tasks:
- go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
verify:busy:
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)'
desc: 'Свёртка под удерживаемой блокировкой базы: доставка остаётся в очереди, а витрина не расходится с пересборкой (около 50 секунд)'
cmds:
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
# при этом центральное решение задачи «разнести ответ и свёртку»:
# занятость базы — обстоятельство, а не свойство доставки.
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
# Второй прогон — композиция, ради которой заведён барьер журнального
# порядка: занятость откладывает доставку, проход прекращается на ней, и
# живая витрина всё равно совпадает с пересборкой. Порознь барьер и
# сходимость проверены в гейте; вместе — только здесь, потому что
# настоящая занятость стоит те же двадцать пять секунд.
- go test ./internal/replay -run TestBusy -healthlog.busy -v -count=1
lint:
desc: Запуск golangci-lint
+7
View File
@@ -36,6 +36,13 @@ func writeReport(w io.Writer, r report) {
// сущностей стало слишком строгим.
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
// То же и по той же причине — про точки. Удержания говорят, спорит ли ещё
// правило полноты с журналом; потери — единственное направление, в котором
// тай-брейк «побеждает пришедшая» способен унести содержание, и человек,
// принимающий по этому отчёту необратимое решение о подмене базы, обязан
// видеть оба числа, а не выводить их из совпавшего отпечатка.
p(" точки: удержано полнотой %d, содержание унесено пришедшей %d",
r.replay.PointsHeld, r.replay.PointsErased)
if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
@@ -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`
+18
View File
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
return out
}
// CarriesKeyAbsentIn отвечает, есть ли у f ключ с НЕПУСТЫМ значением, которого
// нет у g. Значения при этом не сравниваются вовсе.
//
// Заведено ради наблюдения, которого у правила слияния не было: разряд полноты
// гаснет, когда значения общих содержательных ключей разошлись (см. Relate), и
// тогда исход решает тай-брейк — а он может отдать победу точке, у которой
// содержательного ключа нет. Событие редкое (на живом корпусе 2 координаты из
// 80 129 спорных, обе несравнимые), но это единственное направление, в котором
// новое правило способно потерять содержание, и молчать о нём нельзя.
//
// Отдельным методом, а не через Relate: Relate отвечает про СОДЕРЖАНИЕ (с
// условием совпадения значений), здесь же нужен вопрос про имена, и смешение
// этих двух вопросов однажды уже дало правило, которое считало пустое поле
// содержанием.
func (f Fields) CarriesKeyAbsentIn(g Fields) bool {
return hasExtra(keysOf(f.full), keysOf(g.full))
}
// relateKeys сравнивает два множества ключей по включению.
func relateKeys(a, b map[string]struct{}) Fullness {
aExtra := hasExtra(a, b)
+27
View File
@@ -247,6 +247,17 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"buckets", st.Buckets,
"unchanged", st.Unchanged,
"overwrites", st.Overwrites,
// Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым
// тай-брейком это правило полноты, работающее штатно (на живом корпусе
// 981 координата из 80 129 спорных), и эскалация обесценила бы уровень.
// Наблюдаемым событие делает то, что оно посчитано отдельно от
// перезаписей и печатается прогоном пересборки.
"points_held", st.PointsHeld,
// Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже:
// на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет,
// а событие означает потерю содержания — обратимую пересборкой, пока
// жив архив.
"points_erased", st.PointsErased,
"incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits,
@@ -285,6 +296,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
}
if len(st.PointsHeldAt) > 0 {
attrs = append(attrs, "points_held_at", formatCollisions(st.PointsHeldAt))
}
if len(st.PointsErasedAt) > 0 {
attrs = append(attrs, "points_erased_at", formatCollisions(st.PointsErasedAt))
}
if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
}
@@ -338,6 +355,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.PointsErased > 0:
// НИЖЕ несравнимости, хотя событие тяжелее: на живом корпусе эти два
// множества совпадают (2 координаты и там, и там), и несравнимость
// сообщает больше — она называет ещё и то, что объединять поля было бы
// что. Ветвь эта говорит про случай, которого несравнимость не
// покрывает: сохранённая была надмножеством по именам, но значения
// общих ключей разошлись, разряд полноты погас, и содержание унесла
// пришедшая. Счётчики обеих ветвей идут атрибутами всегда, так что
// порядок ветвей ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, arriving point dropped stored field", attrs...)
case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут.
+43
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
@@ -514,3 +515,45 @@ func TestFoldОтказРазбораНеПодделываетСчётчикП
t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities)
}
}
// Удержание точки правилом полноты доезжает до итога свёртки и до лога:
// счётчик отдельный от перезаписей, координаты — без значений.
//
// Тела строятся из РЕАЛЬНОГО пакета: у второй доставки из каждой точки убран
// `qty`. Так получается ровно тот вход, на котором пришедшая точка теряет
// содержание сохранённой, — единственный случай, когда под новым тай-брейком
// она вообще проигрывает.
func TestFoldУдержаниеТочкиСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
rich := fixture(t, "minute.json")
poor := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+\s*,\s*`).ReplaceAll(rich, nil)
if len(poor) == len(rich) {
t.Fatal("фикстура не содержит qty — обеднить тело нечем")
}
deliver(t, arch, st, "d1", "Minutes", "auto-1", rich)
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
deliver(t, arch, st, "d2", "Minutes", "auto-1", poor)
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.PointsHeld == 0 {
t.Fatalf("удержаний 0: обеднённая точка вытеснила измерение, %+v", stats.MergeStats)
}
if len(stats.PointsHeldAt) == 0 {
t.Error("координат удержания нет: счётчик без адреса неразбираем")
}
if stats.PointsHeld > stats.Overwrites {
t.Errorf("удержаний %d при %d перезаписях: удержание — частный случай столкновения",
stats.PointsHeld, stats.Overwrites)
}
}
+78
View File
@@ -389,3 +389,81 @@ func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *t
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
}
}
// Единственное направление, в котором новое правило теряет содержание, не
// молчит: сохранённая была надмножеством по именам, но значения общих ключей
// разошлись — разряд полноты погас, и содержание унесла пришедшая.
//
// От несравнимости этот случай отличается тем, что объединять поля тут было бы
// нечего: у пришедшей своего ключа нет, она просто беднее.
func TestFoldПотеряСодержанияДаётWarn(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
`]}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1,"mealTime":"До еды"`)))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
buf.Reset()
// Беднее по именам И с другим значением общего ключа: Relate объявит пару
// равнополной, и защитить `mealTime` будет некому.
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":6.2`)))
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.PointsErased != 1 {
t.Fatalf("потерь содержания %d, ожидалась 1: тест проверяет не то", stats.PointsErased)
}
if stats.Incomparable != 0 {
t.Fatalf("несравнимых %d, ожидалось 0: случай перепутан с несравнимостью", stats.Incomparable)
}
var rec map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
}
if rec["msg"] != "delivery folded, arriving point dropped stored field" {
t.Errorf("сообщение %v не называет событие", rec["msg"])
}
if rec["points_erased"] != float64(1) {
t.Errorf("счётчик потерь %v, ожидался 1", rec["points_erased"])
}
at, _ := rec["points_erased_at"].(string)
if !strings.Contains(at, "blood_glucose/minute@") {
t.Errorf("координаты объекта в записи не те: %q", at)
}
// Ни значения точки, ни потерянной строки: `mealTime` — контекст измерения,
// то есть данные о здоровье. Проверяется по разобранной записи, а не по
// сырому буферу.
delete(rec, "time")
for k, v := range rec {
if s, ok := v.(string); ok && strings.Contains(s, "До еды") {
t.Errorf("в поле %q оказалось значение точки: %q", k, s)
}
}
}
+12
View File
@@ -82,6 +82,18 @@ func TestReplayЖивогоАрхива(t *testing.T) {
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
first.FailedOther, first.Incomparable)
// Удержания печатаются, а не утверждаются: число производно от размера
// корпуса, а прогон живого архива в гейт не входит — константа покраснела бы
// молча (docs/review.md, 2026-08-02). Утверждается инвариант: удержание
// возможно только там, где сохранённая точка строго полнее, то есть их не
// может быть больше, чем столкновений вообще.
t.Logf("удержано точек правилом полноты %d, содержание унесено пришедшей %d",
first.PointsHeld, first.PointsErased)
// Посылка равенства «пересборка = приём» называется рядом с ним: отказавшие
// доставки живой путь в витрину не записал, а пересборка запишет, и
// расхождение отпечатков было бы отказом не правила, а посылки.
t.Logf("посылка сходимости: отказавших доставок %d",
first.FailedLayer+first.FailedMalformed+first.FailedOther)
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
first.Partial, first.Adopted, first.Orphans)
+95
View File
@@ -0,0 +1,95 @@
package replay_test
import (
"context"
"database/sql"
"flag"
"log/slog"
"path/filepath"
"testing"
_ "modernc.org/sqlite" // тот же чистый Go-драйвер, что и у хранилища
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/replay"
)
// Прогон под удерживаемой блокировкой намеренно не входит в `task test` и
// `task gate`: `busy_timeout` — пять секунд, повторов транзакции пять, то есть
// один этот тест стоит около двадцати пяти секунд, а гейт гоняет тесты трижды.
// Запускается командой `task verify:busy`.
var runBusy = flag.Bool("healthlog.busy", false,
"прогнать свёртку под удерживаемой блокировкой базы (около 25 секунд)")
// Композиция, ради которой заведён барьер: занятость базы откладывает доставку,
// проход прекращается на ней, и живая витрина всё равно совпадает с пересборкой.
//
// Порознь это уже проверено — барьер в `worker_internal_test.go` на подменённой
// свёртке, сходимость в `order_test.go` на журнале без отложенной доставки. Но
// свойство, ради которого изменение существует, — именно композиция: тай-брейк
// «побеждает пришедшая» сделал содержимое витрины функцией порядка свёртки, и
// занятость базы это ровно тот случай, который порядок разводит. Здесь она
// НАСТОЯЩАЯ, а не подменённая: второе соединение держит запись.
func TestBusyОтложеннаяДоставкаНеРазводитПриёмИПересборку(t *testing.T) {
if !*runBusy {
t.Skip("прогон под блокировкой выключен: задайте -healthlog.busy")
}
ctx := context.Background()
dir := t.TempDir()
dbPath := filepath.Join(dir, "live.db")
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, dbPath)
log := slog.New(slog.DiscardHandler)
items := collidingJournal(t, 3)
for i := range items {
writeBody(t, arch, src, items[i], bumped(t, items[i].fixture, i))
}
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
// Второе соединение держит запись, как её держит свёртка широкой доставки:
// измерено 11 секунд на 16 тысячах объектов, то есть окно реальное.
holder, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(100)&_txlock=immediate")
if err != nil {
t.Fatalf("второе соединение: %v", err)
}
defer func() { _ = holder.Close() }()
tx, err := holder.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("удержание записи: %v", err)
}
if _, err := tx.Exec(`UPDATE delivery SET points = points`); err != nil {
t.Fatalf("удержание записи: %v", err)
}
out, err := w.Pass(ctx)
if err != nil {
t.Fatalf("проход под блокировкой: %v", err)
}
if out.Deferred != 1 {
t.Fatalf("отложенных %d, ожидалась 1: занятость не дошла до классификации, %+v", out.Deferred, out)
}
if out.Folded != 0 {
t.Fatalf("свёрнуто %d при отложенной ГОЛОВЕ очереди: барьер не сработал, %+v", out.Folded, out)
}
_ = tx.Rollback()
// Очередь пошла: те же доставки, тот же порядок журнала.
out, err = w.Pass(ctx)
if err != nil {
t.Fatalf("проход после разблокировки: %v", err)
}
if out.Folded != len(items) {
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if got, want := rep.Fingerprint, fingerprint(t, src); got != want {
t.Errorf("отложенная занятостью доставка развела приём и пересборку:\n приём %s\n пересборка %s",
want, got)
}
}
+239
View File
@@ -0,0 +1,239 @@
package replay_test
import (
"context"
"log/slog"
"path/filepath"
"regexp"
"sort"
"strconv"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// qtyPattern находит числовые значения точек в теле HAE.
var qtyPattern = regexp.MustCompile(`("(?:qty|Avg|Min|Max)"\s*:\s*)(\d+(?:\.\d+)?)`)
// bumped берёт РЕАЛЬНЫЙ пакет HAE и сдвигает в нём числовые значения, оставляя
// координаты нетронутыми. Получается доставка, сталкивающаяся с исходной по
// всем координатам и отличающаяся от неё только значениями — то есть ровно та
// форма столкновения, ради которой изменение и делается (равные наборы полей,
// разные значения; на живом корпусе это 98,8% спорных координат).
//
// Синтетическое тело писать нельзя: разбор формата HAE проверяется на реальных
// пакетах — документация формата тонка и местами расходится с потоком. Здесь
// сохраняется и то и другое: пакет настоящий, меняются только числа.
func bumped(t *testing.T, name string, delta int) []byte {
t.Helper()
body := fixture(t, name)
out := qtyPattern.ReplaceAllFunc(body, func(m []byte) []byte {
g := qtyPattern.FindSubmatch(m)
v, err := strconv.ParseFloat(string(g[2]), 64)
if err != nil {
return m
}
return append(append([]byte{}, g[1]...), strconv.FormatFloat(v+float64(delta), 'f', -1, 64)...)
})
if string(out) == string(body) {
t.Fatalf("фикстура %s не содержит числовых значений — столкновение не построить", name)
}
return out
}
// collidingJournal собирает журнал, где каждая следующая доставка приносит те
// же координаты с другими значениями.
func collidingJournal(t *testing.T, n int) []item {
t.Helper()
ids := make([]string, 0, n)
for range n {
ids = append(ids, ident.NewID())
}
sort.Strings(ids)
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
out := make([]item, 0, n)
for i, id := range ids {
out = append(out, item{
id: id,
at: base.Add(time.Duration(i+1) * time.Second),
automationID: "auto-1",
aggregation: "Minutes",
fixture: "minute.json",
})
}
return out
}
// Главный оракул задачи: тай-брейк «побеждает пришедшая» сделал содержимое
// витрины функцией ПОРЯДКА свёртки, и витрина остаётся свёрткой журнала только
// потому, что порядок свёртки приведён к порядку журнала.
//
// Проверяется на входе, воспроизводящем настоящую гонку приёма: строки учёта
// вставляются в ОБРАТНОМ хронологии порядке — так выглядит конкурентный приём,
// где поздняя доставка закоммитила строку первой. Воркер обязан всё равно
// свернуть их в порядке `(received_at, id)`, и отпечаток обязан совпасть с
// пересборкой.
//
// Проверка снабжена отрицательным контролем: те же тела, свёрнутые в обратном
// порядке, обязаны дать ДРУГОЙ отпечаток. Без него тест зеленел бы и на
// правиле, которое к порядку безразлично, — то есть не проверял бы ничего.
func TestЖиваяСвёрткаРавнаПересборкеНаСтолкновениях(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, filepath.Join(dir, "live.db"))
sink := newLogSink()
log := slog.New(slog.NewJSONHandler(sink, nil))
items := collidingJournal(t, 4)
bodies := make([][]byte, len(items))
for i := range items {
bodies[i] = bumped(t, items[i].fixture, i)
}
// Учёт заполняется от последней доставки к первой.
for i := len(items) - 1; i >= 0; i-- {
writeBody(t, arch, src, items[i], bodies[i])
}
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
out, err := w.Pass(ctx)
if err != nil {
t.Fatalf("проход воркера: %v", err)
}
if out.Folded != len(items) {
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
}
// Порядок журнала соблюдён, значит жаловаться не на что.
if n := sink.count("delivery folded out of journal order"); n != 0 {
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
}
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
if got, want := rep.Fingerprint, fingerprint(t, src); got != want {
t.Errorf("живая свёртка и пересборка разошлись:\n приём %s\n пересборка %s", want, got)
}
// Отрицательный контроль: тот же журнал, свёрнутый задом наперёд.
rev := openArchive(t, filepath.Join(dir, "rev-raw"))
revStore := openStore(t, filepath.Join(dir, "rev.db"))
f := fold.New(rev, revStore, 0, slog.New(slog.DiscardHandler))
for i := len(items) - 1; i >= 0; i-- {
writeBody(t, rev, revStore, items[i], bodies[i])
if _, err := f.Fold(ctx, items[i].id); err != nil {
t.Fatalf("свёртка %s: %v", items[i].id, err)
}
}
if fingerprint(t, revStore) == rep.Fingerprint {
t.Error("обратный порядок свёртки дал тот же отпечаток: тай-брейк не зависит от порядка, то есть правило не сработало")
}
}
// Остаточное окно не молчит: доставка, ставшая видимой после того, как её
// преемница по журналу уже свёрнута, обязана оставить `WARN`.
//
// Закрыть окно здесь нечем — метка `received_at` фиксируется при выпуске ULID,
// а строка учёта появляется только после записи тела. Наблюдение и есть всё,
// что можно сделать без изменения приёма; лечится расхождение пересборкой.
func TestСвёрткаВнеПорядкаЖурналаНеМолчит(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
st := openStore(t, filepath.Join(dir, "live.db"))
sink := newLogSink()
log := slog.New(slog.NewJSONHandler(sink, nil))
items := collidingJournal(t, 2)
// Поздняя доставка доехала и свернулась первой — её строка учёта успела
// закоммититься, пока ранняя писала тело.
writeBody(t, arch, st, items[1], bumped(t, items[1].fixture, 1))
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
if _, err := f.Fold(ctx, items[1].id); err != nil {
t.Fatalf("свёртка поздней доставки: %v", err)
}
// Теперь появляется ранняя.
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("проход воркера: %v", err)
}
if n := sink.count("delivery folded out of journal order"); n != 1 {
t.Fatalf("записей о свёртке вне порядка журнала %d, ожидалась 1:\n%s", n, sink.dump())
}
// Значений точек в записи быть не должно — данные о здоровье чувствительнее
// токенов. Проверяется по разобранной записи, а не по сырому буферу: метка
// времени содержит цифры и совпала бы с любым коротким числом.
assertNoPointValues(t, sink, "delivery folded out of journal order")
}
// Доставка в статусе `failed` преемницей для этого наблюдения не считается: она
// в витрину ничего не записала, перестановка относительно неё содержимого не
// разводит, а исход этот штатный — невыводимый слой. Учитывай его предикат,
// `WARN` стал бы шумом, на который перестают смотреть.
func TestОтказавшаяПреемницаНеСчитаетсяРасхождением(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
arch := openArchive(t, filepath.Join(dir, "raw"))
st := openStore(t, filepath.Join(dir, "live.db"))
sink := newLogSink()
log := slog.New(slog.NewJSONHandler(sink, nil))
items := collidingJournal(t, 2)
// Поздняя доставка приезжает без плотных метрик и без предшественника —
// слой выводить не из чего, исход `failed`.
late := items[1]
late.aggregation = "Default"
late.automationID = "auto-lonely"
late.fixture = "sparse_sleep.json"
writeBody(t, arch, st, late, fixture(t, late.fixture))
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
if _, err := f.Fold(ctx, late.id); err == nil {
t.Fatal("поздняя доставка свернулась: слой не должен был вывестись")
}
if status, err := st.DeliveryStatus(ctx, late.id); err != nil || status != store.ParseFailed {
t.Fatalf("статус поздней доставки %q (%v), ожидался %q", status, err, store.ParseFailed)
}
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("проход воркера: %v", err)
}
if n := sink.count("delivery folded out of journal order"); n != 0 {
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
}
}
// assertNoPointValues убеждается, что запись лога не несёт значений точек.
//
// Разбирает запись и выбрасывает служебные поля, а не ищет подстроку в сыром
// буфере: метка времени содержит цифры, и проверка по буферу была бы флаки по
// построению (docs/conventions/testing.md, запись 2026-08-02).
func assertNoPointValues(t *testing.T, sink *logSink, msg string) {
t.Helper()
for _, line := range sink.linesOf(msg) {
for _, k := range []string{"qty", "Avg", "Min", "Max", "value", "points"} {
if _, ok := line[k]; ok {
t.Errorf("запись %q несёт поле %q: значения точек в лог не попадают", msg, k)
}
}
}
}
+16 -1
View File
@@ -30,8 +30,19 @@ type Outcome struct {
// ретеншену трогать нельзя.
Partial int
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей.
// они единичны, и на этом стоит отказ от объединения полей.
Incomparable int
// PointsHeld — точек, удержанных правилом полноты против пришедшей
// доставки. Здесь по той же причине, что EntitiesHeld: сходимость
// отпечатка удержание не проверяет ПО ПОСТРОЕНИЮ — живой приём и пересборка
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
// точке. То есть слишком строгое правило полноты выглядело бы идеальной
// сходимостью.
PointsHeld int
// PointsErased — точек, чьё содержание унесла пришедшая победительница.
// Здесь по той же причине, что PointsHeld, и с большим весом: это
// единственное направление, в котором правило слияния теряет содержание.
PointsErased int
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
// содержания». Без него правило слияния сущностей проверить нечем:
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
@@ -54,6 +65,8 @@ func (o *Outcome) Add(other Outcome) {
o.FailedOther += other.FailedOther
o.Partial += other.Partial
o.Incomparable += other.Incomparable
o.PointsHeld += other.PointsHeld
o.PointsErased += other.PointsErased
o.EntitiesHeld += other.EntitiesHeld
o.EntitiesDiverging += other.EntitiesDiverging
}
@@ -118,6 +131,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
out.Partial++
}
out.Incomparable += st.Incomparable
out.PointsHeld += st.PointsHeld
out.PointsErased += st.PointsErased
out.EntitiesHeld += st.EntitiesHeld
out.EntitiesDiverging += st.EntitiesDiverging
}
+2
View File
@@ -211,6 +211,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
"failed_other", rep.FailedOther,
"partial", rep.Partial,
"incomparable", rep.Incomparable,
"points_held", rep.PointsHeld,
"points_erased", rep.PointsErased,
"entities_held", rep.EntitiesHeld,
"entities_diverging", rep.EntitiesDiverging,
"buckets", rep.Buckets,
+5 -2
View File
@@ -170,8 +170,11 @@ func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
want, rep.Fingerprint)
}
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
// координаты — функция множества кандидатов, а не порядка прихода.
// Повторный прогон в ещё одну базу обязан дать то же самое: журнал тот же и
// проигрывается в том же порядке, а исход слияния — функция множества
// кандидатов вместе с их происхождением, то есть от прогона к прогону не
// плавает. От ПОРЯДКА журнала он зависит намеренно; что порядок этот
// соблюдён и живым путём, проверяет order_test.go.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
+84 -2
View File
@@ -60,16 +60,28 @@ type Worker struct {
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
// запуске обесценила бы уровень.
startupDone bool
// fold — свёртка одной доставки. Полем, а не прямым вызовом, ради ОДНОГО
// шва: исход `Deferred` наступает только от занятости базы, а удержать её
// по-настоящему стоит двадцать пять секунд (`busy_timeout` 5 с × 5 повторов
// транзакции) — столько же, сколько стоит `task verify:busy`, который в
// гейт намеренно не входит. Барьер журнального порядка при этом и есть
// механизм, на котором держится равенство «пересборка = приём», и оставить
// его без дешёвой проверки значило бы проверять его только тем прогоном,
// который никто не гоняет по расписанию.
fold func(ctx context.Context, deliveryID string) Outcome
}
// NewWorker собирает воркер над рабочей базой.
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
return &Worker{
w := &Worker{
store: st,
player: Player{Fold: f},
log: log.With("capability", "fold-worker"),
wake: make(chan struct{}, 1),
}
w.fold = w.foldOne
return w
}
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
@@ -169,7 +181,45 @@ func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
return total, nil
}
lag.add(d)
total.Add(w.foldOne(ctx, d.ID))
// Порядок спрашивается ДО свёртки, а говорится ПОСЛЕ и только если
// свёртка состоялась: отложенная доставка витрину не трогала, и
// запись о свёртке вне порядка утверждала бы событие, которого не
// было, — да ещё повторялась бы каждым проходом, пока голова очереди
// занята. Спросить надо всё же до: после свёртки предикат уже видит
// саму эту доставку разобранной.
outOfOrder := w.outOfOrder(ctx, d)
out := w.fold(ctx, d.ID)
total.Add(out)
if outOfOrder && out.Deferred == 0 {
w.warnOutOfOrder(ctx, d)
}
if out.Deferred > 0 {
// Отложенная доставка ДЕРЖИТ очередь: перешагнув её, проход
// свернул бы её преемниц раньше неё, а тай-брейк равной полноты
// разрешается в пользу пришедшей доставки — то есть исход
// слияния стал бы функцией порядка свёртки, отличного от
// порядка журнала, и живая витрина разошлась бы с пересборкой
// молча, в значениях точек.
//
// Очередь от этого не встаёт: отложенным считается ТОЛЬКО
// занятость базы и отмена снаружи (store.Transient), а
// собственный дедлайн свёртки в него намеренно не входит —
// доставка, не уложившаяся в бюджет, получает `failed` и
// очередь освобождает. Занятость же блокирует запись всем
// одинаково: проход, перешагнувший занятую доставку, упёрся бы
// в ту же занятость на следующей.
//
// Метка отставания пишется ЗДЕСЬ, а не только на пустой
// выборке. Иначе барьер отменял бы её ровно в том состоянии,
// ради которого она заведена: занятая голова очереди не
// пропускает проход до пустой выборки никогда, и «очередь стоит
// два часа» стало бы неотличимо от «споткнулась один раз».
//
// Флаг первого прохода при этом НЕ взводится: проход до конца
// очереди не дошёл, и объявлять задолженность разобранной рано.
w.warnLag(ctx, lag)
return total, nil
}
cursor = d
}
}
@@ -216,6 +266,38 @@ func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
return out
}
// warnOutOfOrder называет свёртку, идущую вне порядка журнала.
//
// Остаточное окно закрыть здесь нечем: метка `received_at` фиксируется при
// выпуске ULID, а строка учёта становится видимой только после записи тела
// (измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
// ранней меткой появляется после того, как её преемница уже свёрнута. Барьер
// выше этого не ловит — отставшей доставки в момент прохода не существует.
//
// Молчать об этом нельзя: правило слияния точек стало функцией порядка свёртки,
// и перестановка оставляет в витрине версию не той доставки, что стоит в
// журнале последней. Лечится `healthlog reindex`; без этой строки узнать о
// необходимости неоткуда.
//
// Одна запись на доставку: событие редкое и адресное, шквала здесь не бывает.
// Отказ запроса свёртку НЕ прекращает — наблюдение не может стоить доставки.
func (w *Worker) outOfOrder(ctx context.Context, d store.PendingDelivery) bool {
later, err := w.store.ParsedAfter(ctx, d.ReceivedAt, d.ID)
if err != nil {
if ctx.Err() == nil {
w.log.ErrorContext(ctx, "journal order check failed", "error", err, "delivery_id", d.ID)
}
return false
}
return later
}
func (w *Worker) warnOutOfOrder(ctx context.Context, d store.PendingDelivery) {
w.log.WarnContext(ctx, "delivery folded out of journal order",
"delivery_id", d.ID,
"waited_sec", int64(store.Now().Sub(d.ReceivedAt).Seconds()))
}
// warnLag называет отставание одной строкой на проход.
//
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
+147
View File
@@ -0,0 +1,147 @@
package replay
import (
"bytes"
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Отложенная доставка ДЕРЖИТ очередь: проход обязан прекратиться на ней, а не
// свернуть её преемниц раньше неё.
//
// Свойство это — единственное, на чём держится равенство «пересборка = приём»
// после смены тай-брейка: при равной полноте побеждает пришедшая доставка, то
// есть исход слияния стал функцией порядка свёртки. Перешагнув отложенную
// доставку, воркер оставил бы в витрине версию не той доставки, что стоит в
// журнале последней, — и расхождение было бы молчаливым.
//
// Свёртка подменяется полем, а не удерживается настоящая блокировка базы:
// `Deferred` наступает от занятости, а удержать её по-настоящему стоит
// двадцать пять секунд (`busy_timeout` 5 с × 5 повторов транзакции). Столько
// стоит `task verify:busy`, который в гейт намеренно не входит, — то есть
// проверка барьера жила бы только в прогоне, который никто не гоняет по
// расписанию.
func TestПроходПрекращаетсяНаОтложеннойДоставке(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
ids := []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB", "01CCCCCCCCCCCCCCCCCCCCCCCC"}
for i, id := range ids {
d := store.Delivery{
ID: id,
ReceivedAt: base.Add(time.Duration(i) * time.Second),
Bytes: 1,
SHA256: "-",
RawPath: "x",
ParseStatus: store.ParsePending,
}
if err := st.CreateDelivery(ctx, d); err != nil {
t.Fatalf("учёт доставки: %v", err)
}
}
w := &Worker{
store: st,
log: slog.New(slog.DiscardHandler),
wake: make(chan struct{}, 1),
}
var seen []string
w.fold = func(_ context.Context, id string) Outcome {
seen = append(seen, id)
if id == ids[1] {
return Outcome{Deferred: 1}
}
return Outcome{Folded: 1}
}
out, err := w.Pass(ctx)
if err != nil {
t.Fatalf("проход: %v", err)
}
if len(seen) != 2 || seen[0] != ids[0] || seen[1] != ids[1] {
t.Fatalf("проход тронул %v: он обязан остановиться на второй доставке", seen)
}
if out.Folded != 1 || out.Deferred != 1 {
t.Errorf("исход прохода %+v: ожидались одна свёрнутая и одна отложенная", out)
}
// Следующий проход начинает с начала очереди и доходит до преемниц: барьер
// откладывает работу, а не отменяет её. Порядок остаётся журнальным.
//
// Первая доставка появляется в списке снова потому, что подменённая свёртка
// исхода разбора не пишет и очередь не покидает: проверяется здесь порядок,
// а не учёт.
seen = nil
w.fold = func(_ context.Context, id string) Outcome {
seen = append(seen, id)
return Outcome{Folded: 1}
}
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("второй проход: %v", err)
}
if len(seen) != 3 || seen[0] != ids[0] || seen[1] != ids[1] || seen[2] != ids[2] {
t.Fatalf("второй проход тронул %v, ожидался порядок журнала %v", seen, ids)
}
}
// Метка отставания обязана срабатывать и на выходе по барьеру.
//
// Иначе барьер отменял бы её ровно в том состоянии, ради которого она заведена:
// занятая голова очереди не пропускает проход до пустой выборки НИКОГДА, и
// «очередь стоит два часа» стало бы неотличимо от «споткнулась один раз».
// Дельта capability приёма обещает это дословно: «Стоящая голова очереди
// молчать MUST NOT».
func TestМеткаОтставанияСрабатываетНаБарьере(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
// Доставка ждёт заведомо дольше порога задержки.
at := store.Now().Add(-time.Hour)
for i, id := range []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB"} {
d := store.Delivery{
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending,
}
if err := st.CreateDelivery(ctx, d); err != nil {
t.Fatalf("учёт доставки: %v", err)
}
}
var buf bytes.Buffer
w := &Worker{
store: st,
log: slog.New(slog.NewJSONHandler(&buf, nil)),
wake: make(chan struct{}, 1),
}
// Первый проход уже был: метка задолженности старта подавляется намеренно.
w.startupDone = true
w.fold = func(_ context.Context, _ string) Outcome { return Outcome{Deferred: 1} }
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("проход: %v", err)
}
if !strings.Contains(buf.String(), "deliveries waited for fold") {
t.Errorf("метка отставания молчит на выходе по барьеру:\n%s", buf.String())
}
}
+25
View File
@@ -98,6 +98,31 @@ func (s *logSink) count(msg string) int {
return n
}
// linesOf отдаёт записи с данным msg РАЗОБРАННЫМИ.
//
// Разобранными, а не строками: проверка «в логе нет значения точки» обязана
// смотреть поля записи, а не сырой буфер — служебное поле `time` содержит доли
// секунды, и подстрока вроде «5.1» находится в нём примерно в одном проценте
// прогонов (docs/review.md, 2026-08-02).
func (s *logSink) linesOf(msg string) []map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
var out []map[string]any
for _, line := range s.lines {
if msgOf(line) != msg {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue
}
delete(rec, "time")
out = append(out, rec)
}
return out
}
func (s *logSink) dump() string {
s.mu.Lock()
defer s.mu.Unlock()
+144 -25
View File
@@ -60,6 +60,38 @@ type MergeStats struct {
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
UnitsConflicts int
// PointsHeld — координаты, где ПРИШЕДШАЯ точка проиграла сохранённой.
//
// Под тай-брейком «побеждает пришедшая» это возможно ровно тогда, когда
// сохранённая строго полнее, — то есть счётчик меряет единственное
// направление, в котором правило полноты спорит с журналом. Обратный случай
// (пришедшая строго полнее) сюда не входит: там полнота и журнал согласны.
//
// Отдельно от Overwrites, и это не педантизм: тот считает столкновение в обе
// стороны — на живом корпусе сработал бы около 80 000 раз, — и отличить
// «оставили пришедшую» от «выбросили пришедшую» по нему нельзя. То есть
// единственное событие, ради наблюдения за которым правило переписано,
// осталось бы невидимым. Тот же довод уже записан для EntitiesHeld.
PointsHeld int
// PointsHeldAt — координаты первых таких объектов, для записи в лог.
PointsHeldAt []Collision
// PointsErased — координаты, где победила ПРИШЕДШАЯ точка, а у проигравшей
// был содержательный ключ, которого у победительницы нет.
//
// Единственное направление, в котором новое правило способно потерять
// содержание. Разряд полноты там погашен по построению: значения общих
// содержательных ключей разошлись, и надмножество имён о полноте не говорит
// ничего. До смены тай-брейка исход решал байтовый порядок — то есть тот же
// исход случался по жребию и так же молча; разница в том, что теперь он
// детерминирован, а значит либо не случается вовсе, либо случается всегда.
// Замер на живом корпусе: 2 координаты из 80 129 спорных, обе — те же, что
// дают несравнимые наборы.
//
// Обратимо пересборкой, пока жив архив. Поэтому счётчик, а не запрет:
// объединение полей отложено тем же решением и по той же причине.
PointsErased int
// PointsErasedAt — координаты первых таких объектов, для записи в лог.
PointsErasedAt []Collision
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
// есть содержательный ключ, которого нет у другой. Правило полноты тут
// бессильно, и вместо объединения полей — которого на живом потоке не
@@ -209,6 +241,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
stats.Stored += res.stored
stats.Overwrites += res.overwrites
stats.Incomparable += res.incomparable
stats.PointsHeld += res.held
stats.PointsErased += res.erased
if res.unchanged {
stats.Unchanged++
}
@@ -228,6 +262,12 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
stats.IncomparableAt = append(stats.IncomparableAt, coord)
}
if res.held > 0 && len(stats.PointsHeldAt) < maxCollisionsReported {
stats.PointsHeldAt = append(stats.PointsHeldAt, coord)
}
if res.erased > 0 && len(stats.PointsErasedAt) < maxCollisionsReported {
stats.PointsErasedAt = append(stats.PointsErasedAt, coord)
}
}
now := Now()
@@ -315,6 +355,8 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
type mergeResult struct {
overwrites int
incomparable int
held int
erased int
stored int
unchanged bool
sealed bool
@@ -334,12 +376,14 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
return res, err
}
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
merged, overwrites, incomparable, held, erased, err := mergePoints(ctx, stored.Points, group.points)
if err != nil {
return res, err
}
res.overwrites = overwrites
res.incomparable = incomparable
res.held = held
res.erased = erased
// Считаем сохранённые точки, а не присланные: точные повторы внутри
// доставки схлопываются, и счётчик присланных систематически завышал бы
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
@@ -383,17 +427,28 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
// победила» стирало бы у сохранённой точки поля, которых новая не несёт.
//
// При равной полноте исход определяется порядком канонических форм, а не
// порядком доставок: у сохранённой точки нет провенанса, а четверть доставок
// несёт столкновения внутри себя, где время приёма общее. Свёртка по журналу
// обязана давать то же состояние, что приём в реальном времени.
// При равной полноте побеждает ПРИШЕДШАЯ точка, а не сохранённая: байтовый
// порядок канонических форм системно брал меньшее значение (1 847 из 1 912,
// находка 49), то есть хранил версию, которую источник уже пересчитал. Ценой
// этого час step_count терял род, и прогон живого архива краснел.
//
// Байтовый порядок остался вторым разрядом и решает столкновения ВНУТРИ одной
// доставки, где провенанс общий, а порядок элементов в JSON-массиве от HAE
// нестабилен.
//
// Плата названа вслух: правило перестало быть коммутативным, и содержимое
// витрины стало функцией ПОРЯДКА свёртки. Витрина остаётся свёрткой журнала
// ровно потому, что порядок свёртки приведён к порядку журнала — барьер на
// отложенной доставке в replay.Worker. Восстановление коммутативности «для
// чистоты» откатит починку молча.
//
// Второй счётчик — несравнимые наборы полей. Они и есть та часть правила,
// которую заменили наблюдением: объединять поля никто не будет, пока счётчик
// не заговорит.
// не заговорит. Третий — удержания: сколько раз сохранённая точка оказалась
// строго полнее пришедшей.
//
// Точки из объекта не удаляются никогда.
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable, held, erased int, err error) {
type coord struct {
start int64
end int64
@@ -409,13 +464,13 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
// чего исход зависит от того, что уже лежало в объекте: одна и та же
// доставка, свёрнутая дважды, давала два разных состояния витрины
// поочерёдно. Это ломало главный инвариант — «состояние пересобираемо».
add := func(p Point) {
add := func(p Point, fromDelivery bool) {
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
cands, seen := byCoord[c]
if !seen {
order = append(order, c)
}
cand := newCandidate(p)
cand := newCandidate(p, fromDelivery)
// Побайтовое равенство — только быстрый путь. Тем же самым считается
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
// байты нестабильны. Из 81952 повторно приехавших точек 67534
@@ -423,8 +478,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
// правила слияния стал бы неотличим от нормы.
for _, q := range cands {
for i, q := range cands {
if bytes.Equal(q.key, cand.key) {
// Схлопывание ПОДНИМАЕТ флаг: доставка, приславшая то же
// содержимое, сказала о нём своё слово, и сохранённый
// кандидат теперь представляет её. Без этого он проигрывал бы
// соседу по доставке, которого обязан обойти по байтам, — то
// есть «внутри доставки решают байты» ломалось бы ровно
// тогда, когда доставка ничего не изменила. Байты остаются
// встреченные первыми: победитель хранится дословно, а разница
// при совпавшей канонической форме ненаблюдаема — хеш объекта
// считается по канонической форме.
cands[i].incoming = q.incoming || fromDelivery
return
}
}
@@ -432,18 +497,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
}
for _, p := range stored {
add(p)
add(p, false)
}
for _, p := range incoming {
add(p)
add(p, true)
}
out := make([]Point, 0, len(order))
for _, c := range order {
cands := byCoord[c]
winner, unrelated, err := resolve(ctx, cands)
winner, unrelated, keptStored, lostKey, err := resolve(ctx, cands)
if err != nil {
return nil, 0, 0, err
return nil, 0, 0, 0, 0, err
}
// Перезаписей столько, сколько точек уступило: при двух кандидатах
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
@@ -452,6 +517,12 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
if unrelated {
incomparable++
}
if keptStored {
held++
}
if lostKey {
erased++
}
out = append(out, winner)
}
@@ -465,7 +536,7 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
}
return out[i].End.Before(out[j].End)
})
return out, overwrites, incomparable, nil
return out, overwrites, incomparable, held, erased, nil
}
// candidate — точка вместе с тем, что о ней нужно знать при выборе
@@ -476,10 +547,19 @@ type candidate struct {
pt Point
key []byte // каноническая форма: она же ключ дедупликации и порядок
fields canon.Fields
// incoming — точка приехала РАЗБИРАЕМОЙ доставкой, а не лежала в объекте.
//
// Это второй и старший разряд тотального порядка: при равной полноте
// побеждает пришедшая. Провенанса у точки нет и не будет (колонка означала
// бы смену формата содержимого объекта), поэтому «позже по журналу»
// выражается единственным доступным способом — происхождением кандидата. И
// ровно поэтому порядок свёртки обязан равняться порядку журнала: правило
// перестало быть коммутативным осознанно, см. openspec storage.
incoming bool
}
func newCandidate(p Point) candidate {
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
func newCandidate(p Point, incoming bool) candidate {
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw), incoming: incoming}
}
// resolve выбирает победителя среди кандидатов одной координаты.
@@ -495,20 +575,49 @@ func newCandidate(p Point) candidate {
// вернуть её значило бы сохранить округлённое число вместо присланного.
//
// Второй возврат — остались ли непревзойдёнными несколько точек с
// несравнимыми наборами содержательных полей. На живом потоке этого не
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если событие наступит.
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
// несравнимыми наборами содержательных полей. На живом потоке событие
// единично (2 пары на 155 доставок), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если оно станет массовым.
//
// Третий возврат — победила ли СОХРАНЁННАЯ точка при наличии пришедших. Под
// новым тай-брейком это возможно ровно тогда, когда сохранённая строго полнее,
// то есть счётчик меряет единственное направление, в котором полнота спорит с
// журналом. Отдельно от Overwrites: тот считает столкновение в обе стороны и
// «оставили пришедшую» от «выбросили пришедшую» не отличает.
//
// Четвёртый — обратное направление, и оно опаснее: победила пришедшая, а у
// проигравшей был содержательный ключ, которого у победительницы нет. Разряд
// полноты в этом случае погашен по построению — значения общих содержательных
// ключей разошлись, и Relate объявляет пару равнополной, — так что защитить
// содержание некому. До смены тай-брейка исход здесь решал байтовый порядок,
// то есть жребий; теперь он детерминирован в пользу пришедшей. Событие редкое
// (на живом корпусе 2 координаты из 80 129 спорных, обе несравнимые), но это
// единственное направление, в котором правило теряет содержание, и оно обязано
// быть видно — иначе «ничего не теряем молча» держится на слове.
func resolve(ctx context.Context, cands []candidate) (Point, bool, bool, bool, error) {
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
if err != nil {
return Point{}, false, err
return Point{}, false, false, false, err
}
held, erased := false, false
for i, c := range cands {
if i == winner {
continue
}
if !cands[winner].incoming && c.incoming {
held = true
}
if cands[winner].incoming && c.fields.CarriesKeyAbsentIn(cands[winner].fields) {
erased = true
}
}
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
// наборами полей и разными значениями тоже остаются обе, и это рядовой
// тай-брейк. Считается только то, ради чего отложено объединение полей:
// у каждой из двух есть содержательный ключ, которого нет у другой.
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
return cands[winner].pt, hasIncomparablePair(cands, maximal), held, erased, nil
}
// pointDominates — строгое превосходство по полноте. Relate возвращает
@@ -518,9 +627,19 @@ func pointDominates(a, b candidate) bool {
return a.fields.Relate(b.fields) == canon.FullnessSuperset
}
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
// кандидаты с равной формой схлопываются ещё при сборе множества.
// pointLess — тотальный порядок: сперва происхождение, затем каноническая
// форма. Минимум единствен: кандидаты с равной формой схлопываются ещё при
// сборе множества, значит ключи различны и лексикографическая пара
// `(не incoming, ключ)` трихотомична.
//
// Пришедшая идёт РАНЬШЕ сохранённой, потому что pickBest берёт минимум. Это и
// есть тай-брейк «побеждает пришедшая»: байтовый порядок остаётся вторым
// разрядом и работает там, где происхождение одинаково, — то есть внутри одной
// доставки, где провенанс общий и различать нечем.
func pointLess(a, b candidate) bool {
if a.incoming != b.incoming {
return a.incoming
}
return bytes.Compare(a.key, b.key) < 0
}
+325 -24
View File
@@ -351,10 +351,16 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
st := open(t)
ctx := context.Background()
// Значение общего содержательного ключа СОВПАДАЕТ — таково условие второго
// разряда в спеке: он сравнивает все ключи только там, где содержание
// сошлось. Прежняя редакция теста давала `qty` разные, второй разряд при
// этом не запускался вовсе, и точка с `Min`/`Max` выигрывала по случайности
// байтового порядка (`M` сортируется раньше `d`). Зелёный тест проверял не
// то требование, которое называл.
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":10,"Min":0,"Max":0}`)
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":12}`)
`{"date":"d","qty":10}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
@@ -372,6 +378,86 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
}
}
// Обратная сторона того же разряда, и она названа ценой изменения: когда
// значения общих содержательных ключей РАЗОШЛИСЬ, полнота ответа не даёт по
// построению («надмножество имён о полноте не говорит ничего»), и пришедшая
// точка побеждает вместе с потерей пустых ключей сохранённой. Прежде исход
// здесь решался байтовым порядком, то есть был случайным.
func TestMergeРазошедшиесяЗначенияНеУдерживаютсяПустымиКлючами(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":10,"Min":0,"Max":0}`)
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":12}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(narrow.Raw) {
t.Errorf("в витрине %s, ожидалась пришедшая точка %s", got, narrow.Raw)
}
if stats.Overwrites != 1 {
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
}
if stats.PointsHeld != 0 {
t.Errorf("удержаний %d: пришедшая победила, удерживать было нечего", stats.PointsHeld)
}
}
// Полнота сильнее происхождения: пришедшая точка, теряющая содержательный ключ
// сохранённой при совпадающих значениях, проигрывает — и это единственный
// случай, когда пришедшая вообще проигрывает. Счётчик удержаний считает ровно
// его и отдельно от перезаписей.
func TestMergeПолнотаСильнееПроисхождения(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
rich := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":72,"Min":60,"Max":90,"context":"rest"}`)
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":72,"Min":60,"Max":90}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(rich.Raw) {
t.Errorf("в витрине %s: пришедшая бедная точка стёрла содержательный ключ", got)
}
if stats.PointsHeld != 1 {
t.Errorf("удержаний %d, ожидалось 1", stats.PointsHeld)
}
if len(stats.PointsHeldAt) != 1 {
t.Fatalf("координат удержания %d, ожидалась 1", len(stats.PointsHeldAt))
}
if stats.PointsHeldAt[0].Metric != "heart_rate" || stats.PointsHeldAt[0].Layer != "minute" {
t.Errorf("координаты удержания %+v: не те", stats.PointsHeldAt[0])
}
}
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
// счётчик и координаты объекта, по которым событие можно будет разобрать.
@@ -480,10 +566,10 @@ func TestMergeСменаИсточникаНеСоздаётВторуюТочк
}
}
// Исход столкновения точек равной полноты обязан зависеть только от значений:
// свёртка по журналу должна давать то же состояние, что приём в реальном
// времени, а внутри одной доставки время приёма общее.
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
// Внутри ОДНОЙ доставки провенанс общий, различать точки нечем, а порядок
// элементов в JSON-массиве от HAE нестабилен — поэтому здесь исход обязан
// остаться функцией множества и решаться порядком канонических форм.
func TestMergeРавнаяПолнотаВнутриДоставкиНеЗависитОтПорядка(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -491,13 +577,11 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
winner := func(order []store.IncomingPoint) string {
winner := func(batch []store.IncomingPoint) string {
st := open(t)
for _, p := range order {
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
}
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
@@ -508,7 +592,119 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
forward := winner([]store.IncomingPoint{a, b})
backward := winner([]store.IncomingPoint{b, a})
if forward != backward {
t.Errorf("исход зависит от порядка доставок: %s против %s", forward, backward)
t.Errorf("исход внутри доставки зависит от порядка элементов: %s против %s", forward, backward)
}
if forward != string(a.Raw) {
t.Errorf("внутри доставки победила %s, ожидался минимум канонической формы %s", forward, a.Raw)
}
}
// А МЕЖДУ доставками побеждает пришедшая, и это осознанная потеря
// коммутативности: байтовый порядок системно хранил меньшее значение и
// стоил `step_count` его рода. Витрина остаётся свёрткой журнала потому, что
// порядок свёртки приведён к порядку журнала, а не потому, что правило
// безразлично к порядку.
func TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую(t *testing.T) {
t.Parallel()
ctx := context.Background()
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
last := func(order []store.IncomingPoint) string {
st := open(t)
for i, p := range order {
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", i)}
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, ref); err != nil {
t.Fatalf("слияние: %v", err)
}
}
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
return string(got.Points[0].Raw)
}
if got := last([]store.IncomingPoint{a, b}); got != string(b.Raw) {
t.Errorf("после доставок a,b в витрине %s, ожидалась пришедшая последней %s", got, b.Raw)
}
// Обратный порядок даёт обратный исход — ровно то свойство, ради которого
// заведён барьер журнального порядка в воркере.
if got := last([]store.IncomingPoint{b, a}); got != string(a.Raw) {
t.Errorf("после доставок b,a в витрине %s, ожидалась пришедшая последней %s", got, a.Raw)
}
}
// Повторная присылка того же содержимого другими байтами ничего не переписывает:
// схлопывание поднимает флаг происхождения, но байты в витрине остаются
// сохранённые, и хеш-детектор гасит запись.
func TestMergeПовторКаноническиТойЖеТочкиНеПереписывает(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":10}`)
// Тот же смысл, другие байты: порядок ключей у HAE нестабилен.
again := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":10,"date":"d"}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{again}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.Overwrites != 0 {
t.Errorf("перезаписей %d: дребезг порядка ключей посчитан столкновением", stats.Overwrites)
}
if stats.Unchanged != 1 {
t.Errorf("неизменившихся объектов %d, ожидался 1: объект переписан зря", stats.Unchanged)
}
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(first.Raw) {
t.Errorf("в витрине %s, ожидались сохранённые байты %s", got, first.Raw)
}
}
// Точка, канонически совпавшая с сохранённой, представляет доставку наравне с
// остальными её точками: иначе она проигрывала бы соседу по телу, которого
// обязана обойти по байтам, — то есть «внутри доставки решают байты» ломалось
// бы ровно тогда, когда доставка ничего не изменила.
func TestMergeСхлопываниеПоднимаетПроисхождение(t *testing.T) {
t.Parallel()
ctx := context.Background()
stored := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
same := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
other := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
st := open(t)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
// Вторая доставка несёт обе формы: совпавшую с сохранённой и другую.
// Победить обязана меньшая каноническая форма, то есть совпавшая, — а не
// вторая только потому, что у первой не поднялся флаг.
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{same, other}}, store.DeliveryRef{ID: "d2"}); err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(stored.Raw) {
t.Errorf("в витрине %s, ожидался минимум канонической формы доставки %s", got, stored.Raw)
}
}
@@ -755,19 +951,18 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
ctx := context.Background()
in := cycleTriple(t)
// Все шесть перестановок тройки, и вдобавок разбиение на разные доставки:
// в живом приёме точки приходят порознь, в пересборке — вместе.
// Все шесть перестановок тройки ВНУТРИ одной доставки. Разбиение на разные
// доставки сюда больше не входит намеренно: между доставками правило
// перестало быть коммутативным — при равной полноте побеждает пришедшая, —
// и требовать здесь независимости от порядка значило бы требовать отката
// починки. Что осталось верным между доставками, проверяют
// `TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую` (исход = последняя
// доставка) и прогон сходимости живого пути с пересборкой в
// `internal/replay`.
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
winner := func(order []int, split bool) string {
winner := func(order []int) string {
st := open(t)
if split {
for _, i := range order {
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
}
} else {
batch := make([]store.IncomingPoint, 0, len(order))
for _, i := range order {
batch = append(batch, in[i])
@@ -775,6 +970,40 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
return string(b.Points[0].Raw)
}
want := winner(perms[0])
for _, p := range perms {
if got := winner(p); got != want {
t.Errorf("перестановка %v дала %s, ожидалось %s", p, got, want)
}
}
}
// Та же тройка, разложенная по РАЗНЫМ доставкам: исход теперь зависит от
// порядка, и это не дефект, а объявленная цена. Проверяется другое и не менее
// важное — что зависимость ДЕТЕРМИНИРОВАНА: один и тот же порядок журнала
// обязан давать один и тот же исход, иначе витрина перестала бы быть свёрткой
// журнала вовсе, а не только функцией его порядка.
func TestMergeПорядокДоставокДаётВоспроизводимыйИсход(t *testing.T) {
t.Parallel()
ctx := context.Background()
in := cycleTriple(t)
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
winner := func(order []int) string {
st := open(t)
for n, i := range order {
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", n)}
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, ref); err != nil {
t.Fatalf("слияние: %v", err)
}
}
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
@@ -783,11 +1012,11 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
return string(b.Points[0].Raw)
}
want := winner(perms[0], false)
for _, p := range perms {
for _, split := range []bool{false, true} {
if got := winner(p, split); got != want {
t.Errorf("перестановка %v (порознь=%v) дала %s, ожидалось %s", p, split, got, want)
first := winner(p)
for pass := 2; pass <= 3; pass++ {
if got := winner(p); got != first {
t.Errorf("порядок %v дал %s, а прежде %s: исход невоспроизводим", p, got, first)
}
}
}
@@ -857,3 +1086,75 @@ func TestMergeИмяМетрикиВКоординатеОбрезано(t *test
t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
}
}
// Единственное направление, в котором новое правило теряет содержание:
// значения общих содержательных ключей разошлись, разряд полноты погас, и
// победила пришедшая точка, у которой содержательного ключа нет.
//
// До смены тай-брейка исход здесь решал байтовый порядок — то есть та же
// потеря случалась по жребию и так же молча. Разница в том, что теперь она
// детерминирована и СЧИТАЕТСЯ.
func TestMergeПришедшаяУнесшаяСодержаниеСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
// `rem` — содержательный ключ сохранённой; у пришедшей его нет, а значение
// общего ключа `asleep` разошлось, поэтому Relate объявляет пару равнополной.
stored := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
`{"date":"d","asleep":7.5,"rem":1.2}`)
arriving := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
`{"date":"d","asleep":7.4}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{arriving}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "sleep_analysis_summary", "day", ts(t, "2025-06-05T00:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(arriving.Raw) {
t.Fatalf("в витрине %s, ожидалась пришедшая точка %s", got, arriving.Raw)
}
if stats.PointsErased != 1 {
t.Errorf("потерь содержания %d, ожидалась 1: направление, в котором правило теряет, осталось невидимым", stats.PointsErased)
}
if len(stats.PointsErasedAt) != 1 {
t.Errorf("координат потери %d, ожидалась 1", len(stats.PointsErasedAt))
}
if stats.PointsHeld != 0 {
t.Errorf("удержаний %d: победила пришедшая, удерживать было нечего", stats.PointsHeld)
}
}
// Обратное: пришедшая победила, ничего не унеся, — счётчик молчит. Без этой
// пары счётчик потерь был бы неотличим от счётчика перезаписей.
func TestMergeПришедшаяБезПотериСодержанияНеСчитается(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":1}`)
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":2}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.PointsErased != 0 {
t.Errorf("потерь содержания %d, ожидалось 0: наборы ключей совпадают, терять нечего", stats.PointsErased)
}
if stats.Overwrites != 1 {
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
}
}
+32
View File
@@ -236,6 +236,38 @@ func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, li
return out, nil
}
// ParsedAfter отвечает, есть ли в журнале доставка ПОЗЖЕ указанной позиции,
// уже записавшая свой разбор в витрину.
//
// Нужен ровно одному наблюдению: свёртка вне порядка журнала. Правило слияния
// точек разрешает равную полноту в пользу пришедшей доставки, то есть исход
// есть функция порядка свёртки; доставка, свёрнутая после своей преемницы,
// возвращает координату к версии, которую источник уже пересчитал, и живая
// витрина расходится с пересборкой. Барьер воркера этого не ловит: отставшей
// доставки в момент прохода просто не существует — строка учёта становится
// видимой только после записи тела.
//
// `failed` в предикат НЕ входит: такая доставка в витрину ничего не записала,
// перестановка относительно неё содержимого не разводит, а исход этот штатный
// (невыводимый слой), и его учёт превратил бы WARN в шум.
//
// Это страж окна, а не постоянная часть свёртки: закрыв порядок на самом
// приёме, метод и его вызов сносят вместе с окном.
func (s *Store) ParsedAfter(ctx context.Context, at time.Time, id string) (bool, error) {
// Кортежное сравнение, а не через OR: развёрнутая форма даёт SCAN по
// индексу вместо SEARCH — тот же приём, что в PendingDeliveries.
const q = `
SELECT EXISTS(
SELECT 1 FROM delivery
WHERE parse_status IN (?, ?) AND (received_at, id) > (?, ?))`
var found bool
if err := s.db.GetContext(ctx, &found, q, ParseDone, ParsePartial, FormatTime(at), id); err != nil {
return false, fmt.Errorf("select parsed after: %w", err)
}
return found, nil
}
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
// разобрать, прежде чем витрина станет полной.
+19 -15
View File
@@ -197,12 +197,16 @@ func (v *entityVersion) sortKey() []byte {
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
// 4. наборы несравнимы → сохранённая
//
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
// кроме отпечатка.
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
// становится видимой только после записи тела, и при конкурентном приёме
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
// незачем. Критерий выбора записан в architecture.md, «Разрешение
// столкновений».
//
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
// решает минимум канонической формы, потому что порядок элементов в
@@ -220,15 +224,15 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
// версий остаётся сохранённая: правило называется «не теряет
// содержания», и приехавшая его теряет.
//
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
// журнала: в витрине лежит победитель прошлых слияний, а не все
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
// при разном порядке. Порядок свёртки журналу не равен — доставка,
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
// проходом, — так что живой приём и пересборка на несравнимых версиях
// законно расходятся. Это единственная точка, где витрина не является
// функцией множества доставок; она названа вслух в architecture.md, и
// счётчик удержаний ниже — единственное, что о ней сообщает.
// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
// лежит победитель прошлых слияний, а не все кандидаты истории, и
// «сохранённая выигрывает» означает разный итог при разном порядке.
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
// не перегоняется своими преемницами — проход воркера прекращается на
// ней. Осталось окно конкурентного приёма, где строка учёта становится
// видимой позже метки; там живой приём и пересборка на несравнимых
// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
// удержаний ниже — второе, что о расхождении сообщает.
return false, true
default:
return laterInJournal(stored, incoming), false
+8 -4
View File
@@ -819,12 +819,16 @@ func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T)
}
}
// Единственная точка, где витрина НЕ является функцией множества доставок, —
// Точка, где витрина сущностей НЕ является функцией множества доставок, —
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
// следующим проходом.
// выигрывает» даёт разный итог при разном порядке.
//
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
// перегоняется своими преемницами — проход воркера прекращается на ней.
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
// метки. У точек единственной такой точки давно нет: там от порядка журнала
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
//
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
+60
View File
@@ -151,3 +151,63 @@ func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
t.Fatalf("запись доставки %q: %v", id, err)
}
}
// Свёртка вне порядка журнала обнаруживается запросом «есть ли позже меня уже
// разобранная доставка». Это единственное наблюдение, по которому расхождение
// живой витрины с пересборкой видно до сверки отпечатков.
func TestParsedAfterВидитТолькоЗаписавшихВВитрину(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
early := at
late := at.Add(time.Second)
seedPending(t, st, "d-early", early)
seedPending(t, st, "d-late", late)
// Пока преемница в очереди, расхождения нет.
found, err := st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("неразобранная преемница засчитана расхождением")
}
// `failed` преемницей не считается: в витрину она ничего не записала, и
// перестановка относительно неё содержимого не разводит. Исход этот
// штатный — невыводимый слой, — и его учёт превратил бы WARN в шум.
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseFailed}); err != nil {
t.Fatalf("FinishParse: %v", err)
}
found, err = st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("отказавшая преемница засчитана расхождением")
}
// А разобранная — считается.
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseDone}); err != nil {
t.Fatalf("FinishParse: %v", err)
}
found, err = st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if !found {
t.Error("разобранная преемница не замечена: свёртка вне порядка журнала осталась бы молчащей")
}
// Сама преемница расхождением не является: предикат строго «позже».
found, err = st.ParsedAfter(ctx, late, "d-late")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("доставка засчитала расхождением саму себя")
}
}
+6 -4
View File
@@ -13,10 +13,12 @@ import "context"
// сущностях, где ту же ошибку повторили молча.
//
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
// род метрики будет измерен, — то есть правка одного экземпляра при живом
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
// свёртки молча.
// отношения разные. Правка одного экземпляра при живом втором уже случилась:
// тай-брейк точек сменён с байтового порядка на «побеждает пришедшая», а
// тай-брейк сущностей остался позицией журнала — потому что у сущности есть
// колонка провенанса, а у точки нет. Механизм при этом не разъехался ровно
// благодаря общему помощнику; критерий выбора между двумя правилами записан в
// `architecture.md`, раздел «Разрешение столкновений».
//
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-04
@@ -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` о свёртке вне порядка журнала может шуметь при подборе
задолженности** → строка пишется одна на доставку и только когда позже неё
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
доставки нет.
@@ -0,0 +1,73 @@
## Why
Тай-брейк при равной полноте точек сегодня — порядок канонических форм, и он
решает **98,8% спорных координат** (83 956 из 84 978 при 453 171 координате):
полнота отвечает лишь в 1,2% случаев. Лексикографический порядок системно берёт
меньшее значение (1 847 из 1 912, находка 49), то есть хранит **устаревшую**
версию точки там, где HAE досчитывает задним числом (находка 10).
У дефекта появился независимый оракул: `task verify:archive` покраснел на
`master` без единого коммита, с ростом корпуса. Час `2026-08-03T07:00Z` метрики
`step_count` получил четыре доставки с двумя различными значениями; победило
меньшее, оно же приехавшее первым, и сверка слоёв объявила метрику мгновенной
против 23 согласных часов. `step_count` ушёл в `unknown` — Read API больше не
имеет права суммировать шаги.
Решение владельца от 2026-08-04: **при равной полноте побеждает пришедшая
точка**, а не сохранённая. Значение при этом не интерпретируется — правило
остаётся структурным.
## What Changes
- **BREAKING (содержимое витрины):** при равной полноте побеждает точка,
пришедшая **этой** доставкой, а не сохранённая. Отпечаток витрины обязан
измениться; состояние восстанавливается пересборкой из архива.
- Порядок канонических форм остаётся тай-брейком **внутри одной доставки**, где
провенанс общий и различать нечем.
- **Правило слияния точек перестаёт быть функцией множества и становится явной
функцией порядка журнала.** Отсюда — плата, которую изменение обязано
внести целиком: порядок свёртки приводится к порядку журнала.
- Фоновый воркер **прекращает проход на первой отложенной доставке**, а не
перешагивает её. Иначе занятость базы переставляет доставки местами, и живая
витрина расходится с пересборкой молча.
- Свёртка доставки, у которой в журнале уже есть свёрнутая преемница, пишет
`WARN`: остаточное окно (конкурентный приём делает строку учёта видимой
не в порядке меток) закрыть без изменения приёма нельзя, но молчать о нём
нельзя тем более.
- Заводится счётчик «пришедшая точка проиграла сохранённой» — отдельно от
общего `MergeStats.Overwrites`, который считает столкновения в обе стороны и
различить их не даёт.
- Схема не трогается: колонок провенанса на точку не заводится, формат
`payload` не меняется, миграции нет.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `storage`: тай-брейк при равной полноте точек — пришедшая побеждает
сохранённую; порядок канонических форм остаётся только внутри доставки.
Требование «победитель есть функция множества точек» заменяется на «функция
множества точек и позиции в журнале». Новый счётчик удержаний.
- `ingest`: проход воркера прекращается на первой отложенной доставке; свёртка
вне порядка журнала становится наблюдаемой.
- `reindex`: посылка сходимости («приём шёл последовательно») из оговорки
сценария становится названным условием требования — теперь от порядка свёртки
зависит не только вывод слоя, но и содержимое точек.
## Impact
- `internal/store/bucket.go``mergePoints`, `resolve`, `candidate`,
`pointLess`, `MergeStats`.
- `internal/store/delivery.go` — запрос «есть ли свёрнутая доставка позже этой».
- `internal/replay/worker.go` — барьер на отложенной доставке, `WARN` о свёртке
вне порядка журнала.
- `internal/fold/fold.go` — проброс нового счётчика в итог свёртки и в лог.
- Витрина в `./data`: отпечаток изменится. Пересборка — действие человека при
остановленном сервисе, этим изменением не выполняется.
- Оракулы: `task verify:archive` (ноль противоречащих часов, `step_count`
снова накопительная), тест сходимости «живой приём = пересборка» с отложенной
доставкой.
@@ -0,0 +1,368 @@
# Триаж: tie-break-equal-completeness
## Сводка
- **Профиль:** `deep`, режим — линейный (по слову оператора). База диффа
`de2001d`.
- **Гейт:** зелёный. Непокрытыми остались 9 изменённых строк — все ветки
обработки ошибок (`internal/replay/worker.go:272-275`,
`internal/store/bucket.go:488,564-565`, `internal/store/delivery.go:266-267`).
- **Проходы поимённо, с исходом** (сверено с профилем: `deep` = 78 проходов по
`docs/review.md`, здесь 7 на коде включая триггерный `reimpl` — расхождения с
составом профиля нет):
- `gate` — отработал, зелёный, 3 находки о покрытии тестами;
- `specs` — отработал, 6 находок; отдельно подтвердил построчно, что
переписанные тесты не подгоняют оракул;
- `code` — отработал, 0 находок;
- `adversary` — отработал, 4 находки + ответ на вопрос журнала (пути значения
точки в лог выше `DEBUG` не найдено, 7 враждебных тел);
- `ops` — отработал, 4 находки + ответ на вопрос журнала (результат — функция
префикса журнала плюс порядка появления строк учёта);
- `reimpl` — запускался по триггеру «новое правило слияния», 3 находки; поимённо
назвал 6 мест, где существующее решение лучше его собственного, и снял одну
чужую находку (стоящая голова очереди не нема: `internal/fold/fold.go:450`
пишет `WARN` на каждую отложенную свёртку);
- `architecture` — отработал, 2 находки + 2 замечания «дешевле до мерджа»;
- до кода — профиль `design` (`specs`, `rubric`, `architecture`): находки
отработаны в спеках и коде, повторно не поднимались.
- **Вход/выход:** на входе 22 именованных находки семи проходов; после
дедупликации, оракулов и отсева — 3 блокирующих, 4 «исправить сейчас»,
6 гипотез, 3 promote-кандидата. Выброшено как вкусовщина 3 (названы в
границах покрытия).
- **Оракулы триажа** (добыты на временных базах, `./tmp/oracle/`, в `./data` не
ходил): падающий тест на разрушительное направление слияния; замер
`ParsedAfter` на 2 000/8 000/20 000 доставках; `EXPLAIN QUERY PLAN`.
- **Разрешённые конфликты проходов:** O1 против R3 — оба замера верны, спор был
об интерпретации; разрешён собственным замером (см. пункт 3 «Стоит
исправить»). A1 сверен с корпусным замером оркестратора — конструкция
воспроизводится, на живом корпусе 0 вхождений.
---
## Блокирует мердж
### 1. Обеднённая доставка стирает измеренное поле сохранённой точки, и ни один счётчик этого не видит
- Файл: `internal/store/bucket.go:561-605` (`resolve`, `pointLess`),
`internal/canon/canon.go:267-283` (`Relate`)
- Severity: critical
- Confidence: high
- Оракул: падающий тест триажа `tmp/oracle/a1_test.go`
(`TestA1ОбеднённаяПришедшаяСтираетПолеБезСчётчика`): сохранено
`{date, asleep:7.5, rem:1.2, deep:0.9}`, приехало
`{date, asleep:7.4, deep:0.9}``Relate` даёт `equal` (значения общего ключа
разошлись, разряд полноты гаснет), тай-брейк отдаёт победу пришедшей, в
витрине остаётся точка **без `rem`**, `PointsHeld=0`, `Incomparable=0`,
`Overwrites=1` (направления не различает). Подтверждает конструкцию
`adversary` (A1).
- Последствие: нарушение инварианта «Ничего не теряем молча» (`CLAUDE.md`,
critical). До изменения этот исход был жребием байтового порядка; теперь он
**детерминированно** в пользу обеднённой пришедшей, а новый счётчик
`PointsHeld` считает только обратное, безобидное направление. Вероятность
низкая: корпусный замер оркестратора — 0 вхождений на 80 129 спорных
координат (2 координаты с потерей ключа — ровно 2 несравнимые пары, их
считает `Incomparable`). Но порча с низкой вероятностью весит больше
гарантированного неудобства, и молчание здесь полное.
- Предложение — выбор владельца, оба варианта названы `adversary`:
- (а) счётчик разрушительного направления: победитель `incoming`, а у
проигравшего сохранённого есть содержательный ключ, которого нет у
победителя → счётчик + координата в лог (зеркально `PointsHeld`, сравнение
уже посчитанных `fields`, цена ~нулевая). Семантика слияния не меняется;
на текущем корпусе счётчик будет нулевым — это и есть утверждение.
- (б) сузить «побеждает пришедшая» до случая совпавших множеств
содержательных ключей; при несовпавших — побеждает более полная по именам.
Меняет правило слияния и текст дельты storage, требует прогона
`verify:archive` заново.
- Рекомендация триажа: (а) — восстанавливает наблюдаемость без смены
семантики, соответствует уже принятому в проекте образцу («объединение
полей отложено до счётчика, который заговорит»).
- Действие: развилка
- Найдено проходом: adversary; сверено с корпусным замером оркестратора
### 2. Стоящая голова очереди молчит меткой отставания — вопреки MUST дельты, а метка порядка при этом врёт и повторяется
- Файл: `internal/replay/worker.go:151-211` (`Pass`), `worker.go:184`
(`warnOutOfOrder` до свёртки), `worker.go:290-298` (`warnLag`)
- Severity: major
- Confidence: high
- Оракул: чтение кода — `warnLag` вызывается только в ветке пустой выборки
(`worker.go:174`), а при `Deferred` проход возвращается из середины цикла
(`worker.go:206`), не взводя и `startupDone`; значит доставка, которую
занятость откладывает проход за проходом, в метку отставания не попадает
никогда. Подтверждено живым замером `adversary` (блокировка 22.1 с: строк о
задержке 0, об отложенной свёртке 1). Дельта ingest (строки 29-33) обещает
дословно: «Стоящая голова очереди молчать MUST NOT… Метка отставания…
покрывает этот случай». Обещание не выполнено. Та же ранняя точка выхода
делает `warnOutOfOrder` лжецом: он пишется **до** свёртки, при `Deferred`
утверждает свёртку, которой не было, и повторяется каждым проходом — против
собственного комментария «одна запись на доставку».
- Последствие: класс «молчание» — задолженность растёт при занятой базе, а
единственный обещанный спекой сигнал о ней не срабатывает (частично прикрыто
`WARN "delivery fold deferred"` из `internal/fold/fold.go:450` — этим
находка понижена с потенциального critical); плюс ложный `WARN` о свёртке
вне порядка, приучающий не верить именно той строке, по которой решают о
`reindex`.
- Предложение (инлайн, одна зона в `Pass`): вызывать `w.warnLag(ctx, lag)` и на
выходе по `Deferred` (метка — одна строка на проход, шквала нет; подавление
первого прохода можно сохранить, взводя `startupDone` по завершении первого
прохода независимо от исхода); `warnOutOfOrder` писать после свёртки и только
при исходе, реально записавшем разбор (не при `Deferred`).
- Действие: инлайн
- Найдено проходами: specs (F1, F4), adversary (A2), ops (O2) — одна причина:
ранний выход `Pass` при `Deferred` не согласован с обеими метками
### 3. Документы в шести местах продолжают утверждать «победитель — функция множества», и следующий автор откатит починку молча
- Файл: `docs/architecture.md:937`, `docs/database.md:130`,
`docs/conventions/storage.md:38-40`, `internal/store/winner.go:16-19`,
`internal/store/entity_test.go:821-828`, `internal/replay/replay_test.go:173-174`
- Severity: major
- Confidence: high
- Оракул: проверено чтением — `docs/architecture.md:937`: «Победитель — функция
множества точек, а не порядка их поступления»; `docs/database.md:130`: «При
столкновении выигрывает более полная точка, а не последняя пришедшая»;
`docs/conventions/storage.md:38-40` утверждает «порядок свёртки порядку
журнала не равен» — ровно то, что это изменение сделало неверным;
`winner.go:16-19` ссылается на обещание architecture.md сменить тай-брейк «когда
род будет измерен» — дизайн это отверг (Non-Goals). Риск назван самим
`design.md`: «чтобы следующий автор не восстановил коммутативность „для
чистоты“ и не откатил починку молча» — а шесть мест документации ровно к
этому и приглашают.
- Последствие: докдрейф на critical-инварианте («Ничего не теряем молча» —
формулировка правила столкновения теперь в `CLAUDE.md` другая); строка
таблицы двух правил завышает гарантию сущностей (на несравнимых версиях они
тоже расходятся).
- Предложение: привести все шесть мест к формулировке «функция множества **и
позиции в журнале**»; заодно выполнить записанное правило `CLAUDE.md`
(«причина отказа от готового решения — промоутом в `docs/adr/`»): причина
отказа от LWW-Register/CRDT уже написана в `design.md`, её осталось поднять
в ADR — это часть той же работы «документы догоняют правило».
- Действие: инлайн
- Найдено проходом: architecture (AR1 + замечание про ADR)
---
## Стоит исправить сейчас
### 1. Отчёт пересборки не называет удержанные точки (SHALL дельты), а одно из требований дельты невыполнимо как записано
- Файл: `cmd/healthlog/reindex_report.go:37-38`;
`openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:30-46`
- Severity: major
- Confidence: high
- Оракул: чтение — отчёт печатает `Partial, Incomparable, EntitiesHeld,
EntitiesDiverging`, но не `PointsHeld`, хотя дельта reindex SHALL требует
«называть число точек, удержанных правилом полноты против пришедшей
доставки», и число уже лежит в `Report` (`internal/replay/replay.go:214`).
Второе: дельта требует «число записей „свёртка вне порядка журнала“ прогон
SHALL печатать рядом с отпечатком» — эти записи существуют только в логе
живого сервиса, нигде не хранятся, а пересборке та же дельта проверку прямо
запрещает (ingest:55). Требование структурно невыполнимо.
- Последствие: SHALL дельты нарушен кодом (счётчик — единственный способ
увидеть, что правило удержания стало слишком строгим, сходимость отпечатка
его не проверяет по построению); невыполнимое SHALL, влитое в спеку, станет
вечно красным пунктом для следующего читателя.
- Предложение: `PointsHeld` — добавить в строку «слияние:» отчёта (инлайн,
одна строка). Невыполнимую величину — переписать в дельте на то, что
измеримо (например: «число `failed` печатает прогон; записи „вне порядка“
наблюдаются в логе сервиса и в отчёт не входят»). Правка текста дельты —
решение не оркестратора.
- Действие: развилка (код — инлайн, но формулировка требования дельты требует
решения владельца: что именно обязана печатать посылка равенства)
- Найдено проходами: specs (F2, F5), reimpl (R1)
### 2. Сценарий дельты «отложенная занятостью доставка не разводит приём и пересборку» не проверен ни одним оракулом
- Файл: `openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:56-62`;
тесты: `internal/replay/worker_internal_test.go` (барьер — на подменённой
свёртке), `internal/replay/order_test.go` (сходимость — без отложенной)
- Severity: major
- Confidence: high
- Оракул: отсутствие теста подтверждено чтением обоих файлов (specs, проверено
и триажем): композиция «настоящий `Deferred` × сходимость отпечатков»
не проверяется ничем; `task verify:busy` (зелёный) проверяет только «занятость
оставляет доставку в очереди», без сверки отпечатков.
- Последствие: равенство «пересборка = приём» — ровно то свойство, ради
которого изменение существует (инвариант «Хранилище — свёртка по журналу»,
critical), — в самом опасном режиме держится на рассуждении, а прецедент
2026-08-01 показал, что такие рассуждения расходятся с кодом молча.
- Предложение — варианты specs: (а) busy-тест в `internal/replay` под флагом
`-healthlog.busy`: журнал с равнополными столкновениями, свёртка под
удерживаемой блокировкой, сверка отпечатков живого пути и пересборки;
(б) сузить сценарий дельты и записать ограничение в границы. Рекомендация:
(а) — цена одного теста против цены молчаливого расхождения витрины.
- Действие: развилка
- Найдено проходом: specs (F3)
### 3. design.md утверждает «цена не измеряется» и «тот же индекс» — оба утверждения неверны, замер триажа прилагается
- Файл: `openspec/changes/tie-break-equal-completeness/design.md:157-159`;
`internal/store/delivery.go:256-269`
- Severity: minor (после разрешения конфликта O1/R3)
- Confidence: high
- Оракул: замер триажа `tmp/oracle/parsedafter_test.go` на временной базе:
полный проход проверок по чистой задолженности — N=2000: 0.26 с; N=8000:
3.12 с; N=20000: 19.8 с (форма квадратичная, экстраполяция N=110 000 → ~10
мин, N=330 000 → ~1.5 ч). Установившийся режим — 0.0077 мс на вызов.
`EXPLAIN QUERY PLAN`: `SEARCH delivery USING INDEX delivery_received_at`
**не** тот же индекс, что у `PendingDeliveries` (частичный `delivery_pending`
требует `parse_status='pending'` и к предикату `IN ('parsed','partial')`
неприменим).
- Последствие: конфликт O1 (ops) против R3 (reimpl) разрешён: оба замера верны,
неверна интерпретация O1 «часами не может разобрать бэклог из-за проверок» —
часы даёт сама свёртка (~0.4 с/доставку по verify:archive: при N=20 000 это
~2 ч свёртки против 20 с проверок, <2% накладных). Реалистичный потолок
задолженности — архив квартала ~27 000 доставок (~300 строк/сутки) → ~35 с
проверок. Оптимизация кода не нужна (правка была бы незаказанной); неверные
утверждения в design.md — нужно поправить, иначе следующий читатель примет
решение по ложному числу.
- Предложение: заменить в design.md «цена не измеряется» на измеренные числа
(квадратична по задолженности, ~1% от цены свёртки, 7.7 мкс в установившемся
режиме) и «по тому же индексу» на `delivery_received_at`. Код не трогать.
- Действие: инлайн
- Найдено проходами: ops (O1), adversary (A3), reimpl (R3) — одна причина
### 4. 71 773 координаты живой витрины остаются на старом исходе до ручного `reindex`, и напоминания об этом нет нигде, кроме отчёта ревью
- Файл: процедура выкладки; `design.md` Risks («Этим изменением не выполняется»)
- Severity: major
- Confidence: high
- Оракул: корпусный замер оркестратора — исход меняется на 75 494 координатах
(95% — `basal_energy_burned/raw`); `verify:archive` после изменения зелёный с
новым отпечатком `03aace91…`, значит рабочая витрина под старым отпечатком с
новым правилом **не сойдётся**, пока её не пересоберут.
- Последствие: до пересборки живая витрина расходится с тем, что даёт
`import + replay` — «состояние, которое даёт пересборка» в этом проекте
объявлено внешним поведением (`docs/review.md`). Подмена файла базы —
необратимое действие человека (`CLAUDE.md`, спрашивается всегда), оркестратор
выполнить его не вправе.
- Предложение — готовый вопрос владельцу: «После мерджа витрина расходится с
новым правилом на ~75 тыс. координат (95% — basal_energy_burned/raw).
(а) выполнить `healthlog reindex` с остановкой сервиса и подменой базы сразу
после выкладки; (б) отложить до планового окна, приняв расхождение витрины на
этот срок; (в) не пересобирать — витрина сойдётся только по метрикам, которые
переприедут доставками.» Рекомендация: (а).
- Действие: развилка
- Найдено проходами: ops (O4), architecture (замечание про подмену базы)
---
## Гипотезы без доказательства
- **Откат бинаря молча возвращает старое правило слияния** (ops O3, было major
confidence medium → остаётся major-гипотезой без оракула). Миграций нет,
откат стартует без слова, свёртка идёт по коммутативному правилу, и витрина
расходится с пересборкой новым бинарём. Путь не построен и не воспроизведён;
частично это плата, уже принятая проектом за «версия базы ниже бинаря — не
отказ». Если владелец сочтёт класс важным — это та же развилка, что решалась
в задаче про откат релиза.
- **`failed`-доставка «в витрину ничего не записала» — посылка предиката
`ParsedAfter` держится раскладкой кода, а не проверкой** (adversary). Если
когда-нибудь `failed` начнёт писать частично, страж окна ослепнет молча.
Оракула нет — гипотеза, потолок major.
- **`hasIncomparablePair` не видит отмены и способен продлить свёртку за
дедлайн под транзакцией записи** (reimpl R2, замер честный: ≈51 с при ~7000
кандидатов на координате). Понижено до гипотезы о масштабе: на живом корпусе
кандидатов на координате единицы, путь к ~7000 версий одной координаты не
построен; `pickBest` при том же масштабе сам стоит 103 с — чинить пришлось бы
оба, то есть это вопрос предела на кандидатов, а не пропущенного `ctx.Err()`.
- **Порядок свёртки под конкурентным приёмом не проверяется ни одним тестом с
параллельными писателями** (gate-3): `-race` зелёный на последовательных
сценариях. Дефекта не построено; остаточное окно конкурентного приёма
осознанно вынесено в `journal-order-on-ingest` (ops O5 туда же присоединился).
- **Ветки ошибок нового кода не покрыты тестами** (gate-1, gate-2): отказ
`ParsedAfter``warnOutOfOrder`/`ERROR`, отмена контекста в
`resolve`/`pickBest` — 9 строк, названных оркестратором. Поведение веток
прямое (залогировать и продолжить / вернуть ошибку), дефекта в них не
предъявлено. Закроются заодно с правкой блокирующего пункта 2, если
оркестратор добавит тест на `Deferred`-выход.
- **Имя метрики из чужого тела уезжает в первичный ключ витрины без предела
длины** (adversary A4). Подтверждено конструкцией (512 КиБ в ключе), но
дефект предсуществующий, изменением не внесён — этому изменению не
предъявляется. Конвенция уже записана (`docs/conventions/storage.md`: «любое
значение из чужого JSON, попадающее в ключ… имеет названный предел длины») —
значит это не promote, а невыполненное правило: заводится задачей вне этого
изменения.
## Promote candidates
- **Ошибки уровня `store` не проходят через единую границу обрезки** (adversary):
сегодня утечку значений в текст ошибки сдерживает дисциплина каждого
call-site. Кандидат в `docs/conventions/errors.md`: текст ошибки хранения
несёт род и координату, но не значение; проверяется тем же разобранным
буфером, что и логи.
- **Агрегат счётчиков (`Outcome.Add`, `MergeStats`) суммируется руками —
забытая строка молча занулит счётчик** (architecture AR2): кандидат в
конвенцию тестирования — рефлексивный тест «каждое числовое поле участвует в
Add», или правило «новое поле счётчика приходит вместе со строкой в Add и
тестом». Не находка против этого кода: все нынешние поля просуммированы.
- **Проверка «в логе нет значения точки» для новых поверхностей вывода**
(по мотивам A1/PointsHeldAt): координата в лог идёт через `clipMetric`, но
правило «новая поверхность вывода получает тест на утечку» записано только
для отчёта пересборки комментарием. Кандидат в `docs/conventions/testing.md`.
## Границы покрытия
**Запускалось:** профиль `deep`, линейно: `gate` (зелёный), `specs`, `code`,
`adversary`, `ops`, `reimpl` (по триггеру «новое правило слияния»),
`architecture`; до кода — профиль `design` (`specs`, `rubric`, `architecture`).
Оркестратор дополнительно прогнал `task verify:archive` (до изменения красный —
унаследованный `step_count`, после — зелёный, отпечаток `03aace91…`,
воспроизведён дважды) и `task verify:busy` (зелёный) — то, что по
`docs/review.md` числится «перестали проверять сознательно», в этой задаче
прогнано, потому что она триггерная (правило слияния).
**Не запускалось:** `rubric` на коде — намеренно, только в `design` (его
свойства легли приёмочными критериями задачи); других проходов профиля не
пропущено.
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
`specs` судит код против дельт, а не поведение под нагрузкой; `code` — только
записанные конвенции; `adversary` назвал свойство без пути — постоянную
остановку очереди построить не удалось (`Deferred` только из
`ErrBusy`/`Canceled`); `ops` не наблюдал реальную ночную нагрузку и реальный
рестарт с большой задолженностью (его замеры — синтетика, как и мои); `reimpl`
переигрывал только правило слияния и порядок, не разбор; `architecture` не
запускает код; триаж ничего нового не находит по определению — пропуск любого
прохода был бы и моим пропуском.
**Целиком на человеке — два списка, намеренно раздельных.**
*Не проверит ни один проход:* реальный профиль нагрузки телефона (объём и
частота меряются только по факту); поведение HAE за пределами наблюдённого —
в том числе пришлёт ли он когда-нибудь ту самую «обеднённую точку с
разошедшимся значением» из блокирующего пункта 1 (сегодня — 0 вхождений на
корпусе); полнота словаря переводов после обновления iOS; секции, которых
поток ещё не приносил (`symptoms`, `ecg`, `heartRateNotifications`,
`cycleTracking`, `medications`); история инцидентов; завязка потребителей на
текущее поведение витрины; вопрос «нужна ли эта функциональность вообще» —
решён владельцем 2026-08-04 (тай-брейк — его выбор), но последствия выбора
наблюдаются только эксплуатацией.
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта
(в этой задаче прогнаны — см. выше; следующая задача без триггера их не
увидит); класс «в Go так не пишут» — не покрыт вовсе после упразднения `idiom`
(запись 2026-08-02), к этому диффу поимённая сверка со стайлгайдами не
применялась; класс «чего нет в зрелой реализации такого узла» — вне профиля
`design`, на коде не проверялся.
**Документы проекта:** всё, что нужно триажу, на месте — `CLAUDE.md` с
инвариантами и severity (веса пунктам присвоены по ним, а не выведены заново),
`docs/review.md` с разделами «Типовые ложноположительные» (использован: ни одна
находка под шесть записанных классов не подпала, отсев шёл по общим критериям
плюс этот раздел) и «Недоступно проверке» (перенесён выше двумя списками),
`docs/security.md`, `docs/research/apple-health.md` (находка 54 — по ней
`adversary` строил обеднённые тела). Ни один проход недостающего документа не
заявил. Единственный документный пробел — не отсутствие, а протухание:
`docs/database.md:130` и ещё пять мест из блокирующего пункта 3.
**Что не влезло в потолок и куда делось:** все понижения названы в «Гипотезах»
поимённо, молча не выброшено ничего. Отсеяно как вкусовщина, целиком по трём
критериям (не меняет поведения, не влияет на цену следующего изменения, не
нарушает записанной конвенции): атрибут `waited_sec` в новом `WARN` (specs F6 —
дельта ограничивает состав «без значений точек», и это выполнено; секунды
ожидания значением точки не являются); схлопывание `Worker.player`+`fold` в
замыкание (architecture); переименования/перестановки из generative-выводов не
поступали. Замечания `reimpl` «где существующее решение лучше» — не находки, а
подтверждение решений; перечислены в его сыром выводе и сохранены в истории
задачи.
@@ -0,0 +1,130 @@
## MODIFIED Requirements
### Requirement: Свёртку ведёт один фоновый воркер в порядке журнала
Свёртку принятых доставок SHALL вести одна горутина, обрабатывающая доставки в
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
Распараллеливать свёртку MUST NOT.
Порядок здесь — не удобство отладки, а условие правильности содержимого:
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
доставка возвращает координату к версии, которую источник уже пересчитал, и
живая витрина расходится с тем, что даёт пересборка.
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
целиком, а приём продолжал бы отвечать `200`.
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
возобновляется сигналом приёма или периодическим пробуждением.
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
Достижимая гарантия называется точно: в порядке `(received_at, id)`
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
ставшая видимой позже курсора прохода, подбирается следующим проходом;
абсолютного порядка при конкурентных приёмах система не обещает, потому что
строка учёта становится видимой только после записи тела (измерено 184 мс на
62 МиБ).
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
секундах и без значений точек.
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
только если свёртка состоялась: после свёртки предикат уже видит саму эту
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
`healthlog reindex`.
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
записала, и перестановка относительно неё содержимого не разводит. А
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
шум, на который перестают смотреть.
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
построению, и её тишина здесь содержательна.
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
объяснения.
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
приёме.
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
курсором в пределах одного прохода. Курсор обязателен для завершимости:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`,
и проход без курсора выбирал бы её бесконечно.
Приём SHALL будить воркер после того, как доставка учтена. Потеря сигнала
отказом быть MUST NOT: доставка от этого не перестаёт числиться `pending`.
Помимо сигнала воркер SHALL просыпаться периодически — иначе доставка,
оставшаяся `pending` по причине выше, ждала бы следующей доставки, а ночью
телефон молчит часами.
Отказ отдельного прохода воркер SHALL переживать: отказ выборки пишется `ERROR`
и прекращает проход, но не цикл. Отмена работы снаружи отказом при этом
считаться MUST NOT — штатная остановка не должна писать `ERROR`. Воркер, умерший
от временного отказа базы, остановил бы свёртку до конца жизни процесса, пока
приём продолжал бы отвечать `200`.
#### Scenario: Видимые доставки сворачиваются в порядке журнала
- **GIVEN** несколько доставок числятся `pending` до начала прохода
- **WHEN** воркер делает проход
- **THEN** он сворачивает их в порядке `(received_at, id)`
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
порядку доставки той же автоматизации, а не соседа по времени вставки
#### Scenario: Отложенная доставка держит очередь
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
занятостью базы
- **WHEN** воркер делает проход
- **THEN** её преемницы в этом проходе не сворачиваются
- **AND** следующий проход снова начинает с отложенной доставки
#### Scenario: Свёртка вне порядка журнала не молчит
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
вышла из очереди
- **WHEN** воркер сворачивает её
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
`pending`
- **THEN** проход воркера завершается, а не выбирает её повторно
#### Scenario: Доставка без входящего потока всё равно подбирается
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
- **WHEN** наступает очередное периодическое пробуждение
- **THEN** воркер пробует свернуть её снова
@@ -0,0 +1,87 @@
## MODIFIED Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
**Условие сходимости SHALL называться требованием, а не оговоркой сценария.**
Правило слияния точек разрешает равную полноту в пользу пришедшей доставки, то
есть содержимое витрины есть функция **порядка** свёртки, а не только множества
доставок. Отпечаток пересборки равен отпечатку накопленной приёмом витрины
тогда и только тогда, когда живая свёртка шла в порядке журнала. Прежде от
порядка зависел только вывод слоя; теперь от него зависят значения точек, то
есть цена нарушения выросла и должна быть названа здесь, а не выведена
читателем.
**Посылка равенства SHALL перечисляться рядом с ним**, а не подразумеваться,
потому что оракул, чья посылка не названа, краснеет по причине, к правилу
отношения не имеющей, и краснота становится неотличимой от дефекта. Посылок
три: живая свёртка шла в порядке журнала; в журнале нет доставок, чью свёртку
живой путь провалил, а пересборка проведёт (статус `failed`); за время
пересборки новых доставок не приезжало.
Из этих трёх посылок прогон пересборки SHALL печатать рядом с отпечатком ту,
которую он измеряет сам, — число `failed`. Первая посылка прогону
**недоступна по построению**: записи «свёртка вне порядка журнала» рождаются
только в живом воркере, нигде не хранятся, и пересборке та же спека проверку
прямо запрещает. Она проверяется журналом сервиса, и это сказано здесь, чтобы
следующий автор не приписал к отпечатку константный ноль, выдав его за
подтверждение.
Равенство «пересборка = приём» SHALL проверяться оракулом, а не рассуждением:
журнал, содержащий доставки с равнополными столкновениями, проигранный живым
путём (фоновый воркер, в том числе с доставкой, отложенной занятостью базы) и
путём пересборки, обязан давать один отпечаток витрины.
**Место снапшота в порядке журнала этой дельтой не определяется, и это сказано
вслух.** Под правилом «побеждает пришедшая» исход столкновения снапшота Apple с
точкой HAE зависит от того, какую позицию журнала получит доставка импорта:
учтённая сегодняшним временем, она перебила бы все равнополные точки, включая
досчитанные задним числом. Стадия снапшота сегодня пуста, поэтому вопрос
отложен — но решать его SHALL задача импорта, и явно, а не выбором первого
автора.
Отчёт пересборки SHALL называть два числа про точки — удержанные правилом
полноты против пришедшей доставки и потерявшие содержание в пользу пришедшей —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка ни одно из них не проверяет по построению.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Отложенная занятостью доставка не разводит приём и пересборку
- **GIVEN** журнал, где одни координаты получают равнополные точки с разными
значениями от разных доставок
- **AND** свёртка одной из доставок откладывается занятостью базы
- **WHEN** тот же журнал проигрывается живым путём и путём пересборки
- **THEN** отпечатки витрин совпадают
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
@@ -0,0 +1,627 @@
## MODIFIED Requirements
### Requirement: Разрешение столкновений по полноте
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
**более полную** точку — ту, чьё множество ключей с непустым значением является
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
бедная доставка стирает у богатой поля, которых сама не несёт.
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
стирала бы его безвозвратно.
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
(0.65%); из них 981 различаются набором полей — это и есть область правила
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
тай-брейк, а несравнимых наборов ноль.
Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат,
настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным
разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает
кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк.
Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 —
`basal_energy_burned` слоя `raw`, то есть посекундная развёртка HAE, которую
Read API суммировать и так не имеет права.
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
любой записи), пустой объект и пустой массив: поле без содержания не делает
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
пустота, что `0` и `{}`.
`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).
Поле `source` в множество не входит — оно нестабильно и переписывается задним
числом (находка 36), так что о полноте измерения ничего не говорит.
Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
множество** ключей: так оно проигрывает любой точке с содержанием и не
загрязняет наблюдение о несравнимых наборах.
Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
канонической формы). Иначе точки несут разные измерения, и надмножество имён
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
Если множества ключей с непустым значением **равны и значения совпали**,
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
строгое надмножество. Без этого разряда правило теряло бы поля там, где
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
лишние ключи там заведомо пусты, объединять в них нечего.
**Если полнота ответа не дала — равные множества с разными значениями либо
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
1 912 (находка 49), то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
суммировать шаги.
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
накопительных метрик и неверно для мгновенных, которые источник досчитывает
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
журнала.
**Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических
форм**: провенанс у таких точек общий, различать их нечем, а порядок элементов
в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой,
SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по
доставке, которого обязан был обойти по байтам, и «внутри доставки решают
байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает
при этом сохранённое содержимое дословно: хеш не двигается, объект не
переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей
канонической формой — в витрине остаются байты **встреченной первой**. Выбор
между ними ненаблюдаем по построению: различие при совпавшей канонической форме
это порядок ключей или последний разряд double, и хеш содержимого объекта
считается по канонической форме, а не по байтам.
Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором
он работает. **Когда значения общих содержательных ключей разошлись, полнота
ответа не даёт по построению** — «надмножество имён о полноте не говорит
ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла
сверх того ключи **без содержания** (`Min:0`, `Max:0`). Такие ключи по
определению пустоты этого же требования содержания не несут, и второй разряд их
бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял
их случайно — по тому, что каноническая форма с ключом `Max` сортируется раньше
формы с одним `date`, — и рассчитывать на такую защиту было нельзя.
**Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь.**
Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату
разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый
исход, новый — чередование по последней доставке, то есть перезапись объекта и
`WARN` на каждой доставке. Защита остаётся операционной («наборы метрик между
автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы
следующий разбор не искал причину заново.
**Победитель SHALL быть функцией множества точек координаты и их происхождения
(доставка или витрина), а не порядка элементов внутри доставки.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы (A превосходит B по
полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная
свёртка одной и той же доставки меняет содержимое объекта. Поэтому система
SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
победителя среди оставшихся по тотальному порядку — сперва происхождение,
затем каноническая форма.
**Правило тем самым есть явная функция порядка журнала, и цена этого называется
вслух.** Функцией множества оно быть перестало: «пришедшая побеждает» не
коммутативно. Витрина остаётся свёрткой журнала только пока порядок свёртки
равен порядку журнала — требование, которое capability приёма обязана
обеспечивать, а capability пересборки обязана проверять оракулом. Восстановить
коммутативность «для чистоты» MUST NOT: это откатило бы починку молча.
Сравнение по `received_at` для тай-брейка не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с
чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала
бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а
«пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку
журнала. Запрет этот **бюджетный, а не принципиальный**: он назван решением
владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного
приёма закрыть на приёме не удастся, провенанс (на объект, не на точку)
остаётся единственным ходом, и спека обязана это допускать, а не запрещать
вечно.
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
координаты попадают в оба списка.
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
станет массовым.
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
выбирает, а не конструирует. Каноническая форма существует только в момент
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
число вместо присланного.
#### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `context`
#### Scenario: Поля без содержания полноты не добавляют
- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
- **THEN** остаётся точка с `date` и `qty`
#### Scenario: При равном содержании поля не теряются
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
- **THEN** остаётся точка с `Min` и `Max`
#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку
- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
- **AND** по тем же координатам следующей доставкой приезжает точка
`{date, qty:12}`
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
- **AND** счётчик перезаписей растёт
#### Scenario: Одинаково полные точки с разными значениями
- **GIVEN** по координатам сохранена точка предыдущей доставки
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
другим значением
- **THEN** в витрине остаётся пришедшая точка
#### Scenario: Одинаково полные точки внутри одной доставки
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
множествами ключей и разными значениями
- **THEN** остаётся точка с меньшей канонической формой
- **AND** исход не зависит от порядка этих точек в массиве
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
- **GIVEN** по координатам сохранена точка
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
другими байтами
- **THEN** в витрине остаются сохранённые байты
- **AND** объект не переписывается
#### Scenario: Полнота сильнее происхождения
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
значениями общих ключей
- **THEN** остаётся сохранённая точка
- **AND** счётчик удержаний в итоге разбора доставки растёт
#### Scenario: Несравнимые множества считаются, а не сливаются
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
ключ с непустым значением, которого нет у другой
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
- **AND** система пишет `WARN` с координатами объекта и без значений точки
#### Scenario: Содержимое, которое не разбирается в объект
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
не разбирающееся как JSON-объект
- **THEN** остаётся точка с полями
- **AND** счётчик несравнимых наборов не растёт
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
#### Scenario: Столкновение с различием содержимого оставляет след
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
отличается от уже сохранённой
- **THEN** система пишет запись уровня `WARN` без значений точки
- **AND** запись несёт координаты объекта: метрику, слой и час
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
#### Scenario: Дребезг сериализации столкновением не считается
- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
последним разрядом числа
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
экспортом Apple — то есть месяцами.
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → содержимое не пишется,
провенанс поднимается до
более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множествами ключей и формой их значений — но не
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
гасит отношение включения, когда значения общих содержательных ключей
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
совпали» здесь быть MUST NOT.
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
содержимого:
1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
непуст;
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
с тем же условием: иначе ключ с пустым значением исчезает по жребию
тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
быть объект; где массив — массив. Версия, подменившая объект или массив
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
`null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
тай-брейк журнала;
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
`[null,null,null]` не теряет и длины — притом что маршрут это 95%
содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
опустошение элементов — законные признаки «приехало меньше».
Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
HAE состоят из объектов, а не из чисел.
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
там, где ни то, ни другое ничего не несёт.
Предел правила называется вслух и не закрывается: сокращение **внутри**
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
длине) не ловится ничем, кроме сверки с телом в архиве.
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
множества ключей — то же правило, что для точки: такая версия проигрывает любой
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
capability приёма, но равенство это неполное: строка учёта становится видимой
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
провенанса, которой у точки нет.
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
доставка, свёрнутая повторно) ничего не меняют.
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
закоммитила последней: исход снова станет функцией порядка коммитов, а не
журнала, причём молча.
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
канонической форме между доставками заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
хранил бы меньшее значение (находка 49).
Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них SHALL быть **функцией множества версий, а не порядка
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
до конца**: при совпавших канонических формах решает минимум исходных байтов —
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
содержимом. Попарная свёртка здесь
неверна ровно так же, как она была неверна для точек: покрытие — частичный
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
SHALL до сравнения с сохранённой.
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
ни на одно. На счётчик удержаний опирается единственный контроль того, что
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
шума.
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
значит ничего. Побайтовое различие при
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Маршрут из пустых элементов сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
элементы которого пусты (`null` либо пустой объект)
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
- **AND** факт учитывается тем же счётчиком
#### Scenario: Скелет из скаляров сохранённую тренировку не затирает
- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
заменён числом, а каждый массив — массивом той же длины из `null`
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Ключ с пустым значением не исчезает по жребию
- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
сохранённой было пустым, при совпадающих содержательных ключах
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком удержаний
#### Scenario: Пустой ключ не запирает законный досчёт
- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
не несёт, но приносит содержательный ключ, которого у сохранённой не было
- **THEN** приехавшая замещает сохранённую
- **AND** счётчик удержаний не растёт
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Три версии одной сущности в одном теле
- **WHEN** тело содержит три элемента секции с одним `id`, из которых один
покрывает второй, а третий несравним с обоими
- **THEN** победитель одинаков при любой перестановке этих трёх элементов
#### Scenario: Две версии разного содержания при равной длине массивов
- **WHEN** тело содержит два элемента секции с одним `id`, содержимое которых
различается, но множества ключей и длины верхнеуровневых массивов совпадают
- **THEN** факт учитывается счётчиком различающихся версий
#### Scenario: Разные байты при совпавшей канонической форме событием не считаются
- **WHEN** тело содержит два элемента секции с одним `id`, различающихся только
порядком ключей либо записью числа
- **THEN** счётчик различающихся версий не растёт
- **AND** в хранилище лежат одни и те же байты при любой перестановке элементов
#### Scenario: Повторная присылка обновляет провенанс
- **WHEN** та же сущность приезжает повторно с тем же содержимым доставкой,
стоящей в журнале позже сохранённой
- **THEN** содержимое не переписывается
- **AND** провенанс сущности указывает на более позднюю доставку
#### Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому
- **WHEN** журнал несёт содержимое A, затем B, затем снова A, и доставка с B
свёрнута последней
- **THEN** содержимое сущности и отпечаток витрины совпадают со свёрткой того
же журнала в его порядке
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
## ADDED Requirements
### Requirement: Проигрыш пришедшей точки считается отдельно
Итог разбора доставки SHALL нести счётчик координат, где пришедшая точка
проиграла сохранённой, и координаты первых таких объектов — метрику, слой и
час, без значений точек.
Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают
столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000
раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя —
то есть единственное событие, ради наблюдения за которым правило и переписано,
остаётся невидимым.
Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая
**строго полнее**. То есть счётчик меряет одно направление правила полноты — то,
в котором оно спорит с журналом; случай «пришедшая строго полнее» им не
считается, потому что там полнота и журнал согласны. Молчащий счётчик означает,
что правило полноты перестало спорить вовсе, и это событие для разбора, а не
отказ.
Счётчик SHALL быть виден не только в логе свёртки, но и в отчёте пересборки —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка правило удержания не проверяет по построению, живой приём и пересборка
пользуются одним правилом и одинаково сойдутся на одинаково удержанной точке.
Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье
чувствительнее токенов.
#### Scenario: Удержание сохранённой точки видно в итоге доставки
- **GIVEN** по координатам сохранена точка, строго более полная, чем пришедшая
- **WHEN** доставка сворачивается
- **THEN** счётчик удержаний растёт
- **AND** координаты объекта попадают в список удержаний
- **AND** счётчик остаётся нулевым, когда побеждает пришедшая точка
### Requirement: Потеря содержания пришедшей точкой считается и не молчит
Система SHALL считать отдельным счётчиком координаты, где победителем оказалась
**пришедшая** точка, а у проигравшей был ключ с непустым значением, которого у
победительницы нет. Координаты таких объектов SHALL попадать в лог, и система
SHALL писать `WARN` — без значений точек.
Это единственное направление, в котором новое правило способно потерять
содержание, и защитить его нечем **по построению**: разряд полноты в этом
случае погашен — значения общих содержательных ключей разошлись, и надмножество
имён о полноте не говорит ничего. До смены тай-брейка тот же исход случался по
жребию байтового порядка и так же молча; разница в том, что теперь он
детерминирован, а значит либо не случается вовсе, либо случается всегда.
Счётчик SHALL быть отдельным и от перезаписей, и от удержаний: перезаписи
считают столкновение в обе стороны, удержания — противоположное направление, и
ни один из них на вопрос «потеряли ли мы содержание» не отвечает.
Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129
спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие
обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем
же решением и по той же причине, по какой отложено объединение полей.
Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.
#### Scenario: Пришедшая точка унесла содержательный ключ сохранённой
- **GIVEN** сохранена точка `{date, asleep:7.5, rem:1.2}`
- **WHEN** следующей доставкой приезжает точка `{date, asleep:7.4}`
- **THEN** в витрине остаётся пришедшая точка
- **AND** счётчик потери содержания растёт, а счётчик удержаний — нет
- **AND** система пишет `WARN` с координатами объекта и без значений точек
#### Scenario: Пришедшая точка ничего не унесла
- **WHEN** множества ключей у сохранённой и пришедшей совпадают, а значения
различаются
- **THEN** счётчик потери содержания не растёт
@@ -0,0 +1,118 @@
## 1. Правило слияния точек
- [x] 1.1 `candidate` получает флаг `incoming`; схлопывание совпавших
канонических форм поднимает флаг, а байты оставляет встреченные первыми
- [x] 1.2 `pointLess` — лексикографический порядок по паре
`(не incoming, каноническая форма)`; `pointDominates` не трогается
- [x] 1.3 `resolve` возвращает признак «победил сохранённый при наличии
пришедших»; `mergePoints` считает удержания
- [x] 1.4 `MergeStats.PointsHeld` и `PointsHeldAt`; проброс через `mergeResult`,
`SaveBuckets`, `fold.Stats`, `replay.Outcome`/`Report` и лог свёртки — без
значений точек
## 2. Порядок свёртки равен порядку журнала
- [x] 2.1 `Worker.Pass` прекращает проход на исходе `Deferred`; доставка,
оставшаяся `pending` без такого исхода, курсор двигает
- [x] 2.2 `Store.ParsedAfter` — есть ли доставка позже данной в статусе
`parsed` или `partial` (кортежный предикат; индекс — `delivery_received_at`,
цена измерена и названа в design.md)
- [x] 2.3 воркер пишет одну запись `WARN` на доставку о свёртке вне порядка
журнала; пересборка эту проверку не выполняет
## 3. Тесты
- [x] 3.1 пришедшая побеждает сохранённую при равной полноте
- [x] 3.2 столкновение внутри одной доставки решается порядком канонических
форм и не зависит от порядка элементов массива
- [x] 3.3 повторная присылка канонически совпавшего содержимого не переписывает
объект (хеш не двигается)
- [x] 3.4 полнота сильнее происхождения; счётчик удержаний растёт
- [x] 3.5 сходимость: живой путь через воркер с отложенной занятостью базы
доставкой даёт тот же отпечаток, что пересборка
- [x] 3.6 проход воркера не перешагивает отложенную доставку
- [x] 3.7 `WARN` о свёртке вне порядка журнала пишется, не содержит значений и
не пишется на `failed`-преемнице
- [x] 3.8 при разошедшихся значениях пустые ключи сохранённой точки её не
удерживают (сценарий, парный к 3.4)
## 4. Документы
- [x] 4.1 `docs/architecture.md` — «Разрешение столкновений» плюс **все**
упоминания правила: инвариант в шапке, «порядок прихода значения не имеет» в
разделе синхронизации; таблица двух правил равной полноты (точки против
сущностей) с критерием выбора
- [x] 4.2 `CLAUDE.md` — формулировка инварианта «ничего не теряем молча»:
«выигрывает более полная, при равной полноте — стоящая позже в журнале»
- [x] 4.3 `internal/store/entity.go` — комментарии, чей довод («порядок свёртки
журналу не равен») этим изменением сужен до окна видимости
- [x] 4.4 `docs/research/apple-health.md` — перемер 2026-08-04 с **методом**:
ключ со слоем, 155 доставок, 460 995 координат, 80 129 спорных, 981 полнотой,
79 148 тай-брейком, 75 494 меняют исход
- [x] 4.5 `docs/review.md` — запись о дефекте (посылка оракула умерла с ростом
корпуса) и о том, что конвейер её не ловил
- [x] 4.7 вопрос владельцу в `docs/tasks/items/journal-order-on-ingest.md`
(раздел «Вопросы» + тег `question`): решение (в) порядок журнала не
восстанавливает, а цена окна выросла с вывода слоя до значений точек
- [x] 4.6 `docs/conventions/` — промоут двух правил: «в проверке на живом
корпусе утверждается инвариант, число печатается» и «оракул сходимости
называет свою посылку рядом с собой»
## 5. Оракулы
- [x] 5.1 `task gate` зелёный
- [x] 5.2 `task verify:archive` — ноль противоречащих часов, `step_count`
накопительная, отпечаток изменился
- [x] 5.3 `task verify:archive` второй раз подряд — тот же отпечаток
- [x] 5.4 `task verify:busy` зелёный
- [x] 5.5 замер удержаний на живом архиве — число названо, не утверждено
## Приёмочные критерии из ревью предложения (профиль `design`, проход `rubric`)
Свойства, по которым судится узел рода «правило слияния версий в
хранилище-свёртке по журналу» плюс «упорядочивающий воркер очереди».
- [x] Р1 исход слияния — функция префикса журнала и только его; запрос воркера
«есть ли доставка позже» влияет на лог и MUST NOT влиять на содержимое
- [x] Р2 законные расхождения живого пути и пересборки перечислены поимённо, и
у оракула названа посылка
- [x] Р3 повторная свёртка идемпотентна на суффиксе журнала; свёртка более
ранней доставки после более поздней меняет состояние наблюдаемо
- [x] Р4 победитель — функция множества кандидатов и их происхождения, но не
порядка точек внутри доставки; отношение победы ациклично (проверка
перестановками ТРОЙКИ, не пары)
- [x] Р5 правило тотально на вырожденном входе: один кандидат, равные
канонические формы с разными байтами, не-JSON, несравнимые множества
- [x] Р6 победитель сохраняется дословно; цена лишних записей названа числом
- [x] Р7 правило структурно: не читает ни значение точки, ни род, ни витрину
- [x] Р8 барьер очереди не останавливает поток: названо, что держит очередь и
что её освобождает; канонизация и сжатие вне транзакции записи
- [x] Р9 наблюдаемость отличает «правило сработало» от «не сработало» и не
несёт значений здоровья; тест на утечку разбирает запись, а не ищет
подстроку в сыром буфере
- [x] Р10 ни одно утверждение теста не пришпилено к числу, производному от
размера корпуса: утверждается инвариант, число печатается
- [x] Р11 два правила равной полноты (точки и сущности) не расходятся молча:
различие механизмов и критерий выбора записаны
- [x] Р12 место снапшота Apple в порядке журнала — либо названо, либо явно
отложено до задачи импорта (сегодня стадия снапшота пуста)
## Критерии приёмки задачи
Приходят из `docs/tasks/items/tie-break-equal-completeness.md`, дословно. Числа
в них производны от размера корпуса — по каждому названо, чем именно измерено и
что при этом печатается, а не утверждается (`docs/review.md`, 2026-08-02).
- [x] ни одна метрика не теряет род из-за столкновения равной полноты;
`step_count` снова накопительная — оракул: `task verify:archive`, ноль
противоречащих часов
- [x] живая свёртка и пересборка дают **один** отпечаток витрины — оракул:
`healthlog reindex` против живого состояния; это же и есть страховка от
потерянной коммутативности
- [x] повторный прогон реплея даёт тот же отпечаток — оракул:
`task verify:archive`, второй прогон подряд
- [x] столкновение **внутри одной доставки** разрешается прежним байтовым
порядком — оракул: тест на двух точках одной доставки с равной полнотой
- [x] «пришедшая точка проиграла сохранённой» считается отдельно от общего
`MergeStats.Overwrites` — оракул: тест плюс прогон на живом архиве, число
сходится с замером 2026-08-04
+81 -11
View File
@@ -120,19 +120,76 @@
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
Распараллеливать свёртку MUST NOT.
Порядок здесь — не удобство отладки, а условие правильности содержимого:
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
доставка возвращает координату к версии, которую источник уже пересчитал, и
живая витрина расходится с тем, что даёт пересборка.
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
целиком, а приём продолжал бы отвечать `200`.
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
возобновляется сигналом приёма или периодическим пробуждением.
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
Достижимая гарантия называется точно: в порядке `(received_at, id)`
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
ставшая видимой позже курсора прохода, подбирается следующим проходом;
абсолютного порядка при конкурентных приёмах система не обещает.
абсолютного порядка при конкурентных приёмах система не обещает, потому что
строка учёта становится видимой только после записи тела (измерено 184 мс на
62 МиБ).
Последствие этого предела называется вслух: доставка без плотных метрик,
свёрнутая раньше своей предшественницы, слоя не выведет и получит `failed` — то
есть её точки в витрину не попадут до пересборки. Живое состояние в этом случае
расходится с тем, что даёт `healthlog reindex`. Окно узкое (обе доставки должны
приниматься одновременно, и только у автоматизации без плотных метрик), и
изменение его сужает, а не открывает: прежде свёртка шла в порядке завершения
обработчиков. Устранение предела — отдельный вопрос, оно требует удерживать
порядок на самом приёме.
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
секундах и без значений точек.
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
только если свёртка состоялась: после свёртки предикат уже видит саму эту
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
`healthlog reindex`.
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
записала, и перестановка относительно неё содержимого не разводит. А
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
шум, на который перестают смотреть.
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
построению, и её тишина здесь содержательна.
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
объяснения.
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
приёме.
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
курсором в пределах одного прохода. Курсор обязателен для завершимости:
@@ -159,6 +216,21 @@
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
порядку доставки той же автоматизации, а не соседа по времени вставки
#### Scenario: Отложенная доставка держит очередь
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
занятостью базы
- **WHEN** воркер делает проход
- **THEN** её преемницы в этом проходе не сворачиваются
- **AND** следующий проход снова начинает с отложенной доставки
#### Scenario: Свёртка вне порядка журнала не молчит
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
вышла из очереди
- **WHEN** воркер сворачивает её
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
@@ -170,7 +242,6 @@
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
- **WHEN** наступает очередное периодическое пробуждение
- **THEN** воркер пробует свернуть её снова
### Requirement: Подбор неразобранного при старте — та же операция
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
@@ -320,4 +391,3 @@ NOT: факт уходит в `DEBUG`, приём продолжается.
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
- **AND** запись отличает занятость базы от прочих причин отказа
- **AND** тот же отказ по другой причине этого признака не несёт
+50 -1
View File
@@ -26,6 +26,48 @@
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
**Условие сходимости SHALL называться требованием, а не оговоркой сценария.**
Правило слияния точек разрешает равную полноту в пользу пришедшей доставки, то
есть содержимое витрины есть функция **порядка** свёртки, а не только множества
доставок. Отпечаток пересборки равен отпечатку накопленной приёмом витрины
тогда и только тогда, когда живая свёртка шла в порядке журнала. Прежде от
порядка зависел только вывод слоя; теперь от него зависят значения точек, то
есть цена нарушения выросла и должна быть названа здесь, а не выведена
читателем.
**Посылка равенства SHALL перечисляться рядом с ним**, а не подразумеваться,
потому что оракул, чья посылка не названа, краснеет по причине, к правилу
отношения не имеющей, и краснота становится неотличимой от дефекта. Посылок
три: живая свёртка шла в порядке журнала; в журнале нет доставок, чью свёртку
живой путь провалил, а пересборка проведёт (статус `failed`); за время
пересборки новых доставок не приезжало.
Из этих трёх посылок прогон пересборки SHALL печатать рядом с отпечатком ту,
которую он измеряет сам, — число `failed`. Первая посылка прогону
**недоступна по построению**: записи «свёртка вне порядка журнала» рождаются
только в живом воркере, нигде не хранятся, и пересборке та же спека проверку
прямо запрещает. Она проверяется журналом сервиса, и это сказано здесь, чтобы
следующий автор не приписал к отпечатку константный ноль, выдав его за
подтверждение.
Равенство «пересборка = приём» SHALL проверяться оракулом, а не рассуждением:
журнал, содержащий доставки с равнополными столкновениями, проигранный живым
путём (фоновый воркер, в том числе с доставкой, отложенной занятостью базы) и
путём пересборки, обязан давать один отпечаток витрины.
**Место снапшота в порядке журнала этой дельтой не определяется, и это сказано
вслух.** Под правилом «побеждает пришедшая» исход столкновения снапшота Apple с
точкой HAE зависит от того, какую позицию журнала получит доставка импорта:
учтённая сегодняшним временем, она перебила бы все равнополные точки, включая
досчитанные задним числом. Стадия снапшота сегодня пуста, поэтому вопрос
отложен — но решать его SHALL задача импорта, и явно, а не выбором первого
автора.
Отчёт пересборки SHALL называть два числа про точки — удержанные правилом
полноты против пришедшей доставки и потерявшие содержание в пользу пришедшей —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка ни одно из них не проверяет по построению.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
@@ -34,6 +76,14 @@
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Отложенная занятостью доставка не разводит приём и пересборку
- **GIVEN** журнал, где одни координаты получают равнополные точки с разными
значениями от разных доставок
- **AND** свёртка одной из доставок откладывается занятостью базы
- **WHEN** тот же журнал проигрывается живым путём и путём пересборки
- **THEN** отпечатки витрин совпадают
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
@@ -43,7 +93,6 @@
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
+218 -44
View File
@@ -79,6 +79,14 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
тай-брейк, а несравнимых наборов ноль.
Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат,
настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным
разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает
кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк.
Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 —
`basal_energy_burned` слоя `raw`, то есть посекундная развёртка HAE, которую
Read API суммировать и так не имеет права.
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
любой записи), пустой объект и пустой массив: поле без содержания не делает
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
@@ -111,37 +119,92 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
лишние ключи там заведомо пусты, объединять в них нечего.
Если равны и эти множества, а значения различаются, исход MUST быть
детерминированным и не зависеть от порядка, в котором доставки дошли до
хранилища: свёртка по журналу обязана давать то же состояние, что приём в
реальном времени.
**Если полнота ответа не дала — равные множества с разными значениями либо
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
1 912 (находка 49), то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
суммировать шаги.
Победитель MUST быть функцией **множества** точек координаты, а не порядка их
поступления. Попарная свёртка этого не даёт: полнота — частичный порядок,
тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы
(A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком).
При таком цикле повторная свёртка одной и той же доставки меняет содержимое
объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL
отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
победителя среди оставшихся по тотальному порядку — обе операции зависят
только от состава множества.
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
накопительных метрик и неверно для мгновенных, которые источник досчитывает
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
журнала.
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий.
**Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических
форм**: провенанс у таких точек общий, различать их нечем, а порядок элементов
в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой,
SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по
доставке, которого обязан был обойти по байтам, и «внутри доставки решают
байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает
при этом сохранённое содержимое дословно: хеш не двигается, объект не
переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей
канонической формой — в витрине остаются байты **встреченной первой**. Выбор
между ними ненаблюдаем по построению: различие при совпавшей канонической форме
это порядок ключей или последний разряд double, и хеш содержимого объекта
считается по канонической форме, а не по байтам.
Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором
он работает. **Когда значения общих содержательных ключей разошлись, полнота
ответа не даёт по построению** — «надмножество имён о полноте не говорит
ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла
сверх того ключи **без содержания** (`Min:0`, `Max:0`). Такие ключи по
определению пустоты этого же требования содержания не несут, и второй разряд их
бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял
их случайно — по тому, что каноническая форма с ключом `Max` сортируется раньше
формы с одним `date`, — и рассчитывать на такую защиту было нельзя.
**Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь.**
Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату
разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый
исход, новый — чередование по последней доставке, то есть перезапись объекта и
`WARN` на каждой доставке. Защита остаётся операционной («наборы метрик между
автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы
следующий разбор не искал причину заново.
**Победитель SHALL быть функцией множества точек координаты и их происхождения
(доставка или витрина), а не порядка элементов внутри доставки.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы (A превосходит B по
полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная
свёртка одной и той же доставки меняет содержимое объекта. Поэтому система
SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
победителя среди оставшихся по тотальному порядку — сперва происхождение,
затем каноническая форма.
**Правило тем самым есть явная функция порядка журнала, и цена этого называется
вслух.** Функцией множества оно быть перестало: «пришедшая побеждает» не
коммутативно. Витрина остаётся свёрткой журнала только пока порядок свёртки
равен порядку журнала — требование, которое capability приёма обязана
обеспечивать, а capability пересборки обязана проверять оракулом. Восстановить
коммутативность «для чистоты» MUST NOT: это откатило бы починку молча.
Сравнение по `received_at` для тай-брейка не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с
чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала
бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а
«пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку
журнала. Запрет этот **бюджетный, а не принципиальный**: он назван решением
владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного
приёма закрыть на приёме не удастся, провенанс (на объект, не на точку)
остаётся единственным ходом, и спека обязана это допускать, а не запрещать
вечно.
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
которого нет у другого, — система SHALL выбрать победителя тем же
детерминированным правилом, что и при равных множествах, и MUST оставить
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
`WARN` без значений точки. Несравнимый набор — частный случай столкновения:
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
координаты попадают в оба списка.
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
больше, чем счётчик, который скажет, если оно наступит.
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
станет массовым.
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
выбирает, а не конструирует. Каноническая форма существует только в момент
@@ -163,21 +226,52 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
#### Scenario: При равном содержании поля не теряются
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
- **AND** по тем же координатам приезжает точка `{date, qty}` с другим `qty`
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
- **THEN** остаётся точка с `Min` и `Max`
#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку
- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
- **AND** по тем же координатам следующей доставкой приезжает точка
`{date, qty:12}`
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
- **AND** счётчик перезаписей растёт
#### Scenario: Одинаково полные точки с разными значениями
- **WHEN** по одним координатам приходят две точки с одинаковыми множествами
ключей и разными значениями
- **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок
- **GIVEN** по координатам сохранена точка предыдущей доставки
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
другим значением
- **THEN** в витрине остаётся пришедшая точка
#### Scenario: Одинаково полные точки внутри одной доставки
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
множествами ключей и разными значениями
- **THEN** остаётся точка с меньшей канонической формой
- **AND** исход не зависит от порядка этих точек в массиве
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
- **GIVEN** по координатам сохранена точка
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
другими байтами
- **THEN** в витрине остаются сохранённые байты
- **AND** объект не переписывается
#### Scenario: Полнота сильнее происхождения
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
значениями общих ключей
- **THEN** остаётся сохранённая точка
- **AND** счётчик удержаний в итоге разбора доставки растёт
#### Scenario: Несравнимые множества считаются, а не сливаются
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
ключ с непустым значением, которого нет у другой
- **THEN** остаётся ровно одна точка, выбранная детерминированно
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
- **AND** система пишет `WARN` с координатами объекта и без значений точки
@@ -631,13 +725,15 @@ HAE состоят из объектов, а не из чисел.
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
capability приёма, но равенство это неполное: строка учёта становится видимой
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
провенанса, которой у точки нет.
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
@@ -669,12 +765,17 @@ HAE состоят из объектов, а не из чисел.
закоммитила последней: исход снова станет функцией порядка коммитов, а не
журнала, причём молча.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
канонической форме между доставками заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
хранил бы меньшее значение (находка 49).
Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них SHALL быть **функцией множества версий, а не порядка
@@ -1494,3 +1595,76 @@ HealthKit рядом. Значение хранится **дословно**, т
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
одного кода
- **AND** счётчики значений без кода в записи присутствуют
### Requirement: Проигрыш пришедшей точки считается отдельно
Итог разбора доставки SHALL нести счётчик координат, где пришедшая точка
проиграла сохранённой, и координаты первых таких объектов — метрику, слой и
час, без значений точек.
Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают
столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000
раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя —
то есть единственное событие, ради наблюдения за которым правило и переписано,
остаётся невидимым.
Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая
**строго полнее**. То есть счётчик меряет одно направление правила полноты — то,
в котором оно спорит с журналом; случай «пришедшая строго полнее» им не
считается, потому что там полнота и журнал согласны. Молчащий счётчик означает,
что правило полноты перестало спорить вовсе, и это событие для разбора, а не
отказ.
Счётчик SHALL быть виден не только в логе свёртки, но и в отчёте пересборки —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка правило удержания не проверяет по построению, живой приём и пересборка
пользуются одним правилом и одинаково сойдутся на одинаково удержанной точке.
Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье
чувствительнее токенов.
#### Scenario: Удержание сохранённой точки видно в итоге доставки
- **GIVEN** по координатам сохранена точка, строго более полная, чем пришедшая
- **WHEN** доставка сворачивается
- **THEN** счётчик удержаний растёт
- **AND** координаты объекта попадают в список удержаний
- **AND** счётчик остаётся нулевым, когда побеждает пришедшая точка
### Requirement: Потеря содержания пришедшей точкой считается и не молчит
Система SHALL считать отдельным счётчиком координаты, где победителем оказалась
**пришедшая** точка, а у проигравшей был ключ с непустым значением, которого у
победительницы нет. Координаты таких объектов SHALL попадать в лог, и система
SHALL писать `WARN` — без значений точек.
Это единственное направление, в котором новое правило способно потерять
содержание, и защитить его нечем **по построению**: разряд полноты в этом
случае погашен — значения общих содержательных ключей разошлись, и надмножество
имён о полноте не говорит ничего. До смены тай-брейка тот же исход случался по
жребию байтового порядка и так же молча; разница в том, что теперь он
детерминирован, а значит либо не случается вовсе, либо случается всегда.
Счётчик SHALL быть отдельным и от перезаписей, и от удержаний: перезаписи
считают столкновение в обе стороны, удержания — противоположное направление, и
ни один из них на вопрос «потеряли ли мы содержание» не отвечает.
Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129
спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие
обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем
же решением и по той же причине, по какой отложено объединение полей.
Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.
#### Scenario: Пришедшая точка унесла содержательный ключ сохранённой
- **GIVEN** сохранена точка `{date, asleep:7.5, rem:1.2}`
- **WHEN** следующей доставкой приезжает точка `{date, asleep:7.4}`
- **THEN** в витрине остаётся пришедшая точка
- **AND** счётчик потери содержания растёт, а счётчик удержаний — нет
- **AND** система пишет `WARN` с координатами объекта и без значений точек
#### Scenario: Пришедшая точка ничего не унесла
- **WHEN** множества ключей у сохранённой и пришедшей совпадают, а значения
различаются
- **THEN** счётчик потери содержания не растёт