Files
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

151 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"net/http"
"net/textproto"
"strings"
)
// Условный запрос читающих маршрутов. Помощник общий намеренно: тот же ответ
// понадобится точкам и MCP, а протокол здесь ровно такой, каким его описывает
// HTTP, — второй его экземпляр разошёлся бы с первым в мелочи вроде слабого
// сравнения.
//
// ГРАНИЦА, которую обязан знать следующий потребитель: метка действительна
// только в пределах одного адреса ресурса. У каталога ответ зависит лишь от
// состояния витрины, поэтому версии достаточно; у Read API точек ответ есть
// функция параметров запроса, а у MCP адреса нет вовсе — там в метку обязана
// входить канонизированная форма запроса, иначе «не изменилось» ответит на
// другой набор данных.
// etag собирает метку HTTP из области действия и версии ответа.
//
// Область — параметр, а не забота вызывающего: она и есть то, что помощник
// обязан не дать забыть. Метка действительна в пределах ОДНОГО ресурса, и
// маршрут, чей ответ зависит от параметров запроса (Read API точек) или у
// которого адреса нет вовсе (MCP), кладёт сюда их канонизированную форму.
// Прозой это уже было написано — и прозу компилятор не проверяет: `etag(v)` у
// второго маршрута собрался бы и отдал `304` на чужой набор данных.
//
// Метка СЛАБАЯ, и это не осторожность. Ответ есть функция не только снимка, но
// и горизонта измерения (`Now()` плюс час), а горизонт едет вместе с часами:
// пока в витрине нет меток из будущего, ход часов ответ не двигает, но метки из
// будущего в ней возможны — сбитые часы телефона, чужое тело в приёме. Слабая
// метка это допускает, сильная обещала бы побайтовое равенство, которого в этом
// случае нет. На исход `304` форма не влияет: `If-None-Match` сравнивается
// слабо в любом случае.
func etag(scope, version string) string {
if version == "" {
return ""
}
return `W/"` + scope + "." + version + `"`
}
// notModified отвечает, покрывает ли условие запроса текущую метку.
//
// Сравнение слабое: `W/"x"` и `"x"` — одна и та же метка, так предписывает
// HTTP для `If-None-Match`. Звёздочка совпадает с любой существующей меткой.
//
// Неразбираемое значение условия — не отказ, а невыполненное условие: клиент,
// приславший мусор, получает данные, а не `400`. Пустая метка (подписать ответ
// нечем) не совпадает ни с чем, включая звёздочку: подтверждать неизменность
// нечем.
//
// Алгоритм повторяет `net/http/fs.go` (`scanETag`, `etagWeakMatch`,
// `checkIfNoneMatch`): там он есть, но неэкспортирован, и копия дешевле
// зависимости. Копия ПОЛНАЯ, включая сканер: резать список по запятой нельзя —
// запятая законный символ внутри метки, а в метку читающего маршрута once
// попадёт канонизированная форма запроса, где запятая естественна. Тогда `304`
// перестал бы срабатывать вообще, и симптом («условный запрос не экономит»)
// увёл бы отладку в маршрут, а не в помощника.
func notModified(header []string, tag string) bool {
if tag == "" {
return false
}
for _, line := range header {
for {
line = textproto.TrimString(line)
if line == "" {
break
}
if line[0] == ',' {
line = line[1:]
continue
}
if line[0] == '*' {
// Звёздочка совпадает с ЛЮБОЙ существующей меткой, а не с любым
// состоянием: метки нет — условие не выполнено, и клиент со
// звёздочкой получает данные, а не вечный `304`.
return true
}
candidate, remain := scanETag(line)
if candidate == "" {
// Нечитаемая метка прекращает разбор строки: так делает stdlib,
// и это не отказ — условие просто не выполнено.
break
}
if strings.TrimPrefix(candidate, `W/`) == strings.TrimPrefix(tag, `W/`) {
return true
}
line = remain
}
}
return false
}
// scanETag откусывает метку от начала строки и отдаёт остаток. Копия
// `net/http/fs.go`; диапазоны символов — `etagc` из RFC 9110, в них ВХОДИТ
// запятая.
func scanETag(s string) (tag, remain string) {
s = textproto.TrimString(s)
start := 0
if strings.HasPrefix(s, "W/") {
start = 2
}
if len(s[start:]) < 2 || s[start] != '"' {
return "", ""
}
for i := start + 1; i < len(s); i++ {
c := s[i]
switch {
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
// шум внутри метки — законные символы
case c == '"':
return s[:i+1], s[i+1:]
default:
return "", ""
}
}
return "", ""
}
// writeNotModified отвечает `304`: та же метка, никакого тела.
//
// Представленческие заголовки снимаются — так делает и stdlib
// (`net/http/fs.go`, `writeNotModified`) со ссылкой на RFC 9110: у ответа без
// тела нечего описывать, а `Content-Length`, доживший до `304`, вводит в
// заблуждение любой кеш. Тело рантайм и так не пропустит
// (`bodyAllowedForStatus`), но полагаться на это значило бы получать
// проглоченную ошибку записи вместо ответа.
func writeNotModified(w http.ResponseWriter, tag string) {
h := w.Header()
h.Del("Content-Type")
h.Del("Content-Length")
h.Del("Content-Encoding")
setReadHeaders(w, tag)
w.WriteHeader(http.StatusNotModified)
}
// setReadHeaders вешает на ответ чтения метку и правило кеширования.
//
// `private, no-cache` означает «кешируй, но каждый раз спрашивай»: ровно то,
// ради чего заведена метка. Заголовок обязателен, а не желателен, — без него
// промежуточный кеш вправе решить по эвристике, что выгрузку истории здоровья
// можно подержать у себя.
func setReadHeaders(w http.ResponseWriter, tag string) {
w.Header().Set("Cache-Control", "private, no-cache")
if tag != "" {
w.Header().Set("ETag", tag)
}
}