Files
healthlog/openspec/specs/ingest/spec.md
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

26 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

Requirement: Отказ учёта доставки называет класс причины

Система SHALL логировать отказ, случившийся после того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал занятость базы от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.

Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.

Уровень при этом остаётся ERROR независимо от класса: тело лежит в архиве без учётной записи, то есть осиротело, и вернуть его в журнал может только пересборка. Занятость базы этого не отменяет — она объясняет причину, а не снимает работу. Смысл различения в другом: занятость означает конкуренцию за запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска или испорченная база.

Scenario: Занятая база при учёте доставки видна как отдельный класс

  • WHEN запись учёта доставки не проходит из-за занятости базы
  • THEN отказ логируется на уровне ERROR вместе с путём тела в архиве
  • AND запись отличает занятость базы от прочих причин отказа
  • AND тот же отказ по другой причине этого признака не несёт