Files
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

394 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** тот же отказ по другой причине этого признака не несёт