Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
+4 -1
View File
@@ -19,9 +19,9 @@
## блокеры
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем — аварийный путь придётся изобретать при остановленном приёме
## высокий
- [Дозакрыть находки ревью по слиянию сущностей](dozakryt-nahodki-sushchnostej.md) — Скелет из null затирает маршрут необратимо, а откат бинаря поверх новой схемы проходит молча: семь находок с прогнанными оракулами
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
@@ -45,6 +45,8 @@
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -58,4 +60,5 @@
- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Data-миграции не отбирают строки по обрезаемым спискам](otbor-strok-data-migraciyami.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
+12
View File
@@ -15,3 +15,15 @@
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
перезапуск хоста или откат деплоя.
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
`negative`).
@@ -1,125 +0,0 @@
# Дозакрыть находки ревью по слиянию сущностей
**Приоритет:** высокий
Задача «Тренировки и секции с собственными id» (`f8200f7`) прошла ревью не
полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались.
Дозапуск принёс девять причин, триаж оставил семь. Оба заявленных `critical`
понижены до `major` с названной причиной — вход недостижим из штатного потока
HAE (корпус в 118 доставок такого не производил), — но остались в работе:
потеря маршрута необратима, а `reindex` проигрывает то же поражение.
Отчёт триажа с прогнанными оракулами — `tmp/triage-late.md`, оракулы —
`tmp/adv/*.go`. Каждый пункт ниже имеет падающий тест; правка считается
сделанной, когда соответствующий оракул зеленеет **и** переезжает из `tmp/` в
обычные тесты пакета.
## Что делать
**1. Скелет не затирает маршрут.** `canon.Fields.Covers` проверяет только
наличие ключа и длину верхнеуровневого массива, поэтому версия, где каждый
массив заменён массивом той же длины из `null`, а каждое не-массивное значение
— скаляром, признаётся равной настоящей и по тай-брейку журнала замещает её.
Оракул: `-run 'Скелет|Маршрут|ДвеВерсии'`.
Решение принято, новое не проектируем: применяем к сущностям **тот же
стандарт, что записан для точек** — второй разряд сравнения, как у
`canon.Relate` («иначе ключ с пустым значением исчезает по жребию»). Плюс
запрет вырождения формы: покрывающая версия не может подменить объект или
массив скаляром — проверка по верхнему уровню, стоимость O(ключей).
Поэлементная содержательность массивов **отвергнута ценой**: пункт 4 измерил
768 МиБ пика на канонизации, полный обход маршрута на каждое сравнение эту цену
умножит. Остающийся предел — порча *внутри* элемента ряда (точка маршрута без
`altitude`) — не закрывается ничем, кроме сверки с телом в архиве, и должен
быть записан в `architecture.md` рядом с описанием `Covers` так же прямо, как
он записан в комментарии кода.
Отдельно: `pickWithinDelivery` при равном содержании и **разных байтах** обязан
считать `differs=true` — сейчас две версии одного `id` в одном теле дают
`удержано=0` и молчащий счётчик.
**2. Одно поле не той формы не уносит сущность.** `entityHead` держит
`ID`/`Name`/`Date`/`Start`/`End` типизированными строками, поэтому смена типа
любого из пяти роняет `json.Unmarshal` целиком, а доставка при этом получает
`parsed` с пустым списком непокрытого. Достижимо из реального потока: дрейф
формата дат у HAE задокументирован. Оракул: `-run ОдноПоле`.
Читать пять полей через `json.RawMessage` и извлекать мягко — это буквально
принцип, уже записанный в коде для `Duration` («нечисловое значение — это
пропуск ОДНОГО поля, а не сломанная сущность»). Плюс пропуски обязаны быть
видны в **учётной записи** доставки, а не только в логе: ретеншен решает по
базе, и сегодня он получит ответ «терять нечего». Хранение сущности с
неразобранной меткой (NULL) в эту задачу не входит — см. остаток ниже.
**3. Откат бинаря не проходит молча.** `store.Open` мигрирует безусловно и не
сверяет версию схемы, поэтому старый бинарь успешно стартует поверх схемы 7,
молча игнорирует незнакомые секции и помечает доставки разобранными. Оракул
прогнан живьём: `-run СтарыйБинарь`. Перенести в `Open` страж из
`OpenForRead` — прецедент записан там же: «расхождение версий — отказ, а не
повод мигрировать».
**4. Канонизация — за транзакцию, по-настоящему.** Комментарий
`bucket.go:143-146` утверждает, что канонизация вынесена наружу; фактически
`analyze()` вызывается из `compareEntities` **внутри** `inTx`, который открывает
`immediate` и повторяет до пяти раз, а кеш `analyze()` пишется в **копию**
элемента среза и не переживает даже одной попытки. Измерено: тело 40 МиБ → пик
768.3 МиБ; 63 МиБ → блокировка удерживается 5.019 с при `busy_timeout` 5000, то
есть конкурентный `CreateDelivery` исчерпывает повторы и приём отвечает 500 по
доставке, тело которой уже на диске.
В этой задаче: вынести `analyze()` наружу по-настоящему, кешировать в срезе, а
не в копии, и различать в логе `delivery failed` занятость базы (`store.ErrBusy`
уже выделен доменной ошибкой) от прочих причин. Пределы на размер сущности и
потоковый расчёт хеша — остатком.
**5. Значения из тела не попадают в текст ошибки.** `fmt.Errorf("… встречено
%v", tok)` подставляет токен целиком: тело 8 МиБ даёт текст ошибки 8 МиБ,
который уходит атрибутом `error` на уровень `WARN`. Инвариант «тела запросов
только на `DEBUG` и с обрезкой» нарушен буквально. Называть тип токена и
`dec.InputOffset()`. Оракул: `-run Тело`. Дефект в базе диффа, не внесён
разбором сущностей.
**6. Победитель внутри доставки — функция множества, а не порядка.**
Попарная свёртка частичного порядка с тотальным тай-брейком нетранзитивна:
`[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Стандарт «победитель — функция множества
точек, а не порядка» записан в `architecture.md` для точек и для сущностей
молча не применён. Собрать версии ключа, отбросить строго покрытые, среди
оставшихся взять минимум канонической формы. Оракул: `-run ПорядокВнутри`.
**7. Провенанс обновляется при равных хешах.** Совпал хеш — запись
пропускается вместе с провенансом, и в `delivery_id`/`delivery_received_at`
остаётся первая свёрнутая копия, а не победитель по журналу. Провенанс
устаревает на каждой из ~26 повторных присылок гарантированно; расхождение
живой витрины с `reindex` латентно (требует возврата содержимого к прежнему —
корпус такого не производил), но нарушает записанный инвариант детерминизма.
Сравнивать позиции в журнале и обновлять провенанс. Оракул: `-run Порядок`.
## Что уходит остатком
- хранение сущности с `id`, но неразобранной меткой (NULL-метка): требует схемы
и правил чтения, а после пункта 2 случай становится редким;
- пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
канонической формы и хеша — заводится задачей вместе с условием из пункта 4;
- принцип «data-миграции не отбирают строки по спискам, которые где-то
обрезаются» (миграция `00007` отбирает по обрезаемому на 32
`uncovered_sections`; для неё дефект пустой — HAE шлёт одну секцию за
доставку, — но следующая покрытая секция унаследует слепую зону);
- длина очереди `pending` в `/stats` **и без WARN**: после миграции, переводящей
доставки в `pending`, отставание по конструкции не WARN-ится (`startupDone`),
и бэклог идёт молча при зелёном `/healthz` — строка уходит в
[наблюдаемость](stats-nablyudaemost.md).
## Кандидаты в конвенции
- текст ошибки разбора не содержит значений из тела — только тип токена и
смещение;
- тест перестановок правила слияния обязан включать версию с содержимым, равным
одной из уже присланных: тест трёх версий с разными хешами ветку равенства не
посещает ни разу.
Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а
`task verify:archive` сходится.
Связано: `internal/canon`, `internal/store/entity.go`, `internal/hae/entity.go`,
`docs/review-journal.md` (пропуск проходов на чекпоинте — отклонение процесса,
ему там место).
@@ -0,0 +1,38 @@
# Сущность с id, но неразобранной меткой
**Приоритет:** средний
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком.
## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
счётчик `SkippedEntityNoTime` растёт.
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
и ретеншен получает честный ответ «терять есть что». То есть событие больше
не молчит — но содержимое всё ещё не хранится.
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
задокументирован (`docs/local-research.md`), то есть вход не выдуман.
## Что решить
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
что делает с такими строками, иначе они молча исчезнут из любого ответа.
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
когда разбор научится читать формат.
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
вернёт `reindex`. После включения ретеншена окно становится необратимым.
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
измерение в колонке, по которой идёт выборка.
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
чтения без читателя проектируется вслепую.
@@ -0,0 +1,45 @@
# Data-миграции не отбирают строки по обрезаемым спискам
**Приоритет:** низкий
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`).
## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
секции стоит в `uncovered_sections`:
```sql
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
WHERE json_each.value IN ('workouts', 'stateOfMind'))
```
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
секцией `ecg` за ними даёт список без `ecg`.
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
(`docs/local-research.md`, находка 50), секций восемь, тела с 32 незнакомыми
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
слепую зону, а к тому времени причину никто не вспомнит.
## Что делать
Записать принцип и выбрать форму отбора:
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
например «эту доставку смотрел разбор старше версии N».
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
означать безусловное пересворачивание — доставка, у которой список обрезан,
про своё покрытие ничего достоверного не говорит.
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора
окажется общей.
## Связано
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) —
именно она следующей сделает секцию покрытой и напишет такую миграцию.
@@ -0,0 +1,56 @@
# Чем откатывать релиз после наката миграции
**Приоритет:** блокеры
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
(проходы `ops` и `negative`, профиль `deep`).
## Что именно решить
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
отказ на старте. Решение принято владельцем и здесь не пересматривается. Но у
него есть следствие, которое до сих пор нигде не было записано:
**после того как новый бинарь накатил миграцию, возврат старого бинаря приёма
не чинит.** Он теперь отказывается стартовать, а понизить схему нечем:
- подкоманды миграции у бинаря нет (`serve`, `reindex`, `healthcheck`);
- `goose` CLI в образ не кладётся;
- блоки `-- +goose Down` в миграциях написаны, но ни один тест их не исполняет,
и на рабочей базе они не выполнялись ни разу (`DROP COLUMN` в SQLite через
`modernc.org/sqlite` не проверялся вовсе);
- `restart: unless-stopped` превращает отказ в цикл перезапуска, а телефон всё
это время шлёт в закрытый порт и **не перешлёт** потом.
То есть аварийный путь придётся изобретать в момент аварии, при остановленном
приёме. Цена простоя для метрик закрывается широким и глубоким проходами
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
единственный источник.
## Варианты и цена
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
импровизацией.
2. **Копия файла базы перед накатом** — entrypoint контейнера делает `cp` рядом,
откат = подмена файла. Цена: место (база растёт), плюс правило «сколько копий
держим». Зато не требует ни кода, ни доверия к `Down`, а база производна от
архива — потеря копии не смертельна.
3. **`goose` CLI в образ.** Цена: образ перестаёт быть одним статическим
бинарём, появляется вторая точка, знающая про схему.
4. **Ничего, но записать вслух**: «понижение схемы не поддерживается, лечение —
только выкатка вперёд». Цена: в аварии выбора нет.
## Рекомендация
(2) плюс уже сделанная запись из (4). Копия файла — единственный вариант,
который не требует доверять непроверенному коду ровно в тот момент, когда
проверять некогда; а `Down`-блоки при этом честно называются декорацией для
локальной разработки.
## Что стоит, пока решения нет
Ничего: страж работает, и это правильно. Стоит только аварийный сценарий —
он существует ровно в том виде, в каком описан выше. Строка «понижение схемы не
поддерживается» уже записана в `docs/architecture.md` (раздел «Деплой»).
@@ -0,0 +1,70 @@
# Пределы на размер сущности и потоковый расчёт формы
**Приоритет:** средний
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
сущности из транзакции и перестала считать каноническую форму дважды. Осталось
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
считаются материализацией значения целиком.
## Оракул: измерено
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
на теле в пределах приёма (64 МиБ):
```
тело 40 МиБ → пик HeapAlloc 768.3 МиБ
тело 63 МиБ → повторная доставка держит блокировку 5.019 с при busy_timeout 5000
```
При `_txlock=immediate` конкурентный `CreateDelivery` получает `SQLITE_BUSY`,
`inTx` повторяет до пяти раз и на исчерпании отдаёт `store.ErrBusy` — приём
отвечает 500 по доставке, тело которой уже в архиве. Осиротевшее тело подберёт
`reindex`, но узнать о нём можно только из лога.
## Что делать
1. Предел на размер **одной сущности** и на суммарный размер секции, отдельно
от предела тела (64 МиБ). Сегодня одна тренировка законно может занять всё
тело целиком. Вход, превышающий предел, обязан отклоняться **до**
канонизации, а не после.
2. Потоковый расчёт канонической формы и хеша: `canon.Form` разворачивает
значение в дерево `any`, из-за чего пик кучи кратен размеру входа (замер даёт
множитель около 19×). Хеш считается по потоку; форма нужна целиком только для
сравнения, и только когда хеш разошёлся.
3. Разбор **сохранённой** версии всё ещё идёт внутри транзакции: её содержимое
читается оттуда же. Убрать это можно оптимистичным чтением до транзакции —
но только с перепроверкой хеша и провенанса **внутри** транзакции, иначе две
конкурентные свёртки одного `id` дадут потерянное обновление и исход снова
станет функцией порядка коммитов, а не журнала.
## Условия, пришедшие из закрывающей задачи
1. Мягкое чтение заголовка сущности увеличило долю тел, доходящих до
канонизации: сущность, которая раньше отсекалась на `json.Unmarshal`
заголовка почти бесплатно, теперь разбирается и канонизируется целиком. То
есть худший случай по памяти стал достижим на входах, которые до него не
доходили, — предел из пункта 1 после этого **обязателен**, а не желателен.
2. Каноническая форма и множества ключей всех версий доставки теперь
**удерживаются** до конца транзакции слияния (раньше считались лениво и на
одной доставке из сорока четырёх). Расход стал пропорционален размеру
ДОСТАВКИ, а не самой большой её сущности; предел обязан считать суммарный
размер секции, а не только одной сущности.
3. **Потолок на число версий одного ключа в одной доставке.** Выбор победителя
квадратичен по числу кандидатов; версии с совпавшей канонической формой
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
вопрос открыт для точек на одной координате: `cena-sliyaniya-na-shirokoj-dostavke.md`,
пункт 4.
## Связано
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) —
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
часа). Задачи делать вместе: половина решения общая — `canon`.
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
отброшено как предел по конструкции, но условием ложится сюда.
+17
View File
@@ -61,3 +61,20 @@ Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбор
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
соблюдается.
## Что читать перед удалением тела
Две колонки учётной записи, и обе обязательны:
- `uncovered_sections` — непустой список означает, что в теле есть секции,
которых разбор не покрывает; удалять нельзя;
- `skipped_entities` — число сущностей с собственным `id`, которые разбор не
понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят
доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не
досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело
тренировки, маршрута которой нет больше нигде: в экспорте Apple его не
существует.
Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей»
(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого
различия.
+8
View File
@@ -22,5 +22,13 @@
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
десятков тысяч доставок.
Активное уведомление — отдельная задача, здесь только факт.