- беклог и план переехали в 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 получил настройки с числовым значением
38 KiB
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), исчерпание бюджета остановки во время загрузки станет обычным делом, и штатная остановка докладывалась бы как провал старта. Контекстная ошибкаShutdown—WARN, а не отказ команды.- База не закрывается, пока воркер не вышел.
defer st.Close()при не уложившемся в бюджет воркере закрыл бы базу под живой транзакцией свёртки, и в лог ушли быERRORпо доставке, с которой всё в порядке. Не уложились — оставляем закрытие процессу, а факт называемWARN.
4б. Отмена и занятость базы оставляют доставку в очереди, всё прочее — нет
Сегодня fold.fail пишет parse_status = failed на любой ошибке. Пока
свёртка шла синхронно, это было терпимо. С воркером — нет: failed из очереди
выбывает навсегда, а вернуть его может только healthlog reindex, то есть
операция с остановкой сервиса и ручной подменой базы. Занятость базы после пяти
попыток inTx (порядка 200 мс на широкой доставке) стирала бы доставку с полки
молча — притом что сама эта задача делает конкуренцию за базу штатной.
Правило: исход разбора отражает доставку, а не обстоятельства.
- Отказ окружения — отмена контекста и занятость базы — статус не меняет:
доставка остаётся
pendingи подбирается следующим проходом или тиком. - Всё остальное (
ErrMalformed,ErrLayerUnknown, нечитаемое тело, тело сверх предела, исчерпанный дедлайн свёртки) —failed, как и сейчас: это свойства самой доставки, и повторять их бесполезно.
Занятость распознаётся сентинелом store.ErrBusy — inTx уже отличает
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 build → task restart. Первый старт нового
бинаря напечатает INFO с размером задолженности и разберёт её проходами
воркера — то есть заодно подберёт доставки, которые числятся pending после
миграции 00005.
Откат — предыдущий бинарь: он свернёт всё синхронно, как раньше;
неразобранное к тому моменту останется pending до следующего reindex. Индекс
старому бинарю не мешает.
Open Questions
- Метка задержки считается от
received_at, который хранится с секундной точностью; для порога в пять минут этого достаточно, но если порог когда-то опустится до секунд, точности не хватит. - Каждая будущая миграция, переводящая строки в
pending(спека хранения этого прямо требует от задач, покрывающих новую секцию), теперь автоматически запускает пересвёртку под живым приёмом. Для00005это желаемое поведение; для миграции размером в годовой архив вопрос о темпе встанет заново.