Приём отвечает 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,2 @@
schema: spec-driven
created: 2026-08-02
@@ -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` это желаемое поведение;
для миграции размером в годовой архив вопрос о темпе встанет заново.
@@ -0,0 +1,86 @@
## Why
Свёртка выполняется **внутри обработчика запроса**, поэтому время ответа равно
времени свёртки: 16 тысяч точек — 11 секунд. `WriteTimeout` в Go ставится в
`readRequest`, то есть до вызова обработчика, и его 30 секунд — общий бюджет на
всё: дочитать тело по мобильной сети, записать архив, вставить строку, свернуть.
Когда бюджет выходит, сервер считает, что отдал `200` (ошибки записи
обработчику не видно, ответ ушёл в буфер), клиент получает обрыв, а `accessLog`
пишет `status_code=200` — единственный канал наблюдаемости в этом сценарии врёт.
Бьёт это по **широким проходам** (`Today`, `Previous 7 Days`, ручной экспорт) —
ровно по тем, ради которых заведён инвариант «дыры закрываются сами».
## What Changes
- Приём отвечает `200` **после архивации тела и вставки строки `delivery`**.
Свёртка из обработчика уходит: время ответа перестаёт зависеть от ширины
доставки.
- Свёртку ведёт **фоновый воркер** — одна горутина, обработка в порядке журнала
(`received_at`, `id`) среди доставок, видимых ему на момент выборки.
- **Очередью служит сама таблица**, а не список идентификаторов в памяти:
доставка ждёт свёртки в статусе `pending`, канал несёт только сигнал «есть
работа». Отсюда три следствия: переполнять нечего (доставка и так `pending`,
это не отказ), падение процесса очередь не теряет, а «подбор `pending` при
старте» перестаёт быть отдельным кодом — это обычный проход воркера.
- Классификация исхода свёртки (`folded` / слой не выведен / содержимое не
разобрано / прочее / частичный разбор) становится **общей с пересборкой**:
один проигрыватель в `internal/replay`, а не второй рядом.
- **Исход свёртки начинает отражать доставку, а не обстоятельства.** Сегодня
`failed` пишется на любой ошибке, включая занятость базы; с воркером это
означало бы, что доставка выбывает из очереди навсегда — а конкуренция за базу
как раз становится штатной. Отмена и занятость статус больше не меняют,
доставка остаётся `pending`; всё прочее по-прежнему `failed` и возвращается
только пересборкой.
- Остановка сервиса формулируется **инвариантом**, а не обещанием досчитать:
приём прекращается раньше воркера, и после остановки нет доставки, которая
числится разобранной, а записана наполовину. Свёртка идёт на контексте,
отвязанном от остановки.
- Наблюдаемость воркера: `WARN`, когда доставка ждала свёртки дольше периода
быстрого прохода, и одна строка `INFO` о размере задолженности при старте.
Метка считается на выборке прохода, а сам проход будит не только сигнал, но и
тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока.
Счётчики в `/stats` — задача `stats-nablyudaemost`, здесь только метки в логе.
- Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout`
(30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть
медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет
даётся **маршруту приёма** собственным дедлайном ответа; общий таймаут и
конфиг не меняются, и прочие маршруты защиту не теряют.
- Миграция: частичный индекс по неразобранным доставкам — воркер спрашивает их
чаще, чем раз в минуту, а таблица растёт на ~300 строк в сутки.
## Capabilities
### New Capabilities
- `ingest`: приём доставки как самостоятельное поведение — что делает ответ
`200` заслуженным, когда он отдаётся, кто и в каком порядке сворачивает
принятое, что происходит с несвёрнутым при остановке и рестарте.
### Modified Capabilities
- `parsing`: требование «Разбор не влияет на код ответа приёма» уточняется —
разбор идёт **после** ответа, поэтому исход становится виден не в ответе и не
сразу, а асинхронно, в `parse_status` и в логе.
- `storage`: требование «Учёт частично разобранной доставки» уточняется — отказ
обстоятельств (отмена, занятость базы) статуса не меняет вовсе, а прежняя
формулировка «ошибка ⇒ `failed`» этого не допускала.
- `reindex`: требование «Отчёт, оракул и исход команды» получает четвёртый класс
отказа — «работа отложена по обстоятельствам»: классы у пересборки и у фоновой
свёртки общие.
## Impact
- `internal/ingest` — теряет зависимость от `internal/fold`: `Accept` кладёт
тело, учитывает доставку и будит воркер.
- `internal/replay` — общий проигрыватель (свернуть доставку, классифицировать
исход) и фоновый воркер поверх него.
- `internal/store` — выборка неразобранных доставок в порядке журнала с
курсором, сентинел занятости базы; миграция `00006` с частичным индексом.
- `internal/fold` — отмена и занятость базы больше не переводят доставку в
`failed`.
- `cmd/healthlog/serve.go` — жизненный цикл воркера и согласованная остановка.
- `internal/httpapi` — сборка `ingest.Service` без свёртки; собственный дедлайн
ответа на маршруте приёма. `config.example.toml` — комментарий к
`write_timeout`.
- `docs/architecture.md`, `docs/database.md` — путь приёма и новый индекс.
@@ -0,0 +1,294 @@
## ADDED Requirements
### Requirement: Ответ приёма отражает сохранность, а не разбор
Приём SHALL отвечать `200` после того, как тело записано в сырой архив и
доставка учтена строкой `delivery`, и MUST NOT ждать свёртки. Время ответа
зависеть от ширины доставки MUST NOT.
Порядок обязателен именно такой: тело на диск, затем строка учёта. Обратный дал
бы учтённую доставку без данных. Отказ на любом из двух шагов — отказ приёма, и
о нём отправителю говорится ошибкой: `400` для неразбираемой верхнеуровневой
формы, `413` для тела сверх предела, `500` для отказа записи.
Учёт доставки SHALL вестись на контексте, не отменяемом обрывом соединения:
тело к этому моменту уже на диске, и отказ вставки из-за ушедшего клиента
оставил бы тело без записи в журнале. Проверка формы тела при этом остаётся
отменяемой — там отмена уместна.
Причина разнесения измерена: свёртка 16 тысяч точек занимает 11 секунд, а
`WriteTimeout` в Go ставится до вызова обработчика и потому является общим
бюджетом на чтение тела, запись архива, учёт и свёртку. Исчерпав его, сервер
считает, что отдал `200`, клиент получает обрыв, а запись `accessLog` называет
статус `200` — то есть единственный канал наблюдаемости врёт.
#### Scenario: Ответ отдан до свёртки
- **WHEN** тело принято, записано в архив и учтено
- **THEN** ответ `200` отдан
- **AND** доставка в этот момент числится неразобранной
#### Scenario: Ширина доставки не удлиняет ответ
- **WHEN** приезжает доставка, свёртка которой занимает секунды
- **THEN** время ответа не включает время свёртки
#### Scenario: Обрыв соединения не оставляет тело без учёта
- **WHEN** соединение обрывается после того, как тело записано в архив
- **THEN** строка учёта доставки всё равно записывается
### Requirement: Несвёрнутая доставка числится неразобранной
Учтённая, но ещё не свёрнутая доставка SHALL числиться в статусе `pending`, и
этот статус SHALL быть единственным признаком того, что свёртка ещё должна
произойти. Отдельного, живущего только в памяти представления той же очереди
система иметь MUST NOT.
Отсюда следуют три свойства, и они и есть смысл требования:
- переполнять нечего — доставка ждёт свёртки в базе, а не в буфере, и «очередь
переполнена» невыразимо;
- падение процесса очереди не теряет — несвёрнутое остаётся `pending`;
- подбор `pending` не является отдельной операцией — он совпадает с обычной
работой свёртки.
#### Scenario: Принятая доставка ждёт свёртки в базе
- **WHEN** доставка учтена, но ещё не свёрнута
- **THEN** её `parse_status` равен `pending`
#### Scenario: Оборванный процесс не теряет несвёрнутое
- **WHEN** процесс прекращается до того, как свёртка доставки завершилась
- **THEN** доставка остаётся `pending`
- **AND** следующий старт сворачивает её
### Requirement: Исход свёртки отражает доставку, а не обстоятельства
Свёртка SHALL оставлять доставку в очереди — то есть **не менять** её статус, —
когда работа не сделана по причине, к самой доставке не относящейся: отмена
контекста и занятость базы после исчерпания повторов транзакции.
Все прочие отказы разбора и записи точек (непонятое содержимое, невыводимый
слой, нечитаемое или слишком большое тело, исчерпанный дедлайн свёртки, паника
самой свёртки) SHALL давать `failed`: это свойства доставки, и повторять их
бесполезно.
Отказы, случившиеся **до** чтения тела, и отказ самой записи исхода статуса не
меняют по другой причине — записать его нечем. Доставка остаётся `pending`, и
это честно: этим разбором её не досмотрели.
Паника свёртки SHALL перехватываться на той же границе, что пишет исход разбора,
и превращаться в `failed`. Иначе она валит процесс целиком — фоновая горутина
ничем не обёрнута, — а перезапуск берёт ту же доставку первой, то есть дефект
одной доставки становится циклом перезапуска, при котором приём не работает
вовсе. До разнесения ответа и свёртки ту же панику ловил транспорт, и стоила она
одного ответа.
Доставка в статусе `failed` в очередь свёртки возвращаться MUST NOT — её
подбирает только пересборка журнала. Это названная граница: обратное означало бы
бесконечный повтор заведомо безнадёжного.
Без такого различения занятость базы — а свёртка теперь конкурирует с приёмом за
неё штатно — стирала бы доставку с полки молча, и вернуть её могла бы только
ручная операция с остановкой сервиса.
#### Scenario: Занятая база не выводит доставку из очереди
- **WHEN** свёртка не прошла из-за занятости базы
- **THEN** доставка остаётся `pending`
- **AND** следующий проход пробует её снова
#### Scenario: Непонятое содержимое выводит доставку из очереди
- **WHEN** свёртка не прошла из-за содержимого тела
- **THEN** доставка получает статус `failed`
- **AND** следующий проход её не выбирает
### Requirement: Свёртку ведёт один фоновый воркер в порядке журнала
Свёртку принятых доставок SHALL вести одна горутина, обрабатывающая доставки в
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
Распараллеливать свёртку MUST NOT.
Достижимая гарантия называется точно: в порядке `(received_at, id)`
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
ставшая видимой позже курсора прохода, подбирается следующим проходом;
абсолютного порядка при конкурентных приёмах система не обещает.
Последствие этого предела называется вслух: доставка без плотных метрик,
свёрнутая раньше своей предшественницы, слоя не выведет и получит `failed` — то
есть её точки в витрину не попадут до пересборки. Живое состояние в этом случае
расходится с тем, что даёт `healthlog reindex`. Окно узкое (обе доставки должны
приниматься одновременно, и только у автоматизации без плотных метрик), и
изменение его сужает, а не открывает: прежде свёртка шла в порядке завершения
обработчиков. Устранение предела — отдельный вопрос, оно требует удерживать
порядок на самом приёме.
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
курсором в пределах одного прохода. Курсор обязателен для завершимости:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`,
и проход без курсора выбирал бы её бесконечно.
Приём SHALL будить воркер после того, как доставка учтена. Потеря сигнала
отказом быть MUST NOT: доставка от этого не перестаёт числиться `pending`.
Помимо сигнала воркер SHALL просыпаться периодически — иначе доставка,
оставшаяся `pending` по причине выше, ждала бы следующей доставки, а ночью
телефон молчит часами.
Отказ отдельного прохода воркер SHALL переживать: отказ выборки пишется `ERROR`
и прекращает проход, но не цикл. Отмена работы снаружи отказом при этом
считаться MUST NOT — штатная остановка не должна писать `ERROR`. Воркер, умерший
от временного отказа базы, остановил бы свёртку до конца жизни процесса, пока
приём продолжал бы отвечать `200`.
#### Scenario: Видимые доставки сворачиваются в порядке журнала
- **GIVEN** несколько доставок числятся `pending` до начала прохода
- **WHEN** воркер делает проход
- **THEN** он сворачивает их в порядке `(received_at, id)`
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
порядку доставки той же автоматизации, а не соседа по времени вставки
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
`pending`
- **THEN** проход воркера завершается, а не выбирает её повторно
#### Scenario: Доставка без входящего потока всё равно подбирается
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
- **WHEN** наступает очередное периодическое пробуждение
- **THEN** воркер пробует свернуть её снова
### Requirement: Подбор неразобранного при старте — та же операция
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
же путём, каким сворачивает вновь принятые: отдельного кода подбора
существовать MUST NOT.
Порядок журнала при подборе SHALL соблюдаться так же, как при обычной работе —
подбор это тот же проход воркера, а не особый режим.
Классификация исхода свёртки (свёрнуто; слой не выведен; содержимое не
разобрано; прочий отказ; частичный разбор; несравнимые наборы полей) SHALL быть
общей с пересборкой журнала: второй классификатор разошёлся бы с первым молча.
Классифицироваться SHALL только ошибка свёртки; решение «работу прекратили
снаружи» MUST NOT приниматься классификатором — у пересборки и у воркера
контекст свёртки означает разное, и общая ветка отмены дала бы одному из них
противоположный смысл.
Счётчики частичного разбора и несравнимых наборов SHALL читаться только у
успешной свёртки — у отказавшей они заполнены частично, и `partial` занижался бы,
а по нему принимается решение о судьбе тела.
#### Scenario: Доставки, оставшиеся неразобранными, подбираются при старте
- **GIVEN** в учёте есть доставки со статусом `pending`
- **WHEN** сервис стартует
- **THEN** они сворачиваются в порядке `(received_at, id)`
#### Scenario: Размер задолженности назван при старте
- **WHEN** сервис стартует и неразобранные доставки есть
- **THEN** их число попадает в лог одной записью уровня `INFO`
### Requirement: Остановка не оставляет доставку в неопределённом состоянии
Остановка сервиса SHALL сперва прекращать приём, затем останавливать воркер.
Обратный порядок оставил бы доставки, принятые после остановки воркера, никого
не разбудившими.
Инвариант остановки: после неё не существует доставки, которая числится
разобранной, а записана частично; всё несвёрнутое остаётся `pending`. Обещать,
что текущая доставка непременно досворачивается, система MUST NOT — бюджет
остановки меньше бюджета свёртки, и такое обещание исполнялось бы не всегда.
Свёртка SHALL идти на контексте, не отменяемом остановкой, а отмена SHALL
проверяться **между** доставками. Причина названа: свёртка помечает доставку
`failed` на ошибке, а `failed` воркер не подбирает — то есть отмена снаружи
превратилась бы в свойство доставки.
Исчерпание бюджета остановки отказом сервиса считаться MUST NOT: и штатное
завершение воркера, и его прерывание оставляют состояние определённым. Факт
SHALL называться предупреждением, а не ошибкой старта.
#### Scenario: Остановка не оставляет половины
- **WHEN** сервис останавливается во время свёртки доставки
- **THEN** доставка либо свёрнута целиком, либо числится `pending`
- **AND** частично записанных объектов от неё не остаётся
#### Scenario: Приём прекращается раньше воркера
- **WHEN** сервис останавливается
- **THEN** приём перестаёт принимать раньше, чем останавливается воркер
#### Scenario: Не уложились в бюджет остановки
- **WHEN** воркер не успевает выйти в отведённый бюджет
- **THEN** факт попадает в лог предупреждением
- **AND** команда не сообщает об ошибке
### Requirement: Отставание воркера видно в логе
Система SHALL писать `WARN`, когда доставка ждала свёртки дольше периода
быстрого прохода синхронизации (пять минут): дольше этого срока очередь растёт,
а не рассасывается. Ожидание считается от `received_at` до начала свёртки.
Записей SHALL быть **одна на проход**, а не одна на доставку: задолженность в
сотню тел давала бы сотню одинаковых предупреждений каждую минуту, и уровень, по
которому вмешиваются, перестал бы что-либо значить. Строка называет число
задержанных и худшее ожидание с идентификатором доставки.
Метка SHALL вычисляться на выборке прохода, а не только по факту успешной
свёртки: состояние «работа есть, прогресса нет» обязано быть отличимо от
здорового пустого потока, иначе наблюдаемость молчит ровно там, где нужна.
Задолженность, накопленную **до** старта, метка задержки помечать MUST NOT: она
названа отдельной записью `INFO` о размере задолженности, а сотня одинаковых
`WARN` при первом же старте обесценила бы уровень. Метка включается после того,
как первый проход воркера завершился.
Записи воркера значений точек и имён устройств содержать MUST NOT — как и любые
записи свёртки.
#### Scenario: Отставший воркер называет задержку
- **GIVEN** первый проход воркера завершён
- **WHEN** доставки дожидаются свёртки дольше пяти минут
- **THEN** в лог идёт одна запись `WARN` на проход с числом задержанных и
худшим ожиданием
#### Scenario: Задолженность при старте не даёт шквала предупреждений
- **GIVEN** неразобранными числятся доставки, накопленные до старта
- **WHEN** воркер сворачивает их первым проходом
- **THEN** записей `WARN` о задержке по ним нет
### Requirement: Длинный бюджет ответа принадлежит маршруту приёма
Обработчик приёма SHALL выставлять собственный дедлайн записи ответа перед
чтением тела, и этот дедлайн SHALL покрывать чтение тела вместе с отправкой
ответа. Полагаться на общий `write_timeout` сервера система MUST NOT: он
ставится до вызова обработчика и потому обрывает загрузку, идущую дольше него, —
делая `read_timeout` обещанием, которого сервер не исполняет.
Общий `write_timeout` сервера при этом расширяться MUST NOT: длинный бюджет
нужен одному маршруту, а остальные теряли бы защиту от застрявшей записи ответа.
Транспорт, не поддерживающий установки дедлайна, отказом приёма считаться MUST
NOT: факт уходит в `DEBUG`, приём продолжается.
#### Scenario: Медленная загрузка тела не обрывается
- **WHEN** тело приезжает дольше, чем общий `write_timeout` сервера, но
укладывается в `read_timeout`
- **THEN** ответ доходит до отправителя
#### Scenario: Прочие маршруты бюджета не наследуют
- **WHEN** запрос идёт не на приём
- **THEN** его бюджет записи ответа остаётся общим `write_timeout`
@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: Разбор не влияет на код ответа приёма
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
меняет код ответа на доставку.
Разбор идёт **после** ответа, поэтому исход виден не в ответе и не в момент
ответа, а асинхронно — в `delivery.parse_status` и в записи лога. Когда именно
отдаётся ответ и кто сворачивает принятое, определяет capability `ingest`;
здесь нормируется только то, что от разбора код ответа не зависит.
#### Scenario: Содержимое не разобралось
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога
@@ -0,0 +1,115 @@
## MODIFIED Requirements
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
дефект там, где его нет. Отдельно называть человеку следует только нештатные
отказы.
Отложенная доставка (занятость базы, отмена работы снаружи) SHALL считаться
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
идёт в свежий файл при единственном писателе, и такая доставка в собранной
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
молча.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
@@ -0,0 +1,94 @@
## MODIFIED Requirements
### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
Статусы разбора:
```
pending этим разбором ещё не смотрели — или смотрели, но работа не сделана
по обстоятельствам (см. ниже)
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed``partial``parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
**Отказ обстоятельств статуса не меняет вовсе.** Отмена работы снаружи и
занятость базы дольше повторов транзакции означают «не сделано», а не «не
выходит»: доставка остаётся `pending` и будет свёрнута снова. Правило появилось
не из аккуратности — фоновая свёртка `failed` не подбирает никогда, и без этого
различения занятость базы (а с фоновой свёрткой конкуренция за неё штатная)
выводила бы доставку из очереди навсегда. Различение живёт **в одном месте**:
тот, кто пишет исход, и тот, кто классифицирует его в счётчики, спрашивают один
предикат.
Дедлайн самой свёртки к обстоятельствам MUST NOT относиться: доставка, не
уложившаяся в бюджет, не уложится в него и в следующий раз, а бесконечный повтор
заведомо безнадёжного — это очередь, которая не движется.
Отказы, случившиеся **до** чтения тела (учётной записи нет, соседний запрос не
прошёл), и отказ самой записи исхода статуса не меняют по другой причине —
записать его нечем. Доставка остаётся `pending`, что честно: этим разбором её не
досмотрели.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
#### Scenario: Доставка с непокрытой секцией отмечается частичной
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Отказ разбора сильнее частичности
- **WHEN** разбор доставки завершился ошибкой в самом разборе или в записи
точек
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
#### Scenario: Занятая база доставку из очереди не выводит
- **WHEN** разбор не состоялся из-за занятости базы или отмены работы снаружи
- **THEN** `parse_status` остаётся `pending`
#### Scenario: Пересвёртка после того, как секция стала покрытой
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
который эту секцию покрывает
- **THEN** `parse_status` становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
@@ -0,0 +1,180 @@
## 1. Опоры в хранилище
- [x] 1.1 Миграция `00006`: частичный индекс
`delivery (received_at, id) WHERE parse_status = 'pending'`. Комментарий
объясняет, почему частичный, а не по `parse_status`: в установившемся
режиме в нём ноль–одна строка, полный хранил бы всю таблицу ради выборки
из одной.
- [x] 1.2 `store.PendingDeliveries(ctx, after, limit)` — неразобранные доставки
в порядке `(received_at, id)`, строго после курсора; отдаёт идентификатор
и `received_at`. Сравнение курсора — **row-value** `(received_at, id) >
(?, ?)`: развёрнутая форма через `OR` даёт `SCAN` вместо `SEARCH`
(проверено `EXPLAIN QUERY PLAN`). Нулевой курсор — нулевое время и пустой
идентификатор, без ветки «первая страница».
- [x] 1.3 `store.CountPendingDeliveries(ctx)` — размер задолженности для
строки `INFO` при старте.
- [x] 1.4 `store.ErrBusy` — доменный сентинел занятости базы; `inTx` оборачивает
им исчерпание повторов, чтобы вызывающий не разбирал коды драйвера.
- [x] 1.5 Обновить `docs/database.md`: новый индекс в перечне индексов
`delivery`.
## 2. Исход свёртки отражает доставку, а не обстоятельства
- [x] 2.1 `fold.fail`: отмена контекста и `store.ErrBusy` статус **не меняют**
доставка остаётся `pending`; всё прочее по-прежнему `failed`. Уровень лога
по адресату: занятость и отмена — `WARN` (пройдёт само), остальное как
сейчас.
- [x] 2.2 Тест: свёртка на занятой базе оставляет доставку `pending`; свёртка
непонятого содержимого оставляет `failed`.
## 3. Общий проигрыватель — `internal/replay`
- [x] 3.1 `replay.Player.Play(ctx, deliveryID) (Outcome, error)` — свернуть одну
доставку и вернуть её исход **значением**: ровно один классовый счётчик
равен единице, плюс `partial`/`incomparable` у успешной свёртки.
Классифицируется **только ошибка**; на контекст `Player` не смотрит —
решение «нас остановили» принимает цикл.
- [x] 3.2 `replay.Outcome` + `(*Outcome).Add(other)`; `replay.Report` встраивает
`Outcome`, чтобы имена исходов не раздвоились. Существующие вызывающие
(`cmd/healthlog/reindex*.go`, тесты) читают поля по-прежнему.
- [x] 3.3 `replay.Run` переводится на `Player`, свою ветку отмены оставляет
себе. Поведение и отчёт не меняются — проверяется существующими тестами
пакета.
- [x] 3.4 Табличный тест классификатора: ошибка → ожидаемый `Outcome`.
## 4. Воркер свёртки
- [x] 4.1 `replay.Worker` с синхронным швом: `Pass(ctx) (Outcome, error)` — один
проход, без каналов; `Run(ctx)` — тонкий `select` поверх него по сигналу,
тику и отмене; `Notify()` — неблокирующая отправка в канал ёмкостью 1;
`done` закрывается в `defer` внутри `Run`.
- [x] 4.2 `Pass`: выбирать `pending` порциями по курсору, сворачивать через
`Player`, курсор строго возрастает; отмена проверяется **между**
доставками; проход конечен даже когда доставка осталась `pending`.
- [x] 4.3 Свёртка внутри прохода идёт на `context.WithoutCancel` от контекста
прохода плюс собственный дедлайн (`foldTimeout`, переезжает из
`internal/ingest`).
- [x] 4.4 Отказ выборки — `ERROR` и выход из `Pass`, но не из `Run`: воркер
переживает временный отказ базы.
- [x] 4.5 Наблюдаемость: `INFO` с размером задолженности перед первым проходом;
`WARN` «доставка ждала свёртки дольше пяти минут» — считается на выборке
прохода, включается после того, как первый проход завершился. Ни значений
точек, ни имён устройств.
## 5. Приём без свёртки
- [x] 5.1 `ingest.Service` теряет зависимость от `fold`: `Accept` пишет тело,
учитывает доставку, логирует принятие и будит воркер. `foldTimeout` и
вызов свёртки уходят.
- [x] 5.2 Учёт доставки — на `context.WithoutCancel` с коротким дедлайном:
обрыв соединения после записи тела не должен оставлять тело без строки в
журнале. Проверка формы тела остаётся на исходном контексте.
- [x] 5.3 Сигнал воркеру — параметр конструктора функцией; `nil` приводится к
пустой функции **один раз в конструкторе**, как это уже делают `fold.New`
и `replay.Run` со своими нулевыми значениями. Проверок на `nil` в местах
вызова быть не должно.
- [x] 5.4 `internal/httpapi` собирается без `fold`; транспорт по-прежнему не
логирует исход и переводит только ошибки приёма.
## 6. Жизненный цикл в `serve.go`
- [x] 6.1 Собрать воркер, запустить `Run` в горутине, передать его `Notify` в
`ingest`, разбудить при старте — этим и делается подбор `pending`.
- [x] 6.2 Остановка: `srv.Shutdown` → отмена контекста воркера → ожидание
`done` в остатке того же бюджета `shutdownTimeout` (30 с, как
`stop_grace_period`).
- [x] 6.3 Контекстная ошибка `Shutdown``WARN`, а не отказ команды: stdlib
возвращает `DeadlineExceeded` штатно, а `main` печатает на любой ошибке
`fatal startup` и выходит с кодом 1.
- [x] 6.4 База не закрывается, пока воркер не вышел: `Close` под живой
транзакцией свёртки дал бы `ERROR` по доставке, с которой всё в порядке.
Не уложились — оставляем закрытие процессу и называем это `WARN`.
## 7. Бюджет ответа маршрута приёма
- [x] 7.1 `handleIngest` перед чтением тела ставит дедлайн записи ответа через
`http.NewResponseController(w).SetWriteDeadline` на `read_timeout +
write_timeout`. `http.ErrNotSupported``DEBUG` и продолжение, а не
отказ приёма.
- [x] 7.2 Общий `write_timeout` и его умолчание не меняются; комментарий в
`config.example.toml` объясняет, что он покрывает и чтение тела и потому
приём держит собственный бюджет.
## 8. Проверки
Приёмочные критерии — рубрика ревью дизайна, перенесена сюда целиком.
- [x] 8.1 **Завершимость прохода.** Доставка, оставшаяся `pending`, не
выбирается проходом повторно; `Pass` возвращает управление.
- [x] 8.2 **Атомарность единицы работы.** Прерванная свёртка оставляет доставку
`pending` и не оставляет частично записанных объектов.
- [x] 8.3 **Идемпотентность повтора.** Двойная свёртка той же доставки даёт тот
же отпечаток витрины.
- [x] 8.4 **Сигнал не теряет работу.** Доставка, чей сигнал потерян, всё равно
подбирается — тиком или следующим проходом.
- [x] 8.5 **Тотальный порядок.** Доставки с одинаковым `received_at`
сворачиваются в порядке `id` при любом размере порции; порядок проверяется
наблюдаемым следствием — наследованием слоя.
- [x] 8.6 **Остановка.** Приём прекращается раньше воркера; после остановки нет
доставки, числящейся разобранной и записанной наполовину; исчерпание
бюджета не даёт ненулевого кода возврата.
- [x] 8.7 **Транзиентный отказ ≠ отказ доставки.** Занятость базы оставляет
`pending`, содержимое даёт `failed` (задача 2.2).
- [x] 8.8 **Отставание наблюдаемо и при отсутствии прогресса.** Метка не
срабатывает на задолженности первого прохода и срабатывает после него;
считается на выборке, а не по факту свёртки.
- [x] 8.9 **Одна классификация на оба входа.** Табличный тест `Player` (задача
3.4) плюс зелёные существующие тесты `replay`.
- [x] 8.10 **Тестируемость без сна.** Ни один тест воркера не ждёт по часам:
проверки идут через `Pass`, тест на `Run` — один, «отмена завершает цикл».
- [x] 8.11 **Приём не платит за воркер.** После `Accept` доставка числится
`pending`, обработчик не ждёт; приём с неработающим воркером отвечает
`200`.
- [x] 8.12 **Данные о здоровье не в логе.** Записи воркера несут только
идентификатор доставки, счётчики и длительности.
- [x] 8.13 `task gate` зелёный; `task verify:archive` даёт то же состояние.
## 9. Документация
- [x] 9.1 `docs/architecture.md`, раздел «Приём»: ответ отдаётся после архивации
и учёта — это **контракт**, а не деталь реализации; очередь — таблица, а
не память; порядок журнала и остановка; классификация «отказ доставки»
против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые
варианты с причинами.
- [x] 9.2 Строка в задачу беклога `stats-nablyudaemost`: длина `pending`,
возраст самой старой неразобранной доставки и то, что `/healthz` их не
отражает.
## 10. Правки по ревью кода (профиль `deep`)
- [x] 10.1 `store.CreateDelivery` — через `inTx` с повторами: одиночная вставка
пересиживала только `busy_timeout`, и приём отвечал `500` по доставке,
тело которой уже на диске (измерено: окно занятости 6.55 с при широкой
свёртке против пяти секунд ожидания).
- [x] 10.2 Паника свёртки перехватывается в `fold.Fold` — у той же границы, что
пишет исход разбора: в фоновой горутине она валила процесс, а
`restart: unless-stopped` превращал дефект одной доставки в цикл
перезапуска. Писатель `parse_status` остался единственным.
- [x] 10.3 Флаг «первый проход завершён» снимается только у прохода, дошедшего
до пустой выборки: взведённый на отказе базы, он включал метку задержки
после прохода, который ничего не свернул.
- [x] 10.4 Метка отставания — одна запись на проход (число задержанных и худшее
ожидание), а не запись на доставку: задолженность в сотню тел давала бы
сотню одинаковых `WARN` каждую минуту.
- [x] 10.5 Отмена не пишется `ERROR`-ом в цикле воркера; ветка отказа `Serve` в
`serve.go` останавливает воркер прежде, чем закрыть базу.
- [x] 10.6 Значения заголовков обрезаются перед логом: `automation-name` длиной
600 КБ выдавливал из ротации всю недавнюю историю (измерено).
- [x] 10.7 Запасной `store.Now()` для метки приёма убран: он заводил второй
источник времени вопреки соседнему комментарию и был недостижим.
- [x] 10.8 `classify` неэкспортируема: вторая публичная дверь возвращала
`Outcome` без `Partial`, то есть молча занижала счётчик, по которому
решается судьба тела.
- [x] 10.9 Оракул правила «занятость — обстоятельство»: `task verify:busy`
свёртка под удерживаемой блокировкой. В гейт не входит (25 секунд), но
мутацию «убрать ветку `Transient`» убивает.
- [x] 10.10 Дельты `storage` и `reindex`: «отказ ⇒ `failed`» сужено до отказов
разбора, добавлен класс «отложено».
- [x] 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и
в `docs/architecture.md`, вынут блокером
(`docs/backlog/poryadok-zhurnala-na-priyome.md`).