## 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` это желаемое поведение; для миграции размером в годовой архив вопрос о темпе встанет заново.