Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
@@ -0,0 +1,431 @@
## Context
Приём и свёртка сегодня — одна операция. `ingest.Accept` пишет тело в архив,
вставляет строку `delivery` и **тут же** зовёт `fold.Fold` на контексте,
отвязанном от запроса, но синхронно; обработчик отвечает только после этого.
Стоимость свёртки измерена: 1001 объект — 815 мс, 4001 — 3.07 с, 16001 —
11.07 с. Переход на одну транзакцию на доставку снял около 0.7 мс на объект
(прогон живого архива ускорился с 64 до 52 секунд), но порядок величины
остался.
Что уже есть и на что опираемся:
- `fold.Fold(ctx, deliveryID)` — свёртка **одной** доставки по идентификатору,
тело читается из архива. Идемпотентна: победитель координаты — функция
множества кандидатов, а не порядка.
- `internal/replay` — проигрывание журнала целиком: состав из архива, порядок
`(received_at, id)`, классификация исходов, отчёт. Появился задачей
`reindex-iz-arhiva`.
- `store.ParsePending` — «этим разбором тело ещё не смотрели». Статус
консервативный: ретеншен его не трогает никогда. Миграция `00005` перевела в
него все доставки, и подобрать их сегодня может только `healthlog reindex`.
- `store.LastDerivedLayer(automationID, before, beforeID)` — наследование слоя
строго от **предшествующей** доставки: слой обязан быть функцией префикса
журнала.
- `store.inTx` — пять попыток с нарастающей паузой при занятости базы,
`_txlock=immediate`, одна транзакция на доставку.
Ограничения окружения: один процесс, SQLite, файлы; «без очередей и внешних
зависимостей» — принцип архитектуры. Телефон шлёт молча каждые пять минут и
доставку не переприсылает. `stop_grace_period` контейнера — 30 секунд.
## Goals / Non-Goals
**Goals:**
- Время ответа на приём перестаёт зависеть от ширины доставки.
- Свёртка идёт в порядке журнала и при конкурентных доставках тоже.
- Несвёрнутое переживает падение и рестарт процесса, а не только штатную
остановку.
- Подбор `pending` и пересборка — один код, а не два похожих.
- Отставание воркера видно **до** того, как станет отставанием на сутки, — в
том числе когда воркер не двигается вовсе.
**Non-Goals:**
- **Параллельная свёртка.** Слой — функция префикса журнала, запись объекта —
read-modify-write. Воркер один, и это требование, а не упрощение.
- **Дедупликация доставок, ретеншен архива, `/stats`.** Свои задачи беклога.
- **Гарантия «доставка свёрнута к моменту ответа».** Она снимается сознательно
— в этом вся задача; взамен даётся «доставка сохранена и учтена к моменту
ответа», а несвёрнутое видно в `parse_status`.
- **Абсолютный порядок журнала при конкурентных приёмах.** Достижимого предела
— «все видимые воркеру неразобранные доставки сворачиваются в порядке
`(received_at, id)`» — достаточно; см. риски.
- **Возврат `failed` в очередь.** Доставка, отказавшая по собственному
содержимому, остаётся `failed` и возвращается только пересборкой. Это
названная граница, см. решение 4б.
## Decisions
### 1. Очередью служит таблица `delivery`, а не список идентификаторов в памяти
Формулировка задачи говорила «очередь идентификаторов доставок» и отдельно
оговаривала поведение при переполнении. Реализуется это **очередью в базе**:
доставка ждёт свёртки в собственном статусе `pending`, а канал между приёмом и
воркером несёт не идентификаторы, а один бит «есть работа» (буфер 1,
неблокирующая отправка).
Prior art здесь однозначен и стар — это **transactional outbox** и его частный
случай «база как очередь заданий»
([AWS Prescriptive Guidance](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html),
[Three Dots Labs, durable execution на Go и SQLite](https://threedots.tech/post/sqlite-durable-execution/)).
Суть шаблона ровно наша: состояние задания пишется в ту же базу той же
транзакцией, что и факт события, а фоновый процесс выбирает необработанные
строки. Всё, что живёт только в памяти, теряется при падении — а у нас падение
означает молчаливую потерю свёртки для доставки, которую телефон не перешлёт.
Что это даёт сверх памяти, по пунктам исходной задачи:
- **Переполнения нет.** «Очередь переполнена — доставка остаётся `pending`, это
не отказ» выполняется по построению: доставка `pending` всегда, пока не
свёрнута. Сигнал теряться может и должен — он ничего не несёт.
- **Подбор `pending` при старте — не отдельный код.** Это обычный проход
воркера: старт просто будит его первым сигналом. Второй путь подбора не
появляется, потому что путь один.
- **Падение и `SIGKILL` не теряют очередь.** Транзакция свёртки откатывается,
статус остаётся `pending`, следующий старт подберёт.
Форма сигнала — канал ёмкостью 1 с неблокирующей отправкой — не изобретение:
это форма `os/signal.Notify` («Package signal will not block sending to c… a
buffer of size 1 is sufficient») и `time.Ticker` («will drop ticks to make up
for slow receivers»), и она же названа в стайлгайде Uber (*Channel Size is One
or None*). `sync.Cond` здесь непригоден механически: `Wait()` не кладётся в
`select` с `ctx.Done()`.
Отвергнуто: **канал идентификаторов в памяти** (буферизованный, с политикой
переполнения). Причина — он вводит второе, недолговечное представление того же
факта: доставка одновременно «в очереди» и «pending в базе», и эти два
представления расходятся при каждом падении. Плюс политика переполнения
(«оставить pending») всё равно требует подбора из базы, то есть кода из
варианта выше — только теперь его два.
Отвергнуто: **опрос базы по таймеру ВМЕСТО сигнала**. Он добавляет задержку в
полпериода на каждую доставку без всякой пользы: сигнал — одна строка. Но тик
**в дополнение** к сигналу берётся, и по другой причине — см. решение 5.
### 2. Порядок — тот же `(received_at, id)`, курсором внутри прохода
Порядок журнала определён capability пересборки (`openspec/specs/reindex/`), и
здесь он не переопределяется, а используется: воркер обрабатывает доставки в
том же порядке и по той же причине. Повторять обоснование в двух спеках нельзя —
правило поехало бы в одной и осталось в другой.
Проход воркера выбирает неразобранные доставки запросом
`WHERE parse_status = 'pending' AND (received_at, id) > (?, ?)
ORDER BY received_at, id LIMIT n`, курсор внутри прохода строго возрастает.
Форма сравнения — **row-value**, а не развёрнутая через `OR`, и это проверено
планом запроса на воспроизведённой схеме:
```
(received_at,id) > (?,?) → SEARCH … COVERING INDEX delivery_pending
received_at > ? OR (received_at = ? AND id > ?) → SCAN … COVERING INDEX delivery_pending
```
Прецедент в проекте уже есть — `store.LastDerivedLayer`. Нулевой курсор —
`(time.Time{}, "")`, то есть `0001-01-01T00:00:00Z`: один текст запроса без
ветки «первая страница».
Строго возрастающий курсор нужен не ради страниц, а ради **завершимости**:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`
и проход без курсора выбирал бы её вечно. С курсором проход конечен всегда.
Доставка, приехавшая во время прохода с меньшим `received_at`, курсором
пропускается — и подбирается следующим проходом, который её же сигнал и
запустит.
Отвергнуто: множество «уже пробованных в этом проходе» вместо курсора.
Эквивалентно по эффекту, но растёт по памяти вместе с задолженностью — а
задолженность после миграции `00005` это весь архив.
### 3. Общий с пересборкой код — классификатор исхода одной доставки
`replay.Run` сегодня несёт в себе цикл, который для каждой доставки зовёт
`fold.Fold` и разбирает исход по классам: `ErrLayerUnknown` — штатный отказ
(слой не выведен), `ErrMalformed` — непонятое содержимое, прочее — настоящая
поломка; счётчики частичного разбора и несравнимых наборов читаются **только**
у успешной свёртки, иначе `Partial` молча занижается, а по нему принимается
решение о судьбе тела.
Это и есть та половина, которую задача требует не дублировать. Она выносится в
`replay.Player.Play(ctx, deliveryID) (Outcome, error)` — исход **одной**
доставки значением, — и её зовут оба: `replay.Run` в своём цикле и воркер в
своём. Накопление — `(*Outcome).Add(other)`; `replay.Report` встраивает
`Outcome`, чтобы у пересборки не появилось второго набора имён для тех же
исходов.
**`Player` не смотрит на контекст.** Он классифицирует только ошибку, которую
вернула свёртка; решение «нас остановили» принимает цикл, каждый по своему
контексту. Иначе один и тот же `ctx.Err() != nil` означал бы у двух вызывающих
противоположное: у пересборки в свёртку уходит тот же отменяемый контекст
(«нас остановили»), у воркера — отвязанный от остановки, с собственным дедлайном
(«доставка не уложилась в две минуты»). Воркер, унаследовавший чужую ветку,
принял бы свой дедлайн за остановку и бросил проход молча.
Возврат значением, а не накопление по указателю: так устроены `fold.Fold`,
`store.MergePoints` и `replay.Run`, аккумулирующего out-параметра в проекте нет
ни одного. Плюс правило «счётчики только у успеха» становится утверждением о
результате одного вызова, а не вычитанием двух состояний — а именно на этом
правиле уже один раз занижался `Partial`.
Целиком общим цикл быть не может, и это названная граница: у пересборки состав
берётся из **архива** (тело без учётной записи — тоже событие) и пишется в
пустую базу, у воркера состав берётся из **учёта** (`pending`) и пишется в
рабочую. Общее у них — порядок, точка входа в свёртку и классификация исхода;
именно они и разошлись бы молча.
Отвергнуто: **звать `replay.Run` из воркера**. Он требует пустой базы
назначения и проигрывает весь журнал с нуля — под живым приёмом это не
операция подбора, а пересборка.
Отвергнуто: **воркер в `internal/ingest`**. Тогда порядок журнала знали бы два
пакета, и правку правила пришлось бы вносить в оба. `internal/replay` уже
объявлен местом, где живут «состав, порядок, отчёт»; фоновое проигрывание
хвоста — тот же предмет, только непрерывный.
### 4. Остановка формулируется инвариантом, а не обещанием досчитать
Свёртка идёт на контексте `context.WithoutCancel` от контекста воркера плюс
собственный дедлайн — ровно так, как сегодня это делает `ingest.Accept`.
Механизм не новый, он переезжает. Отмена контекста воркера проверяется
**между** доставками.
Обещать «текущая доставка досворачивается» нельзя: `foldTimeout` — две минуты, а
весь бюджет остановки — тридцать секунд, и `srv.Shutdown` тратит его первым.
Обещание, которое система не всегда исполняет, — это флакующий приёмочный тест и
неверное представление у следующего читателя. Поэтому требование формулируется
**инвариантом**: после остановки не существует доставки, которая числится
разобранной, а записана частично; несвёрнутое остаётся `pending`.
Порядок остановки: `srv.Shutdown` (перестаём принимать) → отмена контекста
воркера → ожидание его выхода в остатке того же бюджета. Обратный порядок
оставил бы доставки, принятые после остановки воркера, никого не разбудившими.
Механизм ожидания — `done chan struct{}`, закрываемый воркером в `defer`, и
`select` с бюджетом: `sync.WaitGroup.Wait()` бюджета не принимает.
Два следствия, которые надо назвать вслух, иначе они дадут ложные `ERROR`:
- **`Shutdown` возвращает `context.DeadlineExceeded` штатно** — так
задокументировано в stdlib. Сегодня `runServe` возвращает любую его ошибку
наверх, а `main` печатает `fatal startup` и выходит с кодом 1. После того как
бюджет ответа приёма вырос (решение 7), исчерпание бюджета остановки во время
загрузки станет обычным делом, и штатная остановка докладывалась бы как
провал старта. Контекстная ошибка `Shutdown``WARN`, а не отказ команды.
- **База не закрывается, пока воркер не вышел.** `defer st.Close()` при не
уложившемся в бюджет воркере закрыл бы базу под живой транзакцией свёртки, и
в лог ушли бы `ERROR` по доставке, с которой всё в порядке. Не уложились —
оставляем закрытие процессу, а факт называем `WARN`.
### 4б. Отмена и занятость базы оставляют доставку в очереди, всё прочее — нет
Сегодня `fold.fail` пишет `parse_status = failed` на **любой** ошибке. Пока
свёртка шла синхронно, это было терпимо. С воркером — нет: `failed` из очереди
выбывает навсегда, а вернуть его может только `healthlog reindex`, то есть
операция с остановкой сервиса и ручной подменой базы. Занятость базы после пяти
попыток `inTx` (порядка 200 мс на широкой доставке) стирала бы доставку с полки
молча — притом что сама эта задача делает конкуренцию за базу штатной.
Правило: **исход разбора отражает доставку, а не обстоятельства.**
- Отказ окружения — отмена контекста и занятость базы — статус **не меняет**:
доставка остаётся `pending` и подбирается следующим проходом или тиком.
- Всё остальное (`ErrMalformed`, `ErrLayerUnknown`, нечитаемое тело, тело сверх
предела, исчерпанный дедлайн свёртки) — `failed`, как и сейчас: это свойства
самой доставки, и повторять их бесполезно.
Занятость распознаётся сентинелом `store.ErrBusy``inTx` уже отличает
`SQLITE_BUSY`/`SQLITE_BUSY_SNAPSHOT` по коду, осталось назвать исход доменной
ошибкой у источника, как того требуют конвенции.
Отвергнуто: **счётчик попыток с переводом в `failed` после N**. Он нужен
очередям заданий общего назначения, где задание может быть ядовитым. У нас
ядовитость уже отсечена по классу: содержимое даёт `failed` с первого раза, а в
`pending` остаются только те два случая, которые проходят сами. Колонка и
политика «сколько попыток достаточно» были бы изобретением без наблюдения.
### 5. Проход будит не только сигнал: тик — страховка и площадка для метки
К сигналу добавляется тик (порядка минуты) в том же `select`. Он не альтернатива
сигналу (см. решение 1), он закрывает два случая, которые сигнал закрыть не
может:
- **Доставка, оставшаяся `pending` по решению 4б**, ждала бы следующей доставки,
чтобы её кто-то разбудил. Ночью телефон молчит часами.
- **Отставание невидимо ровно тогда, когда оно опасно.** Если метка задержки
вычисляется внутри прохода, а прохода нет, «работа есть, прогресса нет»
неотличимо от здорового пустого потока.
Наблюдаемость — две метки, и обе берутся из строк, которые проход и так
выбрал:
- `WARN` «доставка ждала свёртки дольше пяти минут» с `delivery_id` и
величиной ожидания. Порог — период быстрого прохода синхронизации: если
доставка ждала дольше, чем интервал между доставками, очередь растёт, а не
рассасывается. Считается от `received_at` до **начала** свёртки.
- `INFO` один раз при старте: сколько доставок числится неразобранными. Это
размер задолженности и ответ на вопрос «что сервис будет делать первые минуты
после рестарта».
**Первый проход задержку не считает.** После миграции `00005` неразобранными
числятся все доставки архива, и метка сработала бы сотней строк подряд, ничего
не сообщив: они ждали не воркера, а его появления. Задолженность при старте
называется одним `INFO`, метка включается после первого прохода.
**Отказ прохода воркер переживает.** Отказ `SELECT` (занятая база, отказ диска)
— это `ERROR` и выход из прохода, а не из цикла: воркер, умерший от временного
отказа базы, остановил бы свёртку до конца жизни процесса, а приём продолжал бы
отвечать `200`.
Числа — текущая длина `pending`, возраст самой старой неразобранной доставки —
это `/stats`, и они уезжают строкой в задачу `stats-nablyudaemost`. Здесь их
нет намеренно: отдельного механизма счётчиков в проекте пока не существует.
### 6. Частичный индекс по неразобранным доставкам
Запрос прохода спрашивается чаще, чем раз в минуту, а `delivery` растёт на
~300 строк в сутки (100 тысяч в год). Без индекса это скан таблицы с сортировкой
на каждый проход.
Индекс — **частичный**: `(received_at, id) WHERE parse_status = 'pending'`. В
установившемся режиме в нём ноль–одна строка, потому что свёрнутая доставка из
него выпадает; полный индекс по `parse_status` хранил бы все сто тысяч ради
выборки из одной. План запроса проверен (см. решение 2): индекс покрывающий, и
счёт задолженности по нему тоже не сканирует таблицу.
### 7. Длинный бюджет ответа даётся маршруту приёма, а не всему серверу
`WriteTimeout` у Go ставится в `readRequest`, до вызова обработчика, и потому
покрывает **и чтение тела**: при `read_timeout = 5m` и `write_timeout = 30s`
загрузка дольше 30 секунд обрывается, а `read_timeout` при этом обещает пять
минут. Премисса проверена по исходнику (`net/http/server.go`, постановка
write-дедлайна `defer`-ом внутри `readRequest`), симптом описан
[здесь](https://adam-p.ca/blog/2022/01/golang-http-server-timeouts/) и
[здесь](https://blog.cloudflare.com/exposing-go-on-the-internet/). Для 64 МиБ по
мобильной сети это не теоретический случай, и после выноса свёртки это
**единственный** оставшийся источник того же молчаливого обрыва.
Лечится это не подъёмом глобального умолчания, а дедлайном на том маршруте,
которому длинный бюджет нужен: обработчик приёма перед чтением тела ставит
`http.NewResponseController(w).SetWriteDeadline(now + read_timeout +
write_timeout)`. Тогда `/healthz` и будущий Read API сохраняют тридцатисекундную
защиту от застрявшей записи, конфиг не меняется вовсе, и не появляется пары
таймаутов, из которых один молча отменяет другой.
Механика проверена: `middleware.WrapResponseWriter` из chi реализует
`Unwrap() http.ResponseWriter`, поэтому `ResponseController` до соединения
добирается. Транспорт, не поддерживающий дедлайнов, отвечает
`http.ErrNotSupported` — это `DEBUG` и продолжение работы, а не отказ приёма.
Отвергнуто: **поднять умолчание `write_timeout` до `read_timeout`**. Три
возражения. Оно снимает защиту от застрявшей записи со **всех** маршрутов, ради
одного. Оно кладёт требование о глобальном параметре сервера в capability
приёма, где читатель Read API его не найдёт. И оно порождает вопрос
«сравниваются умолчания или эффективные значения», на который два реализатора
ответят по-разному.
Отвергнуто: **не трогать вовсе**. Так и было бы, будь это вместо выноса
свёртки; вместе с ним это доведение до конца — иначе `read_timeout` остаётся
обещанием, которого сервер не исполняет.
### 8. У цикла воркера есть синхронный шов, и тесты идут через него
`Worker.Pass(ctx) (Outcome, error)` — один проход, синхронный, без каналов;
`Run(ctx)` — тонкий `select` поверх него. Тесты зовут `Pass` напрямую и ничего
не ждут по часам; на `Run` остаётся один тест — «отмена завершает цикл», и он
синхронизируется возвратом `Run`, а не сном.
Без такого шва проверки «все свёрнуты», «проход конечен», «метка не сработала
на первом проходе» пишутся опросом базы с таймаутом, то есть сном в разной
форме, и мигают на загруженной машине. Гейт при этом перестаёт быть
детерминированным, а на нём стоит весь конвейер ревью.
Остальные швы — те, что есть:
- Порядок при конкурентных доставках проверяется наблюдаемым следствием
порядка — **наследованием слоя**: доставка без плотных метрик обязана
получить слой предшествующей ей по `(received_at, id)`.
- Отмена не оставляет половинчатого состояния — доставка остаётся `pending`, а
не `parsed` с половиной объектов.
- `task verify:archive` остаётся оракулом сходимости: пересборка проигрывает
журнал сама и воркера не касается.
### 9. Учёт доставки переживает обрыв соединения
`Accept` всё равно переписывается, и заодно чинится сузившийся до одного шага
риск: `store.CreateDelivery` идёт на контексте запроса, а тот отменяется при
обрыве связи клиентом. Тело к этому моменту уже в архиве (`arch.Write`
контекста не берёт), и отказ на вставке оставляет тело сиротой — восстановимо
только пересборкой с подменой базы. Раньше вероятность обрыва размазывалась по
следующей за вставкой свёртке; теперь вставка — последний шаг перед `200`.
Поэтому учёт ведётся на `context.WithoutCancel` с коротким собственным
дедлайном — тем же приёмом и по той же причине, по какой это делает
`fold.finish`: отмена снаружи не должна превращаться в свойство доставки.
Проверка формы тела остаётся на исходном контексте — там отменяемость уместна.
## Risks / Trade-offs
- **Абсолютный порядок при конкурентных приёмах недостижим** → две доставки,
принимаемые одновременно, могут закоммитить строки в порядке, обратном их
`received_at`; если воркер успел свернуть позднюю до того, как ранняя стала
видимой, наследование слоя разойдётся с тем, что даст пересборка. Смягчение:
окно сузилось (воркер один и берёт минимум из видимых, а не сворачивает в
порядке завершения обработчиков), исход остаётся детерминированно чинимым
(`healthlog reindex`), и сам эффект касается только доставок **без плотных
метрик**. Абсолютную гарантию дало бы удержание порядка на приёме, то есть
сериализация приёма — цена, которую задача платить не собиралась.
- **Ответ `200` больше не означает «разобрано»** → это объявленная смена
контракта, и в `docs/architecture.md` она фиксируется как контракт, а не как
деталь реализации воркера. Клиент HAE о разборе и не спрашивал; владелец
видит исход в `parse_status` и в логе. Читатель, делающий `POST` → чтение,
получает гонку — сегодня такой читатель один, тесты, и они переписаны на
синхронный `Pass`.
- **`failed` из очереди не возвращается** → доставка, отказавшая по
содержимому, ждёт пересборки. Это осознанная граница: обратное означало бы
бесконечный повтор заведомо безнадёжного. Названа в спеке.
- **Задолженность после рестарта разбирается не мгновенно** → 116 тел живого
архива это порядка минуты работы воркера; всё это время витрина неполна.
Названо `INFO`-строкой при старте. `/healthz` этого не отражает — он статичен;
отражать будет `/stats`, задача `stats-nablyudaemost`.
- **Второй процесс на той же базе даёт двух воркеров** → «одна горутина» —
свойство процесса, а не файла базы. Порчи витрины ждать не приходится
(`_txlock=immediate` и повтор транзакции сериализуют слияние), но наследование
слоя перестаёт быть функцией префикса. Механизма против этого не вводим:
запуск второго `serve` на той же базе не входит ни в один сценарий проекта, а
блокировка файла — отдельная задача с собственной ценой. Названо, чтобы не
было открытием.
- **Свёртка теперь конкурирует с приёмом за базу** → она и раньше шла на
отвязанном контексте, то есть параллельно следующему запросу; новое здесь
только то, что параллельность стала штатной. `busy_timeout`,
`_txlock=immediate` и повтор транзакции уже есть, а исчерпание повторов теперь
не стирает доставку с полки (решение 4б). Наблюдение за этим — задача
`cena-sliyaniya-na-shirokoj-dostavke`.
- **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по
покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая,
а без него состояние «работа есть, прогресса нет» невидимо.
## Migration Plan
Миграция схемы одна — `00006`, частичный индекс по неразобранным доставкам.
Данных она не трогает; `Down` снимает индекс.
Порядок выкладки обычный: `task build``task restart`. Первый старт нового
бинаря напечатает `INFO` с размером задолженности и разберёт её проходами
воркера — то есть заодно подберёт доставки, которые числятся `pending` после
миграции `00005`.
Откат — предыдущий бинарь: он свернёт всё синхронно, как раньше;
неразобранное к тому моменту останется `pending` до следующего `reindex`. Индекс
старому бинарю не мешает.
## Open Questions
- Метка задержки считается от `received_at`, который хранится с секундной
точностью; для порога в пять минут этого достаточно, но если порог когда-то
опустится до секунд, точности не хватит.
- Каждая будущая миграция, переводящая строки в `pending` (спека хранения этого
прямо требует от задач, покрывающих новую секцию), теперь автоматически
запускает пересвёртку под живым приёмом. Для `00005` это желаемое поведение;
для миграции размером в годовой архив вопрос о темпе встанет заново.