Цена читающего маршрута: чекпойнт 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
+27
View File
@@ -23,6 +23,10 @@ var migrationsFS embed.FS
// Store — доступ к витрине.
type Store struct {
db *sqlx.DB
// probe — закреплённое соединение версии витрины, заводится по первому
// запросу версии. См. version.go: значение `data_version` локально для
// соединения, и брать его из пула нельзя.
probe versionProbe
}
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
@@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64,
// Close закрывает соединение с БД.
func (s *Store) Close() error {
// Щуп версии закрывается сам и раньше пула: закреплённое соединение
// переживает `db.Close()` и продолжает отвечать на запросы — проверено.
s.closeProbe()
if err := s.db.Close(); err != nil {
return fmt.Errorf("close sqlite: %w", err)
}
return nil
}
// `journal_size_limit` держит верхнюю границу файла журнала. Чекпойнт
// возвращает страницы в базу, но файл оставляет на пике: измерено — 51 МБ до и
// после успешного переноса 12502 страниц. С лимитом первая же следующая запись
// усекает файл до предела.
//
// Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
// собственного измерения: суточный поток даёт около 23 МБ архива, то есть
// журнал такого размера означает не всплеск, а удерживаемый снимок. С пределом
// тела приёма она НЕ связана, хотя и совпадает по порядку: журнал растёт от
// чтения, а не от размера доставки, и менять её вслед за `ingest.max_body_mb`
// незачем.
const journalSizeLimit = 64 << 20
// JournalSizeLimitMB — тот же предел для строки старта: владелец обязан видеть
// в логе, с какими параметрами обслуживается журнал, а число живёт здесь.
const JournalSizeLimitMB = journalSizeLimit >> 20
// dsn собирает строку подключения: WAL для параллельного чтения во время
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
//
@@ -166,11 +190,14 @@ func (s *Store) Close() error {
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
// слияния из 800 против 800 из 800.
//
// `journal_size_limit` — см. константу выше.
func dsn(path string) string {
q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(on)")
q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit))
q.Add("_txlock", "immediate")
return "file:" + path + "?" + q.Encode()
}