Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+34 -2
View File
@@ -21,8 +21,36 @@ type catalogResponse struct {
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
// будет канонизированная форма запроса.
const scopeMetrics = "metrics"
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
metrics, err := a.catalog.Metrics(r.Context())
// Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
// строками, и `Get` увидел бы только первую — часть меток осталась бы
// нерассмотренной.
if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
// Отказ пробы глушится намеренно: он означает лишь, что условного
// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
// строкой ниже и будет назван ею один раз.
// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
// только состояния витрины, и что ещё в него входит, знает домен. Read
// API точек ответит здесь же своей версией, включающей параметры
// запроса, — транспорту эти правила знать незачем.
if version, err := a.catalog.Version(r.Context()); err == nil {
if tag := etag(scopeMetrics, version); notModified(cond, tag) {
writeNotModified(w, tag)
return
}
}
}
snap, err := a.catalog.Metrics(r.Context())
if err != nil {
// Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
@@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "каталог не собрался")
return
}
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
}