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

32 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.

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

Проход воркера 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 тот же отказ по другой причине этого признака не несёт