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

8.0 KiB
Raw Blame History

Why

Свёртка выполняется внутри обработчика запроса, поэтому время ответа равно времени свёртки: 16 тысяч точек — 11 секунд. WriteTimeout в Go ставится в readRequest, то есть до вызова обработчика, и его 30 секунд — общий бюджет на всё: дочитать тело по мобильной сети, записать архив, вставить строку, свернуть. Когда бюджет выходит, сервер считает, что отдал 200 (ошибки записи обработчику не видно, ответ ушёл в буфер), клиент получает обрыв, а accessLog пишет status_code=200 — единственный канал наблюдаемости в этом сценарии врёт.

Бьёт это по широким проходам (Today, Previous 7 Days, ручной экспорт) — ровно по тем, ради которых заведён инвариант «дыры закрываются сами».

What Changes

  • Приём отвечает 200 после архивации тела и вставки строки delivery. Свёртка из обработчика уходит: время ответа перестаёт зависеть от ширины доставки.
  • Свёртку ведёт фоновый воркер — одна горутина, обработка в порядке журнала (received_at, id) среди доставок, видимых ему на момент выборки.
  • Очередью служит сама таблица, а не список идентификаторов в памяти: доставка ждёт свёртки в статусе pending, канал несёт только сигнал «есть работа». Отсюда три следствия: переполнять нечего (доставка и так pending, это не отказ), падение процесса очередь не теряет, а «подбор pending при старте» перестаёт быть отдельным кодом — это обычный проход воркера.
  • Классификация исхода свёртки (folded / слой не выведен / содержимое не разобрано / прочее / частичный разбор) становится общей с пересборкой: один проигрыватель в internal/replay, а не второй рядом.
  • Исход свёртки начинает отражать доставку, а не обстоятельства. Сегодня failed пишется на любой ошибке, включая занятость базы; с воркером это означало бы, что доставка выбывает из очереди навсегда — а конкуренция за базу как раз становится штатной. Отмена и занятость статус больше не меняют, доставка остаётся pending; всё прочее по-прежнему failed и возвращается только пересборкой.
  • Остановка сервиса формулируется инвариантом, а не обещанием досчитать: приём прекращается раньше воркера, и после остановки нет доставки, которая числится разобранной, а записана наполовину. Свёртка идёт на контексте, отвязанном от остановки.
  • Наблюдаемость воркера: WARN, когда доставка ждала свёртки дольше периода быстрого прохода, и одна строка INFO о размере задолженности при старте. Метка считается на выборке прохода, а сам проход будит не только сигнал, но и тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока. Счётчики в /stats — задача stats-nablyudaemost, здесь только метки в логе.
  • Убирается второй, оставшийся источник молчаливого обрыва: общий write_timeout (30 с) меньше read_timeout (5 мин), а он покрывает и чтение тела — то есть медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет даётся маршруту приёма собственным дедлайном ответа; общий таймаут и конфиг не меняются, и прочие маршруты защиту не теряют.
  • Миграция: частичный индекс по неразобранным доставкам — воркер спрашивает их чаще, чем раз в минуту, а таблица растёт на ~300 строк в сутки.

Capabilities

New Capabilities

  • ingest: приём доставки как самостоятельное поведение — что делает ответ 200 заслуженным, когда он отдаётся, кто и в каком порядке сворачивает принятое, что происходит с несвёрнутым при остановке и рестарте.

Modified Capabilities

  • parsing: требование «Разбор не влияет на код ответа приёма» уточняется — разбор идёт после ответа, поэтому исход становится виден не в ответе и не сразу, а асинхронно, в parse_status и в логе.
  • storage: требование «Учёт частично разобранной доставки» уточняется — отказ обстоятельств (отмена, занятость базы) статуса не меняет вовсе, а прежняя формулировка «ошибка ⇒ failed» этого не допускала.
  • reindex: требование «Отчёт, оракул и исход команды» получает четвёртый класс отказа — «работа отложена по обстоятельствам»: классы у пересборки и у фоновой свёртки общие.

Impact

  • internal/ingest — теряет зависимость от internal/fold: Accept кладёт тело, учитывает доставку и будит воркер.
  • internal/replay — общий проигрыватель (свернуть доставку, классифицировать исход) и фоновый воркер поверх него.
  • internal/store — выборка неразобранных доставок в порядке журнала с курсором, сентинел занятости базы; миграция 00006 с частичным индексом.
  • internal/fold — отмена и занятость базы больше не переводят доставку в failed.
  • cmd/healthlog/serve.go — жизненный цикл воркера и согласованная остановка.
  • internal/httpapi — сборка ingest.Service без свёртки; собственный дедлайн ответа на маршруте приёма. config.example.toml — комментарий к write_timeout.
  • docs/architecture.md, docs/database.md — путь приёма и новый индекс.