diff --git a/CLAUDE.md b/CLAUDE.md index c117840..09f033a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/Taskfile.yml b/Taskfile.yml index 441e0a6..7f2232e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/cmd/healthlog/reindex_report.go b/cmd/healthlog/reindex_report.go index 67704ed..7cd03ad 100644 --- a/cmd/healthlog/reindex_report.go +++ b/cmd/healthlog/reindex_report.go @@ -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 { // Ни отпечаток пересобранной витрины, ни число доставок после прогона при diff --git a/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md b/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md new file mode 100644 index 0000000..a6536f7 --- /dev/null +++ b/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md @@ -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` расходится с новым правилом до пересборки: + подмена файла базы — необратимое действие человека и этим изменением не + выполняется. diff --git a/docs/adr/README.md b/docs/adr/README.md index 822655f..02f1443 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 кладётся реестром рядом со строкой, а не полем внутри точки; словарь живёт в бинаре, выведенный код в отпечаток витрины не входит. diff --git a/docs/architecture.md b/docs/architecture.md index 0fe3de6..d420893 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` | не расходится | +| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть | + +Правило выбора для будущего: есть где хранить позицию журнала — храним её; +негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки. ### Измерение рода агрегации diff --git a/docs/conventions/storage.md b/docs/conventions/storage.md index 612679c..39e1490 100644 --- a/docs/conventions/storage.md +++ b/docs/conventions/storage.md @@ -36,9 +36,15 @@ элементов на проводе решает состав витрины. - Правило выбора между двумя версиями одних данных объявляется либо **функцией множества версий**, либо явно **функцией порядка журнала** — третьего - состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: - порядок свёртки порядку журнала не равен, и живая витрина расходится с - пересборкой молча. + состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является: + порядок свёртки сам по себе порядку журнала не равен, и живая витрина + расходится с пересборкой молча. Объявив правило функцией порядка журнала, + изменение обязано **внести плату целиком**: привести порядок свёртки к + журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать + его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с + отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос + отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий + выбора в `architecture.md`, «Разрешение столкновений». - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет названный предел длины (имена непокрытых секций, `id` сущности). - **Колонка, по которой принимается необратимое решение, отличает ноль от «не diff --git a/docs/conventions/testing.md b/docs/conventions/testing.md index 0e3d843..d974e57 100644 --- a/docs/conventions/testing.md +++ b/docs/conventions/testing.md @@ -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 шлёт неразрывные пробелы diff --git a/docs/database.md b/docs/database.md index cc6afd0..b869cad 100644 --- a/docs/database.md +++ b/docs/database.md @@ -127,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж **Идентичность точки внутри объекта** — координаты `метрика + слой + начало + конец`, у точки-измерения конец равен началу. `source` в ключ не входит: он нестабилен и переписывается задним числом. При -столкновении выигрывает более полная точка, а не последняя пришедшая. +столкновении выигрывает более полная точка, а при равной полноте — стоящая +позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса +у точки нет: «позже в журнале» выражено происхождением кандидата, и потому +порядок свёртки обязан равняться журнальному. ## `workout` и `record` — сущности с собственным `id` diff --git a/docs/research/apple-health.md b/docs/research/apple-health.md index 4fed28a..70c632b 100644 --- a/docs/research/apple-health.md +++ b/docs/research/apple-health.md @@ -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» неудачную отправку.** Ключевой вопрос для diff --git a/docs/review.md b/docs/review.md index 81cd3b9..d382ec0 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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` он теперь правило, а не запись в журнале. diff --git a/docs/tasks/items/journal-order-on-ingest.md b/docs/tasks/items/journal-order-on-ingest.md index f442d83..a66057f 100644 --- a/docs/tasks/items/journal-order-on-ingest.md +++ b/docs/tasks/items/journal-order-on-ingest.md @@ -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 — **до** записи diff --git a/docs/tasks/items/tie-break-equal-completeness.md b/docs/tasks/items/tie-break-equal-completeness.md index ef73b30..60758db 100644 --- a/docs/tasks/items/tie-break-equal-completeness.md +++ b/docs/tasks/items/tie-break-equal-completeness.md @@ -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` diff --git a/internal/canon/canon.go b/internal/canon/canon.go index c0a09fb..1af220a 100644 --- a/internal/canon/canon.go +++ b/internal/canon/canon.go @@ -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) diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 30b98f6..55a4084 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -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 оно тонуло: поток идёт раз в пять минут. diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index 8e204f5..a79cb87 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -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) + } +} diff --git a/internal/fold/log_test.go b/internal/fold/log_test.go index b912389..ece5237 100644 --- a/internal/fold/log_test.go +++ b/internal/fold/log_test.go @@ -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) + } + } +} diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index 17e994b..2677183 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -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) diff --git a/internal/replay/busy_test.go b/internal/replay/busy_test.go new file mode 100644 index 0000000..9b4295a --- /dev/null +++ b/internal/replay/busy_test.go @@ -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) + } +} diff --git a/internal/replay/order_test.go b/internal/replay/order_test.go new file mode 100644 index 0000000..7b304ae --- /dev/null +++ b/internal/replay/order_test.go @@ -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) + } + } + } +} diff --git a/internal/replay/player.go b/internal/replay/player.go index 19be835..1f3b6d7 100644 --- a/internal/replay/player.go +++ b/internal/replay/player.go @@ -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 } diff --git a/internal/replay/replay.go b/internal/replay/replay.go index 731807b..896af69 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -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, diff --git a/internal/replay/replay_test.go b/internal/replay/replay_test.go index c6493b8..8a94ee8 100644 --- a/internal/replay/replay_test.go +++ b/internal/replay/replay_test.go @@ -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) diff --git a/internal/replay/worker.go b/internal/replay/worker.go index 5c4c751..ae9d14e 100644 --- a/internal/replay/worker.go +++ b/internal/replay/worker.go @@ -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 называет отставание одной строкой на проход. // // Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню diff --git a/internal/replay/worker_internal_test.go b/internal/replay/worker_internal_test.go new file mode 100644 index 0000000..bcb2743 --- /dev/null +++ b/internal/replay/worker_internal_test.go @@ -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()) + } +} diff --git a/internal/replay/worker_test.go b/internal/replay/worker_test.go index 3c13489..e2123fe 100644 --- a/internal/replay/worker_test.go +++ b/internal/replay/worker_test.go @@ -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() diff --git a/internal/store/bucket.go b/internal/store/bucket.go index 9e1c202..6776d8c 100644 --- a/internal/store/bucket.go +++ b/internal/store/bucket.go @@ -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 } diff --git a/internal/store/bucket_test.go b/internal/store/bucket_test.go index 5cb9fec..d279e6c 100644 --- a/internal/store/bucket_test.go +++ b/internal/store/bucket_test.go @@ -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,12 +577,10 @@ 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 { - t.Fatalf("слияние: %v", err) - } + 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 { @@ -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,24 +951,57 @@ 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]) - } - if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil { + batch := make([]store.IncomingPoint, 0, len(order)) + for _, i := range order { + batch = append(batch, in[i]) + } + 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) } } @@ -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) + } +} diff --git a/internal/store/delivery.go b/internal/store/delivery.go index 59b5f9a..b6b053a 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -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 возвращает размер задолженности — сколько доставок // ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен // разобрать, прежде чем витрина станет полной. diff --git a/internal/store/entity.go b/internal/store/entity.go index 95f7dd7..442d9cc 100644 --- a/internal/store/entity.go +++ b/internal/store/entity.go @@ -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 diff --git a/internal/store/entity_test.go b/internal/store/entity_test.go index 3b1b0ce..39bb760 100644 --- a/internal/store/entity_test.go +++ b/internal/store/entity_test.go @@ -819,12 +819,16 @@ func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T) } } -// Единственная точка, где витрина НЕ является функцией множества доставок, — +// Точка, где витрина сущностей НЕ является функцией множества доставок, — // несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит // победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая -// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не -// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается -// следующим проходом. +// выигрывает» даёт разный итог при разном порядке. +// +// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не +// перегоняется своими преемницами — проход воркера прекращается на ней. +// Осталось окно конкурентного приёма, где строка учёта становится видимой позже +// метки. У точек единственной такой точки давно нет: там от порядка журнала +// зависит весь тай-брейк равной полноты, и это объявлено вслух. // // Предел назван в pickEntity и в architecture.md; наблюдается счётчиком // удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что diff --git a/internal/store/pending_test.go b/internal/store/pending_test.go index e610527..98b8f4d 100644 --- a/internal/store/pending_test.go +++ b/internal/store/pending_test.go @@ -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("доставка засчитала расхождением саму себя") + } +} diff --git a/internal/store/winner.go b/internal/store/winner.go index 0dd014e..dfa929d 100644 --- a/internal/store/winner.go +++ b/internal/store/winner.go @@ -13,10 +13,12 @@ import "context" // сущностях, где ту же ошибку повторили молча. // // Отсюда и общий помощник вместо второй рукописной копии: механизм один, -// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда -// род метрики будет измерен, — то есть правка одного экземпляра при живом -// втором запланирована заранее, и расхождение правил слияния ломает детерминизм -// свёртки молча. +// отношения разные. Правка одного экземпляра при живом втором уже случилась: +// тай-брейк точек сменён с байтового порядка на «побеждает пришедшая», а +// тай-брейк сущностей остался позицией журнала — потому что у сущности есть +// колонка провенанса, а у точки нет. Механизм при этом не разъехался ровно +// благодаря общему помощнику; критерий выбора между двумя правилами записан в +// `architecture.md`, раздел «Разрешение столкновений». // // dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не // хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/.openspec.yaml b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/.openspec.yaml new file mode 100644 index 0000000..1b062d3 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md new file mode 100644 index 0000000..5fca1b2 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md @@ -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` о свёртке вне порядка журнала может шуметь при подборе + задолженности** → строка пишется одна на доставку и только когда позже неё + уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой + доставки нет. diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/proposal.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/proposal.md new file mode 100644 index 0000000..536eaaa --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/proposal.md @@ -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` + снова накопительная), тест сходимости «живой приём = пересборка» с отложенной + доставкой. diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/review/triage.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/review/triage.md new file mode 100644 index 0000000..0a3dc0e --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/review/triage.md @@ -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` = 7–8 проходов по + `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` «где существующее решение лучше» — не находки, а +подтверждение решений; перечислены в его сыром выводе и сохранены в истории +задачи. diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/ingest/spec.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/ingest/spec.md new file mode 100644 index 0000000..87f19a4 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/ingest/spec.md @@ -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** воркер пробует свернуть её снова diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/reindex/spec.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/reindex/spec.md new file mode 100644 index 0000000..320a54c --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/reindex/spec.md @@ -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** проигрываются обе diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/storage/spec.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/storage/spec.md new file mode 100644 index 0000000..6b4d816 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/specs/storage/spec.md @@ -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** счётчик потери содержания не растёт diff --git a/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/tasks.md b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/tasks.md new file mode 100644 index 0000000..deb49bd --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/tasks.md @@ -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 diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index 07bdff2..0514679 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -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** тот же отказ по другой причине этого признака не несёт - diff --git a/openspec/specs/reindex/spec.md b/openspec/specs/reindex/spec.md index 045b10e..2e7627e 100644 --- a/openspec/specs/reindex/spec.md +++ b/openspec/specs/reindex/spec.md @@ -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)`, а не diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index fa30704..b0e35be 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -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** счётчик потери содержания не растёт