Files
healthlog/openspec/changes/archive/2026-08-02-otvet-i-svyortka/design.md
T
av d79189be18 docs: документация переведена на канон av-dev-pm
- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги
  переименованы с транслита на английские, 85 ссылок поправлены
- conventions.md разобран в docs/conventions/, local-research.md — в
  docs/research/, review-journal.md — в docs/review.md с разделом настройки
  конвейера; заведены security.md, adr/ и .pm.json
- шаг docs.py check добавлен в task gate; поведение в architecture.md помечено
  девятью маркерами долга, database.md получил настройки с числовым значением
2026-08-03 17:14:53 +03:00

38 KiB
Raw Blame History

Context

Приём и свёртка сегодня — одна операция. ingest.Accept пишет тело в архив, вставляет строку delivery и тут же зовёт fold.Fold на контексте, отвязанном от запроса, но синхронно; обработчик отвечает только после этого. Стоимость свёртки измерена: 1001 объект — 815 мс, 4001 — 3.07 с, 16001 — 11.07 с. Переход на одну транзакцию на доставку снял около 0.7 мс на объект (прогон живого архива ускорился с 64 до 52 секунд), но порядок величины остался.

Что уже есть и на что опираемся:

  • fold.Fold(ctx, deliveryID) — свёртка одной доставки по идентификатору, тело читается из архива. Идемпотентна: победитель координаты — функция множества кандидатов, а не порядка.
  • internal/replay — проигрывание журнала целиком: состав из архива, порядок (received_at, id), классификация исходов, отчёт. Появился задачей reindex-iz-arhiva.
  • store.ParsePending — «этим разбором тело ещё не смотрели». Статус консервативный: ретеншен его не трогает никогда. Миграция 00005 перевела в него все доставки, и подобрать их сегодня может только healthlog reindex.
  • store.LastDerivedLayer(automationID, before, beforeID) — наследование слоя строго от предшествующей доставки: слой обязан быть функцией префикса журнала.
  • store.inTx — пять попыток с нарастающей паузой при занятости базы, _txlock=immediate, одна транзакция на доставку.

Ограничения окружения: один процесс, SQLite, файлы; «без очередей и внешних зависимостей» — принцип архитектуры. Телефон шлёт молча каждые пять минут и доставку не переприсылает. stop_grace_period контейнера — 30 секунд.

Goals / Non-Goals

Goals:

  • Время ответа на приём перестаёт зависеть от ширины доставки.
  • Свёртка идёт в порядке журнала и при конкурентных доставках тоже.
  • Несвёрнутое переживает падение и рестарт процесса, а не только штатную остановку.
  • Подбор pending и пересборка — один код, а не два похожих.
  • Отставание воркера видно до того, как станет отставанием на сутки, — в том числе когда воркер не двигается вовсе.

Non-Goals:

  • Параллельная свёртка. Слой — функция префикса журнала, запись объекта — read-modify-write. Воркер один, и это требование, а не упрощение.
  • Дедупликация доставок, ретеншен архива, /stats. Свои задачи беклога.
  • Гарантия «доставка свёрнута к моменту ответа». Она снимается сознательно — в этом вся задача; взамен даётся «доставка сохранена и учтена к моменту ответа», а несвёрнутое видно в parse_status.
  • Абсолютный порядок журнала при конкурентных приёмах. Достижимого предела — «все видимые воркеру неразобранные доставки сворачиваются в порядке (received_at, id)» — достаточно; см. риски.
  • Возврат failed в очередь. Доставка, отказавшая по собственному содержимому, остаётся failed и возвращается только пересборкой. Это названная граница, см. решение 4б.

Decisions

1. Очередью служит таблица delivery, а не список идентификаторов в памяти

Формулировка задачи говорила «очередь идентификаторов доставок» и отдельно оговаривала поведение при переполнении. Реализуется это очередью в базе: доставка ждёт свёртки в собственном статусе pending, а канал между приёмом и воркером несёт не идентификаторы, а один бит «есть работа» (буфер 1, неблокирующая отправка).

