первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Новизна имени секции выводится из журнала, а не хранится реестром
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается
|
||||
запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой
|
||||
по паре `(received_at, id)`». Реестр-таблица по образцу `category_value` —
|
||||
очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата из источника:
|
||||
|
||||
> Форма ответа взята у `category_value` — «когда имя встретилось впервые по
|
||||
> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
|
||||
> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
|
||||
> считанные разы за жизнь имени.
|
||||
|
||||
И там же, о цене реестра:
|
||||
|
||||
> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
|
||||
> пересборке, плюс миграция и новая единица хранения витрины (а значит и
|
||||
> отпечатка). Ноль новых сведений: имя выводимо из журнала.
|
||||
|
||||
Третья рассмотренная форма — множество виденных имён в памяти процесса —
|
||||
отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы
|
||||
функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что
|
||||
считает первой встречей.
|
||||
|
||||
## Чем платим
|
||||
|
||||
Ценой названы три вещи, и все они следствия выбранного носителя:
|
||||
|
||||
- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на
|
||||
синтетическом журнале годового объёма: у секции, приезжающей давно, ранний
|
||||
выход даёт десятки микросекунд, у появившейся только что — около 52 мс на
|
||||
доставку, пока её не покроет отдельная задача;
|
||||
- **границы носителя наследуются целиком**: имя, вытесненное границей списка в
|
||||
32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только
|
||||
по сохранившимся телам; покрытая разбором секция уходит из перечня;
|
||||
- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с
|
||||
собой и записи о непокрытых секциях за те же периоды.
|
||||
|
||||
## Когда пересматривать
|
||||
|
||||
Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена
|
||||
архива реестр понадобится** — именно затем, чтобы история пережила удаление тел,
|
||||
и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке
|
||||
`uncovered-sections` — решение этого изменения, а не запрет навсегда.
|
||||
@@ -33,6 +33,10 @@
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
|
||||
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
|
||||
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
|
||||
образцу `category_value` отвергнут как вторая копия факта, с названным
|
||||
условием пересмотра — ретеншен архива.
|
||||
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
|
||||
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
|
||||
становится явной функцией порядка журнала; хранимая метка провенанса
|
||||
|
||||
+35
-1
@@ -226,7 +226,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
@@ -355,6 +355,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
|
||||
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
|
||||
|
||||
**Первая встреча имени — другое дело**
|
||||
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
|
||||
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
|
||||
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
|
||||
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
|
||||
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
|
||||
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
|
||||
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
|
||||
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
|
||||
полного журнала повторяет ровно те же события.
|
||||
|
||||
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
|
||||
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
|
||||
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
|
||||
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
|
||||
меняет, доставка вернётся следующим проходом.
|
||||
|
||||
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
|
||||
первая и последняя встреча, чтением только на чтение и с экранированием имён
|
||||
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
|
||||
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
|
||||
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
|
||||
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||||
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
|
||||
истории.
|
||||
|
||||
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
|
||||
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
|
||||
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
|
||||
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
|
||||
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
|
||||
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
|
||||
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
|
||||
|
||||
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
|
||||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||||
|
||||
@@ -71,3 +71,12 @@
|
||||
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
||||
структуры обновляем схему в [database.md](../database.md) тем же изменением —
|
||||
это проверяет `task gate`.
|
||||
|
||||
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
|
||||
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
|
||||
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
|
||||
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
|
||||
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
|
||||
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
||||
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
||||
состоялась» на каждой доставке), и перечень целиком.
|
||||
|
||||
@@ -35,6 +35,17 @@
|
||||
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
|
||||
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
|
||||
2026-08-03 и 2026-08-04 в [review.md](../review.md).
|
||||
|
||||
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
|
||||
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
|
||||
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
|
||||
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
|
||||
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
|
||||
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
|
||||
|
||||
И **число живёт в одном месте.** Один и тот же замер, записанный в
|
||||
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
|
||||
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
|
||||
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
|
||||
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
|
||||
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
|
||||
|
||||
@@ -1852,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
||||
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
|
||||
доставок.
|
||||
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
|
||||
ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в
|
||||
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
|
||||
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
|
||||
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
|
||||
её будет на чём проверить, — вслепую он не пишется.
|
||||
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
||||
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
||||
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
||||
|
||||
@@ -499,3 +499,56 @@
|
||||
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
|
||||
вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
|
||||
ни `Taskfile.yml`, ни скилл батча его не несут.
|
||||
|
||||
## 2026-08-04 — событие о новой секции терялось на отказе слияния [пойман]
|
||||
|
||||
- **Где:** `internal/fold/fold.go`, ветвь отказа `store.Merge` в change
|
||||
`2026-08-04-aktivnaya-proverka-novyh-sekcij`
|
||||
- **Симптом:** доставка, принёсшая имя секции впервые, при нетранзиентном отказе
|
||||
слияния писала имя в `delivery.uncovered_sections`, но запись об отказе его не
|
||||
называла. Следующая доставка считала имя виденным — событие, однократное за
|
||||
всю жизнь имени, пропадало **навсегда**, то есть ровно то, ради чего задача и
|
||||
делалась.
|
||||
- **Причина:** ветвей записи исхода в свёртке четыре, а дизайн рассмотрел одну.
|
||||
Признак новизны считался до ветвления и корректно доезжал до `residueOf`
|
||||
(отказ разбора), но ветвь отказа слияния собирала остаток **вручную** и поле
|
||||
новизны в него не клала. Дельта-спека говорила «до ветвления на успех и
|
||||
отказ», подразумевая один отказ.
|
||||
- **Чем воспроизведён:** свёртка доставки с новой секцией при снесённой таблице
|
||||
`bucket` — запись `ERROR` без `uncovered_new`, а `SectionsSeenBefore` на
|
||||
следующей доставке уже отвечает «виденное». Тест закреплён:
|
||||
`TestFoldОтказСлиянияНазываетНовуюСекцию`.
|
||||
- **Чем пойман:** тремя проходами независимо (`specs`, `code`, `adversary`),
|
||||
причём двое написали падающий тест. Дешёвый `code`-проход нашёл его наравне с
|
||||
дорогими — признак того, что дефект был в форме «ветвь собрана руками рядом с
|
||||
ветвью, собранной функцией», а такое видно чтением.
|
||||
- **Что изменено:** новизна передаётся и в эту ветвь; дельта-спека переписана в
|
||||
терминах «каждый исход, который пишет список в учётную запись», и отдельно
|
||||
названы исходы, которые список очищают (нечитаемое тело, паника) и потому
|
||||
события не теряют.
|
||||
|
||||
## 2026-08-04 — замер стоимости снят на корпусе, где измеряемого случая не бывает [пойман]
|
||||
|
||||
- **Где:** `design.md` того же change, решение 3; утверждение «в режиме
|
||||
постоянного приезда секции сверка стоит 18 мкс на доставку»
|
||||
- **Симптом:** на числе стояло решение «частичный индекс не нужен». Число
|
||||
описывало **не тот** режим.
|
||||
- **Причина:** синтетический журнал наполнялся так, что новая секция была во
|
||||
**всех** доставках, то есть её первая встреча лежала в самом начале журнала —
|
||||
и `LIMIT 1` выходил рано. В жизни секцию включают на телефоне сегодня: первая
|
||||
встреча оказывается в хвосте, и проход идёт почти по всему журналу на каждой
|
||||
доставке. Разница — три порядка (31 мкс против 52 мс).
|
||||
- **Чем воспроизведён:** `tmp/seenmeasure` с хвостовым именем: голова 31 мкс,
|
||||
хвост 52 мс, отсутствующее имя 50 мс.
|
||||
- **Чем пойман:** `adversary` — он не поверил числу и построил корпус, в котором
|
||||
измеряемый случай выглядит как в жизни. Это третий случай за три дня, когда
|
||||
оценка оказалась функцией того, **как устроен корпус**, а не того, что
|
||||
измеряют (записи 2026-08-02, 2026-08-04 про `verify:archive`).
|
||||
- **Что изменено:** замер перемерян тремя случаями (голова, хвост, отсутствие),
|
||||
числа сведены в одно место (`design.md`), код и `architecture.md` формулируют
|
||||
правило и ссылаются на источник. Развилка «принять цену или завести индекс»
|
||||
вынесена владельцу.
|
||||
- **Что осталось незакрытым:** правило «число замера обязано нести метод и
|
||||
описывать тот случай, ради которого снято» действует только для тестов
|
||||
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
|
||||
ниже, но механизировать его нечем.
|
||||
|
||||
@@ -44,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
||||
`export.xml`, который выбирает человек, но формируется он устройством и по
|
||||
объёму (3,6 млн записей) глазами не проверяется.
|
||||
|
||||
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
|
||||
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
|
||||
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
|
||||
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
|
||||
последовательность из тела подделала бы строки вывода. Тот же вход попадает
|
||||
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
|
||||
|
||||
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
|
||||
сервиса нет.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user