store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
@@ -51,7 +51,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
|||||||
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
||||||
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
||||||
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
|
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
|
||||||
столкновении выигрывает **более полная** точка, а не последняя. Изменение
|
столкновении выигрывает **более полная** точка, а при равной полноте —
|
||||||
|
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
|
||||||
|
форм). Второе означает, что содержимое витрины есть функция **порядка**
|
||||||
|
свёртки, и порядок этот обязан равняться журнальному. Изменение
|
||||||
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||||||
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
|
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
|
||||||
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||||||
@@ -90,7 +93,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
|||||||
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
||||||
минута прогона и данные, которых нет ни на какой другой машине
|
минута прогона и данные, которых нет ни на какой другой машине
|
||||||
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
|
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
|
||||||
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон
|
обязана оставить доставку в очереди, а отложенная доставка не должна развести
|
||||||
|
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
|
||||||
- `task tidy` — `go mod tidy`
|
- `task tidy` — `go mod tidy`
|
||||||
- `task setup` — установка golangci-lint
|
- `task setup` — установка golangci-lint
|
||||||
|
|
||||||
@@ -111,7 +115,7 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
|||||||
необратимо.
|
необратимо.
|
||||||
- **Чего в гейте намеренно нет и кто обязан это гонять:**
|
- **Чего в гейте намеренно нет и кто обязан это гонять:**
|
||||||
`task verify:archive` (минута прогона, данные есть только на этой машине) и
|
`task verify:archive` (минута прогона, данные есть только на этой машине) и
|
||||||
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи**
|
`task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
|
||||||
перед любым изменением правила разбора, идентичности или слияния — а не «когда
|
перед любым изменением правила разбора, идентичности или слияния — а не «когда
|
||||||
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
|
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
|
||||||
[docs/review.md](docs/review.md).
|
[docs/review.md](docs/review.md).
|
||||||
|
|||||||
+7
-1
@@ -45,13 +45,19 @@ tasks:
|
|||||||
- go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
|
- go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
|
||||||
|
|
||||||
verify:busy:
|
verify:busy:
|
||||||
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)'
|
desc: 'Свёртка под удерживаемой блокировкой базы: доставка остаётся в очереди, а витрина не расходится с пересборкой (около 50 секунд)'
|
||||||
cmds:
|
cmds:
|
||||||
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
|
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
|
||||||
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
|
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
|
||||||
# при этом центральное решение задачи «разнести ответ и свёртку»:
|
# при этом центральное решение задачи «разнести ответ и свёртку»:
|
||||||
# занятость базы — обстоятельство, а не свойство доставки.
|
# занятость базы — обстоятельство, а не свойство доставки.
|
||||||
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
|
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
|
||||||
|
# Второй прогон — композиция, ради которой заведён барьер журнального
|
||||||
|
# порядка: занятость откладывает доставку, проход прекращается на ней, и
|
||||||
|
# живая витрина всё равно совпадает с пересборкой. Порознь барьер и
|
||||||
|
# сходимость проверены в гейте; вместе — только здесь, потому что
|
||||||
|
# настоящая занятость стоит те же двадцать пять секунд.
|
||||||
|
- go test ./internal/replay -run TestBusy -healthlog.busy -v -count=1
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
desc: Запуск golangci-lint
|
desc: Запуск golangci-lint
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ func writeReport(w io.Writer, r report) {
|
|||||||
// сущностей стало слишком строгим.
|
// сущностей стало слишком строгим.
|
||||||
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
|
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
|
||||||
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
|
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 {
|
if r.replay.Canceled {
|
||||||
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
|
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Тай-брейк точек — порядок журнала, а не хранимая метка
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-04
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
При равной полноте побеждает точка, пришедшая разбираемой доставкой. Правило
|
||||||
|
слияния точек тем самым перестаёт быть функцией множества и становится **явной
|
||||||
|
функцией порядка журнала**; за это платится приведением порядка живой свёртки к
|
||||||
|
журнальному. Хранимая метка провенанса у точки — очевидный ответ на тот же
|
||||||
|
вопрос — отвергнута по цене.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Байтовый порядок канонических форм, стоявший тай-брейком прежде, оказался не
|
||||||
|
крайним разрядом правила, а главным: перемер на живом корпусе дал 80 129 спорных
|
||||||
|
координат, из которых полнота отбрасывает кого-то лишь в 981 (1,2%), а 79 148
|
||||||
|
(98,8%) решает тай-брейк. И решает измеримо неверно — берёт меньшее значение в
|
||||||
|
1 847 случаях из 1 912, то есть системно хранит версию, которую источник уже
|
||||||
|
пересчитал. Ценой этого час `2026-08-03T07:00Z` метрики `step_count` остался
|
||||||
|
недосчитанным, сверка слоёв объявила метрику мгновенной против 23 согласных
|
||||||
|
часов, и род ушёл в `unknown`.
|
||||||
|
|
||||||
|
Готовое решение известно и рассмотрено первым. Цитата из источника:
|
||||||
|
|
||||||
|
> Регистр «последняя запись побеждает» (LWW-Register, Shapiro et al.,
|
||||||
|
> «A comprehensive study of Convergent and Commutative Replicated Data Types»,
|
||||||
|
> INRIA RR-7506) сходится **только** потому, что метка времени хранится
|
||||||
|
> **вместе со значением**: слияние сравнивает две метки, а не «кто пришёл
|
||||||
|
> вторым». Без хранимой метки то же правило вырождается в last-writer-wins по
|
||||||
|
> порядку применения — а он у реплик разный, и сходимости нет. Ровно это и
|
||||||
|
> означает «не полурешётка».
|
||||||
|
>
|
||||||
|
> Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на
|
||||||
|
> точку, то есть смена формата `payload` и миграция, которые постановка
|
||||||
|
> запрещает. Отвергнуто **с названной причиной**, и причина не «нам не
|
||||||
|
> подходит», а «цена выше разрешённой рамки».
|
||||||
|
|
||||||
|
Что взято вместо метки — вывод той же литературы о плате за отказ от неё:
|
||||||
|
|
||||||
|
> Если состояние не решётка, сходимость обеспечивается **единственным
|
||||||
|
> детерминированным порядком применения операций** — это уже не CRDT, а
|
||||||
|
> конвейер репликации с журналом (state machine replication: Schneider,
|
||||||
|
> «Implementing fault-tolerant services using the state machine approach», и то
|
||||||
|
> же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
|
||||||
|
> потребители применяют журнал в одном порядке.
|
||||||
|
|
||||||
|
Внутренний прецедент сильнее внешнего и решён иначе: слияние сущностей ту же
|
||||||
|
развилку прошло и выбрало хранимую позицию журнала `(received_at, id)`, прямо
|
||||||
|
отвергнув «побеждает приехавшая». Разница не в намерении, а в том, что у
|
||||||
|
сущности колонка провенанса есть, а у точки нет. Критерий выбора между двумя
|
||||||
|
механизмами записан в `docs/architecture.md`, раздел «Разрешение столкновений».
|
||||||
|
|
||||||
|
Значение точки и род метрики в правило не входят намеренно: «брать бо́льшее»
|
||||||
|
неверно для мгновенных метрик, которые источник досчитывает вниз, а род есть
|
||||||
|
функция витрины — правило, читающее собственную выдачу, перестаёт быть функцией
|
||||||
|
префикса журнала (тот же дефект уже ловили на наследовании слоя «из будущего»).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` `step_count` вернул род (`cumulative`, ноль противоречащих часов), заодно
|
||||||
|
вернулся `headphone_audio_exposure` (`instant`); общий станок
|
||||||
|
`task verify:archive` из красного стал зелёным.
|
||||||
|
- `+` Систематический недосчёт на 75 494 координатах прекращён (95% из них —
|
||||||
|
`basal_energy_burned` слоя `raw`).
|
||||||
|
- `−` Правило больше не коммутативно: содержимое витрины стало функцией порядка
|
||||||
|
свёртки. Живой порядок приведён к журнальному барьером — проход воркера
|
||||||
|
прекращается на первой отложенной занятостью доставке, — но голова очереди
|
||||||
|
теперь блокирует хвост.
|
||||||
|
- `−` Остаточное окно конкурентного приёма (строка учёта видна позже метки)
|
||||||
|
закрыть без изменения приёма нельзя; оно сделано наблюдаемым (`WARN`) и
|
||||||
|
оставлено вопросом владельца в `docs/tasks/items/journal-order-on-ingest.md`.
|
||||||
|
- `−` Появилось направление, в котором правило теряет содержание: разряд полноты
|
||||||
|
гаснет при разошедшихся значениях общих ключей, и пришедшая точка может унести
|
||||||
|
ключ сохранённой. Замерено — 2 координаты из 80 129 спорных; вместо запрета
|
||||||
|
заведён счётчик и `WARN`, тем же решением и по той же причине, по какой
|
||||||
|
отложено объединение полей.
|
||||||
|
- `−` Восстановление коммутативности «для чистоты» молча откатит починку.
|
||||||
|
Поэтому запрет записан нормативно в спеке хранения, а формулировки во всех
|
||||||
|
документах приведены к «функция множества **и позиции в журнале**».
|
||||||
|
- `−` Живая витрина в `./data` расходится с новым правилом до пересборки:
|
||||||
|
подмена файла базы — необратимое действие человека и этим изменением не
|
||||||
|
выполняется.
|
||||||
@@ -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)
|
- [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
|
||||||
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
|
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
|
||||||
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
|
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
|
||||||
|
|||||||
+55
-23
@@ -33,10 +33,11 @@ healthlog принимает выгрузки Apple Health из приложен
|
|||||||
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
|
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
|
||||||
`source` в ключ не входит, он
|
`source` в ключ не входит, он
|
||||||
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
||||||
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
|
чтобы не писать зря. При столкновении выигрывает более полная точка, а при
|
||||||
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
|
равной полноте — стоящая **позже в журнале**: бедная доставка не должна
|
||||||
Полнота — **множество** ключей с непустым значением, а не их число (см.
|
стирать поля у богатой, но и устаревшее значение не должно пережить свой
|
||||||
«Разрешение столкновений»).
|
досчёт. Полнота — **множество** ключей с непустым значением, а не их число
|
||||||
|
(см. «Разрешение столкновений»).
|
||||||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||||||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||||||
см. «Модель синхронизации».
|
см. «Модель синхронизации».
|
||||||
@@ -203,10 +204,11 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
||||||
единицы записей, и месячное окно там почти ничего не стоит.
|
единицы записей, и месячное окно там почти ничего не стоит.
|
||||||
|
|
||||||
Правило слияния одинаково для всех проходов, и порядок прихода значения не
|
Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
|
||||||
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
|
**имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
|
||||||
не было верным: при столкновении выигрывает более полная точка, а не последняя
|
позже по журналу. «Последние данные всегда актуализируют картину» остаётся
|
||||||
пришедшая (см. «Разрешение столкновений»).
|
неверным ровно в одном разряде — более полная точка бедную не пропускает
|
||||||
|
(см. «Разрешение столкновений»).
|
||||||
|
|
||||||
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
||||||
иначе `automation-name` приходит пустым (находка 12).
|
иначе `automation-name` приходит пустым (находка 12).
|
||||||
@@ -417,10 +419,14 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
|||||||
пересобрать что угодно.
|
пересобрать что угодно.
|
||||||
|
|
||||||
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
||||||
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
|
состояние, что и приём в реальном времени. Разряд полноты коммутативен и
|
||||||
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
|
порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
|
||||||
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
|
есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
|
||||||
строго по `received_at`, а не по порядку файлов в каталоге.
|
идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
|
||||||
|
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
|
||||||
|
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
|
||||||
|
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
|
||||||
|
можно только на приёме.
|
||||||
|
|
||||||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||||||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||||||
@@ -928,24 +934,50 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
|
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
|
||||||
не является: лишние ключи там заведомо пусты, объединять в них нечего.
|
не является: лишние ключи там заведомо пусты, объединять в них нечего.
|
||||||
|
|
||||||
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
|
**Победитель — функция множества кандидатов вместе с их происхождением, а не
|
||||||
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
|
порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
|
||||||
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
|
частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
|
||||||
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
|
отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
|
||||||
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
|
доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
|
||||||
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
|
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
|
||||||
минимум по каноническому порядку. Обе операции зависят только от состава
|
минимум тотального порядка — сперва происхождение (пришедшая раньше
|
||||||
множества.
|
сохранённой), затем каноническая форма. Антицикловое свойство от этого не
|
||||||
|
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
|
||||||
|
отдельно (см. ниже).
|
||||||
|
|
||||||
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
|
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
|
||||||
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
|
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
|
||||||
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
|
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
|
||||||
событие наступит, оно будет видно, а не додумано заранее.
|
событие наступит, оно будет видно, а не додумано заранее.
|
||||||
|
|
||||||
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
|
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
|
||||||
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
|
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
|
||||||
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
|
стоил `step_count` его рода. Значение точки в правило не входит («брать
|
||||||
значит он и станет известен точно, вместо того чтобы быть угаданным.
|
бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
|
||||||
|
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
|
||||||
|
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
|
||||||
|
доставки**, где провенанс общий.
|
||||||
|
|
||||||
|
Цена названа вслух: правило перестало быть функцией множества и стало явной
|
||||||
|
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
|
||||||
|
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
|
||||||
|
детерминированной»).
|
||||||
|
|
||||||
|
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
|
||||||
|
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
|
||||||
|
открывала спор заново:
|
||||||
|
|
||||||
|
| | точка | сущность (`workout`, `record`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
|
||||||
|
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
|
||||||
|
| внутри одной доставки | порядок канонических форм | он же |
|
||||||
|
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
|
||||||
|
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
|
||||||
|
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
|
||||||
|
|
||||||
|
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
|
||||||
|
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
|
||||||
|
|
||||||
### Измерение рода агрегации
|
### Измерение рода агрегации
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,15 @@
|
|||||||
элементов на проводе решает состав витрины.
|
элементов на проводе решает состав витрины.
|
||||||
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||||
множества версий**, либо явно **функцией порядка журнала** — третьего
|
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||||
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является:
|
||||||
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
порядок свёртки сам по себе порядку журнала не равен, и живая витрина
|
||||||
пересборкой молча.
|
расходится с пересборкой молча. Объявив правило функцией порядка журнала,
|
||||||
|
изменение обязано **внести плату целиком**: привести порядок свёртки к
|
||||||
|
журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать
|
||||||
|
его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с
|
||||||
|
отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос
|
||||||
|
отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий
|
||||||
|
выбора в `architecture.md`, «Разрешение столкновений».
|
||||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||||
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
||||||
|
|||||||
@@ -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`, а не из литерала в тесте.** Литерал, набранный руками, не
|
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
|
||||||
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
|
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
|
||||||
|
|||||||
+4
-1
@@ -127,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
|
|||||||
**Идентичность точки внутри объекта** — координаты
|
**Идентичность точки внутри объекта** — координаты
|
||||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
столкновении выигрывает более полная точка, а при равной полноте — стоящая
|
||||||
|
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
|
||||||
|
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
|
||||||
|
порядок свёртки обязан равняться журнальному.
|
||||||
|
|
||||||
## `workout` и `record` — сущности с собственным `id`
|
## `workout` и `record` — сущности с собственным `id`
|
||||||
|
|
||||||
|
|||||||
@@ -1774,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
|||||||
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
|
заполненности (`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» неудачную отправку.** Ключевой вопрос для
|
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
|
||||||
|
|||||||
+39
-1
@@ -143,7 +143,7 @@
|
|||||||
**Перестали проверять сознательно.**
|
**Перестали проверять сознательно.**
|
||||||
|
|
||||||
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
||||||
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд
|
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
|
||||||
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
|
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
|
||||||
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
|
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
|
||||||
прогон живого архива был красным и об этом никто не знал).
|
прогон живого архива был красным и об этом никто не знал).
|
||||||
@@ -436,3 +436,41 @@
|
|||||||
файлов каталога получили одну дату при переезде на канон (коммит `d79189b`),
|
файлов каталога получили одну дату при переезде на канон (коммит `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` он теперь правило, а не запись в журнале.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- **Секция:** ядро
|
- **Секция:** ядро
|
||||||
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
|
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
|
||||||
- **Теги:** goal:journal-and-rebuild
|
- **Теги:** goal:journal-and-rebuild, question
|
||||||
|
|
||||||
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
||||||
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
||||||
@@ -13,6 +13,70 @@
|
|||||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
`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 — **до** записи
|
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||||
|
|||||||
@@ -31,6 +31,52 @@
|
|||||||
слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из
|
слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из
|
||||||
будущего» (`docs/review.md`, 2026-08-01).
|
будущего» (`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`
|
- ни одна метрика не теряет род из-за столкновения равной полноты; `step_count`
|
||||||
|
|||||||
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
|
|||||||
return out
|
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 сравнивает два множества ключей по включению.
|
// relateKeys сравнивает два множества ключей по включению.
|
||||||
func relateKeys(a, b map[string]struct{}) Fullness {
|
func relateKeys(a, b map[string]struct{}) Fullness {
|
||||||
aExtra := hasExtra(a, b)
|
aExtra := hasExtra(a, b)
|
||||||
|
|||||||
@@ -247,6 +247,17 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
"buckets", st.Buckets,
|
"buckets", st.Buckets,
|
||||||
"unchanged", st.Unchanged,
|
"unchanged", st.Unchanged,
|
||||||
"overwrites", st.Overwrites,
|
"overwrites", st.Overwrites,
|
||||||
|
// Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым
|
||||||
|
// тай-брейком это правило полноты, работающее штатно (на живом корпусе
|
||||||
|
// 981 координата из 80 129 спорных), и эскалация обесценила бы уровень.
|
||||||
|
// Наблюдаемым событие делает то, что оно посчитано отдельно от
|
||||||
|
// перезаписей и печатается прогоном пересборки.
|
||||||
|
"points_held", st.PointsHeld,
|
||||||
|
// Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже:
|
||||||
|
// на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет,
|
||||||
|
// а событие означает потерю содержания — обратимую пересборкой, пока
|
||||||
|
// жив архив.
|
||||||
|
"points_erased", st.PointsErased,
|
||||||
"incomparable", st.Incomparable,
|
"incomparable", st.Incomparable,
|
||||||
"units_conflicts", st.UnitsConflicts,
|
"units_conflicts", st.UnitsConflicts,
|
||||||
"sealed_hits", st.SealedHits,
|
"sealed_hits", st.SealedHits,
|
||||||
@@ -285,6 +296,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
if len(st.IncomparableAt) > 0 {
|
if len(st.IncomparableAt) > 0 {
|
||||||
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
|
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 {
|
if len(st.HeldAt) > 0 {
|
||||||
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
|
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...)
|
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:
|
case st.Overwrites > 0:
|
||||||
// Единственное наблюдение, по которому проверяется правило слияния.
|
// Единственное наблюдение, по которому проверяется правило слияния.
|
||||||
// В INFO оно тонуло: поток идёт раз в пять минут.
|
// В INFO оно тонуло: поток идёт раз в пять минут.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -514,3 +515,45 @@ func TestFoldОтказРазбораНеПодделываетСчётчикП
|
|||||||
t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -389,3 +389,81 @@ func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *t
|
|||||||
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
|||||||
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
|
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
|
||||||
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
|
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
|
||||||
first.FailedOther, first.Incomparable)
|
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",
|
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
|
||||||
first.Partial, first.Adopted, first.Orphans)
|
first.Partial, first.Adopted, first.Orphans)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,19 @@ type Outcome struct {
|
|||||||
// ретеншену трогать нельзя.
|
// ретеншену трогать нельзя.
|
||||||
Partial int
|
Partial int
|
||||||
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
|
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
|
||||||
// их не было ни разу, и на этом стоит отказ от объединения полей.
|
// они единичны, и на этом стоит отказ от объединения полей.
|
||||||
Incomparable int
|
Incomparable int
|
||||||
|
// PointsHeld — точек, удержанных правилом полноты против пришедшей
|
||||||
|
// доставки. Здесь по той же причине, что EntitiesHeld: сходимость
|
||||||
|
// отпечатка удержание не проверяет ПО ПОСТРОЕНИЮ — живой приём и пересборка
|
||||||
|
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
|
||||||
|
// точке. То есть слишком строгое правило полноты выглядело бы идеальной
|
||||||
|
// сходимостью.
|
||||||
|
PointsHeld int
|
||||||
|
// PointsErased — точек, чьё содержание унесла пришедшая победительница.
|
||||||
|
// Здесь по той же причине, что PointsHeld, и с большим весом: это
|
||||||
|
// единственное направление, в котором правило слияния теряет содержание.
|
||||||
|
PointsErased int
|
||||||
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
|
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
|
||||||
// содержания». Без него правило слияния сущностей проверить нечем:
|
// содержания». Без него правило слияния сущностей проверить нечем:
|
||||||
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
|
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
|
||||||
@@ -54,6 +65,8 @@ func (o *Outcome) Add(other Outcome) {
|
|||||||
o.FailedOther += other.FailedOther
|
o.FailedOther += other.FailedOther
|
||||||
o.Partial += other.Partial
|
o.Partial += other.Partial
|
||||||
o.Incomparable += other.Incomparable
|
o.Incomparable += other.Incomparable
|
||||||
|
o.PointsHeld += other.PointsHeld
|
||||||
|
o.PointsErased += other.PointsErased
|
||||||
o.EntitiesHeld += other.EntitiesHeld
|
o.EntitiesHeld += other.EntitiesHeld
|
||||||
o.EntitiesDiverging += other.EntitiesDiverging
|
o.EntitiesDiverging += other.EntitiesDiverging
|
||||||
}
|
}
|
||||||
@@ -118,6 +131,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
|
|||||||
out.Partial++
|
out.Partial++
|
||||||
}
|
}
|
||||||
out.Incomparable += st.Incomparable
|
out.Incomparable += st.Incomparable
|
||||||
|
out.PointsHeld += st.PointsHeld
|
||||||
|
out.PointsErased += st.PointsErased
|
||||||
out.EntitiesHeld += st.EntitiesHeld
|
out.EntitiesHeld += st.EntitiesHeld
|
||||||
out.EntitiesDiverging += st.EntitiesDiverging
|
out.EntitiesDiverging += st.EntitiesDiverging
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
|||||||
"failed_other", rep.FailedOther,
|
"failed_other", rep.FailedOther,
|
||||||
"partial", rep.Partial,
|
"partial", rep.Partial,
|
||||||
"incomparable", rep.Incomparable,
|
"incomparable", rep.Incomparable,
|
||||||
|
"points_held", rep.PointsHeld,
|
||||||
|
"points_erased", rep.PointsErased,
|
||||||
"entities_held", rep.EntitiesHeld,
|
"entities_held", rep.EntitiesHeld,
|
||||||
"entities_diverging", rep.EntitiesDiverging,
|
"entities_diverging", rep.EntitiesDiverging,
|
||||||
"buckets", rep.Buckets,
|
"buckets", rep.Buckets,
|
||||||
|
|||||||
@@ -170,8 +170,11 @@ func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
|
|||||||
want, rep.Fingerprint)
|
want, rep.Fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
|
// Повторный прогон в ещё одну базу обязан дать то же самое: журнал тот же и
|
||||||
// координаты — функция множества кандидатов, а не порядка прихода.
|
// проигрывается в том же порядке, а исход слияния — функция множества
|
||||||
|
// кандидатов вместе с их происхождением, то есть от прогона к прогону не
|
||||||
|
// плавает. От ПОРЯДКА журнала он зависит намеренно; что порядок этот
|
||||||
|
// соблюдён и живым путём, проверяет order_test.go.
|
||||||
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
|
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
|
||||||
if again.Fingerprint != rep.Fingerprint {
|
if again.Fingerprint != rep.Fingerprint {
|
||||||
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
|
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
|
||||||
|
|||||||
@@ -60,16 +60,28 @@ type Worker struct {
|
|||||||
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
|
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
|
||||||
// запуске обесценила бы уровень.
|
// запуске обесценила бы уровень.
|
||||||
startupDone bool
|
startupDone bool
|
||||||
|
|
||||||
|
// fold — свёртка одной доставки. Полем, а не прямым вызовом, ради ОДНОГО
|
||||||
|
// шва: исход `Deferred` наступает только от занятости базы, а удержать её
|
||||||
|
// по-настоящему стоит двадцать пять секунд (`busy_timeout` 5 с × 5 повторов
|
||||||
|
// транзакции) — столько же, сколько стоит `task verify:busy`, который в
|
||||||
|
// гейт намеренно не входит. Барьер журнального порядка при этом и есть
|
||||||
|
// механизм, на котором держится равенство «пересборка = приём», и оставить
|
||||||
|
// его без дешёвой проверки значило бы проверять его только тем прогоном,
|
||||||
|
// который никто не гоняет по расписанию.
|
||||||
|
fold func(ctx context.Context, deliveryID string) Outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorker собирает воркер над рабочей базой.
|
// NewWorker собирает воркер над рабочей базой.
|
||||||
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
|
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
|
||||||
return &Worker{
|
w := &Worker{
|
||||||
store: st,
|
store: st,
|
||||||
player: Player{Fold: f},
|
player: Player{Fold: f},
|
||||||
log: log.With("capability", "fold-worker"),
|
log: log.With("capability", "fold-worker"),
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
|
w.fold = w.foldOne
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
|
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
|
||||||
@@ -169,7 +181,45 @@ func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
lag.add(d)
|
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
|
cursor = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +266,38 @@ func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
|
|||||||
return out
|
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 называет отставание одной строкой на проход.
|
// warnLag называет отставание одной строкой на проход.
|
||||||
//
|
//
|
||||||
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
|
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,31 @@ func (s *logSink) count(msg string) int {
|
|||||||
return n
|
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 {
|
func (s *logSink) dump() string {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
+144
-25
@@ -60,6 +60,38 @@ type MergeStats struct {
|
|||||||
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
|
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
|
||||||
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
|
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
|
||||||
UnitsConflicts int
|
UnitsConflicts int
|
||||||
|
// PointsHeld — координаты, где ПРИШЕДШАЯ точка проиграла сохранённой.
|
||||||
|
//
|
||||||
|
// Под тай-брейком «побеждает пришедшая» это возможно ровно тогда, когда
|
||||||
|
// сохранённая строго полнее, — то есть счётчик меряет единственное
|
||||||
|
// направление, в котором правило полноты спорит с журналом. Обратный случай
|
||||||
|
// (пришедшая строго полнее) сюда не входит: там полнота и журнал согласны.
|
||||||
|
//
|
||||||
|
// Отдельно от Overwrites, и это не педантизм: тот считает столкновение в обе
|
||||||
|
// стороны — на живом корпусе сработал бы около 80 000 раз, — и отличить
|
||||||
|
// «оставили пришедшую» от «выбросили пришедшую» по нему нельзя. То есть
|
||||||
|
// единственное событие, ради наблюдения за которым правило переписано,
|
||||||
|
// осталось бы невидимым. Тот же довод уже записан для EntitiesHeld.
|
||||||
|
PointsHeld int
|
||||||
|
// PointsHeldAt — координаты первых таких объектов, для записи в лог.
|
||||||
|
PointsHeldAt []Collision
|
||||||
|
// PointsErased — координаты, где победила ПРИШЕДШАЯ точка, а у проигравшей
|
||||||
|
// был содержательный ключ, которого у победительницы нет.
|
||||||
|
//
|
||||||
|
// Единственное направление, в котором новое правило способно потерять
|
||||||
|
// содержание. Разряд полноты там погашен по построению: значения общих
|
||||||
|
// содержательных ключей разошлись, и надмножество имён о полноте не говорит
|
||||||
|
// ничего. До смены тай-брейка исход решал байтовый порядок — то есть тот же
|
||||||
|
// исход случался по жребию и так же молча; разница в том, что теперь он
|
||||||
|
// детерминирован, а значит либо не случается вовсе, либо случается всегда.
|
||||||
|
// Замер на живом корпусе: 2 координаты из 80 129 спорных, обе — те же, что
|
||||||
|
// дают несравнимые наборы.
|
||||||
|
//
|
||||||
|
// Обратимо пересборкой, пока жив архив. Поэтому счётчик, а не запрет:
|
||||||
|
// объединение полей отложено тем же решением и по той же причине.
|
||||||
|
PointsErased int
|
||||||
|
// PointsErasedAt — координаты первых таких объектов, для записи в лог.
|
||||||
|
PointsErasedAt []Collision
|
||||||
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
|
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
|
||||||
// есть содержательный ключ, которого нет у другой. Правило полноты тут
|
// есть содержательный ключ, которого нет у другой. Правило полноты тут
|
||||||
// бессильно, и вместо объединения полей — которого на живом потоке не
|
// бессильно, и вместо объединения полей — которого на живом потоке не
|
||||||
@@ -209,6 +241,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
|||||||
stats.Stored += res.stored
|
stats.Stored += res.stored
|
||||||
stats.Overwrites += res.overwrites
|
stats.Overwrites += res.overwrites
|
||||||
stats.Incomparable += res.incomparable
|
stats.Incomparable += res.incomparable
|
||||||
|
stats.PointsHeld += res.held
|
||||||
|
stats.PointsErased += res.erased
|
||||||
if res.unchanged {
|
if res.unchanged {
|
||||||
stats.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 {
|
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
|
||||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
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()
|
now := Now()
|
||||||
@@ -315,6 +355,8 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
|
|||||||
type mergeResult struct {
|
type mergeResult struct {
|
||||||
overwrites int
|
overwrites int
|
||||||
incomparable int
|
incomparable int
|
||||||
|
held int
|
||||||
|
erased int
|
||||||
stored int
|
stored int
|
||||||
unchanged bool
|
unchanged bool
|
||||||
sealed bool
|
sealed bool
|
||||||
@@ -334,12 +376,14 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
|||||||
return res, err
|
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 {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
res.overwrites = overwrites
|
res.overwrites = overwrites
|
||||||
res.incomparable = incomparable
|
res.incomparable = incomparable
|
||||||
|
res.held = held
|
||||||
|
res.erased = erased
|
||||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
|
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
|
||||||
@@ -383,17 +427,28 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
|||||||
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
|
// 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 {
|
type coord struct {
|
||||||
start int64
|
start int64
|
||||||
end 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()}
|
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
|
||||||
cands, seen := byCoord[c]
|
cands, seen := byCoord[c]
|
||||||
if !seen {
|
if !seen {
|
||||||
order = append(order, c)
|
order = append(order, c)
|
||||||
}
|
}
|
||||||
cand := newCandidate(p)
|
cand := newCandidate(p, fromDelivery)
|
||||||
// Побайтовое равенство — только быстрый путь. Тем же самым считается
|
// Побайтовое равенство — только быстрый путь. Тем же самым считается
|
||||||
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
|
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
|
||||||
// байты нестабильны. Из 81952 повторно приехавших точек 67534
|
// байты нестабильны. Из 81952 повторно приехавших точек 67534
|
||||||
@@ -423,8 +478,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
|||||||
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
|
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
|
||||||
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
|
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
|
||||||
// правила слияния стал бы неотличим от нормы.
|
// правила слияния стал бы неотличим от нормы.
|
||||||
for _, q := range cands {
|
for i, q := range cands {
|
||||||
if bytes.Equal(q.key, cand.key) {
|
if bytes.Equal(q.key, cand.key) {
|
||||||
|
// Схлопывание ПОДНИМАЕТ флаг: доставка, приславшая то же
|
||||||
|
// содержимое, сказала о нём своё слово, и сохранённый
|
||||||
|
// кандидат теперь представляет её. Без этого он проигрывал бы
|
||||||
|
// соседу по доставке, которого обязан обойти по байтам, — то
|
||||||
|
// есть «внутри доставки решают байты» ломалось бы ровно
|
||||||
|
// тогда, когда доставка ничего не изменила. Байты остаются
|
||||||
|
// встреченные первыми: победитель хранится дословно, а разница
|
||||||
|
// при совпавшей канонической форме ненаблюдаема — хеш объекта
|
||||||
|
// считается по канонической форме.
|
||||||
|
cands[i].incoming = q.incoming || fromDelivery
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,18 +497,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range stored {
|
for _, p := range stored {
|
||||||
add(p)
|
add(p, false)
|
||||||
}
|
}
|
||||||
for _, p := range incoming {
|
for _, p := range incoming {
|
||||||
add(p)
|
add(p, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Point, 0, len(order))
|
out := make([]Point, 0, len(order))
|
||||||
for _, c := range order {
|
for _, c := range order {
|
||||||
cands := byCoord[c]
|
cands := byCoord[c]
|
||||||
winner, unrelated, err := resolve(ctx, cands)
|
winner, unrelated, keptStored, lostKey, err := resolve(ctx, cands)
|
||||||
if err != nil {
|
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 {
|
if unrelated {
|
||||||
incomparable++
|
incomparable++
|
||||||
}
|
}
|
||||||
|
if keptStored {
|
||||||
|
held++
|
||||||
|
}
|
||||||
|
if lostKey {
|
||||||
|
erased++
|
||||||
|
}
|
||||||
out = append(out, winner)
|
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[i].End.Before(out[j].End)
|
||||||
})
|
})
|
||||||
return out, overwrites, incomparable, nil
|
return out, overwrites, incomparable, held, erased, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||||
@@ -476,10 +547,19 @@ type candidate struct {
|
|||||||
pt Point
|
pt Point
|
||||||
key []byte // каноническая форма: она же ключ дедупликации и порядок
|
key []byte // каноническая форма: она же ключ дедупликации и порядок
|
||||||
fields canon.Fields
|
fields canon.Fields
|
||||||
|
// incoming — точка приехала РАЗБИРАЕМОЙ доставкой, а не лежала в объекте.
|
||||||
|
//
|
||||||
|
// Это второй и старший разряд тотального порядка: при равной полноте
|
||||||
|
// побеждает пришедшая. Провенанса у точки нет и не будет (колонка означала
|
||||||
|
// бы смену формата содержимого объекта), поэтому «позже по журналу»
|
||||||
|
// выражается единственным доступным способом — происхождением кандидата. И
|
||||||
|
// ровно поэтому порядок свёртки обязан равняться порядку журнала: правило
|
||||||
|
// перестало быть коммутативным осознанно, см. openspec storage.
|
||||||
|
incoming bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCandidate(p Point) candidate {
|
func newCandidate(p Point, incoming bool) candidate {
|
||||||
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
|
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw), incoming: incoming}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||||
@@ -495,20 +575,49 @@ func newCandidate(p Point) candidate {
|
|||||||
// вернуть её значило бы сохранить округлённое число вместо присланного.
|
// вернуть её значило бы сохранить округлённое число вместо присланного.
|
||||||
//
|
//
|
||||||
// Второй возврат — остались ли непревзойдёнными несколько точек с
|
// Второй возврат — остались ли непревзойдёнными несколько точек с
|
||||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
// несравнимыми наборами содержательных полей. На живом потоке событие
|
||||||
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
|
// единично (2 пары на 155 доставок), поэтому объединение полей не
|
||||||
// реализовано: вместо него счётчик, который скажет, если событие наступит.
|
// реализовано: вместо него счётчик, который скажет, если оно станет массовым.
|
||||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
|
//
|
||||||
|
// Третий возврат — победила ли СОХРАНЁННАЯ точка при наличии пришедших. Под
|
||||||
|
// новым тай-брейком это возможно ровно тогда, когда сохранённая строго полнее,
|
||||||
|
// то есть счётчик меряет единственное направление, в котором полнота спорит с
|
||||||
|
// журналом. Отдельно от 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)
|
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
|
||||||
if err != nil {
|
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 возвращает
|
// pointDominates — строгое превосходство по полноте. Relate возвращает
|
||||||
@@ -518,9 +627,19 @@ func pointDominates(a, b candidate) bool {
|
|||||||
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
||||||
}
|
}
|
||||||
|
|
||||||
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
|
// pointLess — тотальный порядок: сперва происхождение, затем каноническая
|
||||||
// кандидаты с равной формой схлопываются ещё при сборе множества.
|
// форма. Минимум единствен: кандидаты с равной формой схлопываются ещё при
|
||||||
|
// сборе множества, значит ключи различны и лексикографическая пара
|
||||||
|
// `(не incoming, ключ)` трихотомична.
|
||||||
|
//
|
||||||
|
// Пришедшая идёт РАНЬШЕ сохранённой, потому что pickBest берёт минимум. Это и
|
||||||
|
// есть тай-брейк «побеждает пришедшая»: байтовый порядок остаётся вторым
|
||||||
|
// разрядом и работает там, где происхождение одинаково, — то есть внутри одной
|
||||||
|
// доставки, где провенанс общий и различать нечем.
|
||||||
func pointLess(a, b candidate) bool {
|
func pointLess(a, b candidate) bool {
|
||||||
|
if a.incoming != b.incoming {
|
||||||
|
return a.incoming
|
||||||
|
}
|
||||||
return bytes.Compare(a.key, b.key) < 0
|
return bytes.Compare(a.key, b.key) < 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+325
-24
@@ -351,10 +351,16 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
|
|||||||
st := open(t)
|
st := open(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Значение общего содержательного ключа СОВПАДАЕТ — таково условие второго
|
||||||
|
// разряда в спеке: он сравнивает все ключи только там, где содержание
|
||||||
|
// сошлось. Прежняя редакция теста давала `qty` разные, второй разряд при
|
||||||
|
// этом не запускался вовсе, и точка с `Min`/`Max` выигрывала по случайности
|
||||||
|
// байтового порядка (`M` сортируется раньше `d`). Зелёный тест проверял не
|
||||||
|
// то требование, которое называл.
|
||||||
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"date":"d","qty":10,"Min":0,"Max":0}`)
|
`{"date":"d","qty":10,"Min":0,"Max":0}`)
|
||||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
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 {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
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
|
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||||
@@ -480,10 +566,10 @@ func TestMergeСменаИсточникаНеСоздаётВторуюТочк
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
// Внутри ОДНОЙ доставки провенанс общий, различать точки нечем, а порядок
|
||||||
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
// элементов в JSON-массиве от HAE нестабилен — поэтому здесь исход обязан
|
||||||
// времени, а внутри одной доставки время приёма общее.
|
// остаться функцией множества и решаться порядком канонических форм.
|
||||||
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
func TestMergeРавнаяПолнотаВнутриДоставкиНеЗависитОтПорядка(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -491,13 +577,11 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
|
|||||||
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
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}`)
|
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)
|
st := open(t)
|
||||||
for _, p := range order {
|
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("чтение: %v", err)
|
t.Fatalf("чтение: %v", err)
|
||||||
@@ -508,7 +592,119 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
|
|||||||
forward := winner([]store.IncomingPoint{a, b})
|
forward := winner([]store.IncomingPoint{a, b})
|
||||||
backward := winner([]store.IncomingPoint{b, a})
|
backward := winner([]store.IncomingPoint{b, a})
|
||||||
if forward != backward {
|
if forward != backward {
|
||||||
t.Errorf("исход зависит от порядка доставок: %s против %s", forward, backward)
|
t.Errorf("исход внутри доставки зависит от порядка элементов: %s против %s", forward, backward)
|
||||||
|
}
|
||||||
|
if forward != string(a.Raw) {
|
||||||
|
t.Errorf("внутри доставки победила %s, ожидался минимум канонической формы %s", forward, a.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// А МЕЖДУ доставками побеждает пришедшая, и это осознанная потеря
|
||||||
|
// коммутативности: байтовый порядок системно хранил меньшее значение и
|
||||||
|
// стоил `step_count` его рода. Витрина остаётся свёрткой журнала потому, что
|
||||||
|
// порядок свёртки приведён к порядку журнала, а не потому, что правило
|
||||||
|
// безразлично к порядку.
|
||||||
|
func TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||||
|
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
|
||||||
|
|
||||||
|
last := func(order []store.IncomingPoint) string {
|
||||||
|
st := open(t)
|
||||||
|
for i, p := range order {
|
||||||
|
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", i)}
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, ref); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
return string(got.Points[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := last([]store.IncomingPoint{a, b}); got != string(b.Raw) {
|
||||||
|
t.Errorf("после доставок a,b в витрине %s, ожидалась пришедшая последней %s", got, b.Raw)
|
||||||
|
}
|
||||||
|
// Обратный порядок даёт обратный исход — ровно то свойство, ради которого
|
||||||
|
// заведён барьер журнального порядка в воркере.
|
||||||
|
if got := last([]store.IncomingPoint{b, a}); got != string(a.Raw) {
|
||||||
|
t.Errorf("после доставок b,a в витрине %s, ожидалась пришедшая последней %s", got, a.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторная присылка того же содержимого другими байтами ничего не переписывает:
|
||||||
|
// схлопывание поднимает флаг происхождения, но байты в витрине остаются
|
||||||
|
// сохранённые, и хеш-детектор гасит запись.
|
||||||
|
func TestMergeПовторКаноническиТойЖеТочкиНеПереписывает(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
|
`{"date":"d","qty":10}`)
|
||||||
|
// Тот же смысл, другие байты: порядок ключей у HAE нестабилен.
|
||||||
|
again := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
|
`{"qty":10,"date":"d"}`)
|
||||||
|
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
|
t.Fatalf("первое слияние: %v", err)
|
||||||
|
}
|
||||||
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{again}}, store.DeliveryRef{ID: "d2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("второе слияние: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Overwrites != 0 {
|
||||||
|
t.Errorf("перезаписей %d: дребезг порядка ключей посчитан столкновением", stats.Overwrites)
|
||||||
|
}
|
||||||
|
if stats.Unchanged != 1 {
|
||||||
|
t.Errorf("неизменившихся объектов %d, ожидался 1: объект переписан зря", stats.Unchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(b.Points[0].Raw); got != string(first.Raw) {
|
||||||
|
t.Errorf("в витрине %s, ожидались сохранённые байты %s", got, first.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точка, канонически совпавшая с сохранённой, представляет доставку наравне с
|
||||||
|
// остальными её точками: иначе она проигрывала бы соседу по телу, которого
|
||||||
|
// обязана обойти по байтам, — то есть «внутри доставки решают байты» ломалось
|
||||||
|
// бы ровно тогда, когда доставка ничего не изменила.
|
||||||
|
func TestMergeСхлопываниеПоднимаетПроисхождение(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
stored := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||||
|
same := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||||
|
other := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
|
t.Fatalf("первое слияние: %v", err)
|
||||||
|
}
|
||||||
|
// Вторая доставка несёт обе формы: совпавшую с сохранённой и другую.
|
||||||
|
// Победить обязана меньшая каноническая форма, то есть совпавшая, — а не
|
||||||
|
// вторая только потому, что у первой не поднялся флаг.
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{same, other}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||||
|
t.Fatalf("второе слияние: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(b.Points[0].Raw); got != string(stored.Raw) {
|
||||||
|
t.Errorf("в витрине %s, ожидался минимум канонической формы доставки %s", got, stored.Raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,19 +951,18 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
in := cycleTriple(t)
|
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}}
|
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)
|
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))
|
batch := make([]store.IncomingPoint, 0, len(order))
|
||||||
for _, i := range order {
|
for _, i := range order {
|
||||||
batch = append(batch, in[i])
|
batch = append(batch, in[i])
|
||||||
@@ -775,6 +970,40 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
|
|||||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
return string(b.Points[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := winner(perms[0])
|
||||||
|
for _, p := range perms {
|
||||||
|
if got := winner(p); got != want {
|
||||||
|
t.Errorf("перестановка %v дала %s, ожидалось %s", p, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Та же тройка, разложенная по РАЗНЫМ доставкам: исход теперь зависит от
|
||||||
|
// порядка, и это не дефект, а объявленная цена. Проверяется другое и не менее
|
||||||
|
// важное — что зависимость ДЕТЕРМИНИРОВАНА: один и тот же порядок журнала
|
||||||
|
// обязан давать один и тот же исход, иначе витрина перестала бы быть свёрткой
|
||||||
|
// журнала вовсе, а не только функцией его порядка.
|
||||||
|
func TestMergeПорядокДоставокДаётВоспроизводимыйИсход(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
in := cycleTriple(t)
|
||||||
|
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||||
|
|
||||||
|
winner := func(order []int) string {
|
||||||
|
st := open(t)
|
||||||
|
for n, i := range order {
|
||||||
|
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", n)}
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, ref); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -783,11 +1012,11 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
|
|||||||
return string(b.Points[0].Raw)
|
return string(b.Points[0].Raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := winner(perms[0], false)
|
|
||||||
for _, p := range perms {
|
for _, p := range perms {
|
||||||
for _, split := range []bool{false, true} {
|
first := winner(p)
|
||||||
if got := winner(p, split); got != want {
|
for pass := 2; pass <= 3; pass++ {
|
||||||
t.Errorf("перестановка %v (порознь=%v) дала %s, ожидалось %s", p, split, got, want)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,6 +236,38 @@ func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, li
|
|||||||
return out, nil
|
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 возвращает размер задолженности — сколько доставок
|
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
|
||||||
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
|
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
|
||||||
// разобрать, прежде чем витрина станет полной.
|
// разобрать, прежде чем витрина станет полной.
|
||||||
|
|||||||
+19
-15
@@ -197,12 +197,16 @@ func (v *entityVersion) sortKey() []byte {
|
|||||||
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
||||||
// 4. наборы несравнимы → сохранённая
|
// 4. наборы несравнимы → сохранённая
|
||||||
//
|
//
|
||||||
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
|
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
|
||||||
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
|
||||||
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
|
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
|
||||||
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
|
// становится видимой только после записи тела, и при конкурентном приёме
|
||||||
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
|
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
|
||||||
// кроме отпечатка.
|
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
|
||||||
|
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
|
||||||
|
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
|
||||||
|
// незачем. Критерий выбора записан в architecture.md, «Разрешение
|
||||||
|
// столкновений».
|
||||||
//
|
//
|
||||||
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
||||||
// решает минимум канонической формы, потому что порядок элементов в
|
// решает минимум канонической формы, потому что порядок элементов в
|
||||||
@@ -220,15 +224,15 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
|||||||
// версий остаётся сохранённая: правило называется «не теряет
|
// версий остаётся сохранённая: правило называется «не теряет
|
||||||
// содержания», и приехавшая его теряет.
|
// содержания», и приехавшая его теряет.
|
||||||
//
|
//
|
||||||
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
|
// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
|
||||||
// журнала: в витрине лежит победитель прошлых слияний, а не все
|
// лежит победитель прошлых слияний, а не все кандидаты истории, и
|
||||||
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
|
// «сохранённая выигрывает» означает разный итог при разном порядке.
|
||||||
// при разном порядке. Порядок свёртки журналу не равен — доставка,
|
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
|
||||||
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
|
// не перегоняется своими преемницами — проход воркера прекращается на
|
||||||
// проходом, — так что живой приём и пересборка на несравнимых версиях
|
// ней. Осталось окно конкурентного приёма, где строка учёта становится
|
||||||
// законно расходятся. Это единственная точка, где витрина не является
|
// видимой позже метки; там живой приём и пересборка на несравнимых
|
||||||
// функцией множества доставок; она названа вслух в architecture.md, и
|
// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
|
||||||
// счётчик удержаний ниже — единственное, что о ней сообщает.
|
// удержаний ниже — второе, что о расхождении сообщает.
|
||||||
return false, true
|
return false, true
|
||||||
default:
|
default:
|
||||||
return laterInJournal(stored, incoming), false
|
return laterInJournal(stored, incoming), false
|
||||||
|
|||||||
@@ -819,12 +819,16 @@ func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Единственная точка, где витрина НЕ является функцией множества доставок, —
|
// Точка, где витрина сущностей НЕ является функцией множества доставок, —
|
||||||
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
|
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
|
||||||
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
|
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
|
||||||
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
|
// выигрывает» даёт разный итог при разном порядке.
|
||||||
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
|
//
|
||||||
// следующим проходом.
|
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
|
||||||
|
// перегоняется своими преемницами — проход воркера прекращается на ней.
|
||||||
|
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
|
||||||
|
// метки. У точек единственной такой точки давно нет: там от порядка журнала
|
||||||
|
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
|
||||||
//
|
//
|
||||||
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
|
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
|
||||||
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
|
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
|
||||||
|
|||||||
@@ -151,3 +151,63 @@ func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
|
|||||||
t.Fatalf("запись доставки %q: %v", id, err)
|
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("доставка засчитала расхождением саму себя")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import "context"
|
|||||||
// сущностях, где ту же ошибку повторили молча.
|
// сущностях, где ту же ошибку повторили молча.
|
||||||
//
|
//
|
||||||
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
|
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
|
||||||
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
|
// отношения разные. Правка одного экземпляра при живом втором уже случилась:
|
||||||
// род метрики будет измерен, — то есть правка одного экземпляра при живом
|
// тай-брейк точек сменён с байтового порядка на «побеждает пришедшая», а
|
||||||
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
|
// тай-брейк сущностей остался позицией журнала — потому что у сущности есть
|
||||||
// свёртки молча.
|
// колонка провенанса, а у точки нет. Механизм при этом не разъехался ровно
|
||||||
|
// благодаря общему помощнику; критерий выбора между двумя правилами записан в
|
||||||
|
// `architecture.md`, раздел «Разрешение столкновений».
|
||||||
//
|
//
|
||||||
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
|
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
|
||||||
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
|
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-04
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Правило слияния точек стоит на двух отношениях: полнота — частичный порядок,
|
||||||
|
тай-брейк — тотальный. Победитель сегодня есть **функция множества** кандидатов
|
||||||
|
координаты, и это записано в спеке хранения отдельным требованием: витрина
|
||||||
|
обязана быть свёрткой журнала, а функция множества коммутативна и потому от
|
||||||
|
порядка свёртки не зависит.
|
||||||
|
|
||||||
|
Решение владельца 2026-08-04 ломает ровно это свойство: «пришедшая побеждает» —
|
||||||
|
не полурешётка. Правило перестаёт быть коммутативным, и исход слияния
|
||||||
|
становится функцией **порядка свёртки**. Задача обязана внести плату целиком:
|
||||||
|
привести порядок свёртки к порядку журнала и доказать равенство «пересборка =
|
||||||
|
приём» оракулом, а не рассуждением.
|
||||||
|
|
||||||
|
Осложняющий факт назван в постановке: `replay` идёт по `(received_at, id)`,
|
||||||
|
живая свёртка — по факту свёртки, и доставка, отложенная занятостью базы, их
|
||||||
|
разводит.
|
||||||
|
|
||||||
|
Схему трогать нельзя: колонок провенанса на точку не заводим, формат `payload`
|
||||||
|
не меняем, миграции нет.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- При равной полноте побеждает точка, пришедшая этой доставкой.
|
||||||
|
- Внутри одной доставки исход по-прежнему решает порядок канонических форм.
|
||||||
|
- Порядок свёртки равен порядку журнала везде, где это достижимо без изменения
|
||||||
|
приёма; недостижимый остаток — наблюдаемый, а не молчащий.
|
||||||
|
- «Пришедшая проиграла сохранённой» считается отдельным счётчиком.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Провенанс на точку, смена формата `payload`, миграция.
|
||||||
|
- Тай-брейк, зависящий от значения точки (вариант «б») или от измеренного рода
|
||||||
|
метрики — род есть функция витрины, и правило слияния, читающее собственную
|
||||||
|
выдачу, повторяет дефект наследования слоя «из будущего»
|
||||||
|
(`docs/review.md`, 2026-08-01).
|
||||||
|
- Удержание порядка на самом приёме — это [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||||
|
у неё своё решение владельца и своя очередь.
|
||||||
|
- Пересборка витрины и подмена файла базы — необратимое действие человека.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Прежде своего — prior art
|
||||||
|
|
||||||
|
**CRDT-литература отвечает на вопрос прямо.** Регистр «последняя запись
|
||||||
|
побеждает» (LWW-Register, Shapiro et al., «A comprehensive study of Convergent
|
||||||
|
and Commutative Replicated Data Types», INRIA RR-7506) сходится **только**
|
||||||
|
потому, что метка времени хранится **вместе со значением**: слияние сравнивает
|
||||||
|
две метки, а не «кто пришёл вторым». Без хранимой метки то же правило вырождается
|
||||||
|
в last-writer-wins по порядку применения — а он у реплик разный, и сходимости
|
||||||
|
нет. Ровно это и означает «не полурешётка».
|
||||||
|
|
||||||
|
Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на точку,
|
||||||
|
то есть смена формата `payload` и миграция, которые постановка запрещает.
|
||||||
|
Отвергнуто **с названной причиной**, и причина не «нам не подходит», а «цена
|
||||||
|
выше разрешённой рамки».
|
||||||
|
|
||||||
|
Что из литературы берётся: её же вывод о том, чем платят за отказ от метки.
|
||||||
|
Если состояние не решётка, сходимость обеспечивается **единственным
|
||||||
|
детерминированным порядком применения операций** — это уже не CRDT, а
|
||||||
|
конвейер репликации с журналом (state machine replication: Schneider,
|
||||||
|
«Implementing fault-tolerant services using the state machine approach»,
|
||||||
|
и то же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
|
||||||
|
потребители применяют журнал в одном порядке. Наш журнал есть, порядок в нём
|
||||||
|
определён (`(received_at, id)`), и задача сводится к тому, чтобы **живая свёртка
|
||||||
|
применяла его в том же порядке, что и пересборка**.
|
||||||
|
|
||||||
|
**Прецедент внутри проекта — сильнее внешнего.** Слияние сущностей
|
||||||
|
(`internal/store/entity.go`, `openspec/specs/storage/spec.md`) ту же развилку
|
||||||
|
уже прошло и выбрало **позицию доставки в журнале**, явно отвергнув «побеждает
|
||||||
|
приехавшая» такими словами: «"Побеждает приехавшая" было бы функцией порядка
|
||||||
|
свёртки, а он порядку журнала не равен». Сущность могла себе это позволить —
|
||||||
|
у неё есть колонка провенанса. У точки её нет и не будет, значит равенство
|
||||||
|
порядков обязано быть обеспечено, а не предположено.
|
||||||
|
|
||||||
|
### Решение 1: тай-брейк по происхождению кандидата, затем по канонической форме
|
||||||
|
|
||||||
|
`candidate` получает флаг `incoming` — «эта точка приехала разбираемой
|
||||||
|
доставкой, а не лежала в объекте». Тотальный порядок `pointLess` становится
|
||||||
|
лексикографическим по паре `(не incoming, каноническая форма)`: пришедшая
|
||||||
|
предшествует сохранённой, при равном происхождении решают байты канонической
|
||||||
|
формы.
|
||||||
|
|
||||||
|
Отношение полноты (`pointDominates`) **не трогается**: более полная точка
|
||||||
|
побеждает по-прежнему, и происхождение на это не влияет. Флаг участвует только
|
||||||
|
в тотальном порядке, то есть ровно там, где полнота ответа не дала.
|
||||||
|
|
||||||
|
Механизм `pickBest` остаётся общим с сущностями: он требует от `less` тотального
|
||||||
|
строгого порядка, и пара `(ранг, ключ)` его даёт — ключи различны, потому что
|
||||||
|
совпавшие канонические формы схлопываются до выбора.
|
||||||
|
|
||||||
|
**Схлопывание поднимает флаг.** Если приехавшая точка канонически совпала с
|
||||||
|
сохранённой, выживает сохранённый кандидат (его байты дословны), но флаг
|
||||||
|
`incoming` он получает. Без этого «внутри доставки решают байты» нарушалось бы
|
||||||
|
ровно в том случае, когда одна из точек доставки совпала с сохранённой:
|
||||||
|
сохранённый кандидат проигрывал бы соседу по доставке, которого сам обязан был
|
||||||
|
обойти по байтам. С поднятием флага доставка сравнивается с доставкой, и
|
||||||
|
сохранённые байты остаются в витрине — то есть хеш не двигается и объект не
|
||||||
|
переписывается.
|
||||||
|
|
||||||
|
Альтернатива — двухстадийная схема (сперва победитель внутри доставки, потом
|
||||||
|
он же против сохранённой) — отвергнута: она гасит полноту. Точка, не
|
||||||
|
превзойдённая никем, вылетала бы на внутридоставочном байтовом тай-брейке
|
||||||
|
против точки, которую сохранённая заведомо превосходит. Одна стадия над
|
||||||
|
объединением кандидатов сохраняет инвариант «выигрывает более полная».
|
||||||
|
|
||||||
|
### Решение 2: проход воркера прекращается на первой отложенной доставке
|
||||||
|
|
||||||
|
Сегодня `Worker.Pass` двигает курсор и после отложенной доставки, то есть
|
||||||
|
сворачивает её преемниц раньше неё. При коммутативном правиле это было
|
||||||
|
безразлично; теперь это молчаливое расхождение с пересборкой.
|
||||||
|
|
||||||
|
Проход прекращается на первой доставке с исходом `Deferred`. Следующий проход
|
||||||
|
(сигнал приёма или тик раз в минуту) начинает с начала очереди и берёт её же.
|
||||||
|
|
||||||
|
Опасности «очередь встала навсегда» здесь нет, и это следствие уже принятого
|
||||||
|
определения: `store.Transient` — это **только** занятость базы и отмена
|
||||||
|
снаружи; собственный дедлайн свёртки в него намеренно не входит, и доставка,
|
||||||
|
не уложившаяся в бюджет, получает `failed` и очередь освобождает. Занятость же
|
||||||
|
блокирует запись всем одинаково — проход, перешагнувший занятую доставку, всё
|
||||||
|
равно упёрся бы в ту же занятость на следующей.
|
||||||
|
|
||||||
|
Плата называется вслух: голова очереди блокирует хвост. Раньше проход при
|
||||||
|
занятости базы двигался дальше и мог свернуть хоть что-то. Это осознанный
|
||||||
|
размен «полнота витрины сейчас» на «витрина есть свёртка журнала», и он
|
||||||
|
соответствует тому, как проект уже разрешил тот же спор у сущностей.
|
||||||
|
|
||||||
|
### Решение 3: остаточное окно делается наблюдаемым
|
||||||
|
|
||||||
|
Строка учёта доставки становится видимой воркеру **после** записи тела
|
||||||
|
(измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
|
||||||
|
ранней меткой может появиться после того, как её преемница уже свёрнута.
|
||||||
|
Барьер этого не ловит: отставшей доставки в момент прохода просто не
|
||||||
|
существует.
|
||||||
|
|
||||||
|
Закрыть окно можно только на приёме — резервированием строки учёта до записи
|
||||||
|
тела либо выдержкой перед свёрткой. И то и другое — предмет отдельной задачи
|
||||||
|
[journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||||
|
и переигрывать её здесь нельзя.
|
||||||
|
|
||||||
|
**Названо прямо, потому что иначе прочтётся как «закрыто»:** решение владельца
|
||||||
|
в той задаче — вариант (в), повторы при `ErrLayerUnknown`, — порядок журнала
|
||||||
|
**не восстанавливает**. Он лечит невыводимый слой, а доставка всё равно
|
||||||
|
сворачивается после своей преемницы. Значит после его реализации равенство
|
||||||
|
«пересборка = приём» останется условным. Это записано вопросом владельцу в
|
||||||
|
раздел «Вопросы» той задачи вместе с тегом `question`, четвёртым вариантом
|
||||||
|
(сериализовать выпуск ULID с записью тела и вставкой строки) и рекомендацией.
|
||||||
|
|
||||||
|
Что делается вместо: перед свёрткой воркер спрашивает журнал, есть ли доставка
|
||||||
|
**позже** этой, которая уже вышла из очереди. Есть — пишется `WARN` с
|
||||||
|
идентификатором доставки: порядок свёртки разошёлся с порядком журнала, и
|
||||||
|
витрина в этом месте не равна пересборке. Событие перестаёт быть невидимым,
|
||||||
|
а лечится оно `healthlog reindex`.
|
||||||
|
|
||||||
|
Запрос — тот же кортежный предикат `(received_at, id) > (?, ?)`, что уже
|
||||||
|
используется в `PendingDeliveries` и `LastDerivedLayer`, но **не по тому же
|
||||||
|
индексу**, и это названо, потому что первая редакция утверждала обратное.
|
||||||
|
`delivery_pending` — индекс частичный, построен для `parse_status = 'pending'`,
|
||||||
|
а предикат просит `IN ('parsed','partial')`; планировщик берёт
|
||||||
|
`delivery_received_at` (`EXPLAIN QUERY PLAN`: `SEARCH delivery USING INDEX
|
||||||
|
delivery_received_at`).
|
||||||
|
|
||||||
|
Цена измерена, а не объявлена нулевой. В установившемся режиме (очередь пуста
|
||||||
|
или почти пуста) — единицы микросекунд. На **чистой задолженности**, где
|
||||||
|
преемниц в статусе `parsed` нет вовсе, поиск доходит до конца хвоста, и
|
||||||
|
суммарная цена прохода квадратична по длине задолженности: 0,26 с при 2 000
|
||||||
|
доставок, 3,12 с при 8 000, 19,8 с при 20 000. Это меньше 2% от цены самой
|
||||||
|
свёртки (порядка 0,4 с на доставку по прогону живого архива), поэтому индекс не
|
||||||
|
заводится: миграция ради двух процентов — плохой размен, а числа названы, чтобы
|
||||||
|
следующий читатель не принимал решение по слову «дёшево».
|
||||||
|
|
||||||
|
Проверка живёт в **воркере**, а не в свёртке: пересборка зовёт `Player.Play`
|
||||||
|
напрямую и по построению идёт в порядке журнала, а её тишина здесь
|
||||||
|
содержательна.
|
||||||
|
|
||||||
|
### Решение 4: счётчик удержаний
|
||||||
|
|
||||||
|
`MergeStats.PointsHeld` — сколько пришедших точек проиграло сохранённой, плюс
|
||||||
|
`PointsHeldAt` с координатами первых (`Collision`, тот же потолок
|
||||||
|
`maxCollisionsReported`). Считается координата, где победил кандидат **без**
|
||||||
|
флага `incoming`, а кандидаты с флагом были: под новым правилом это возможно
|
||||||
|
ровно тогда, когда сохранённая строго полнее.
|
||||||
|
|
||||||
|
Отдельно от `Overwrites` — потому что `Overwrites` считает столкновение в обе
|
||||||
|
стороны (на корпусе он сработал бы ~84 000 раз) и отличить «оставили новое» от
|
||||||
|
«выбросили новое» по нему нельзя. Ровно тот же довод уже записан для
|
||||||
|
`EntitiesHeld` у сущностей, и именование берётся оттуда же.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Витрина в `./data` расходится с новым правилом** → отпечаток обязан
|
||||||
|
измениться, иначе правило не сработало. Приведение в соответствие —
|
||||||
|
`healthlog reindex` с остановкой сервиса и подменой файла базы, необратимое
|
||||||
|
действие человека. Этим изменением не выполняется.
|
||||||
|
- **Голова очереди блокирует хвост при занятой базе** → занятость и раньше
|
||||||
|
блокировала запись всем; проход возобновляется сигналом приёма или тиком раз
|
||||||
|
в минуту, тела всё это время лежат в архиве и не теряются.
|
||||||
|
- **Остаточное окно конкурентного приёма** → не закрывается, но перестаёт быть
|
||||||
|
молчащим (`WARN`). Закрытие — за [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||||
|
чьё нынешнее решение его не закрывает; вопрос владельцу записан туда же.
|
||||||
|
- **Цена изменения сосредоточена в одной метрике одного слоя** → из 75 494
|
||||||
|
координат, где меняется исход, 71 773 (95%) — `basal_energy_burned` слоя
|
||||||
|
`raw`. Ошибка была массовой по координатам и узкой по метрикам, и число
|
||||||
|
переписываемых объектов растёт соответственно.
|
||||||
|
- **Правило стало функцией порядка, а значит хрупче** → это записывается в
|
||||||
|
спеку хранения нормативно (было «функция множества», стало «функция множества
|
||||||
|
и позиции в журнале»), чтобы следующий автор не восстановил коммутативность
|
||||||
|
«для чистоты» и не откатил починку молча.
|
||||||
|
- **Больше записей в витрину** → повторная доставка той же координаты с другим
|
||||||
|
значением теперь всегда переписывает объект, тогда как байтовый порядок часто
|
||||||
|
давал `unchanged`. Хеш-детектор продолжает гасить точные повторы; рост
|
||||||
|
ограничен долей спорных координат.
|
||||||
|
- **`WARN` о свёртке вне порядка журнала может шуметь при подборе
|
||||||
|
задолженности** → строка пишется одна на доставку и только когда позже неё
|
||||||
|
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
|
||||||
|
доставки нет.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Тай-брейк при равной полноте точек сегодня — порядок канонических форм, и он
|
||||||
|
решает **98,8% спорных координат** (83 956 из 84 978 при 453 171 координате):
|
||||||
|
полнота отвечает лишь в 1,2% случаев. Лексикографический порядок системно берёт
|
||||||
|
меньшее значение (1 847 из 1 912, находка 49), то есть хранит **устаревшую**
|
||||||
|
версию точки там, где HAE досчитывает задним числом (находка 10).
|
||||||
|
|
||||||
|
У дефекта появился независимый оракул: `task verify:archive` покраснел на
|
||||||
|
`master` без единого коммита, с ростом корпуса. Час `2026-08-03T07:00Z` метрики
|
||||||
|
`step_count` получил четыре доставки с двумя различными значениями; победило
|
||||||
|
меньшее, оно же приехавшее первым, и сверка слоёв объявила метрику мгновенной
|
||||||
|
против 23 согласных часов. `step_count` ушёл в `unknown` — Read API больше не
|
||||||
|
имеет права суммировать шаги.
|
||||||
|
|
||||||
|
Решение владельца от 2026-08-04: **при равной полноте побеждает пришедшая
|
||||||
|
точка**, а не сохранённая. Значение при этом не интерпретируется — правило
|
||||||
|
остаётся структурным.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING (содержимое витрины):** при равной полноте побеждает точка,
|
||||||
|
пришедшая **этой** доставкой, а не сохранённая. Отпечаток витрины обязан
|
||||||
|
измениться; состояние восстанавливается пересборкой из архива.
|
||||||
|
- Порядок канонических форм остаётся тай-брейком **внутри одной доставки**, где
|
||||||
|
провенанс общий и различать нечем.
|
||||||
|
- **Правило слияния точек перестаёт быть функцией множества и становится явной
|
||||||
|
функцией порядка журнала.** Отсюда — плата, которую изменение обязано
|
||||||
|
внести целиком: порядок свёртки приводится к порядку журнала.
|
||||||
|
- Фоновый воркер **прекращает проход на первой отложенной доставке**, а не
|
||||||
|
перешагивает её. Иначе занятость базы переставляет доставки местами, и живая
|
||||||
|
витрина расходится с пересборкой молча.
|
||||||
|
- Свёртка доставки, у которой в журнале уже есть свёрнутая преемница, пишет
|
||||||
|
`WARN`: остаточное окно (конкурентный приём делает строку учёта видимой
|
||||||
|
не в порядке меток) закрыть без изменения приёма нельзя, но молчать о нём
|
||||||
|
нельзя тем более.
|
||||||
|
- Заводится счётчик «пришедшая точка проиграла сохранённой» — отдельно от
|
||||||
|
общего `MergeStats.Overwrites`, который считает столкновения в обе стороны и
|
||||||
|
различить их не даёт.
|
||||||
|
- Схема не трогается: колонок провенанса на точку не заводится, формат
|
||||||
|
`payload` не меняется, миграции нет.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Новых нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `storage`: тай-брейк при равной полноте точек — пришедшая побеждает
|
||||||
|
сохранённую; порядок канонических форм остаётся только внутри доставки.
|
||||||
|
Требование «победитель есть функция множества точек» заменяется на «функция
|
||||||
|
множества точек и позиции в журнале». Новый счётчик удержаний.
|
||||||
|
- `ingest`: проход воркера прекращается на первой отложенной доставке; свёртка
|
||||||
|
вне порядка журнала становится наблюдаемой.
|
||||||
|
- `reindex`: посылка сходимости («приём шёл последовательно») из оговорки
|
||||||
|
сценария становится названным условием требования — теперь от порядка свёртки
|
||||||
|
зависит не только вывод слоя, но и содержимое точек.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/store/bucket.go` — `mergePoints`, `resolve`, `candidate`,
|
||||||
|
`pointLess`, `MergeStats`.
|
||||||
|
- `internal/store/delivery.go` — запрос «есть ли свёрнутая доставка позже этой».
|
||||||
|
- `internal/replay/worker.go` — барьер на отложенной доставке, `WARN` о свёртке
|
||||||
|
вне порядка журнала.
|
||||||
|
- `internal/fold/fold.go` — проброс нового счётчика в итог свёртки и в лог.
|
||||||
|
- Витрина в `./data`: отпечаток изменится. Пересборка — действие человека при
|
||||||
|
остановленном сервисе, этим изменением не выполняется.
|
||||||
|
- Оракулы: `task verify:archive` (ноль противоречащих часов, `step_count`
|
||||||
|
снова накопительная), тест сходимости «живой приём = пересборка» с отложенной
|
||||||
|
доставкой.
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# Триаж: tie-break-equal-completeness
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Профиль:** `deep`, режим — линейный (по слову оператора). База диффа
|
||||||
|
`de2001d`.
|
||||||
|
- **Гейт:** зелёный. Непокрытыми остались 9 изменённых строк — все ветки
|
||||||
|
обработки ошибок (`internal/replay/worker.go:272-275`,
|
||||||
|
`internal/store/bucket.go:488,564-565`, `internal/store/delivery.go:266-267`).
|
||||||
|
- **Проходы поимённо, с исходом** (сверено с профилем: `deep` = 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` «где существующее решение лучше» — не находки, а
|
||||||
|
подтверждение решений; перечислены в его сыром выводе и сохранены в истории
|
||||||
|
задачи.
|
||||||
+130
@@ -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** воркер пробует свернуть её снова
|
||||||
+87
@@ -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** проигрываются обе
|
||||||
+627
@@ -0,0 +1,627 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Разрешение столкновений по полноте
|
||||||
|
|
||||||
|
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||||||
|
**более полную** точку — ту, чьё множество ключей с непустым значением является
|
||||||
|
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
|
||||||
|
бедная доставка стирает у богатой поля, которых сама не несёт.
|
||||||
|
|
||||||
|
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
|
||||||
|
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
|
||||||
|
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
|
||||||
|
стирала бы его безвозвратно.
|
||||||
|
|
||||||
|
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
|
||||||
|
(0.65%); из них 981 различаются набором полей — это и есть область правила
|
||||||
|
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
|
||||||
|
тай-брейк, а несравнимых наборов ноль.
|
||||||
|
|
||||||
|
Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат,
|
||||||
|
настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным
|
||||||
|
разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает
|
||||||
|
кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк.
|
||||||
|
Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 —
|
||||||
|
`basal_energy_burned` слоя `raw`, то есть посекундная развёртка HAE, которую
|
||||||
|
Read API суммировать и так не имеет права.
|
||||||
|
|
||||||
|
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
|
||||||
|
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
||||||
|
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
||||||
|
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
|
||||||
|
пустота, что `0` и `{}`.
|
||||||
|
|
||||||
|
`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
|
||||||
|
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).
|
||||||
|
|
||||||
|
Поле `source` в множество не входит — оно нестабильно и переписывается задним
|
||||||
|
числом (находка 36), так что о полноте измерения ничего не говорит.
|
||||||
|
|
||||||
|
Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
|
||||||
|
множество** ключей: так оно проигрывает любой точке с содержанием и не
|
||||||
|
загрязняет наблюдение о несравнимых наборах.
|
||||||
|
|
||||||
|
Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
|
||||||
|
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
|
||||||
|
канонической формы). Иначе точки несут разные измерения, и надмножество имён
|
||||||
|
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
|
||||||
|
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
|
||||||
|
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
|
||||||
|
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
|
||||||
|
|
||||||
|
Если множества ключей с непустым значением **равны и значения совпали**,
|
||||||
|
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
|
||||||
|
строгое надмножество. Без этого разряда правило теряло бы поля там, где
|
||||||
|
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
|
||||||
|
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
|
||||||
|
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||||||
|
лишние ключи там заведомо пусты, объединять в них нечего.
|
||||||
|
|
||||||
|
**Если полнота ответа не дала — равные множества с разными значениями либо
|
||||||
|
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
|
||||||
|
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
|
||||||
|
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
|
||||||
|
1 912 (находка 49), то есть системно хранит версию, которую источник уже
|
||||||
|
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
|
||||||
|
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
|
||||||
|
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
|
||||||
|
суммировать шаги.
|
||||||
|
|
||||||
|
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
|
||||||
|
накопительных метрик и неверно для мгновенных, которые источник досчитывает
|
||||||
|
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
|
||||||
|
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
|
||||||
|
журнала.
|
||||||
|
|
||||||
|
**Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических
|
||||||
|
форм**: провенанс у таких точек общий, различать их нечем, а порядок элементов
|
||||||
|
в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой,
|
||||||
|
SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по
|
||||||
|
доставке, которого обязан был обойти по байтам, и «внутри доставки решают
|
||||||
|
байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает
|
||||||
|
при этом сохранённое содержимое дословно: хеш не двигается, объект не
|
||||||
|
переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей
|
||||||
|
канонической формой — в витрине остаются байты **встреченной первой**. Выбор
|
||||||
|
между ними ненаблюдаем по построению: различие при совпавшей канонической форме
|
||||||
|
это порядок ключей или последний разряд double, и хеш содержимого объекта
|
||||||
|
считается по канонической форме, а не по байтам.
|
||||||
|
|
||||||
|
Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором
|
||||||
|
он работает. **Когда значения общих содержательных ключей разошлись, полнота
|
||||||
|
ответа не даёт по построению** — «надмножество имён о полноте не говорит
|
||||||
|
ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла
|
||||||
|
сверх того ключи **без содержания** (`Min:0`, `Max:0`). Такие ключи по
|
||||||
|
определению пустоты этого же требования содержания не несут, и второй разряд их
|
||||||
|
бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял
|
||||||
|
их случайно — по тому, что каноническая форма с ключом `Max` сортируется раньше
|
||||||
|
формы с одним `date`, — и рассчитывать на такую защиту было нельзя.
|
||||||
|
|
||||||
|
**Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь.**
|
||||||
|
Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату
|
||||||
|
разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый
|
||||||
|
исход, новый — чередование по последней доставке, то есть перезапись объекта и
|
||||||
|
`WARN` на каждой доставке. Защита остаётся операционной («наборы метрик между
|
||||||
|
автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы
|
||||||
|
следующий разбор не искал причину заново.
|
||||||
|
|
||||||
|
**Победитель SHALL быть функцией множества точек координаты и их происхождения
|
||||||
|
(доставка или витрина), а не порядка элементов внутри доставки.** Попарная
|
||||||
|
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
|
||||||
|
вместе они образуют нетранзитивное отношение победы (A превосходит B по
|
||||||
|
полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная
|
||||||
|
свёртка одной и той же доставки меняет содержимое объекта. Поэтому система
|
||||||
|
SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
||||||
|
победителя среди оставшихся по тотальному порядку — сперва происхождение,
|
||||||
|
затем каноническая форма.
|
||||||
|
|
||||||
|
**Правило тем самым есть явная функция порядка журнала, и цена этого называется
|
||||||
|
вслух.** Функцией множества оно быть перестало: «пришедшая побеждает» не
|
||||||
|
коммутативно. Витрина остаётся свёрткой журнала только пока порядок свёртки
|
||||||
|
равен порядку журнала — требование, которое capability приёма обязана
|
||||||
|
обеспечивать, а capability пересборки обязана проверять оракулом. Восстановить
|
||||||
|
коммутативность «для чистоты» MUST NOT: это откатило бы починку молча.
|
||||||
|
|
||||||
|
Сравнение по `received_at` для тай-брейка не годится: у сохранённой точки нет
|
||||||
|
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с
|
||||||
|
чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала
|
||||||
|
бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а
|
||||||
|
«пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку
|
||||||
|
журнала. Запрет этот **бюджетный, а не принципиальный**: он назван решением
|
||||||
|
владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного
|
||||||
|
приёма закрыть на приёме не удастся, провенанс (на объект, не на точку)
|
||||||
|
остаётся единственным ходом, и спека обязана это допускать, а не запрещать
|
||||||
|
вечно.
|
||||||
|
|
||||||
|
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
|
||||||
|
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
|
||||||
|
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
|
||||||
|
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
|
||||||
|
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
|
||||||
|
координаты попадают в оба списка.
|
||||||
|
|
||||||
|
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
|
||||||
|
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
|
||||||
|
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
|
||||||
|
станет массовым.
|
||||||
|
|
||||||
|
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||||||
|
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||||||
|
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
|
||||||
|
число вместо присланного.
|
||||||
|
|
||||||
|
#### Scenario: Бедная точка не стирает поля богатой
|
||||||
|
|
||||||
|
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
|
||||||
|
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
|
||||||
|
- **THEN** сохранённая точка остаётся с `context`
|
||||||
|
|
||||||
|
#### Scenario: Поля без содержания полноты не добавляют
|
||||||
|
|
||||||
|
- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
|
||||||
|
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
|
||||||
|
- **THEN** остаётся точка с `date` и `qty`
|
||||||
|
|
||||||
|
#### Scenario: При равном содержании поля не теряются
|
||||||
|
|
||||||
|
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||||||
|
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
|
||||||
|
- **THEN** остаётся точка с `Min` и `Max`
|
||||||
|
|
||||||
|
#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку
|
||||||
|
|
||||||
|
- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
|
||||||
|
- **AND** по тем же координатам следующей доставкой приезжает точка
|
||||||
|
`{date, qty:12}`
|
||||||
|
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
|
||||||
|
- **AND** счётчик перезаписей растёт
|
||||||
|
|
||||||
|
#### Scenario: Одинаково полные точки с разными значениями
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка предыдущей доставки
|
||||||
|
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
|
||||||
|
другим значением
|
||||||
|
- **THEN** в витрине остаётся пришедшая точка
|
||||||
|
|
||||||
|
#### Scenario: Одинаково полные точки внутри одной доставки
|
||||||
|
|
||||||
|
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
|
||||||
|
множествами ключей и разными значениями
|
||||||
|
- **THEN** остаётся точка с меньшей канонической формой
|
||||||
|
- **AND** исход не зависит от порядка этих точек в массиве
|
||||||
|
|
||||||
|
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка
|
||||||
|
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
|
||||||
|
другими байтами
|
||||||
|
- **THEN** в витрине остаются сохранённые байты
|
||||||
|
- **AND** объект не переписывается
|
||||||
|
|
||||||
|
#### Scenario: Полнота сильнее происхождения
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
|
||||||
|
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
|
||||||
|
значениями общих ключей
|
||||||
|
- **THEN** остаётся сохранённая точка
|
||||||
|
- **AND** счётчик удержаний в итоге разбора доставки растёт
|
||||||
|
|
||||||
|
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||||||
|
|
||||||
|
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||||||
|
ключ с непустым значением, которого нет у другой
|
||||||
|
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
|
||||||
|
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||||||
|
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||||||
|
|
||||||
|
#### Scenario: Содержимое, которое не разбирается в объект
|
||||||
|
|
||||||
|
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
|
||||||
|
не разбирающееся как JSON-объект
|
||||||
|
- **THEN** остаётся точка с полями
|
||||||
|
- **AND** счётчик несравнимых наборов не растёт
|
||||||
|
|
||||||
|
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
|
||||||
|
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
|
||||||
|
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
|
||||||
|
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
|
||||||
|
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
|
||||||
|
|
||||||
|
#### Scenario: Столкновение с различием содержимого оставляет след
|
||||||
|
|
||||||
|
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
|
||||||
|
отличается от уже сохранённой
|
||||||
|
- **THEN** система пишет запись уровня `WARN` без значений точки
|
||||||
|
- **AND** запись несёт координаты объекта: метрику, слой и час
|
||||||
|
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
|
||||||
|
|
||||||
|
#### Scenario: Дребезг сериализации столкновением не считается
|
||||||
|
|
||||||
|
- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
|
||||||
|
последним разрядом числа
|
||||||
|
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется
|
||||||
|
|
||||||
|
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
|
||||||
|
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
|
||||||
|
экспортом Apple — то есть месяцами.
|
||||||
|
|
||||||
|
### Requirement: Замена версии сущности не теряет содержания
|
||||||
|
|
||||||
|
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||||
|
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||||||
|
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||||||
|
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||||||
|
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||||||
|
`basalEnergy`.
|
||||||
|
|
||||||
|
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||||||
|
содержания** сохранённой. Порядок разбора:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. хеш канонического содержимого совпал → содержимое не пишется,
|
||||||
|
провенанс поднимается до
|
||||||
|
более поздней позиции журнала
|
||||||
|
2. содержание приехавшей покрывает сохранённую
|
||||||
|
и сверх того → приехавшая замещает целиком
|
||||||
|
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
4. содержание сравнимо, наборы равны → версия из более поздней
|
||||||
|
доставки журнала
|
||||||
|
5. наборы несравнимы → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||||||
|
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
|
||||||
|
гасит отношение включения, когда значения общих содержательных ключей
|
||||||
|
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
|
||||||
|
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
|
||||||
|
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
|
||||||
|
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
|
||||||
|
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
|
||||||
|
совпали» здесь быть MUST NOT.
|
||||||
|
|
||||||
|
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
|
||||||
|
содержимого:
|
||||||
|
|
||||||
|
1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
|
||||||
|
непуст;
|
||||||
|
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
|
||||||
|
включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
|
||||||
|
с тем же условием: иначе ключ с пустым значением исчезает по жребию
|
||||||
|
тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
|
||||||
|
пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
|
||||||
|
которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
|
||||||
|
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
|
||||||
|
быть объект; где массив — массив. Версия, подменившая объект или массив
|
||||||
|
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
|
||||||
|
`null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
|
||||||
|
тай-брейк журнала;
|
||||||
|
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
|
||||||
|
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
|
||||||
|
`[null,null,null]` не теряет и длины — притом что маршрут это 95%
|
||||||
|
содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
|
||||||
|
опустошение элементов — законные признаки «приехало меньше».
|
||||||
|
|
||||||
|
Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
|
||||||
|
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
|
||||||
|
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
|
||||||
|
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
|
||||||
|
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
|
||||||
|
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||||||
|
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
|
||||||
|
HAE состоят из объектов, а не из чисел.
|
||||||
|
|
||||||
|
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
|
||||||
|
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
|
||||||
|
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
|
||||||
|
там, где ни то, ни другое ничего не несёт.
|
||||||
|
|
||||||
|
Предел правила называется вслух и не закрывается: сокращение **внутри**
|
||||||
|
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
|
||||||
|
длине) не ловится ничем, кроме сверки с телом в архиве.
|
||||||
|
|
||||||
|
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
|
||||||
|
множества ключей — то же правило, что для точки: такая версия проигрывает любой
|
||||||
|
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
|
||||||
|
|
||||||
|
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||||||
|
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||||||
|
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||||||
|
необратимым.
|
||||||
|
|
||||||
|
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||||
|
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
|
||||||
|
capability приёма, но равенство это неполное: строка учёта становится видимой
|
||||||
|
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
|
||||||
|
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
|
||||||
|
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||||
|
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
|
||||||
|
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
|
||||||
|
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
|
||||||
|
провенанса, которой у точки нет.
|
||||||
|
|
||||||
|
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||||||
|
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||||||
|
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||||||
|
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||||
|
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
|
||||||
|
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
|
||||||
|
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
|
||||||
|
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
|
||||||
|
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
|
||||||
|
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
|
||||||
|
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
|
||||||
|
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||||||
|
|
||||||
|
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
|
||||||
|
доставка, свёрнутая повторно) ничего не меняют.
|
||||||
|
|
||||||
|
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
|
||||||
|
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||||||
|
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||||||
|
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
|
||||||
|
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
|
||||||
|
|
||||||
|
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
|
||||||
|
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
|
||||||
|
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
|
||||||
|
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
|
||||||
|
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
|
||||||
|
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||||||
|
журнала, причём молча.
|
||||||
|
|
||||||
|
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
|
||||||
|
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
|
||||||
|
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
|
||||||
|
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
|
||||||
|
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
|
||||||
|
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
|
||||||
|
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
|
||||||
|
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
|
||||||
|
канонической форме между доставками заморозил бы тренировку на произвольной из
|
||||||
|
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
|
||||||
|
хранил бы меньшее значение (находка 49).
|
||||||
|
|
||||||
|
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||||
|
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
||||||
|
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
|
||||||
|
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
|
||||||
|
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
|
||||||
|
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
|
||||||
|
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
|
||||||
|
до конца**: при совпавших канонических формах решает минимум исходных байтов —
|
||||||
|
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
|
||||||
|
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
|
||||||
|
содержимом. Попарная свёртка здесь
|
||||||
|
неверна ровно так же, как она была неверна для точек: покрытие — частичный
|
||||||
|
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
|
||||||
|
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
|
||||||
|
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
|
||||||
|
SHALL до сравнения с сохранённой.
|
||||||
|
|
||||||
|
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
|
||||||
|
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
|
||||||
|
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
|
||||||
|
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
|
||||||
|
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
|
||||||
|
ни на одно. На счётчик удержаний опирается единственный контроль того, что
|
||||||
|
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
|
||||||
|
шума.
|
||||||
|
|
||||||
|
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
|
||||||
|
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
|
||||||
|
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
|
||||||
|
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
|
||||||
|
значит ничего. Побайтовое различие при
|
||||||
|
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
|
||||||
|
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
|
||||||
|
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
|
||||||
|
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
|
||||||
|
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
|
||||||
|
|
||||||
|
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||||
|
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||||
|
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||||||
|
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||||||
|
наблюдение.
|
||||||
|
|
||||||
|
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||||
|
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
|
||||||
|
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
|
||||||
|
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
|
||||||
|
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
|
||||||
|
счётчиком и `WARN`.
|
||||||
|
|
||||||
|
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно, добавив `route`
|
||||||
|
- **THEN** в хранилище лежит версия с маршрутом
|
||||||
|
|
||||||
|
#### Scenario: Досчитанные значения при том же наборе полей побеждают
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
|
||||||
|
изменившимися значениями, доставкой с более поздней позицией журнала
|
||||||
|
- **THEN** в хранилище лежит приехавшая версия
|
||||||
|
|
||||||
|
#### Scenario: Версия из более ранней доставки не откатывает витрину
|
||||||
|
|
||||||
|
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
|
||||||
|
свёрнута сперва более поздняя по журналу, затем более ранняя
|
||||||
|
- **THEN** в хранилище лежит версия из более поздней доставки
|
||||||
|
- **AND** тот же исход даёт свёртка в обратном порядке
|
||||||
|
|
||||||
|
#### Scenario: Обеднённая версия сохранённую не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
|
||||||
|
сохранённой, **и** с изменившимися значениями общих полей
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
|
||||||
|
тренировки
|
||||||
|
|
||||||
|
#### Scenario: Усечённый маршрут сохранённый не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
|
||||||
|
`route` короче сохранённого
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Маршрут из пустых элементов сохранённый не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
|
||||||
|
элементы которого пусты (`null` либо пустой объект)
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Скелет из скаляров сохранённую тренировку не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
|
||||||
|
заменён числом, а каждый массив — массивом той же длины из `null`
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Ключ с пустым значением не исчезает по жребию
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
|
||||||
|
сохранённой было пустым, при совпадающих содержательных ключах
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком удержаний
|
||||||
|
|
||||||
|
#### Scenario: Пустой ключ не запирает законный досчёт
|
||||||
|
|
||||||
|
- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
|
||||||
|
не несёт, но приносит содержательный ключ, которого у сохранённой не было
|
||||||
|
- **THEN** приехавшая замещает сохранённую
|
||||||
|
- **AND** счётчик удержаний не растёт
|
||||||
|
|
||||||
|
#### Scenario: Две версии одной сущности в одном теле
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два элемента секции с одним `id`
|
||||||
|
- **THEN** исход не зависит от их порядка в массиве
|
||||||
|
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||||||
|
|
||||||
|
#### Scenario: Три версии одной сущности в одном теле
|
||||||
|
|
||||||
|
- **WHEN** тело содержит три элемента секции с одним `id`, из которых один
|
||||||
|
покрывает второй, а третий несравним с обоими
|
||||||
|
- **THEN** победитель одинаков при любой перестановке этих трёх элементов
|
||||||
|
|
||||||
|
#### Scenario: Две версии разного содержания при равной длине массивов
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два элемента секции с одним `id`, содержимое которых
|
||||||
|
различается, но множества ключей и длины верхнеуровневых массивов совпадают
|
||||||
|
- **THEN** факт учитывается счётчиком различающихся версий
|
||||||
|
|
||||||
|
#### Scenario: Разные байты при совпавшей канонической форме событием не считаются
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два элемента секции с одним `id`, различающихся только
|
||||||
|
порядком ключей либо записью числа
|
||||||
|
- **THEN** счётчик различающихся версий не растёт
|
||||||
|
- **AND** в хранилище лежат одни и те же байты при любой перестановке элементов
|
||||||
|
|
||||||
|
#### Scenario: Повторная присылка обновляет провенанс
|
||||||
|
|
||||||
|
- **WHEN** та же сущность приезжает повторно с тем же содержимым доставкой,
|
||||||
|
стоящей в журнале позже сохранённой
|
||||||
|
- **THEN** содержимое не переписывается
|
||||||
|
- **AND** провенанс сущности указывает на более позднюю доставку
|
||||||
|
|
||||||
|
#### Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому
|
||||||
|
|
||||||
|
- **WHEN** журнал несёт содержимое A, затем B, затем снова A, и доставка с B
|
||||||
|
свёрнута последней
|
||||||
|
- **THEN** содержимое сущности и отпечаток витрины совпадают со свёрткой того
|
||||||
|
же журнала в его порядке
|
||||||
|
|
||||||
|
#### Scenario: Составной ключ не даёт коллизии отпечатка
|
||||||
|
|
||||||
|
- **WHEN** две витрины различаются только тем, где проходит граница между родом
|
||||||
|
и идентификатором записи
|
||||||
|
- **THEN** отпечатки не совпадают
|
||||||
|
|
||||||
|
#### Scenario: Несравнимые наборы полей не объединяются
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
|
||||||
|
сохранённой, и теряет содержательный ключ, который у сохранённой есть
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Повторная свёртка того же журнала состояния не меняет
|
||||||
|
|
||||||
|
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||||
|
- **THEN** содержимое сущностей не меняется
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Проигрыш пришедшей точки считается отдельно
|
||||||
|
|
||||||
|
Итог разбора доставки SHALL нести счётчик координат, где пришедшая точка
|
||||||
|
проиграла сохранённой, и координаты первых таких объектов — метрику, слой и
|
||||||
|
час, без значений точек.
|
||||||
|
|
||||||
|
Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают
|
||||||
|
столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000
|
||||||
|
раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя —
|
||||||
|
то есть единственное событие, ради наблюдения за которым правило и переписано,
|
||||||
|
остаётся невидимым.
|
||||||
|
|
||||||
|
Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая
|
||||||
|
**строго полнее**. То есть счётчик меряет одно направление правила полноты — то,
|
||||||
|
в котором оно спорит с журналом; случай «пришедшая строго полнее» им не
|
||||||
|
считается, потому что там полнота и журнал согласны. Молчащий счётчик означает,
|
||||||
|
что правило полноты перестало спорить вовсе, и это событие для разбора, а не
|
||||||
|
отказ.
|
||||||
|
|
||||||
|
Счётчик SHALL быть виден не только в логе свёртки, но и в отчёте пересборки —
|
||||||
|
рядом с удержанными версиями сущностей и по той же причине: сходимость
|
||||||
|
отпечатка правило удержания не проверяет по построению, живой приём и пересборка
|
||||||
|
пользуются одним правилом и одинаково сойдутся на одинаково удержанной точке.
|
||||||
|
|
||||||
|
Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье
|
||||||
|
чувствительнее токенов.
|
||||||
|
|
||||||
|
#### Scenario: Удержание сохранённой точки видно в итоге доставки
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка, строго более полная, чем пришедшая
|
||||||
|
- **WHEN** доставка сворачивается
|
||||||
|
- **THEN** счётчик удержаний растёт
|
||||||
|
- **AND** координаты объекта попадают в список удержаний
|
||||||
|
- **AND** счётчик остаётся нулевым, когда побеждает пришедшая точка
|
||||||
|
|
||||||
|
### Requirement: Потеря содержания пришедшей точкой считается и не молчит
|
||||||
|
|
||||||
|
Система SHALL считать отдельным счётчиком координаты, где победителем оказалась
|
||||||
|
**пришедшая** точка, а у проигравшей был ключ с непустым значением, которого у
|
||||||
|
победительницы нет. Координаты таких объектов SHALL попадать в лог, и система
|
||||||
|
SHALL писать `WARN` — без значений точек.
|
||||||
|
|
||||||
|
Это единственное направление, в котором новое правило способно потерять
|
||||||
|
содержание, и защитить его нечем **по построению**: разряд полноты в этом
|
||||||
|
случае погашен — значения общих содержательных ключей разошлись, и надмножество
|
||||||
|
имён о полноте не говорит ничего. До смены тай-брейка тот же исход случался по
|
||||||
|
жребию байтового порядка и так же молча; разница в том, что теперь он
|
||||||
|
детерминирован, а значит либо не случается вовсе, либо случается всегда.
|
||||||
|
|
||||||
|
Счётчик SHALL быть отдельным и от перезаписей, и от удержаний: перезаписи
|
||||||
|
считают столкновение в обе стороны, удержания — противоположное направление, и
|
||||||
|
ни один из них на вопрос «потеряли ли мы содержание» не отвечает.
|
||||||
|
|
||||||
|
Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129
|
||||||
|
спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие
|
||||||
|
обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем
|
||||||
|
же решением и по той же причине, по какой отложено объединение полей.
|
||||||
|
|
||||||
|
Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.
|
||||||
|
|
||||||
|
#### Scenario: Пришедшая точка унесла содержательный ключ сохранённой
|
||||||
|
|
||||||
|
- **GIVEN** сохранена точка `{date, asleep:7.5, rem:1.2}`
|
||||||
|
- **WHEN** следующей доставкой приезжает точка `{date, asleep:7.4}`
|
||||||
|
- **THEN** в витрине остаётся пришедшая точка
|
||||||
|
- **AND** счётчик потери содержания растёт, а счётчик удержаний — нет
|
||||||
|
- **AND** система пишет `WARN` с координатами объекта и без значений точек
|
||||||
|
|
||||||
|
#### Scenario: Пришедшая точка ничего не унесла
|
||||||
|
|
||||||
|
- **WHEN** множества ключей у сохранённой и пришедшей совпадают, а значения
|
||||||
|
различаются
|
||||||
|
- **THEN** счётчик потери содержания не растёт
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
## 1. Правило слияния точек
|
||||||
|
|
||||||
|
- [x] 1.1 `candidate` получает флаг `incoming`; схлопывание совпавших
|
||||||
|
канонических форм поднимает флаг, а байты оставляет встреченные первыми
|
||||||
|
- [x] 1.2 `pointLess` — лексикографический порядок по паре
|
||||||
|
`(не incoming, каноническая форма)`; `pointDominates` не трогается
|
||||||
|
- [x] 1.3 `resolve` возвращает признак «победил сохранённый при наличии
|
||||||
|
пришедших»; `mergePoints` считает удержания
|
||||||
|
- [x] 1.4 `MergeStats.PointsHeld` и `PointsHeldAt`; проброс через `mergeResult`,
|
||||||
|
`SaveBuckets`, `fold.Stats`, `replay.Outcome`/`Report` и лог свёртки — без
|
||||||
|
значений точек
|
||||||
|
|
||||||
|
## 2. Порядок свёртки равен порядку журнала
|
||||||
|
|
||||||
|
- [x] 2.1 `Worker.Pass` прекращает проход на исходе `Deferred`; доставка,
|
||||||
|
оставшаяся `pending` без такого исхода, курсор двигает
|
||||||
|
- [x] 2.2 `Store.ParsedAfter` — есть ли доставка позже данной в статусе
|
||||||
|
`parsed` или `partial` (кортежный предикат; индекс — `delivery_received_at`,
|
||||||
|
цена измерена и названа в design.md)
|
||||||
|
- [x] 2.3 воркер пишет одну запись `WARN` на доставку о свёртке вне порядка
|
||||||
|
журнала; пересборка эту проверку не выполняет
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
- [x] 3.1 пришедшая побеждает сохранённую при равной полноте
|
||||||
|
- [x] 3.2 столкновение внутри одной доставки решается порядком канонических
|
||||||
|
форм и не зависит от порядка элементов массива
|
||||||
|
- [x] 3.3 повторная присылка канонически совпавшего содержимого не переписывает
|
||||||
|
объект (хеш не двигается)
|
||||||
|
- [x] 3.4 полнота сильнее происхождения; счётчик удержаний растёт
|
||||||
|
- [x] 3.5 сходимость: живой путь через воркер с отложенной занятостью базы
|
||||||
|
доставкой даёт тот же отпечаток, что пересборка
|
||||||
|
- [x] 3.6 проход воркера не перешагивает отложенную доставку
|
||||||
|
- [x] 3.7 `WARN` о свёртке вне порядка журнала пишется, не содержит значений и
|
||||||
|
не пишется на `failed`-преемнице
|
||||||
|
- [x] 3.8 при разошедшихся значениях пустые ключи сохранённой точки её не
|
||||||
|
удерживают (сценарий, парный к 3.4)
|
||||||
|
|
||||||
|
## 4. Документы
|
||||||
|
|
||||||
|
- [x] 4.1 `docs/architecture.md` — «Разрешение столкновений» плюс **все**
|
||||||
|
упоминания правила: инвариант в шапке, «порядок прихода значения не имеет» в
|
||||||
|
разделе синхронизации; таблица двух правил равной полноты (точки против
|
||||||
|
сущностей) с критерием выбора
|
||||||
|
- [x] 4.2 `CLAUDE.md` — формулировка инварианта «ничего не теряем молча»:
|
||||||
|
«выигрывает более полная, при равной полноте — стоящая позже в журнале»
|
||||||
|
- [x] 4.3 `internal/store/entity.go` — комментарии, чей довод («порядок свёртки
|
||||||
|
журналу не равен») этим изменением сужен до окна видимости
|
||||||
|
- [x] 4.4 `docs/research/apple-health.md` — перемер 2026-08-04 с **методом**:
|
||||||
|
ключ со слоем, 155 доставок, 460 995 координат, 80 129 спорных, 981 полнотой,
|
||||||
|
79 148 тай-брейком, 75 494 меняют исход
|
||||||
|
- [x] 4.5 `docs/review.md` — запись о дефекте (посылка оракула умерла с ростом
|
||||||
|
корпуса) и о том, что конвейер её не ловил
|
||||||
|
- [x] 4.7 вопрос владельцу в `docs/tasks/items/journal-order-on-ingest.md`
|
||||||
|
(раздел «Вопросы» + тег `question`): решение (в) порядок журнала не
|
||||||
|
восстанавливает, а цена окна выросла с вывода слоя до значений точек
|
||||||
|
- [x] 4.6 `docs/conventions/` — промоут двух правил: «в проверке на живом
|
||||||
|
корпусе утверждается инвариант, число печатается» и «оракул сходимости
|
||||||
|
называет свою посылку рядом с собой»
|
||||||
|
|
||||||
|
## 5. Оракулы
|
||||||
|
|
||||||
|
- [x] 5.1 `task gate` зелёный
|
||||||
|
- [x] 5.2 `task verify:archive` — ноль противоречащих часов, `step_count`
|
||||||
|
накопительная, отпечаток изменился
|
||||||
|
- [x] 5.3 `task verify:archive` второй раз подряд — тот же отпечаток
|
||||||
|
- [x] 5.4 `task verify:busy` зелёный
|
||||||
|
- [x] 5.5 замер удержаний на живом архиве — число названо, не утверждено
|
||||||
|
|
||||||
|
## Приёмочные критерии из ревью предложения (профиль `design`, проход `rubric`)
|
||||||
|
|
||||||
|
Свойства, по которым судится узел рода «правило слияния версий в
|
||||||
|
хранилище-свёртке по журналу» плюс «упорядочивающий воркер очереди».
|
||||||
|
|
||||||
|
- [x] Р1 исход слияния — функция префикса журнала и только его; запрос воркера
|
||||||
|
«есть ли доставка позже» влияет на лог и MUST NOT влиять на содержимое
|
||||||
|
- [x] Р2 законные расхождения живого пути и пересборки перечислены поимённо, и
|
||||||
|
у оракула названа посылка
|
||||||
|
- [x] Р3 повторная свёртка идемпотентна на суффиксе журнала; свёртка более
|
||||||
|
ранней доставки после более поздней меняет состояние наблюдаемо
|
||||||
|
- [x] Р4 победитель — функция множества кандидатов и их происхождения, но не
|
||||||
|
порядка точек внутри доставки; отношение победы ациклично (проверка
|
||||||
|
перестановками ТРОЙКИ, не пары)
|
||||||
|
- [x] Р5 правило тотально на вырожденном входе: один кандидат, равные
|
||||||
|
канонические формы с разными байтами, не-JSON, несравнимые множества
|
||||||
|
- [x] Р6 победитель сохраняется дословно; цена лишних записей названа числом
|
||||||
|
- [x] Р7 правило структурно: не читает ни значение точки, ни род, ни витрину
|
||||||
|
- [x] Р8 барьер очереди не останавливает поток: названо, что держит очередь и
|
||||||
|
что её освобождает; канонизация и сжатие вне транзакции записи
|
||||||
|
- [x] Р9 наблюдаемость отличает «правило сработало» от «не сработало» и не
|
||||||
|
несёт значений здоровья; тест на утечку разбирает запись, а не ищет
|
||||||
|
подстроку в сыром буфере
|
||||||
|
- [x] Р10 ни одно утверждение теста не пришпилено к числу, производному от
|
||||||
|
размера корпуса: утверждается инвариант, число печатается
|
||||||
|
- [x] Р11 два правила равной полноты (точки и сущности) не расходятся молча:
|
||||||
|
различие механизмов и критерий выбора записаны
|
||||||
|
- [x] Р12 место снапшота Apple в порядке журнала — либо названо, либо явно
|
||||||
|
отложено до задачи импорта (сегодня стадия снапшота пуста)
|
||||||
|
|
||||||
|
## Критерии приёмки задачи
|
||||||
|
|
||||||
|
Приходят из `docs/tasks/items/tie-break-equal-completeness.md`, дословно. Числа
|
||||||
|
в них производны от размера корпуса — по каждому названо, чем именно измерено и
|
||||||
|
что при этом печатается, а не утверждается (`docs/review.md`, 2026-08-02).
|
||||||
|
|
||||||
|
- [x] ни одна метрика не теряет род из-за столкновения равной полноты;
|
||||||
|
`step_count` снова накопительная — оракул: `task verify:archive`, ноль
|
||||||
|
противоречащих часов
|
||||||
|
- [x] живая свёртка и пересборка дают **один** отпечаток витрины — оракул:
|
||||||
|
`healthlog reindex` против живого состояния; это же и есть страховка от
|
||||||
|
потерянной коммутативности
|
||||||
|
- [x] повторный прогон реплея даёт тот же отпечаток — оракул:
|
||||||
|
`task verify:archive`, второй прогон подряд
|
||||||
|
- [x] столкновение **внутри одной доставки** разрешается прежним байтовым
|
||||||
|
порядком — оракул: тест на двух точках одной доставки с равной полнотой
|
||||||
|
- [x] «пришедшая точка проиграла сохранённой» считается отдельно от общего
|
||||||
|
`MergeStats.Overwrites` — оракул: тест плюс прогон на живом архиве, число
|
||||||
|
сходится с замером 2026-08-04
|
||||||
@@ -120,19 +120,76 @@
|
|||||||
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
|
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
|
||||||
Распараллеливать свёртку MUST NOT.
|
Распараллеливать свёртку MUST NOT.
|
||||||
|
|
||||||
|
Порядок здесь — не удобство отладки, а условие правильности содержимого:
|
||||||
|
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
|
||||||
|
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
|
||||||
|
доставка возвращает координату к версии, которую источник уже пересчитал, и
|
||||||
|
живая витрина расходится с тем, что даёт пересборка.
|
||||||
|
|
||||||
|
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
|
||||||
|
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
|
||||||
|
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
|
||||||
|
|
||||||
|
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
|
||||||
|
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
|
||||||
|
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
|
||||||
|
целиком, а приём продолжал бы отвечать `200`.
|
||||||
|
|
||||||
|
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
|
||||||
|
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
|
||||||
|
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
|
||||||
|
возобновляется сигналом приёма или периодическим пробуждением.
|
||||||
|
|
||||||
|
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
|
||||||
|
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
|
||||||
|
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
|
||||||
|
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
|
||||||
|
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
|
||||||
|
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
|
||||||
|
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
|
||||||
|
|
||||||
Достижимая гарантия называется точно: в порядке `(received_at, id)`
|
Достижимая гарантия называется точно: в порядке `(received_at, id)`
|
||||||
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
|
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
|
||||||
ставшая видимой позже курсора прохода, подбирается следующим проходом;
|
ставшая видимой позже курсора прохода, подбирается следующим проходом;
|
||||||
абсолютного порядка при конкурентных приёмах система не обещает.
|
абсолютного порядка при конкурентных приёмах система не обещает, потому что
|
||||||
|
строка учёта становится видимой только после записи тела (измерено 184 мс на
|
||||||
|
62 МиБ).
|
||||||
|
|
||||||
Последствие этого предела называется вслух: доставка без плотных метрик,
|
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
|
||||||
свёрнутая раньше своей предшественницы, слоя не выведет и получит `failed` — то
|
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
|
||||||
есть её точки в витрину не попадут до пересборки. Живое состояние в этом случае
|
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
|
||||||
расходится с тем, что даёт `healthlog reindex`. Окно узкое (обе доставки должны
|
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
|
||||||
приниматься одновременно, и только у автоматизации без плотных метрик), и
|
секундах и без значений точек.
|
||||||
изменение его сужает, а не открывает: прежде свёртка шла в порядке завершения
|
|
||||||
обработчиков. Устранение предела — отдельный вопрос, оно требует удерживать
|
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
|
||||||
порядок на самом приёме.
|
только если свёртка состоялась: после свёртки предикат уже видит саму эту
|
||||||
|
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
|
||||||
|
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
|
||||||
|
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
|
||||||
|
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
|
||||||
|
`healthlog reindex`.
|
||||||
|
|
||||||
|
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
|
||||||
|
записала, и перестановка относительно неё содержимого не разводит. А
|
||||||
|
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
|
||||||
|
шум, на который перестают смотреть.
|
||||||
|
|
||||||
|
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
|
||||||
|
построению, и её тишина здесь содержательна.
|
||||||
|
|
||||||
|
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
|
||||||
|
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
|
||||||
|
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
|
||||||
|
объяснения.
|
||||||
|
|
||||||
|
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
|
||||||
|
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
|
||||||
|
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
|
||||||
|
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
|
||||||
|
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
|
||||||
|
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
|
||||||
|
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
|
||||||
|
приёме.
|
||||||
|
|
||||||
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
|
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
|
||||||
курсором в пределах одного прохода. Курсор обязателен для завершимости:
|
курсором в пределах одного прохода. Курсор обязателен для завершимости:
|
||||||
@@ -159,6 +216,21 @@
|
|||||||
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
|
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
|
||||||
порядку доставки той же автоматизации, а не соседа по времени вставки
|
порядку доставки той же автоматизации, а не соседа по времени вставки
|
||||||
|
|
||||||
|
#### Scenario: Отложенная доставка держит очередь
|
||||||
|
|
||||||
|
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
|
||||||
|
занятостью базы
|
||||||
|
- **WHEN** воркер делает проход
|
||||||
|
- **THEN** её преемницы в этом проходе не сворачиваются
|
||||||
|
- **AND** следующий проход снова начинает с отложенной доставки
|
||||||
|
|
||||||
|
#### Scenario: Свёртка вне порядка журнала не молчит
|
||||||
|
|
||||||
|
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
|
||||||
|
вышла из очереди
|
||||||
|
- **WHEN** воркер сворачивает её
|
||||||
|
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
|
||||||
|
|
||||||
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
|
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
|
||||||
|
|
||||||
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
|
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
|
||||||
@@ -170,7 +242,6 @@
|
|||||||
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
|
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
|
||||||
- **WHEN** наступает очередное периодическое пробуждение
|
- **WHEN** наступает очередное периодическое пробуждение
|
||||||
- **THEN** воркер пробует свернуть её снова
|
- **THEN** воркер пробует свернуть её снова
|
||||||
|
|
||||||
### Requirement: Подбор неразобранного при старте — та же операция
|
### Requirement: Подбор неразобранного при старте — та же операция
|
||||||
|
|
||||||
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
|
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
|
||||||
@@ -320,4 +391,3 @@ NOT: факт уходит в `DEBUG`, приём продолжается.
|
|||||||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||||||
- **AND** запись отличает занятость базы от прочих причин отказа
|
- **AND** запись отличает занятость базы от прочих причин отказа
|
||||||
- **AND** тот же отказ по другой причине этого признака не несёт
|
- **AND** тот же отказ по другой причине этого признака не несёт
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,48 @@
|
|||||||
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
|
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
|
||||||
принятое со `200`, вечно отказывает на каждой пересборке.
|
принятое со `200`, вечно отказывает на каждой пересборке.
|
||||||
|
|
||||||
|
**Условие сходимости SHALL называться требованием, а не оговоркой сценария.**
|
||||||
|
Правило слияния точек разрешает равную полноту в пользу пришедшей доставки, то
|
||||||
|
есть содержимое витрины есть функция **порядка** свёртки, а не только множества
|
||||||
|
доставок. Отпечаток пересборки равен отпечатку накопленной приёмом витрины
|
||||||
|
тогда и только тогда, когда живая свёртка шла в порядке журнала. Прежде от
|
||||||
|
порядка зависел только вывод слоя; теперь от него зависят значения точек, то
|
||||||
|
есть цена нарушения выросла и должна быть названа здесь, а не выведена
|
||||||
|
читателем.
|
||||||
|
|
||||||
|
**Посылка равенства SHALL перечисляться рядом с ним**, а не подразумеваться,
|
||||||
|
потому что оракул, чья посылка не названа, краснеет по причине, к правилу
|
||||||
|
отношения не имеющей, и краснота становится неотличимой от дефекта. Посылок
|
||||||
|
три: живая свёртка шла в порядке журнала; в журнале нет доставок, чью свёртку
|
||||||
|
живой путь провалил, а пересборка проведёт (статус `failed`); за время
|
||||||
|
пересборки новых доставок не приезжало.
|
||||||
|
|
||||||
|
Из этих трёх посылок прогон пересборки SHALL печатать рядом с отпечатком ту,
|
||||||
|
которую он измеряет сам, — число `failed`. Первая посылка прогону
|
||||||
|
**недоступна по построению**: записи «свёртка вне порядка журнала» рождаются
|
||||||
|
только в живом воркере, нигде не хранятся, и пересборке та же спека проверку
|
||||||
|
прямо запрещает. Она проверяется журналом сервиса, и это сказано здесь, чтобы
|
||||||
|
следующий автор не приписал к отпечатку константный ноль, выдав его за
|
||||||
|
подтверждение.
|
||||||
|
|
||||||
|
Равенство «пересборка = приём» SHALL проверяться оракулом, а не рассуждением:
|
||||||
|
журнал, содержащий доставки с равнополными столкновениями, проигранный живым
|
||||||
|
путём (фоновый воркер, в том числе с доставкой, отложенной занятостью базы) и
|
||||||
|
путём пересборки, обязан давать один отпечаток витрины.
|
||||||
|
|
||||||
|
**Место снапшота в порядке журнала этой дельтой не определяется, и это сказано
|
||||||
|
вслух.** Под правилом «побеждает пришедшая» исход столкновения снапшота Apple с
|
||||||
|
точкой HAE зависит от того, какую позицию журнала получит доставка импорта:
|
||||||
|
учтённая сегодняшним временем, она перебила бы все равнополные точки, включая
|
||||||
|
досчитанные задним числом. Стадия снапшота сегодня пуста, поэтому вопрос
|
||||||
|
отложен — но решать его SHALL задача импорта, и явно, а не выбором первого
|
||||||
|
автора.
|
||||||
|
|
||||||
|
Отчёт пересборки SHALL называть два числа про точки — удержанные правилом
|
||||||
|
полноты против пришедшей доставки и потерявшие содержание в пользу пришедшей —
|
||||||
|
рядом с удержанными версиями сущностей и по той же причине: сходимость
|
||||||
|
отпечатка ни одно из них не проверяет по построению.
|
||||||
|
|
||||||
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
|
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
|
||||||
|
|
||||||
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
|
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
|
||||||
@@ -34,6 +76,14 @@
|
|||||||
- **WHEN** журнал проигрывается заново с пустой витрины
|
- **WHEN** журнал проигрывается заново с пустой витрины
|
||||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
|
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
|
||||||
|
|
||||||
|
#### Scenario: Отложенная занятостью доставка не разводит приём и пересборку
|
||||||
|
|
||||||
|
- **GIVEN** журнал, где одни координаты получают равнополные точки с разными
|
||||||
|
значениями от разных доставок
|
||||||
|
- **AND** свёртка одной из доставок откладывается занятостью базы
|
||||||
|
- **WHEN** тот же журнал проигрывается живым путём и путём пересборки
|
||||||
|
- **THEN** отпечатки витрин совпадают
|
||||||
|
|
||||||
#### Scenario: Повторная пересборка ничего не меняет
|
#### Scenario: Повторная пересборка ничего не меняет
|
||||||
|
|
||||||
- **WHEN** пересборка того же журнала выполняется второй раз
|
- **WHEN** пересборка того же журнала выполняется второй раз
|
||||||
@@ -43,7 +93,6 @@
|
|||||||
|
|
||||||
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
|
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
|
||||||
- **THEN** проигрываются обе
|
- **THEN** проигрываются обе
|
||||||
|
|
||||||
### Requirement: Порядок проигрывания задаётся журналом
|
### Requirement: Порядок проигрывания задаётся журналом
|
||||||
|
|
||||||
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
|
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
|
||||||
|
|||||||
+218
-44
@@ -79,6 +79,14 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
|||||||
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
|
полноты, — 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 считаться `null`, пустая строка, число, равное нулю (в
|
||||||
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
||||||
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
||||||
@@ -111,37 +119,92 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
|||||||
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||||||
лишние ключи там заведомо пусты, объединять в них нечего.
|
лишние ключи там заведомо пусты, объединять в них нечего.
|
||||||
|
|
||||||
Если равны и эти множества, а значения различаются, исход MUST быть
|
**Если полнота ответа не дала — равные множества с разными значениями либо
|
||||||
детерминированным и не зависеть от порядка, в котором доставки дошли до
|
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
|
||||||
хранилища: свёртка по журналу обязана давать то же состояние, что приём в
|
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
|
||||||
реальном времени.
|
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
|
||||||
|
1 912 (находка 49), то есть системно хранит версию, которую источник уже
|
||||||
|
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
|
||||||
|
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
|
||||||
|
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
|
||||||
|
суммировать шаги.
|
||||||
|
|
||||||
Победитель MUST быть функцией **множества** точек координаты, а не порядка их
|
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
|
||||||
поступления. Попарная свёртка этого не даёт: полнота — частичный порядок,
|
накопительных метрик и неверно для мгновенных, которые источник досчитывает
|
||||||
тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы
|
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
|
||||||
(A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком).
|
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
|
||||||
При таком цикле повторная свёртка одной и той же доставки меняет содержимое
|
журнала.
|
||||||
объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL
|
|
||||||
отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
|
||||||
победителя среди оставшихся по тотальному порядку — обе операции зависят
|
|
||||||
только от состава множества.
|
|
||||||
|
|
||||||
Сравнение по `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 выбрать победителя тем же
|
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
|
||||||
детерминированным правилом, что и при равных множествах, и MUST оставить
|
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
|
||||||
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
|
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
|
||||||
`WARN` без значений точки. Несравнимый набор — частный случай столкновения:
|
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
|
||||||
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
|
координаты попадают в оба списка.
|
||||||
|
|
||||||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
|
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
|
||||||
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
|
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
|
||||||
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
|
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
|
||||||
больше, чем счётчик, который скажет, если оно наступит.
|
станет массовым.
|
||||||
|
|
||||||
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||||||
выбирает, а не конструирует. Каноническая форма существует только в момент
|
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||||||
@@ -163,21 +226,52 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
|||||||
#### Scenario: При равном содержании поля не теряются
|
#### Scenario: При равном содержании поля не теряются
|
||||||
|
|
||||||
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||||||
- **AND** по тем же координатам приезжает точка `{date, qty}` с другим `qty`
|
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
|
||||||
- **THEN** остаётся точка с `Min` и `Max`
|
- **THEN** остаётся точка с `Min` и `Max`
|
||||||
|
|
||||||
|
#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку
|
||||||
|
|
||||||
|
- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
|
||||||
|
- **AND** по тем же координатам следующей доставкой приезжает точка
|
||||||
|
`{date, qty:12}`
|
||||||
|
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
|
||||||
|
- **AND** счётчик перезаписей растёт
|
||||||
|
|
||||||
#### Scenario: Одинаково полные точки с разными значениями
|
#### Scenario: Одинаково полные точки с разными значениями
|
||||||
|
|
||||||
- **WHEN** по одним координатам приходят две точки с одинаковыми множествами
|
- **GIVEN** по координатам сохранена точка предыдущей доставки
|
||||||
ключей и разными значениями
|
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
|
||||||
- **THEN** исход определяется детерминированно и не зависит от порядка
|
другим значением
|
||||||
воспроизведения доставок
|
- **THEN** в витрине остаётся пришедшая точка
|
||||||
|
|
||||||
|
#### Scenario: Одинаково полные точки внутри одной доставки
|
||||||
|
|
||||||
|
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
|
||||||
|
множествами ключей и разными значениями
|
||||||
|
- **THEN** остаётся точка с меньшей канонической формой
|
||||||
|
- **AND** исход не зависит от порядка этих точек в массиве
|
||||||
|
|
||||||
|
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка
|
||||||
|
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
|
||||||
|
другими байтами
|
||||||
|
- **THEN** в витрине остаются сохранённые байты
|
||||||
|
- **AND** объект не переписывается
|
||||||
|
|
||||||
|
#### Scenario: Полнота сильнее происхождения
|
||||||
|
|
||||||
|
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
|
||||||
|
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
|
||||||
|
значениями общих ключей
|
||||||
|
- **THEN** остаётся сохранённая точка
|
||||||
|
- **AND** счётчик удержаний в итоге разбора доставки растёт
|
||||||
|
|
||||||
#### Scenario: Несравнимые множества считаются, а не сливаются
|
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||||||
|
|
||||||
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||||||
ключ с непустым значением, которого нет у другой
|
ключ с непустым значением, которого нет у другой
|
||||||
- **THEN** остаётся ровно одна точка, выбранная детерминированно
|
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
|
||||||
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||||||
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||||||
|
|
||||||
@@ -631,13 +725,15 @@ HAE состоят из объектов, а не из чисел.
|
|||||||
необратимым.
|
необратимым.
|
||||||
|
|
||||||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
|
||||||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
capability приёма, но равенство это неполное: строка учёта становится видимой
|
||||||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
|
||||||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
|
||||||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
|
||||||
а не от того, кто раньше добрался до базы.
|
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
|
||||||
|
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
|
||||||
|
провенанса, которой у точки нет.
|
||||||
|
|
||||||
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||||||
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||||||
@@ -669,12 +765,17 @@ HAE состоят из объектов, а не из чисел.
|
|||||||
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||||||
журнала, причём молча.
|
журнала, причём молча.
|
||||||
|
|
||||||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
|
||||||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
|
||||||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
|
||||||
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
|
||||||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
|
||||||
версий навсегда, вместе с недосчитанной энергией.
|
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
|
||||||
|
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
|
||||||
|
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
|
||||||
|
канонической форме между доставками заморозил бы тренировку на произвольной из
|
||||||
|
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
|
||||||
|
хранил бы меньшее значение (находка 49).
|
||||||
|
|
||||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||||
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
||||||
@@ -1494,3 +1595,76 @@ HealthKit рядом. Значение хранится **дословно**, т
|
|||||||
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
||||||
одного кода
|
одного кода
|
||||||
- **AND** счётчики значений без кода в записи присутствуют
|
- **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** счётчик потери содержания не растёт
|
||||||
|
|||||||
Reference in New Issue
Block a user