Prior art здесь однозначен и стар — это transactional outbox и его частный случай «база как очередь заданий» (AWS Prescriptive Guidance, Three Dots Labs, durable execution на Go и SQLite). Суть шаблона ровно наша: состояние задания пишется в ту же базу той же транзакцией, что и факт события, а фоновый процесс выбирает необработанные строки. Всё, что живёт только в памяти, теряется при падении — а у нас падение означает молчаливую потерю свёртки для доставки, которую телефон не перешлёт.

Что это даёт сверх памяти, по пунктам исходной задачи:

  • Переполнения нет. «Очередь переполнена — доставка остаётся pending, это не отказ» выполняется по построению: доставка pending всегда, пока не свёрнута. Сигнал теряться может и должен — он ничего не несёт.
  • Подбор pending при старте — не отдельный код. Это обычный проход воркера: старт просто будит его первым сигналом. Второй путь подбора не появляется, потому что путь один.
  • Падение и SIGKILL не теряют очередь. Транзакция свёртки откатывается, статус остаётся pending, следующий старт подберёт.

Форма сигнала — канал ёмкостью 1 с неблокирующей отправкой — не изобретение: это форма os/signal.Notify («Package signal will not block sending to c… a buffer of size 1 is sufficient») и time.Ticker («will drop ticks to make up for slow receivers»), и она же названа в стайлгайде Uber (Channel Size is One or None). sync.Cond здесь непригоден механически: Wait() не кладётся в select с ctx.Done().

Отвергнуто: канал идентификаторов в памяти (буферизованный, с политикой переполнения). Причина — он вводит второе, недолговечное представление того же факта: доставка одновременно «в очереди» и «pending в базе», и эти два представления расходятся при каждом падении. Плюс политика переполнения («оставить pending») всё равно требует подбора из базы, то есть кода из варианта выше — только теперь его два.

Отвергнуто: опрос базы по таймеру ВМЕСТО сигнала. Он добавляет задержку в полпериода на каждую доставку без всякой пользы: сигнал — одна строка. Но тик в дополнение к сигналу берётся, и по другой причине — см. решение 5.

2. Порядок — тот же (received_at, id), курсором внутри прохода

Порядок журнала определён capability пересборки (openspec/specs/reindex/), и здесь он не переопределяется, а используется: воркер обрабатывает доставки в том же порядке и по той же причине. Повторять обоснование в двух спеках нельзя — правило поехало бы в одной и осталось в другой.

Проход воркера выбирает неразобранные доставки запросом WHERE parse_status = 'pending' AND (received_at, id) > (?, ?) ORDER BY received_at, id LIMIT n, курсор внутри прохода строго возрастает.

Форма сравнения — row-value, а не развёрнутая через OR, и это проверено планом запроса на воспроизведённой схеме:

(received_at,id) > (?,?)                          → SEARCH … COVERING INDEX delivery_pending
received_at > ? OR (received_at = ? AND id > ?)   → SCAN  … COVERING INDEX delivery_pending

Прецедент в проекте уже есть — store.LastDerivedLayer. Нулевой курсор — (time.Time{}, ""), то есть 0001-01-01T00:00:00Z: один текст запроса без ветки «первая страница».

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

Доставка, приехавшая во время прохода с меньшим received_at, курсором пропускается — и подбирается следующим проходом, который её же сигнал и запустит.

Отвергнуто: множество «уже пробованных в этом проходе» вместо курсора. Эквивалентно по эффекту, но растёт по памяти вместе с задолженностью — а задолженность после миграции 00005 это весь архив.

3. Общий с пересборкой код — классификатор исхода одной доставки

