первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
## 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` при отказе сверки** (спека «Несостоявшаяся сверка не молчит»)
|
||||
→ ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о
|
||||
причине.
|
||||
Reference in New Issue
Block a user