Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
@@ -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` это желаемое поведение;
|
||||
для миграции размером в годовой архив вопрос о темпе встанет заново.
|
||||
Reference in New Issue
Block a user