replay.Run сегодня несёт в себе цикл, который для каждой доставки зовёт fold.Fold и разбирает исход по классам: ErrLayerUnknown — штатный отказ (слой не выведен), ErrMalformed — непонятое содержимое, прочее — настоящая поломка; счётчики частичного разбора и несравнимых наборов читаются только у успешной свёртки, иначе Partial молча занижается, а по нему принимается решение о судьбе тела.

Это и есть та половина, которую задача требует не дублировать. Она выносится в replay.Player.Play(ctx, deliveryID) (Outcome, error) — исход одной доставки значением, — и её зовут оба: replay.Run в своём цикле и воркер в своём. Накопление — (*Outcome).Add(other); replay.Report встраивает Outcome, чтобы у пересборки не появилось второго набора имён для тех же исходов.

Player не смотрит на контекст. Он классифицирует только ошибку, которую вернула свёртка; решение «нас остановили» принимает цикл, каждый по своему контексту. Иначе один и тот же ctx.Err() != nil означал бы у двух вызывающих противоположное: у пересборки в свёртку уходит тот же отменяемый контекст («нас остановили»), у воркера — отвязанный от остановки, с собственным дедлайном («доставка не уложилась в две минуты»). Воркер, унаследовавший чужую ветку, принял бы свой дедлайн за остановку и бросил проход молча.

Возврат значением, а не накопление по указателю: так устроены fold.Fold, store.MergePoints и replay.Run, аккумулирующего out-параметра в проекте нет ни одного. Плюс правило «счётчики только у успеха» становится утверждением о результате одного вызова, а не вычитанием двух состояний — а именно на этом правиле уже один раз занижался Partial.

Целиком общим цикл быть не может, и это названная граница: у пересборки состав берётся из архива (тело без учётной записи — тоже событие) и пишется в пустую базу, у воркера состав берётся из учёта (pending) и пишется в рабочую. Общее у них — порядок, точка входа в свёртку и классификация исхода; именно они и разошлись бы молча.

Отвергнуто: звать replay.Run из воркера. Он требует пустой базы назначения и проигрывает весь журнал с нуля — под живым приёмом это не операция подбора, а пересборка.

Отвергнуто: воркер в internal/ingest. Тогда порядок журнала знали бы два пакета, и правку правила пришлось бы вносить в оба. internal/replay уже объявлен местом, где живут «состав, порядок, отчёт»; фоновое проигрывание хвоста — тот же предмет, только непрерывный.

4. Остановка формулируется инвариантом, а не обещанием досчитать

Свёртка идёт на контексте context.WithoutCancel от контекста воркера плюс собственный дедлайн — ровно так, как сегодня это делает ingest.Accept. Механизм не новый, он переезжает. Отмена контекста воркера проверяется между доставками.

Обещать «текущая доставка досворачивается» нельзя: foldTimeout — две минуты, а весь бюджет остановки — тридцать секунд, и srv.Shutdown тратит его первым. Обещание, которое система не всегда исполняет, — это флакующий приёмочный тест и неверное представление у следующего читателя. Поэтому требование формулируется инвариантом: после остановки не существует доставки, которая числится разобранной, а записана частично; несвёрнутое остаётся pending.

Порядок остановки: srv.Shutdown (перестаём принимать) → отмена контекста воркера → ожидание его выхода в остатке того же бюджета. Обратный порядок оставил бы доставки, принятые после остановки воркера, никого не разбудившими.

Механизм ожидания — done chan struct{}, закрываемый воркером в defer, и select с бюджетом: sync.WaitGroup.Wait() бюджета не принимает.

Два следствия, которые надо назвать вслух, иначе они дадут ложные ERROR:

  • Shutdown возвращает context.DeadlineExceeded штатно — так задокументировано в stdlib. Сегодня runServe возвращает любую его ошибку наверх, а main печатает fatal startup и выходит с кодом 1. После того как бюджет ответа приёма вырос (решение 7), исчерпание бюджета остановки во время загрузки станет обычным делом, и штатная остановка докладывалась бы как провал старта. Контекстная ошибка ShutdownWARN, а не отказ команды.
  • База не закрывается, пока воркер не вышел. defer st.Close() при не уложившемся в бюджет воркере закрыл бы базу под живой транзакцией свёртки, и в лог ушли бы ERROR по доставке, с которой всё в порядке. Не уложились — оставляем закрытие процессу, а факт называем WARN.

