- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
394 lines
32 KiB
Markdown
394 lines
32 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.
|
||
|
||
Порядок здесь — не удобство отладки, а условие правильности содержимого:
|
||
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
|
||
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
|
||
доставка возвращает координату к версии, которую источник уже пересчитал, и
|
||
живая витрина расходится с тем, что даёт пересборка.
|
||
|
||
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
|
||
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
|
||
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
|
||
|
||
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
|
||
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
|
||
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
|
||
целиком, а приём продолжал бы отвечать `200`.
|
||
|
||
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
|
||
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
|
||
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
|
||
возобновляется сигналом приёма или периодическим пробуждением.
|
||
|
||
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
|
||
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
|
||
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
|
||
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
|
||
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
|
||
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
|
||
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
|
||
|
||
Достижимая гарантия называется точно: в порядке `(received_at, id)`
|
||
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
|
||
ставшая видимой позже курсора прохода, подбирается следующим проходом;
|
||
абсолютного порядка при конкурентных приёмах система не обещает, потому что
|
||
строка учёта становится видимой только после записи тела (измерено 184 мс на
|
||
62 МиБ).
|
||
|
||
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
|
||
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
|
||
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
|
||
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
|
||
секундах и без значений точек.
|
||
|
||
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
|
||
только если свёртка состоялась: после свёртки предикат уже видит саму эту
|
||
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
|
||
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
|
||
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
|
||
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
|
||
`healthlog reindex`.
|
||
|
||
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
|
||
записала, и перестановка относительно неё содержимого не разводит. А
|
||
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
|
||
шум, на который перестают смотреть.
|
||
|
||
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
|
||
построению, и её тишина здесь содержательна.
|
||
|
||
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
|
||
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
|
||
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
|
||
объяснения.
|
||
|
||
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
|
||
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
|
||
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
|
||
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
|
||
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
|
||
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
|
||
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
|
||
приёме.
|
||
|
||
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
|
||
курсором в пределах одного прохода. Курсор обязателен для завершимости:
|
||
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`,
|
||
и проход без курсора выбирал бы её бесконечно.
|
||
|
||
Приём SHALL будить воркер после того, как доставка учтена. Потеря сигнала
|
||
отказом быть MUST NOT: доставка от этого не перестаёт числиться `pending`.
|
||
Помимо сигнала воркер SHALL просыпаться периодически — иначе доставка,
|
||
оставшаяся `pending` по причине выше, ждала бы следующей доставки, а ночью
|
||
телефон молчит часами.
|
||
|
||
Отказ отдельного прохода воркер SHALL переживать: отказ выборки пишется `ERROR`
|
||
и прекращает проход, но не цикл. Отмена работы снаружи отказом при этом
|
||
считаться MUST NOT — штатная остановка не должна писать `ERROR`. Воркер, умерший
|
||
от временного отказа базы, остановил бы свёртку до конца жизни процесса, пока
|
||
приём продолжал бы отвечать `200`.
|
||
|
||
#### Scenario: Видимые доставки сворачиваются в порядке журнала
|
||
|
||
- **GIVEN** несколько доставок числятся `pending` до начала прохода
|
||
- **WHEN** воркер делает проход
|
||
- **THEN** он сворачивает их в порядке `(received_at, id)`
|
||
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
|
||
порядку доставки той же автоматизации, а не соседа по времени вставки
|
||
|
||
#### Scenario: Отложенная доставка держит очередь
|
||
|
||
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
|
||
занятостью базы
|
||
- **WHEN** воркер делает проход
|
||
- **THEN** её преемницы в этом проходе не сворачиваются
|
||
- **AND** следующий проход снова начинает с отложенной доставки
|
||
|
||
#### Scenario: Свёртка вне порядка журнала не молчит
|
||
|
||
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
|
||
вышла из очереди
|
||
- **WHEN** воркер сворачивает её
|
||
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
|
||
|
||
#### 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** тот же отказ по другой причине этого признака не несёт
|