- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
432 lines
38 KiB
Markdown
432 lines
38 KiB
Markdown
## 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` это желаемое поведение;
|
||
для миграции размером в годовой архив вопрос о темпе встанет заново.
|