- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
4.6 KiB
Остановка и миграция: раздельные бюджеты и следы в логе
Приоритет: средний
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
GET /api/v1/metrics — первый обработчик, способный законно работать заметное
время.
Бюджет остановки один на оба этапа. shutdownCtx в runServe передаётся и
в srv.Shutdown, и в ожидание фонового воркера. Shutdown ждёт, пока
обработчики вернутся; контексты обработчиков он при этом не отменяет
(BaseContext не задан), так что долгий запрос каталога может съесть бюджет
целиком. Дальше select видит два готовых случая и выбирает равновероятно: база
закрывается или нет от запуска к запуску, а в лог уходит
shutdown budget exceeded stage=fold-worker — обвинение воркеру, который бюджета
не превышал. Цена именно в диагнозе: этот WARN означает «доставка осталась
pending, данные под вопросом», и ложное срабатывание обесценивает настоящее.
Чинится двумя движениями: собственный context.WithTimeout второму этапу вместо
исчерпанного первого, и BaseContext, производный от контекста жизненного цикла,
чтобы долгий запрос об остановке узнавал.
Цена этой ветки выросла (change cena-chitayushchego-marshruta): база в ней
не закрывается, а значит не закрывается и закреплённое соединение версии
витрины — последнего соединения к базе не наступает, SQLite не делает финального
чекпойнта, и рядом с базой остаётся неразобранный -wal до 64 МиБ. Данные целы
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
переносить без его -wal. Обвинение в логе при этом стало честнее: этап
называется background, а не fold-worker, потому что ждут двоих.
Миграция молчит и не прерывается штатной остановкой. store.migrate не
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
долго, владелец не отличит «ещё мигрирует» от «зависло» и от «упало»: тишина
одинакова во всех трёх случаях. Плюс migrate работает на context.Background(),
то есть SIGTERM она не видит и ждать придётся 30-секундного SIGKILL.
Порчи данных при этом нет: goose оборачивает миграцию в транзакцию, обрыв
откатывает её целиком, и следующий старт повторяет с нуля. Замер на синтетической
копии годового объёма (260 тысяч объектов, 483 МБ): CREATE INDEX миграции
00009 — 297 мс тёплым кешем. То есть сегодня окно тишины — доли секунды;
опасность в том, что оно растёт вместе с витриной незаметно.
Готово, когда WARN о превышении бюджета называет виновный этап честно, а в логе
старта видно, что миграции накатывались и сколько это заняло.
Связано: cmd/healthlog/serve.go, internal/store/store.go, change
2026-08-02-cena-chitayushchego-marshruta (архив).