4б. Отмена и занятость базы оставляют доставку в очереди, всё прочее — нет

Сегодня fold.fail пишет parse_status = failed на любой ошибке. Пока свёртка шла синхронно, это было терпимо. С воркером — нет: failed из очереди выбывает навсегда, а вернуть его может только healthlog reindex, то есть операция с остановкой сервиса и ручной подменой базы. Занятость базы после пяти попыток inTx (порядка 200 мс на широкой доставке) стирала бы доставку с полки молча — притом что сама эта задача делает конкуренцию за базу штатной.

Правило: исход разбора отражает доставку, а не обстоятельства.

  • Отказ окружения — отмена контекста и занятость базы — статус не меняет: доставка остаётся pending и подбирается следующим проходом или тиком.
  • Всё остальное (ErrMalformed, ErrLayerUnknown, нечитаемое тело, тело сверх предела, исчерпанный дедлайн свёртки) — failed, как и сейчас: это свойства самой доставки, и повторять их бесполезно.

Занятость распознаётся сентинелом store.ErrBusyinTx уже отличает SQLITE_BUSY/SQLITE_BUSY_SNAPSHOT по коду, осталось назвать исход доменной ошибкой у источника, как того требуют конвенции.

Отвергнуто: счётчик попыток с переводом в failed после N. Он нужен очередям заданий общего назначения, где задание может быть ядовитым. У нас ядовитость уже отсечена по классу: содержимое даёт failed с первого раза, а в pending остаются только те два случая, которые проходят сами. Колонка и политика «сколько попыток достаточно» были бы изобретением без наблюдения.

5. Проход будит не только сигнал: тик — страховка и площадка для метки

К сигналу добавляется тик (порядка минуты) в том же select. Он не альтернатива сигналу (см. решение 1), он закрывает два случая, которые сигнал закрыть не может:

  • Доставка, оставшаяся pending по решению 4б, ждала бы следующей доставки, чтобы её кто-то разбудил. Ночью телефон молчит часами.
  • Отставание невидимо ровно тогда, когда оно опасно. Если метка задержки вычисляется внутри прохода, а прохода нет, «работа есть, прогресса нет» неотличимо от здорового пустого потока.

Наблюдаемость — две метки, и обе берутся из строк, которые проход и так выбрал:

  • WARN «доставка ждала свёртки дольше пяти минут» с delivery_id и величиной ожидания. Порог — период быстрого прохода синхронизации: если доставка ждала дольше, чем интервал между доставками, очередь растёт, а не рассасывается. Считается от received_at до начала свёртки.
  • INFO один раз при старте: сколько доставок числится неразобранными. Это размер задолженности и ответ на вопрос «что сервис будет делать первые минуты после рестарта».

Первый проход задержку не считает. После миграции 00005 неразобранными числятся все доставки архива, и метка сработала бы сотней строк подряд, ничего не сообщив: они ждали не воркера, а его появления. Задолженность при старте называется одним INFO, метка включается после первого прохода.

Отказ прохода воркер переживает. Отказ SELECT (занятая база, отказ диска) — это ERROR и выход из прохода, а не из цикла: воркер, умерший от временного отказа базы, остановил бы свёртку до конца жизни процесса, а приём продолжал бы отвечать 200.

Числа — текущая длина pending, возраст самой старой неразобранной доставки — это /stats, и они уезжают строкой в задачу stats-endpoint. Здесь их нет намеренно: отдельного механизма счётчиков в проекте пока не существует.

6. Частичный индекс по неразобранным доставкам

Запрос прохода спрашивается чаще, чем раз в минуту, а delivery растёт на ~300 строк в сутки (100 тысяч в год). Без индекса это скан таблицы с сортировкой на каждый проход.

