Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -519,6 +519,132 @@ HAE. Значит для него доставки не хвост журнал
|
||||
пересборки старый период честно объявляет один слой вместо трёх, а не
|
||||
притворяется, что ничего не изменилось.
|
||||
|
||||
### Версия витрины и обслуживание журнала
|
||||
|
||||
Два механизма живут рядом и держатся друг за друга: один говорит читателю «в
|
||||
базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.
|
||||
|
||||
#### Версия витрины: пара «поколение + счётчик»
|
||||
|
||||
Читающие маршруты обязаны уметь отвечать «не изменилось» без сборки ответа —
|
||||
самый частый запрос трёх потребителей это повтор неизменившегося. Признак
|
||||
изменения берётся у SQLite: `PRAGMA data_version` меняется, когда в базу
|
||||
закоммитило **другое** соединение.
|
||||
|
||||
Голым значением его брать нельзя, и обе причины измерены на стенде проекта:
|
||||
|
||||
- **счётчик несравним между соединениями.** На одном состоянии базы два
|
||||
соединения пула отвечают разными числами, а любое свежее соединение отвечает
|
||||
одним и тем же значением независимо от содержимого. Версия из пула давала бы
|
||||
не только ложную инвалидацию (не страшно), но и **одинаковые метки на разных
|
||||
состояниях** — то есть подтверждение неизменности на изменившихся данных.
|
||||
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново.
|
||||
|
||||
Поэтому версия читается с одного **закреплённого соединения-щупа**, а метка это
|
||||
`поколение-счётчик`, где поколение — ULID, выданный соединению. Поколение
|
||||
меняется при каждом пересоздании щупа и заодно при выкатке нового бинаря, то
|
||||
есть смена **формы** ответа при неизменившихся данных тоже обнуляет метки.
|
||||
Монотонной метка не является: сравнивать её можно только на равенство.
|
||||
|
||||
Три свойства щупа названы вслух, потому что каждое из них можно нарушить
|
||||
незаметно:
|
||||
|
||||
- **щуп не пишет** — собственный коммит соединения его версию не двигает;
|
||||
- **щуп не удерживает транзакцию**: только `QueryRowContext(...).Scan(...)`,
|
||||
никаких `QueryContext` и `BeginTx`. Иначе единственное долгоживущее соединение
|
||||
процесса становится вечным читателем — тем самым, из-за которого чекпойнт
|
||||
перестаёт продвигаться;
|
||||
- **щуп непригоден только после закрытия** (`sql.ErrConnDone`). Отмена запроса
|
||||
клиентом соединение не убивает (измерено), и считать её смертью щупа значило
|
||||
бы менять поколение на каждом оборванном запросе — механизм схлопывался бы под
|
||||
той самой нагрузкой, ради которой заведён.
|
||||
|
||||
Закрытие хранилища освобождает щуп **раньше пула**: закреплённое соединение
|
||||
переживает закрытие пула, а финальный чекпойнт SQLite делает при закрытии
|
||||
последнего соединения. Забытый щуп оставил бы рядом с базой неразобранный
|
||||
`-wal`, а пересборка, переносящая один файл `.db`, потеряла бы хвост записей
|
||||
молча.
|
||||
|
||||
**Подписывается не ответ, а чтение целиком.** Версия снимается до и после
|
||||
чтения, и метка выдаётся, только если обе пробы совпали. Порядок здесь не
|
||||
стилистический: версия, снятая ПОСЛЕ чтения, пометила бы устаревший снимок
|
||||
свежим номером и заперла бы клиента на нём навсегда; версия, снятая только ДО,
|
||||
допускает два разных ответа под одной меткой. Правило живёт в одном месте
|
||||
(`store.VersionedRead`), потому что Read API точек и MCP берут ту же машинерию,
|
||||
а вторая реализация «по образцу» отличалась бы ровно на этот порядок.
|
||||
|
||||
Отказ пробы версией не является: читающий маршрут деградирует до полного
|
||||
ответа, а не до отказа.
|
||||
|
||||
#### Обслуживание журнала WAL
|
||||
|
||||
`wal_autocheckpoint` включён по умолчанию и срабатывает **по концу записи**.
|
||||
Отсюда дыра: всплеск, раздувший журнал, оставляет его неразобранным до следующей
|
||||
доставки — а поток пачечный, ночью телефон молчит часами. Поэтому рядом с
|
||||
воркером свёртки живёт горутина, раз в минуту делающая
|
||||
`PRAGMA wal_checkpoint(PASSIVE)`.
|
||||
|
||||
Режим `PASSIVE`, и это тоже измерение: `TRUNCATE` двигает `data_version`, то
|
||||
есть каждый тик обнулял бы условный запрос у всех потребителей, а вдобавок ждёт
|
||||
читателей. `PASSIVE` не двигает версию даже перенося 12502 страницы.
|
||||
|
||||
**Признак беды — не флаг занятости.** Пассивный чекпойнт не идёт дальше снимка
|
||||
самого старого активного читателя и ошибки при этом не возвращает: измерено
|
||||
`busy=0` при 6256 страницах в журнале и пяти перенесённых. Признаком служит пара
|
||||
чисел — страниц больше порога **и** перенесено меньше, чем лежало.
|
||||
|
||||
**Флаг занятости при этом означает не «не продвинулись», а «не измерено».** Не
|
||||
взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и `-1` вместо обоих чисел —
|
||||
измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле. Сравнивать
|
||||
`-1` на шкале страниц нельзя буквально: `-1 >= -1` истинно, то есть
|
||||
незамеренный тик читался бы как «журнал разобран целиком» — владельцу уходила бы
|
||||
строка о выздоровлении посреди болезни, с числом, которого не бывает, а
|
||||
подавитель повторов сбрасывался бы и давал пару строк в минуту вместо молчания.
|
||||
Незамеренный тик поэтому не меняет ни объявленного состояния, ни накопленного о
|
||||
нём. Размер страницы берётся у самой базы: он свойство файла, и чужое умолчание
|
||||
сместило бы порог в разы.
|
||||
|
||||
Порог и `journal_size_limit` — одно число (64 МиБ), выраженное в двух видах:
|
||||
предел возвращает файл, порог сообщает, что вернуть его не выходит. Двумя
|
||||
константами они разъехались бы молча.
|
||||
|
||||
**Предела роста журнала это не даёт, и умалчивать об этом нельзя.** Измерено:
|
||||
под удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит
|
||||
действует только после полного чекпойнта, усечение делает первая запись за ним.
|
||||
Пока читатель держит снимок, журнал растёт, и единственный исход — `WARN`
|
||||
владельцу. Аварийный клапан (блокирующий `TRUNCATE` по порогу размера, как у
|
||||
Litestream) не взят по названной причине: он двигает версию витрины.
|
||||
|
||||
Строка о непродвижении пишется при входе в состояние и повторяется, только
|
||||
когда журнал вырос вдвое; возврат к норме — отдельная строка. Признак заведён
|
||||
ради состояния, которое само не проходит (в Go самый частый вечный читатель —
|
||||
незакрытый `sql.Rows`), а строка в минуту дала бы 1440 одинаковых записей в
|
||||
сутки.
|
||||
|
||||
#### Как это решают другие
|
||||
|
||||
- **Документация SQLite** (`wal.html`) называет наш случай дословно: при
|
||||
перекрывающихся читателях, среди которых всегда есть активный, чекпойнты не
|
||||
смогут завершиться, и файл журнала будет расти без границы. Оттуда же взято,
|
||||
что `PASSIVE` «делает столько, сколько может» и может не дойти до конца, а
|
||||
полнота проверяется равенством `checkpointed == log`.
|
||||
- **Litestream** — интервал чекпойнта минута, режим `PASSIVE`, блокирующий
|
||||
`TRUNCATE` только как клапан по порогу размера. Взят период и режим; не взят
|
||||
его совет отключать `wal_autocheckpoint` (он владеет чекпойнтами целиком, у нас
|
||||
автоматический — первая линия) и не взят клапан.
|
||||
- **rqlite** всегда просит `TRUNCATE` и ждёт читателя до 250 мс — продиктовано
|
||||
требованием нулевого журнала для снапшота Raft, которого у нас нет.
|
||||
- **Гайды по SQLite в проде** (Django, `dj-lite`) из всего этого ставят одно —
|
||||
`journal_size_limit` порядка 26–64 МБ. Взято 64 МиБ.
|
||||
- **`PRAGMA data_version`**: рекомендация держать для наблюдения отдельное
|
||||
соединение взята с форума SQLite. Отвергнуты: `FileControlDataVersion`
|
||||
драйвера (снимает требование «щуп не пишет», но стоит доступа через
|
||||
`(*sql.Conn).Raw` в самом чувствительном месте), счётчик изменений со
|
||||
страницы 1 (`SQLITE_DBPAGE` — в режиме WAL инкрементируется не на каждой
|
||||
транзакции), хеш файла базы (так делает Datasette в неизменяемом режиме —
|
||||
наша база пишется непрерывно) и собственный счётчик версии в таблице (второе
|
||||
производное состояние рядом с витриной и лишняя запись на каждый коммит).
|
||||
|
||||
### Устаревание нижнего слоя
|
||||
|
||||
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
||||
@@ -1258,6 +1384,47 @@ GET /healthz
|
||||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||
границе периода нельзя: ряд поедет незаметно для клиента.
|
||||
|
||||
### Условный запрос
|
||||
|
||||
Ресурсы чтения отвечают `304 Not Modified` на `If-None-Match` с непротухшей
|
||||
меткой и **не открывают снимок витрины вовсе**.
|
||||
|
||||
**Метка собирается из всего, от чего зависит ответ.** У каталога это версия
|
||||
витрины (см. «Версия витрины и обслуживание журнала») и **горизонт измерения**:
|
||||
горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает
|
||||
в окно сама, без единого коммита. Путь построен враждебным проходом ревью и
|
||||
прогнан: та же версия витрины, `cumulative` против `unknown`. Горизонт входит в
|
||||
метку огрублённым до часа — огрубление точное, потому что метки объектов лежат
|
||||
ровно на часах; цена — один полный ответ в час на потребителя.
|
||||
|
||||
Форма метки **слабая** (`W/"…"`): она выведена из состояния, а не из байтов
|
||||
ответа — так предписывает общая практика для валидаторов такого рода. На исход
|
||||
`304` это не влияет, `If-None-Match` сравнивается слабо в любом случае.
|
||||
|
||||
**Область действия метки — часть самой метки.** Она действительна в пределах
|
||||
одного ресурса, поэтому маршрут, чей ответ есть функция параметров (точки), и
|
||||
транспорт без адреса вовсе (MCP) кладут в неё канонизированную форму запроса.
|
||||
Прозой это требовать бесполезно — прозу компилятор не проверяет, а забыть
|
||||
область значит однажды ответить `304` на чужой набор данных; поэтому она
|
||||
параметр помощника, а не забота вызывающего.
|
||||
|
||||
Три правила разбора, каждое из которых легко нарушить: неразбираемое условие
|
||||
даёт `200`, а не `400`; `*` совпадает с любой **существующей** меткой, а при её
|
||||
отсутствии условие не выполнено; `304` уходит без тела и без представленческих
|
||||
заголовков. Токен чтения проверяется **раньше** условия: `304` без токена
|
||||
подтверждал бы состояние витрины тому, кому она не открыта.
|
||||
|
||||
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
|
||||
валидатора эвристическое кеширование посредником было маловероятным; с меткой
|
||||
ответ становится штатно кешируемым, а при выключенной проверке токенов в
|
||||
запросе нет и `Authorization`.
|
||||
|
||||
Следствие названо вслух: **`304` не выполняет измерения и потому не пишет
|
||||
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
|
||||
опросом они становятся функцией смены версии витрины, а не числа запросов;
|
||||
состояние при этом не теряется — следующая доставка меняет версию, ответ
|
||||
собирается, и предупреждение пишется.
|
||||
|
||||
### Свёртка и размер ответа
|
||||
|
||||
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
## высокий
|
||||
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
|
||||
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Решено: чекпойнт по таймеру плюс ETag по data_version. Берётся перед Read API — тот строится поверх этой машинерии
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# Цена первого читающего маршрута: память, WAL и повторный опрос
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
**Решение принято 2026-08-02: вариант (г) плюс (в), именно в таком порядке.**
|
||||
Разбирается без владельца: контракт хранения не меняется, данные не трогаются,
|
||||
а обе части — общепринятая практика, а не собственный дизайн. Чекпойнт по
|
||||
таймеру закрывает единственное проявление, которое ломает приём (диск), и стоит
|
||||
одной горутины; `ETag` по `PRAGMA data_version` — один запрос к базе, снимает и
|
||||
повтор, и большую часть читающих транзакций, не заводя кеша ответа.
|
||||
|
||||
Вариант (а) — предел и дедлайн маршрута — **не отвергнут, а отложен** до
|
||||
[Read API точек](read-api-tochki.md): там предел размера ответа всё равно
|
||||
проектируется, и делать его дважды не нужно. Вариант (б) не берём, пока
|
||||
счётчик не заговорит. Вариант (д) — последним, если (в) окажется мало.
|
||||
|
||||
Задача берётся **перед** Read API: тот строится поверх этой машинерии.
|
||||
Ниже — исходная постановка блокера, она же ТЗ.
|
||||
|
||||
## Что решить
|
||||
|
||||
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
|
||||
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
|
||||
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
|
||||
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
|
||||
трижды нельзя.
|
||||
|
||||
Три измеренных проявления одной причины.
|
||||
|
||||
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
|
||||
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
|
||||
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
|
||||
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
|
||||
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
|
||||
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
|
||||
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
|
||||
переприсылается.
|
||||
|
||||
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
|
||||
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
||||
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
|
||||
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
|
||||
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
|
||||
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
|
||||
вызывается нигде.
|
||||
|
||||
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
|
||||
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
|
||||
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
|
||||
игра), и самый частый их запрос — повтор неизменившегося.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
|
||||
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
|
||||
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
|
||||
чтения усложняется на первой же ручке.
|
||||
|
||||
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
|
||||
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
|
||||
хранилище перестаёт возвращать снимок значением и начинает отдавать его
|
||||
последовательно (итератор или колбэк) — то есть меняется форма границы
|
||||
`store`/`catalog`, ради случая, которого живой поток пока не производит.
|
||||
|
||||
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
|
||||
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
|
||||
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
|
||||
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
|
||||
|
||||
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
|
||||
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
|
||||
остаются.
|
||||
|
||||
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
|
||||
представление того же факта, и его инвалидация становится новым местом, где можно
|
||||
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
|
||||
|
||||
## Что заблокировано
|
||||
|
||||
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
|
||||
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
|
||||
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
|
||||
непрерывным.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
|
||||
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
|
||||
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
|
||||
большую часть читающих транзакций, и делает это без кеша ответа.
|
||||
|
||||
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
|
||||
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
|
||||
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
|
||||
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
|
||||
|
||||
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
|
||||
`stats-nablyudaemost.md`.
|
||||
@@ -21,6 +21,14 @@
|
||||
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
|
||||
чтобы долгий запрос об остановке узнавал.
|
||||
|
||||
**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней
|
||||
не закрывается, а значит не закрывается и закреплённое соединение версии
|
||||
витрины — последнего соединения к базе не наступает, SQLite не делает финального
|
||||
чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы
|
||||
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
|
||||
переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап
|
||||
называется `background`, а не `fold-worker`, потому что ждут двоих.
|
||||
|
||||
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
|
||||
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
|
||||
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
|
||||
@@ -37,5 +45,5 @@
|
||||
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
|
||||
старта видно, что миграции накатывались и сколько это заняло.
|
||||
|
||||
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
|
||||
`cena-chitayushchego-marshruta.md`.
|
||||
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
|
||||
`2026-08-02-cena-chitayushchego-marshruta` (архив).
|
||||
|
||||
@@ -36,10 +36,26 @@
|
||||
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
|
||||
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
|
||||
|
||||
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
|
||||
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
|
||||
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
|
||||
станет первым его потребителем.
|
||||
**Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
|
||||
каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
|
||||
задавать его мимоходом на первой ручке значило бы решить контракт до того, как
|
||||
известна форма тяжёлого ответа. Каталог станет первым его потребителем.
|
||||
|
||||
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
|
||||
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
|
||||
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
|
||||
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
|
||||
столько же, а множители «метрики × окно × точки × одновременные запросы»
|
||||
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
|
||||
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
|
||||
если счётчик заговорит).
|
||||
|
||||
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
|
||||
`store.VersionedRead` держит правило «версией, снятой после чтения, не
|
||||
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
|
||||
**область действия**: у точек ответ есть функция параметров запроса, и
|
||||
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
|
||||
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
|
||||
|
||||
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
|
||||
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
|
||||
|
||||
@@ -48,3 +48,12 @@
|
||||
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
|
||||
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
|
||||
корреляция есть (`delivery_id`), у чтения аналога нет.
|
||||
|
||||
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
|
||||
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
|
||||
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
|
||||
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
|
||||
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
|
||||
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
|
||||
чтения, которые удалось подписать `ETag`: механизм условного запроса может
|
||||
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
|
||||
|
||||
@@ -161,3 +161,9 @@
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
|
||||
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||
запросов, координаты объектов).
|
||||
|
||||
@@ -110,3 +110,24 @@
|
||||
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
|
||||
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
|
||||
сработали ровно так, как задуманы, — их просто не позвали.
|
||||
|
||||
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов
|
||||
|
||||
- **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn`
|
||||
- **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом
|
||||
тесте: «в логе оказалось значение точки "5.1"». Значения в логе не было —
|
||||
подстрока нашлась в метке времени записи (`…T20:23:35.193…` содержит `5.1`).
|
||||
Повторный прогон зелёный.
|
||||
- **Причина:** утверждение искало секрет в **сыром буфере** записи, а буфер
|
||||
содержит служебное поле `time` с долями секунды. Вероятность совпадения для
|
||||
двухсимвольного числа с точкой — около процента на прогон, то есть тест
|
||||
флаки по построению, и краснеет он у того, кто мимо проходил.
|
||||
- **Почему не поймали:** шаг `flaky` гейта гоняет набор дважды подряд —
|
||||
вероятность поймать однопроцентную флаки за два прогона мала, а сам тест
|
||||
выглядит образцовым: он проверяет ровно тот инвариант, который проекту
|
||||
дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход
|
||||
ревью не смотрит на тесты чужих задач.
|
||||
- **Что меняем:** правило в [conventions.md](conventions.md) — проверка «в логе
|
||||
нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром
|
||||
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
|
||||
а десять стоили бы дороже самой находки.
|
||||
|
||||
Reference in New Issue
Block a user