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

22 KiB
Raw Permalink Blame History

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 при отказе сверки (спека «Несостоявшаяся сверка не молчит») → ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о причине.