Индекс — частичный: (received_at, id) WHERE parse_status = 'pending'. В установившемся режиме в нём ноль–одна строка, потому что свёрнутая доставка из него выпадает; полный индекс по parse_status хранил бы все сто тысяч ради выборки из одной. План запроса проверен (см. решение 2): индекс покрывающий, и счёт задолженности по нему тоже не сканирует таблицу.

7. Длинный бюджет ответа даётся маршруту приёма, а не всему серверу

WriteTimeout у Go ставится в readRequest, до вызова обработчика, и потому покрывает и чтение тела: при read_timeout = 5m и write_timeout = 30s загрузка дольше 30 секунд обрывается, а read_timeout при этом обещает пять минут. Премисса проверена по исходнику (net/http/server.go, постановка write-дедлайна defer-ом внутри readRequest), симптом описан здесь и здесь. Для 64 МиБ по мобильной сети это не теоретический случай, и после выноса свёртки это единственный оставшийся источник того же молчаливого обрыва.

Лечится это не подъёмом глобального умолчания, а дедлайном на том маршруте, которому длинный бюджет нужен: обработчик приёма перед чтением тела ставит http.NewResponseController(w).SetWriteDeadline(now + read_timeout + write_timeout). Тогда /healthz и будущий Read API сохраняют тридцатисекундную защиту от застрявшей записи, конфиг не меняется вовсе, и не появляется пары таймаутов, из которых один молча отменяет другой.

Механика проверена: middleware.WrapResponseWriter из chi реализует Unwrap() http.ResponseWriter, поэтому ResponseController до соединения добирается. Транспорт, не поддерживающий дедлайнов, отвечает http.ErrNotSupported — это DEBUG и продолжение работы, а не отказ приёма.

Отвергнуто: поднять умолчание write_timeout до read_timeout. Три возражения. Оно снимает защиту от застрявшей записи со всех маршрутов, ради одного. Оно кладёт требование о глобальном параметре сервера в capability приёма, где читатель Read API его не найдёт. И оно порождает вопрос «сравниваются умолчания или эффективные значения», на который два реализатора ответят по-разному.

Отвергнуто: не трогать вовсе. Так и было бы, будь это вместо выноса свёртки; вместе с ним это доведение до конца — иначе read_timeout остаётся обещанием, которого сервер не исполняет.

8. У цикла воркера есть синхронный шов, и тесты идут через него

Worker.Pass(ctx) (Outcome, error) — один проход, синхронный, без каналов; Run(ctx) — тонкий select поверх него. Тесты зовут Pass напрямую и ничего не ждут по часам; на Run остаётся один тест — «отмена завершает цикл», и он синхронизируется возвратом Run, а не сном.

Без такого шва проверки «все свёрнуты», «проход конечен», «метка не сработала на первом проходе» пишутся опросом базы с таймаутом, то есть сном в разной форме, и мигают на загруженной машине. Гейт при этом перестаёт быть детерминированным, а на нём стоит весь конвейер ревью.

Остальные швы — те, что есть:

  • Порядок при конкурентных доставках проверяется наблюдаемым следствием порядка — наследованием слоя: доставка без плотных метрик обязана получить слой предшествующей ей по (received_at, id).
  • Отмена не оставляет половинчатого состояния — доставка остаётся pending, а не parsed с половиной объектов.
  • task verify:archive остаётся оракулом сходимости: пересборка проигрывает журнал сама и воркера не касается.

9. Учёт доставки переживает обрыв соединения

Accept всё равно переписывается, и заодно чинится сузившийся до одного шага риск: store.CreateDelivery идёт на контексте запроса, а тот отменяется при обрыве связи клиентом. Тело к этому моменту уже в архиве (arch.Write контекста не берёт), и отказ на вставке оставляет тело сиротой — восстановимо только пересборкой с подменой базы. Раньше вероятность обрыва размазывалась по следующей за вставкой свёртке; теперь вставка — последний шаг перед 200.

