Files
healthlog/openspec/specs/ingest/spec.md
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

23 KiB
Raw Blame History

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