Files
av bd5d17b079 первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
2026-08-04 13:39:48 +03:00

249 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Разбор перечисляет верхнеуровневые ключи `data`, которых он не покрывает, и
свёртка пишет их в `delivery.uncovered_sections` JSON-массивом (change
`2026-08-01-nerazobrannye-sekcii-dostavki`). Покрыты три секции — `metrics`,
`workouts`, `stateOfMind`; не виденными живьём остаются `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`.
Колонка отвечает на вопрос «что останется потерянным, если тело удалить». На
вопрос «а когда именно поток принёс что-то новое» она отвечает только тому, кто
догадается спросить: события нет, а лог свёртки печатает `uncovered` атрибутом
на уровне `INFO`, где оно неотличимо от рутины — поток идёт раз в пять минут.
Ограничения, в которых живёт решение:
- **Хранилище — свёртка по журналу** (`critical`). Пересборка обязана дать то же
состояние; всё, что наблюдаемо, обязано быть функцией журнала, а не порядка
прогонов.
- **Поток не останавливается** — наблюдательный механизм не имеет права ронять
свёртку и не имеет права держать блокировку базы: конкурирующий приём при
исчерпанном `busy_timeout` отвечает `500` по доставке, тело которой уже на
диске.
- Схема не трогается: колонка уже есть, задача её и использует.
## Goals / Non-Goals
**Goals:**
- Первая по журналу встреча имени непокрытой секции видна владельцу без запроса
в базу; повторные встречи молчат.
- Событие переживает отказ свёртки — иначе оно теряется навсегда.
- Перечень накопленного достаётся одной командой.
- Пересборка полного журнала воспроизводит ровно те же события.
**Non-Goals:**
- Разбор новых секций. Формы никто не видел; вслепую разбор не пишется.
- Хранение реестра секций, HTTP-эндпоинт, уведомление наружу. Первое —
вторая копия факта, второе и третье — отдельные цели (`Read API`,
`Наблюдаемость`).
- История непокрытых секций, переживающая удаление тел архива. Это предмет
ретеншена, и цена решения названа ниже.
## Три формы решения и компромисс каждой
Рассматривались три носителя признака «имя встречено впервые»; выбрана первая.
1. **Вывод из журнала запросом (выбрано).** Носителя нет вовсе — признак
считается по существующей колонке. Компромисс: платим запросом по журналу на
каждую доставку с непокрытыми секциями (см. решение 3) и наследуем все
границы колонки — обрезку списка и обнуление пересборкой (решение 7).
2. **Реестр-таблица по образцу `category_value`.** Строка на имя с провенансом
первой встречи; запрос новизны становится точечным по первичному ключу.
Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
пересборке, плюс миграция и новая единица хранения витрины (а значит и
отпечатка). Ноль новых сведений: имя выводимо из журнала. Отвергнуто —
но не навсегда: ретеншену, срезающему тела, реестр понадобится именно затем,
чтобы история пережила удаление, и тогда это уже другая цена.
3. **Множество виденных имён в памяти процесса**, наполняемое при старте.
Запрос один на запуск, дальше — проверка по map. Компромисс: состояние
становится функцией жизни процесса, а не журнала; наполнение при старте — тот
же полный проход, только раньше; пересборка и живой приём расходятся в том,
что считают первой встречей. Отвергнуто по инварианту «свёртка по журналу».
## Decisions
### 1. Новизна выводится из журнала, а не хранится реестром
Форма ответа взята у `category_value` — «когда имя встретилось впервые по
журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
считанные разы за жизнь имени.
Новизна считается запросом: «какие из этих имён встречались в доставках, стоящих
в журнале **строго раньше** текущей». Пусто — имя новое.
Прочтение колонки — `json_each` по `delivery`; колонка заведена массивом ровно с
этим расчётом («читается из SQLite через `json_each`», миграция `00005`).
### 2. Сравнение парой `(received_at, id)`, а не по идентификатору
Строгое сравнение пары решает три вещи разом:
- **самоисключение** — своя же строка (её пишет `finish` после) в счёт не идёт
при любом порядке записи;
- **идемпотентность** — повторная свёртка той же доставки даёт тот же исход,
поэтому пересборка не выдумывает и не глотает события;
- **порядок журнала, а не порядок прогона.** `received_at` хранится с секундной
точностью, а строка учёта становится видимой воркеру только после записи тела:
при конкурентном приёме доставка может стать видимой после более новой. Пока
окно существует, сравнение по журналу делает исход от него независимым; когда
окно закроют на самом приёме, сравнение всё равно останется — на нём стоят
самоисключение и идемпотентность.
Та же лексикографическая пара уже стоит в `mergeCategories` и по той же причине;
здесь она в `WHERE`, а не в `ON CONFLICT`.
Следствие названо вслух: если две доставки стали видимы не в журнальном порядке,
имя может дать событие дважды — сначала на более новой, потом на более старой.
Это не дефект, а честное «первой в журнале была вот эта»; потери здесь нет, а
дублирование ограничено одной парой.
### 3. Цена запроса измерена, а форма выбрана по замеру
Индекса по `uncovered_sections` в схеме нет, и это изменение его не заводит.
Значит запрос — **полный проход по `delivery`** с фильтром по непустому списку,
а не «индексный».
**Это единственное место, где живут числа замера.** Комментарий кода и
`docs/architecture.md` формулируют правило и ссылаются сюда; дублировать цифры
запрещено — они уже разошлись однажды внутри одного изменения.
Метод: синтетический журнал в `./tmp` (`tmp/seenmeasure`, прогон повторяем),
105 тысяч доставок — годовой объём при 288 в сутки; 20 прогонов на случай;
машина ничем другим не занята.
| случай | по имени отдельно | одним запросом (в коде) |
|---|---|---|
| одно имя, первая встреча в начале журнала | 31 мкс | 31 мкс |
| одно имя, первая встреча в хвосте | — | 52 мс |
| одно имя, в журнале не встречалось | 50 мс | 50 мс |
| 32 имени | 1.36 с | 65 мс |
Читается это так, и первое было названо неверно в первой редакции дизайна:
- **ранний выход есть только у давно приезжающей секции.** Строки
просматриваются от старых к новым, поэтому `LIMIT 1` выходит рано, лишь когда
первая встреча имени лежит в начале журнала;
- **у секции, появившейся только что, раннему выходу не на чем сработать** — её
первая встреча в хвосте, и проход идёт почти по всему журналу. Это и есть
заявленный сценарий задачи: 52 мс на каждой доставке, 288 раз в сутки — около
15 секунд чтения в сутки, пока секцию не покроет отдельная задача. Позиция
первой встречи зафиксирована навсегда, поэтому цена сама не рассосётся;
- **32 имени по одному стоили секунду с лишним на доставку**, и тело, выбившее
границу списка, приезжает раз в пять минут — это режим, а не случай. Поэтому
имён больше одного спрашиваются одним запросом; частому случаю (одно имя
давней секции) это ничего не стоит.
Что это стоит в жизни: **пока непокрытых секций нет** (сегодняшний режим — все
три приезжающие секции покрыты) запрос не берётся вовсе, он берётся только при
непустом списке.
**Развилка, вынесенная владельцу** (записана в докладе задачи): принять ли эти
52 мс на доставку или завести частичный индекс по непустому списку. Индекс — это
миграция и правка `docs/database.md`, то есть выход за рамку «схема не
трогается», поэтому в этом изменении он не делается. Остаток доведён с принятой
ценой, названной здесь числом.
### 4. Запрос идёт вне транзакции записи
Прецедент проекта прямой: канонизация внутри транзакции держала блокировку
5.019 с и дала 768 МиБ пика. Проход по журналу внутри транзакции записи объектов
повторил бы ровно этот класс — с той разницей, что отказ конкурирующего приёма
необратим. Сверка выполняется отдельным чтением до записи.
### 5. Событие живёт в едином логирующем чекпоинте свёртки
Своей строки лога у события нет: конвенция проекта — один логирующий чекпоинт на
доменной границе. Имена новых секций идут **атрибутом всегда**, а уровень
поднимается веткой `switch`.
Нормируется **поведение**, а не позиция ветви: запись обязана называть новую
секцию, какие бы повторяющиеся события ни случились в той же доставке. В коде это
достигается тем, что ветвь стоит первой, и это остаётся решением кода, а не
требованием спеки — иначе следующая задача, добавляющая свою ветвь, получит
арбитра в чужой capability.
Уровень назван поимённо — `WARN`. «Выше `INFO`» зеленело бы и на `ERROR`, а
`ERROR` у владельца означает сбой, который надо разбирать.
### 6. Путь отказа проверяется, отложенный — нет
Список непокрытых секций переживает отказ разбора (`residueOf`): доставка, у
которой не вывелся слой, всё равно записывает имена. Значит проверка новизны идёт
**до** ветвления на успех и отказ, а новые имена доезжают до `fail` и печатаются
его строкой.
Отложенный исход (занятость базы, отмена) — отдельный случай: он не пишет учётной
записи вовсе, доставка возвращается следующим проходом. Событие там не
печатается: оно не потеряно, а повторение на каждом проходе занятой базы
превратило бы однократный признак в дребезг.
### 7. Границы носителя названы, а не замолчаны
Признак и перечень производны от колонки, у которой три слепые зоны, и все три
идут в спеку:
- **обрезка списка**: имя, стоящее в теле после 32 незнакомых ключей, в колонку
не попадает — события не будет. Наблюдаемым остаётся счётчик отброшенных имён,
который уже поднимает уровень записи («тело на HAE не похоже вовсе»).
Объявлять при обрезке новыми **видимые** имена рассматривалось и отвергнуто:
невидимого это не возвращает, а сигнал об обрезке уже есть и уже громкий;
- **пересборка** обнуляет производные от разбора поля и заполняет их заново
только по сохранившимся телам. Ретеншен, срезающий тела, стирает историю
непокрытых секций вместе с ними — поэтому «те же события» пересборка
воспроизводит при **полном** архиве. Это же и есть будущая цена решения 1;
- **покрытие секции**: пересвёртка убирает имя из колонки, и перечень отвечает о
текущем состоянии покрытия, а не об истории. Она же может дать событие
повторно — журнал тот же, а колонка заполняется заново.
### 8. Перечень отдаёт подкоманда `healthlog uncovered`
Той же формы, что `reindex` и `healthcheck`: конфиг флагом, человекочитаемый
вывод в stdout, ненулевой код на отказ. HTTP-маршрут отвергнут — Read API ещё
нет, а инструмент нужен на той же машине, где лежит база.
Имя `uncovered`, а не `sections`: команда перечисляет только непокрытое, а
широкое имя заняло бы место под будущий вопрос «какие секции покрыты» (его
сегодня закрывает сверка глазами, критерий К4). Словарь при этом закреплён:
`uncovered` — состояние покрытия (колонка, атрибут, capability
`uncovered-sections`), `new` — событие первой встречи (атрибут новых секций).
База открывается **только на чтение** (`store.OpenForRead`): команда
диагностическая, и запуск её при живом сервисе не должен ни мигрировать схему,
ни писать. Отказ открытия говорит причину и даёт ненулевой код — пустой перечень
от молчаливого отказа неотличим.
Строки — имя, число доставок, первая и последняя встреча (метка журнала и
идентификатор доставки; по идентификатору достают тело из архива). Вывод имеет
объявленный предел строк с остатком числом: предел разбора в 32 имени действует
на одну доставку, а различных имён журнал накопит сколько угодно — достаточно
версии HAE, кладущей в ключ переменную часть.
Имя печатается **экранированным** (`%q`): оно приходит верхнеуровневым ключом
чужого тела, обрезано по длине на разборе, но по содержимому не ограничено ничем
— сырая печать в терминал впустила бы туда управляющие последовательности.
Данных о здоровье в выводе нет: имя секции — структурный ключ, а не измерение.
## Risks / Trade-offs
- **Полный проход по журналу на каждой доставке с непокрытыми секциями**
(решение 3) → измерено на синтетическом годовом журнале; цена принята и
названа числом там же, развилка про индекс вынесена владельцу. Замер
повторяем (`tmp/seenmeasure`).
- **Долгий читатель против чекпоинта WAL**: команда перечня делает проход по
`delivery` вторым процессом, а удерживаемый читатель останавливает продвижение
чекпоинта (измерено раньше: журнал 51 МБ при лимите 8 МиБ) → запрос
одиночный и короткий, снимок не удерживается дольше вывода; команда
диагностическая и запускается руками.
- **Слепые зоны носителя** (решение 7) → названы в спеке требованием «перечень
честен относительно своего носителя»; молчание о них было бы хуже самих зон.
- **Дублирование события при несовпадении видимости с журналом** (решение 2) →
ограничено парой строк, потери нет; альтернатива сделала бы событие функцией
очереди и разошлась бы с пересборкой.
- **Лишние `WARN` при отказе сверки** (спека «Несостоявшаяся сверка не молчит»)
→ ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о
причине.