Поэтому учёт ведётся на context.WithoutCancel с коротким собственным дедлайном — тем же приёмом и по той же причине, по какой это делает fold.finish: отмена снаружи не должна превращаться в свойство доставки. Проверка формы тела остаётся на исходном контексте — там отменяемость уместна.

Risks / Trade-offs

  • Абсолютный порядок при конкурентных приёмах недостижим → две доставки, принимаемые одновременно, могут закоммитить строки в порядке, обратном их received_at; если воркер успел свернуть позднюю до того, как ранняя стала видимой, наследование слоя разойдётся с тем, что даст пересборка. Смягчение: окно сузилось (воркер один и берёт минимум из видимых, а не сворачивает в порядке завершения обработчиков), исход остаётся детерминированно чинимым (healthlog reindex), и сам эффект касается только доставок без плотных метрик. Абсолютную гарантию дало бы удержание порядка на приёме, то есть сериализация приёма — цена, которую задача платить не собиралась.
  • Ответ 200 больше не означает «разобрано» → это объявленная смена контракта, и в docs/architecture.md она фиксируется как контракт, а не как деталь реализации воркера. Клиент HAE о разборе и не спрашивал; владелец видит исход в parse_status и в логе. Читатель, делающий POST → чтение, получает гонку — сегодня такой читатель один, тесты, и они переписаны на синхронный Pass.
  • failed из очереди не возвращается → доставка, отказавшая по содержимому, ждёт пересборки. Это осознанная граница: обратное означало бы бесконечный повтор заведомо безнадёжного. Названа в спеке.
  • Задолженность после рестарта разбирается не мгновенно → 116 тел живого архива это порядка минуты работы воркера; всё это время витрина неполна. Названо INFO-строкой при старте. /healthz этого не отражает — он статичен; отражать будет /stats, задача stats-endpoint.
  • Второй процесс на той же базе даёт двух воркеров → «одна горутина» — свойство процесса, а не файла базы. Порчи витрины ждать не приходится (_txlock=immediate и повтор транзакции сериализуют слияние), но наследование слоя перестаёт быть функцией префикса. Механизма против этого не вводим: запуск второго serve на той же базе не входит ни в один сценарий проекта, а блокировка файла — отдельная задача с собственной ценой. Названо, чтобы не было открытием.
  • Свёртка теперь конкурирует с приёмом за базу → она и раньше шла на отвязанном контексте, то есть параллельно следующему запросу; новое здесь только то, что параллельность стала штатной. busy_timeout, _txlock=immediate и повтор транзакции уже есть, а исчерпание повторов теперь не стирает доставку с полки (решение 4б). Наблюдение за этим — задача merge-cost-wide-delivery.
  • Тик даёт проход раз в минуту при пустой очереди → это один запрос по покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая, а без него состояние «работа есть, прогресса нет» невидимо.

Migration Plan

Миграция схемы одна — 00006, частичный индекс по неразобранным доставкам. Данных она не трогает; Down снимает индекс.

Порядок выкладки обычный: task buildtask restart. Первый старт нового бинаря напечатает INFO с размером задолженности и разберёт её проходами воркера — то есть заодно подберёт доставки, которые числятся pending после миграции 00005.

Откат — предыдущий бинарь: он свернёт всё синхронно, как раньше; неразобранное к тому моменту останется pending до следующего reindex. Индекс старому бинарю не мешает.

Open Questions

  • Метка задержки считается от received_at, который хранится с секундной точностью; для порога в пять минут этого достаточно, но если порог когда-то опустится до секунд, точности не хватит.
  • Каждая будущая миграция, переводящая строки в pending (спека хранения этого прямо требует от задач, покрывающих новую секцию), теперь автоматически запускает пересвёртку под живым приёмом. Для 00005 это желаемое поведение; для миграции размером в годовой архив вопрос о темпе встанет заново.