- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
26 KiB
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 тот же отказ по другой причине этого признака не несёт