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