- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
324 lines
26 KiB
Markdown
324 lines
26 KiB
Markdown
# ingest Specification
|
||
|
||
## Purpose
|
||
|
||
Приём доставки от Health Auto Export как самостоятельное поведение: что делает
|
||
ответ `200` заслуженным, когда он отдаётся, кто и в каком порядке сворачивает
|
||
принятое, что происходит с несвёрнутым при остановке и рестарте. Цена ошибки
|
||
здесь наивысшая в проекте — доставка, не попавшая в архив и в журнал, не
|
||
восстанавливается: телефон её не перешлёт.
|
||
## 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`
|
||
|
||
### Requirement: Отказ учёта доставки называет класс причины
|
||
|
||
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
|
||
|
||
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
|
||
|
||
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
|
||
учётной записи, то есть осиротело, и вернуть его в журнал может только
|
||
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
|
||
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
|
||
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
|
||
или испорченная база.
|
||
|
||
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
|
||
|
||
- **WHEN** запись учёта доставки не проходит из-за занятости базы
|
||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||
- **AND** запись отличает занятость базы от прочих причин отказа
|
||
- **AND** тот же отказ по другой причине этого признака не несёт
|
||
|