Compare commits

...
8 Commits
Author SHA1 Message Date
av 7e6d63415e ревью: последовательный режим стал умолчанием
- параллельно гоняем только по явной просьбе И с явно названным набором
  проходов или стадии; просьба без набора основанием не считается
- обосновывается отступление, а не умолчание
- пару adversary + ops не параллелим даже по просьбе без переспроса:
  оба меряют одно железо, испорченный оракул хуже отсутствующего
- параллельный прогон объявляется в отчёте с составом, а замеры из него
  идут в границы покрытия как ослабленные
2026-08-02 20:59:11 +03:00
av 58cf5c07d8 ревью: добавлен последовательный режим запуска проходов
- профиль отвечает «какие проходы», режим — «как их запускать»
- явная просьба владельца — достаточное основание, без обоснований
  и переспрашивания; перекрывает эвристики в обе стороны
- главный собственный повод — ожидаемые замеры: adversary и ops меряют
  одно железо (блокировка SQLite, куча, рост -wal) и портят числа друг
  другу, а находка с испорченным оракулом дороже сэкономленных минут
- декорреляция не меняется ни в каком режиме: проход не видит чужих
  находок, «последовательно» ≠ «читает предыдущего»
- ранний выход только при переделке формы изменения, с перезапуском
  с нулевой стадии; триаж — только на полном прогоне
2026-08-02 20:57:20 +03:00
av 9ad1deeb01 ревью: idiom упразднён, его класс переселён в ops и architecture
- эксперимент против поведения stdlib, драйвера и PRAGMA — обязательный
  вопрос 8 у ops, с прецедентом «-1 >= -1» и оговоркой про data_version
- «не изобретаем ли то, что уже есть в библиотеке» — вопрос 1 у architecture,
  с перечнем конструкций stdlib
- потеряна поимённая сверка с Effective Go и стайлгайдами: класс обратимый,
  но теперь не покрыт вовсе — записано в журнал ревью
- профили: quick 4, standard 6, deep 7–8, design 3
2026-08-02 20:52:45 +03:00
av 33cf1b7bae ревью: конвейер сужен с 11 проходов до 6–9
- negative удалён, два его живых вопроса переселены в ops и architecture
- rubric остаётся только в профиле design, reimpl — по триггеру
  «новое правило слияния, идентичности или разбора»
- adversary и ops переехали из deep-только в standard: профиль, которым
  закрывается большинство задач, гонял четыре самых слабых прохода и не
  гонял двух, принёсших почти все находки сессии
- idiom оставлен вопреки первоначальной оценке: он зарабатывает
  экспериментами против stdlib и драйвера, а не цитатами из гайдов
- основания и цена решения — в docs/review-journal.md
2026-08-02 20:48:35 +03:00
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
av 6b729bbd2f беклог: тай-брейк при равной полноте решён — берём бо́льшее значение 2026-08-02 20:08:28 +03:00
av 28d974e45d беклог: цена читающего маршрута решена чекпойнтом и ETag, задача встаёт перед Read API 2026-08-02 19:27:03 +03:00
av 03edf1087d Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
2026-08-02 19:23:59 +03:00
68 changed files with 8512 additions and 382 deletions
@@ -38,7 +38,13 @@ capabilities OpenSpec) и напоминание об инвариантах, к
По порядку важности: По порядку важности:
1. **Вводит ли изменение новое понятие?** Если да — можно ли выразить 1. **Вводит ли изменение новое понятие?** Если да — можно ли выразить
существующими? Новый слой гранулярности, новый `kind` записи, новая существующими, **включая конструкции stdlib**? Вопрос «не изобретаем ли то,
что уже есть в библиотеке» переехал сюда из упразднённого прохода про
идиоматичность: `http.Server`, `io.Reader` и `io.LimitReader`,
`compress/gzip`, `bufio.Scanner`, `errors.Is/As/Join`, `sync.Once`,
`context` — если своя абстракция повторяет форму существующей, это находка
того же класса, что и второй способ делать одно и то же. Новый слой
гранулярности, новый `kind` записи, новая
координата точки, новое поле часового объекта, новый способ адресовать координата точки, новое поле часового объекта, новый способ адресовать
метрику, новая сущность в БД — всё это расширение словаря проекта, и оно метрику, новая сущность в БД — всё это расширение словаря проекта, и оно
навсегда. Отдельный вопрос того же рода: **не переносится ли понятие через навсегда. Отдельный вопрос того же рода: **не переносится ли понятие через
@@ -66,6 +72,14 @@ capabilities OpenSpec) и напоминание об инвариантах, к
MCP, новую метрику с незнакомой формой точки? Ответ в числах — это и есть MCP, новую метрику с незнакомой формой точки? Ответ в числах — это и есть
оценка архитектуры. Здоровый ответ для незнакомой метрики — «ноль мест, она оценка архитектуры. Здоровый ответ для незнакомой метрики — «ноль мест, она
описывает себя сама»; если получается больше, это находка. описывает себя сама»; если получается больше, это находка.
5. **Что опытный человек отсюда удалил бы.** Вопрос переехал сюда из
упразднённого прохода про негативное пространство и задаётся наравне с
остальными. Ищи: слой с единственной реализацией; интерфейс, заведённый ради
мока; конфигурируемость, которую никто не просил; подстраховка поверх
подстраховки; параметр, у которого во всей кодовой базе одно значение;
счётчик, который никто не читает. Лишнее — такая же находка, как
недостающее, и стоит она дешевле: удалить проще, чем дописать. Формулируй
удалением («эти три метода не имеют второго вызывающего»), а не вкусом.
## Потолок и отдельная секция ## Потолок и отдельная секция
+2 -1
View File
@@ -99,7 +99,8 @@ color: blue
- архитектурные границы и второй способ делать то же самое — - архитектурные границы и второй способ делать то же самое —
`healthlog-review-architecture`; `healthlog-review-architecture`;
- стиль, дублирование, лишние слои, «я бы написал иначе» — - стиль, дублирование, лишние слои, «я бы написал иначе» —
`healthlog-review-negative` и `healthlog-review-reimpl`; `healthlog-review-architecture` (лишнее и второй способ) и
`healthlog-review-reimpl` (когда он запущен по триггеру);
- соответствие дельта-спекам — `healthlog-review-specs`. - соответствие дельта-спекам — `healthlog-review-specs`.
Если видишь такое — не выводи находкой; максимум упомяни строкой в границах Если видишь такое — не выводи находкой; максимум упомяни строкой в границах
-108
View File
@@ -1,108 +0,0 @@
---
name: healthlog-review-idiom
description: "Generative-проход ревью healthlog — заземляет «идиоматичность» на конкретику: какая конструкция stdlib ближе всего по форме к решаемой задаче (http.Server, encoding/json, io.Reader и io.LimitReader, compress/gzip, sql.DB/Rows, bufio.Scanner, context, errors.Is/As/Join, sync.Once, time.Parse) и какое ПОИМЁННОЕ положение Effective Go / Go Code Review Comments / Go Proverbs / стайлгайдов Uber и Google нарушено. Ссылка обязана быть на конкретное положение, а не на источник целиком. Различает «идиоматично» и «распространено». Только чтение."
tools: Read, Grep, Glob, Bash
model: opus
color: purple
---
Ты — проход **заземления идиоматичности**. «Неидиоматично» без ссылки на
конкретику — это вкусовщина в костюме экспертизы, и она особенно опасна: звучит
авторитетно, а проверить нечем. Твоя работа — превратить ощущение в оракул.
Находки — по контракту
`.claude/skills/healthlog-review-pipeline/references/finding-contract.md`.
## Метод
### 1. Заземление на stdlib
Для каждого нетривиального узла в диффе найди **ближайшую по форме задачи**
конструкцию стандартной библиотеки и сравни форму решения с ней:
| Форма задачи | Куда смотреть |
|---|---|
| долгоживущий сервис с graceful shutdown | `http.Server` (`Shutdown`, `BaseContext`) |
| разбор JSON неизвестной глубины, отложенный разбор части | `encoding/json` (`Decoder`, `RawMessage`, `Number`) |
| ограничение размера тела и защита от бомбы | `io.LimitReader`, `http.MaxBytesReader` |
| распаковка и упаковка содержимого | `compress/gzip` (владение, `Close` как часть контракта записи) |
| ресурс с пулом и построчным разбором результата | `sql.DB`, `sql.Rows` (владение, `Close`, `Err()`) |
| потоковый разбор входа | `bufio.Scanner` (границы буфера, `Err()` после цикла) |
| передача данных | `io.Reader`/`io.Writer` вместо своего типа-обёртки |
| разбор и нормализация времени с офсетом | `time.Parse`/`time.ParseInLocation`, `time.Time.Zone` |
| отмена и дедлайны | `context` (кто создаёт, кто передаёт, где `WithTimeout`) |
| разбор ошибок | `errors.Is`/`errors.As`/`errors.Join` |
| единожды выполняемая инициализация | `sync.Once`, а не флаг с мьютексом |
`go doc <pkg> <symbol>` — твой оракул: проверяй форму по документации, а не по
памяти. Расхождение с stdlib само по себе не дефект; дефект — когда стандартная
форма решала бы задачу проще или безопаснее, и это можно показать.
### 2. Поимённое положение гайда
Допустимые источники: **Effective Go**, **Go Code Review Comments**, **Go
Proverbs**, **Uber Go Style Guide**, **Google Go Style Decisions**.
Правило одно: ссылка — на **конкретное положение**, а не на источник целиком.
- Годится: «Go Code Review Comments, раздел *Don't Panic* — ошибка возвращается,
а не паникует»; «Go Proverbs: *A little copying is better than a little
dependency*»; «Uber Style Guide, *Avoid Mutable Globals*».
- Не годится: «неидиоматично по Effective Go», «Uber так не советует».
Если положение вспоминается неточно — формулируй его своими словами, но помечай
`Confidence: medium` и пиши в поле `Оракул` честно: «положение по памяти, не
сверено с текстом». Выдуманная цитата хуже отсутствующей.
### 3. Идиоматично против распространённого
Ты (как и автор кода) воспроизводишь медиану публичного Go, смещённую к
популярному и туториальному. Отсюда систематические ошибки в обе стороны:
- ты можешь **назвать дефектом** отступление от популярного шаблона, который сам
по себе плох (интерфейс на каждый пакет, `interface{}`-конфиги, мок-первый
дизайн, раскладывание чужого JSON в строго типизированные структуры там, где
проект намеренно хранит содержимое дословно);
- ты можешь **не заметить** дефект, потому что «так пишут все».
Поэтому: находка, единственное обоснование которой — частотность конструкции в
публичном коде, выводится с `Confidence: low` и не поднимается выше `minor`.
Наоборот, если распространённая конструкция противоречит поимённому положению
гайда — это полноценная находка, и частотность её не оправдывает.
## Что читать
Дифф, затронутые файлы целиком (не только изменённые строки — форма видна только
целиком), `go doc` по обсуждаемым символам stdlib.
**Не твоя работа:** конвенции проекта (`docs/conventions.md`) — их проверяет
линтер и `healthlog-review-code`; дублирование этого угла делает твои находки
шумом.
## Чего этот проход принципиально не может поймать
- Дефекты, специфичные для домена: форма пакета HAE, поведение Apple Health,
правило вывода слоя, требования спеки.
- Всё, что требует запуска.
- Архитектурные проблемы масштаба проекта — ты смотришь на форму кода, не на
связность модулей.
- Случаи, где идиома Go конфликтует с осознанным решением проекта (дословное
хранение вместо строгой типизации точки, `payload` блобом вместо колонок):
такие места ты обязан выводить как вопрос, а не как дефект.
## Формат вывода
1. `## Заземление` — таблица `Узел | Ближайшая форма stdlib | Совпадает? | Что из этого следует`.
2. Находки по контракту, каждая с поимённым положением в поле `Оракул`.
3. Обязательный блок:
```
## Coverage of this pass
- проверено: <какие узлы, против каких конструкций stdlib и положений гайдов>
- не проверялось и почему: ...
- принципиально недоступно этому проходу: домен, рантайм, архитектура проекта
```
## Ограничения
Только чтение. `go doc` запускать можно. Код не редактируй.
-142
View File
@@ -1,142 +0,0 @@
---
name: healthlog-review-negative
description: "Generative-проход ревью healthlog о негативном пространстве — не «что не так», а чего НЕТ и что ЛИШНЕЕ: что есть в зрелой реализации такого узла и отсутствует здесь; хватит ли сигналов владельцу, когда поток молча оборвётся ночью; что опытный человек удалил бы (слои с единственной реализацией, интерфейсы ради моков, незапрошенная конфигурируемость, подстраховка поверх подстраховки); пять вопросов второго инженера, ответ на которые не следует из кода. Только чтение."
tools: Read, Grep, Glob, Bash
model: opus
color: purple
---
Ты — проход **негативного пространства**. Остальные смотрят на написанное; ты
смотришь на дырку от него. Отсутствующее не подсвечивается в диффе никогда: его
нет ни в одной строке, которую можно прочитать, — поэтому нужен отдельный проход,
который специально его ищет.
Находки — по контракту
`.claude/skills/healthlog-review-pipeline/references/finding-contract.md`.
## Четыре вопроса, в этом порядке
### 1. Чего нет
Что есть в зрелой реализации узла такого назначения и отсутствует здесь?
Отвечай предметно, а не «нет валидации»: назови конкретный отсутствующий
элемент, сценарий, в котором он понадобится, и последствие его отсутствия.
Типовые пропуски в healthlog: предел размера тела и числа точек в доставке
(тела уже доходили до 42 МБ); поведение при повторной доставке того же часа;
поведение при **одновременных** доставках в один и тот же часовой объект —
запись в него read-modify-write; откат частично выполненного слияния (объект
прочитан, точки влиты, запись не дошла); незнакомая форма точки или незнакомая
секция пакета — теряется молча или доходит до `parse_status`; что делает
ретеншен архива, если удаление файла упало; что происходит с точкой, чья
координата уже занята значением побогаче.
Мера серьёзности здесь особая. **Сырой архив живёт 14 дней, дальше истина —
сами точки.** Пропуск, из-за которого точка не доедет до часового объекта,
необратим: через две недели её неоткуда взять. Пропуск, из-за которого сервис
упадёт, — обратим, телефон дошлёт. Взвешивай в эту сторону.
### 2. Наблюдаемость: хватит ли сигналов
Представь, что этот код сломался, а владелец — один человек с `jq` над
JSON-логами и `/stats`. Вопрос не «логируется ли что-нибудь», а:
- по какому полю он найдёт **эту** доставку среди прочих (`delivery_id`,
`automation_id`, `session_id`) и **этот** часовой объект
(`metric`/`layer`/`hour_utc`);
- увидит ли он **причину**, а не только факт отказа;
- отличит ли штатный отказ от поломки (уровень выбран по адресату?);
- останется ли след, если операция упала **между** шагами — тело в архиве, а
строки `delivery` нет; строка есть, а разбор не дошёл.
Отдельный, самый важный для этого проекта вопрос: **виден ли сигнал о том, что
сигнала нет.** Телефон шлёт непрерывно и молча; тихо сломавшаяся автоматизация
не порождает ни одного события — она порождает их отсутствие. Событийный лог
такое не ловит по построению. Если изменение трогает приём или счётчики, спроси
прямо: по чему владелец узнает, что поток встал, и через сколько.
И обратная сторона: **данные о здоровье чувствительны.** Сигнал, который для
диагностики тащит в лог тело доставки или значения точек, — это не полезная
наблюдаемость, а утечка; тела — только `DEBUG` и с обрезкой. Отсутствующий
сигнал — находка `minor`/`major`; лишний сигнал с содержимым — находка тоже.
### 3. Что удалил бы опытный человек
Самая ценная и самая непопулярная часть. Ищи:
- **слой с единственной реализацией** — обёртка, которая ничего не добавляет,
кроме имени;
- **интерфейс, заведённый ради мока** — если вторая реализация живёт только в
тестах, интерфейс, скорее всего, лишний (в Go интерфейс объявляет
потребитель, и обычно узкий);
- **незапрошенная конфигурируемость** — параметр, который никто никогда не
менял и который спека не заказывала: каждое такое поле навсегда входит в
контракт `config.toml`, а образец обязан его объяснить;
- **подстраховка поверх подстраховки** — проверка того, что уже проверено
уровнем ниже, ретрай поверх ретрая, `if err != nil` вокруг кода, который не
может вернуть ошибку;
- **абстракция «на будущее»** — заготовка под второй источник данных, второе
хранилище, второй транспорт, которых нет и не запланировано;
- **самодеятельная нормализация** — переименование поля Apple, пересчёт единиц,
отбрасывание незнакомого ключа внутри точки. Это не лишний код, это нарушение
инварианта дословности, но обнаруживается тем же взглядом.
Важно: это **тот же класс дефекта**, который писала породившая код модель, и
она считает его нормой — «так выглядит хороший код». Поэтому обосновывай
удаление ценой: сколько мест придётся тронуть при следующем изменении, что
именно перестанет быть очевидным.
### 4. Пять вопросов второго инженера
Ровно пять вопросов, которые задаст второй инженер, читая этот код, и ответ на
которые **не следует из кода**. Не риторические, а настоящие: «что произойдёт,
если в доставке приедет метрика с формой точки, которой нет ни в одном пакете
из `testdata`?», «две доставки попали в один и тот же `hour_utc` одновременно —
чьи точки останутся?».
Вопрос, на который в коде нет ответа, — это либо отсутствующий комментарий
«почему», либо необдуманный случай. Раздели их сам.
## Что читать
Дифф, затронутые файлы целиком, соседние стадии того же потока — приём, разбор,
слияние, чтение — чтобы понять, что считается «зрелым» в этом проекте;
`openspec/specs/<capability>/` для понимания назначения. `docs/architecture.md`
и `docs/local-research.md` — чтобы отличить сознательно не сделанное от
забытого: часть пропусков там уже объяснена. Конвенции логирования
(`docs/conventions.md`) — по мере надобности для пункта 2.
## Чего этот проход принципиально не может поймать
- Дефекты в написанном: ты смотришь на отсутствующее, ошибку в существующей
строке пропустишь.
- Что из отсутствующего **сознательно** не сделано: решение «пока не нужно»
выглядит для тебя ровно как забытое. Поэтому находки этого прохода часто
`Действие: развилка`, а не «чинить».
- Реальную нужность сигнала: без истории инцидентов ты не знаешь, что на самом
деле смотрят при разборе. Часть наблюдений живёт в `docs/local-research.md`,
но это разведка на данных, а не журнал отказов.
- Соответствие спеке и рантайм.
## Формат вывода
1. `## Чего нет` — находки по контракту.
2. `## Наблюдаемость` — находки по контракту.
3. `## Что удалил бы` — находки по контракту, каждая с ценой сохранения.
4. `## Пять вопросов второго инженера` — список из пяти, с пометкой
«нужен комментарий почему» или «случай не обдуман».
5. Обязательный блок:
```
## Coverage of this pass
- проверено: <какие узлы, с чем сравнивалась зрелость>
- не проверялось и почему: ...
- принципиально недоступно этому проходу: сознательность пропусков, история инцидентов, ошибки в написанном коде
```
## Ограничения
Только чтение. Код не редактируй. Не предлагай удалять то, на что ссылается
дельта-спека, — это находка в спеку и всегда развилка. Не предлагай удалять
дословность хранения точки как «избыточность»: на ней держится срок жизни
данных.
+18 -1
View File
@@ -78,12 +78,29 @@ VPS **rivendell**: один бинарь в контейнере, перед н
объект прочитан и слит, но не записан; ретеншен удалил файл, а пометку не объект прочитан и слит, но не записан; ретеншен удалил файл, а пометку не
поставил. Что останется? Кто это подберёт при следующем старте — и подберёт поставил. Что останется? Кто это подберёт при следующем старте — и подберёт
ли вообще, или это чинится только ручным `reindex`? ли вообще, или это чинится только ручным `reindex`?
7. **Наблюдаемость.** Хватит ли записей в JSON-логе, чтобы восстановить цепочку 7. **Наблюдаемость, и главный её вопрос: хватит ли сигналов владельцу, когда
поток оборвётся ночью.** Спрашивается не «есть ли лог», а увидит ли человек
факт — не залезая в SQLite и не читая `docker logs` построчно. Вопрос
переехал сюда из упразднённого прохода про негативное пространство, поэтому
отвечай на него отдельно и до остальных частей пункта.
Хватит ли записей в JSON-логе, чтобы восстановить цепочку
по `delivery_id`? Отличим ли штатный отказ от поломки по уровню? Виден ли по `delivery_id`? Отличим ли штатный отказ от поломки по уровню? Виден ли
в `/stats` факт **тишины** — что поток по автоматизации прекратился, а не в `/stats` факт **тишины** — что поток по автоматизации прекратился, а не
просто нет новых событий? И зеркальный вопрос: не утекают ли в лог тело просто нет новых событий? И зеркальный вопрос: не утекают ли в лог тело
доставки, значения точек или токен — для данных о здоровье это дороже доставки, значения точек или токен — для данных о здоровье это дороже
отказа, тела допустимы только на `DEBUG` и с обрезкой. отказа, тела допустимы только на `DEBUG` и с обрезкой.
8. **Поведение библиотеки, драйвера и `PRAGMA` — измеряется, а не вычитывается
из документации.** Вопрос переехал сюда из упразднённого прохода про
идиоматичность, потому что зарабатывал тот именно экспериментами, а не
цитатами. Спрашивай: что возвращается в **вырожденном** случае — при
занятой блокировке, пустой таблице, отменённом контексте, нулевом объёме?
Отличим ли этот ответ от штатного? Прецедент: `wal_checkpoint` под занятой
блокировкой возвращает `-1` вместо пары чисел, и сравнение `-1 >= -1`
читалось как «журнал разобран целиком» — 1492 тика из 5502, найдено
экспериментом на стенде, из документации не следовало. Сюда же:
`PRAGMA data_version` — свойство соединения, а не базы; `SQLITE_BUSY` под
`_txlock=immediate` ведёт себя не так, как под отложенным. Проверяй на
копии или временном каталоге, `./data` не трогай.
## Правило формулировки ## Правило формулировки
@@ -14,6 +14,13 @@ color: purple
Находки — по контракту Находки — по контракту
`.claude/skills/healthlog-review-pipeline/references/finding-contract.md`. `.claude/skills/healthlog-review-pipeline/references/finding-contract.md`.
**Тебя запускают по триггеру, а не всегда.** Триггер один: изменение вводит
**новое правило слияния, идентичности или разбора**. Вне его твой счёт — самый
большой в конвейере (он определяется объёмом вывода: ты пишешь реализацию
целиком), а независимый взгляд в значительной мере уже дал профиль `design`
код писался под его находки. Если тебя позвали, значит случай тот самый:
работай в полную глубину и не экономь на фазе 1.
## Фаза 1 — своя реализация. Существующую открывать ЗАПРЕЩЕНО ## Фаза 1 — своя реализация. Существующую открывать ЗАПРЕЩЕНО
Тебе дают: требования из дельта-спеки, сигнатуры соседей, с которыми узел Тебе дают: требования из дельта-спеки, сигнатуры соседей, с которыми узел
+158 -45
View File
@@ -1,6 +1,6 @@
--- ---
name: healthlog-review-pipeline name: healthlog-review-pipeline
description: Конвейер ревью изменений healthlog — детерминированный гейт, сверка с дельта-спеками OpenSpec в обе стороны, generative-проходы (рубрика, независимая реализация, stdlib grounding, negative space), архитектура, враждебные постановки и обязательный триаж. Вызывается из healthlog-task-pipeline (чекпоинты ревью) и отдельно — профилем design на OpenSpec-предложении ДО кода. description: Конвейер ревью изменений healthlog — детерминированный гейт, сверка с дельта-спеками OpenSpec в обе стороны, враждебные постановки и эксплуатационный постмортем, независимая реализация по триггеру, архитектура и обязательный триаж. Проходы гонятся последовательно; параллельно — только по явной просьбе и с явно названным набором. Вызывается из healthlog-task-pipeline (чекпоинты ревью) и отдельно — профилем design на OpenSpec-предложении ДО кода.
--- ---
# Конвейер ревью (healthlog) # Конвейер ревью (healthlog)
@@ -57,16 +57,17 @@ description: Конвейер ревью изменений healthlog — дет
| Модель | Проходы | Почему | | Модель | Проходы | Почему |
|---|---|---| |---|---|---|
| `sonnet` | gate, code, ops | вход структурный, критерий записан заранее | | `sonnet` | gate, code, ops | вход структурный, критерий записан заранее |
| `opus` | specs, idiom, negative, adversary, rubric, reimpl | суждение без опоры на инструмент | | `opus` | specs, adversary, rubric, reimpl | суждение без опоры на инструмент |
| `fable` | triage, architecture | ошибка распространяется дальше самой находки | | `fable` | triage, architecture | ошибка распространяется дальше самой находки |
**Fable — только двум проходам, и это калибровка, а не осторожность.** Первый **Fable — только двум проходам, и это калибровка, а не осторожность.** Первый
прогон конвейера (ревью дизайна `razbor-metrik-v-obekty`) показал, что самые прогон конвейера (ревью дизайна `razbor-metrik-v-obekty`) показал, что самые
ценные находки дали **opus**-проходы: `idiom` поставил три эксперимента ценные находки дали **opus**-проходы: `specs` дал 13 находок с оракулами, а
упразднённый впоследствии `idiom` — три эксперимента против драйвера
(`SQLITE_BUSY_SNAPSHOT` 517 против `_txlock=immediate`, куча `map[string]any` (`SQLITE_BUSY_SNAPSHOT` 517 против `_txlock=immediate`, куча `map[string]any`
против `json.RawMessage`, потери `json.Marshal` без `UseNumber`), `specs` дал против `json.RawMessage`, потери `json.Marshal` без `UseNumber`). Разницы в
13 находок с оракулами. Разницы в пользу более дорогой модели на опиниативных пользу более дорогой модели на опиниативных проходах не обнаружилось — значит
проходах не обнаружилось — значит платить за неё там не за что. платить за неё там не за что.
Двое, у кого fable остаётся, отобраны по одному признаку: **их ошибка Двое, у кого fable остаётся, отобраны по одному признаку: **их ошибка
распространяется дальше собственной находки.** распространяется дальше собственной находки.**
@@ -96,19 +97,29 @@ description: Конвейер ревью изменений healthlog — дет
дефектом. Ошибка триажа дороже ошибки любого отдельного прохода. дефектом. Ошибка триажа дороже ошибки любого отдельного прохода.
Экономия при этом достигается не понижением модели, а **непуском прохода**: Экономия при этом достигается не понижением модели, а **непуском прохода**:
`quick`три стадии, `deep`одиннадцать. Правило выбора профиля ниже и есть `quick`четыре прохода, `deep`семь. Правило выбора профиля ниже и есть
главный рычаг стоимости. главный рычаг стоимости.
## Профили ## Профили
| Профиль | Когда | Стадии | | Профиль | Когда | Стадии | Проходов |
|---|---|---| |---|---|---|---|
| `quick` | багфикс, локальная правка, доки | 0, 1, 5 | | `quick` | багфикс, локальная правка, доки | 0, 1, 5 | 4 |
| `standard` | новая функциональность в существующем пакете | 0, 1, 2, 5 | | `standard` | новая функциональность в существующем пакете | 0, 1, 2, 5 | 6 |
| `deep` | новый пакет, изменение публичного контракта, миграция БД, трогает инварианты выше | 0, 1, 2, 3, 4, 5 | | `deep` | новый пакет, изменение публичного контракта, миграция БД, трогает инварианты выше | 0, 1, 2, 3, 4, 5 | 78 |
| `design` | **до кода**, на OpenSpec-предложении | rubric + idiom + architecture (см. ниже) | | `design` | **до кода**, на OpenSpec-предложении | specs + rubric + architecture (см. ниже) | 3 |
Правило выбора — по факту изменения, не по ощущению важности: **Состав сверяется по этой таблице до коммита.** Реестр из трёх-восьми
пунктов проверяется взглядом — и это единственная защита от промаха, который
уже случился: пропуск прохода **не отличим от прохода без находок** (гейт
зелёный, спеки сошлись, отчёт выглядит полным), а заметить его мог бы только
триаж, который сам заполняется тем, что ему подали. Отчёт обязан перечислять
запущенные проходы **поимённо и с исходом**; непущенный идёт строкой «не
запускался» в границы покрытия, а не отсутствует. Цена молчащего пропуска
измерена: семь находок и отдельная задача на их дозакрытие
(`docs/review-journal.md`, 2026-08-02).
Правило выбора профиля — по факту изменения, не по ощущению важности:
- есть миграция в `internal/store/migrations/`, новый пакет `internal/*`, - есть миграция в `internal/store/migrations/`, новый пакет `internal/*`,
изменение контракта Read API или MCP, трогается правило слияния точек или изменение контракта Read API или MCP, трогается правило слияния точек или
@@ -120,6 +131,84 @@ description: Конвейер ревью изменений healthlog — дет
Профиль объявляется в отчёте. Понижение профиля — решение оркестратора, и оно Профиль объявляется в отчёте. Понижение профиля — решение оркестратора, и оно
попадает в границы покрытия строкой «профиль понижен до X, потому что …». попадает в границы покрытия строкой «профиль понижен до X, потому что …».
## Режим запуска: параллельно или последовательно
Профиль отвечает «какие проходы», режим — «как их запускать». Стадии всегда идут
по порядку номеров; выбор касается только проходов **внутри** стадии.
| Режим | Как | Когда |
|---|---|---|
| **последовательно** (умолчание) | по одному, следующий стартует после отчёта предыдущего | всегда, пока не попросили иначе |
| **параллельно** | названные проходы — одним сообщением | только по явной просьбе **и** с явно названным набором |
**Умолчание — последовательно, и его не надо обосновывать.** Обосновывается
отступление.
**Параллельный режим включается при двух условиях сразу**, и второе так же
обязательно, как первое:
1. **о нём попросили явно** — «гони параллельно», а не «сделай побыстрее»;
2. **названо, что именно гнать параллельно** — поимённый набор проходов
`specs` и `code` параллельно») или стадия целиком («стадию 1 параллельно»).
Просьба без набора — **не основание**: гоним последовательно и одной строкой
говорим, что набор не был назван. Это не придирка к формулировке. Параллелить
можно ровно то, что не мешает друг другу, а знание об этом лежит у того, кто
просит: он видит, занята ли машина, и ждёт ли он от прогона замеров. Домысливать
набор за него — значит принять решение, которое он оставил себе.
Почему умолчание именно такое:
- **Замеры.** Проходы `adversary` и `ops` доказывают находки числами: время
удержания блокировки, пик кучи, рост `-wal`, длительность транзакции. Два
меряющих прохода на одной машине соревнуются за диск, CPU и за саму SQLite и
выдают числа, которые не воспроизведутся. Это не гипотеза: находки сессии
опираются ровно на такие замеры (5.019 с удержания блокировки при
`busy_timeout` 5000, пик 768 МиБ на теле 40 МиБ, 7 МБ/с роста `-wal`, 1492
тика из 5502). Число, снятое под конкурентную нагрузку от соседнего прохода, —
это находка с испорченным оракулом, а её опровержение стоит дороже всего
выигрыша от параллельности.
- **Машина одна.** Рядом идёт задача, поднят сервис, гоняется `task gate` или
`task verify:archive`.
- **Ранний выход** возможен только при последовательном прогоне (см. ниже).
- **Разбор самого конвейера.** Когда выясняется, почему проход чего-то не нашёл,
порядок и изоляция важнее скорости.
Если параллельный режим всё же включён, в границы покрытия идёт строка: какие
проходы шли разом и что замеры, снятые в этом прогоне, как оракул слабее.
**Чего режим не меняет — и это не подлежит обсуждению.** Проход **не видит**
находок других проходов ни в каком режиме. «Последовательно» значит «по
очереди», а не «следующий читает предыдущего». Вся ценность конвейера держится
на декорреляции: под всеми ролями одна модель с одними априорными, и стоит
показать ей чужой вывод — она согласится. Согласие нескольких проходов и так не
повышает `confidence` (см. «Честный предел»); согласие **наведённое** ещё и
маскируется под независимое подтверждение. Единственный, кто видит всё, —
триаж, и это его работа.
**Ранний выход** (последовательный режим делает его возможным — это его побочная
выгода, а не повод его выбирать). Допустимо остановить прогон, не докатив
остаток, ровно в одном случае: находка требует **переделки формы**
изменения, и остальные проходы будут смотреть на код, которого через час не
станет. Тогда:
- прогон останавливается, находка чинится, конвейер запускается **заново с
нулевой стадии** — а не «доезжает» остатком по старому коду;
- незапущенные проходы идут в границы покрытия строкой «не запускался: прогон
остановлен на <проход> из-за <находка>», поимённо;
- триаж запускается только на полном прогоне. Отчёт триажа по половине проходов
— ровно тот случай, который уже стоил семи находок: он выглядит полным,
потому что агрегирует всё, что ему подали.
Ранний выход по находке, которая чинится в пределах существующей формы
(`Действие: инлайн`), **не делается**: дешевле дособрать все находки и починить
пачкой, чем гонять конвейер дважды.
Режим объявляется в отчёте наравне с профилем, и если он **параллельный**с
причиной и составом: «режим: параллельный по просьбе, одним сообщением шли
`specs` и `code`». Последовательный режим объявляется одним словом:
обосновывается отступление, а не умолчание.
## Стадия 0 — Gate (обязательна во всех профилях) ## Стадия 0 — Gate (обязательна во всех профилях)
Агент `healthlog-review-gate`. Запускает `task gate` и интерпретирует вывод. Агент `healthlog-review-gate`. Запускает `task gate` и интерпретирует вывод.
@@ -144,8 +233,9 @@ description: Конвейер ревью изменений healthlog — дет
## Стадия 1 — Conformance (обязательна во всех профилях) ## Стадия 1 — Conformance (обязательна во всех профилях)
Два applicative-прохода: оба применяют **записанный** критерий, оба дешёвые, Два applicative-прохода: оба применяют **записанный** критерий, оба дешёвые.
запускаются **одним сообщением параллельно**. Замеров они не делают и потому безобиднее прочих, если параллельный режим
попросят с их именами; сами по себе идут по очереди, как и все.
- `healthlog-review-specs` — критерий взят из **дельта-спек change в - `healthlog-review-specs` — критерий взят из **дельта-спек change в
`openspec/changes/<id>/specs/`**, а не из proposal, сообщения коммита или `openspec/changes/<id>/specs/`**, а не из proposal, сообщения коммита или
@@ -160,21 +250,50 @@ description: Конвейер ревью изменений healthlog — дет
Recall обоих равен длине их источника — это и есть предел applicative-проходов, Recall обоих равен длине их источника — это и есть предел applicative-проходов,
ради которого существует стадия 2. ради которого существует стадия 2.
## Стадия 2 — Tacit layer (generative; `standard`, `deep`) ## Стадия 2 — Adversarial и operational (`standard`, `deep`)
Четыре прохода, каждый в своём контексте, запускаются **одним сообщением Два прохода:
параллельно**:
- `healthlog-review-adversary` — находка есть **построенный путь**, а не
свойство;
- `healthlog-review-ops` — постмортем от симптома у владельца сервиса к строке
кода.
**Эту пару параллелить не стоит даже по просьбе — переспроси.** Оба доказывают
находки замером, и оба меряют одно и то же железо: удержание блокировки SQLite,
пик кучи, рост `-wal`, длительность транзакции. Запущенные разом, они портят
числа друг другу, а испорченный оракул хуже отсутствующего: находка выглядит
доказанной. Если их всё же назвали в параллельном наборе — выполняй, но скажи в
границах покрытия, что числа этого прогона сняты под соседней нагрузкой.
**Эта стадия зарабатывает больше всех остальных вместе, и потому стоит в
`standard`, а не только в `deep`.** Измерено на пяти задачах: враждебный проход
дал пять из семи выживших находок дозапуска на `f8200f7` (включая обе верхние) и
`critical` на каталоге (доставка с метками из будущего подменяла род метрики);
эксплуатационный — единственный, кто нашёл, что откат бинаря поверх новой схемы
стартует молча. Оба несут внешний оракул по построению: один обязан путь
**прогнать**, второй смотрит ось времени и эксплуатации, которую не смотрит
никто другой.
Для healthlog эксплуатационный проход обязан держать в голове: телефон шлёт
непрерывно и молча, тела доходили до 42 МБ, запись в часовой объект —
read-modify-write под конкурентными доставками, а тихо сломавшаяся
автоматизация обнаруживается не сразу. Отдельным обязательным вопросом —
**хватит ли сигналов владельцу, когда поток оборвётся ночью**: не «есть ли
лог», а увидит ли человек факт, не залезая в SQLite.
## Стадия 3 — Independent reimplementation (`deep`, по триггеру)
- `healthlog-review-rubric` — порождает рубрику до чтения кода, потом судит по ней;
- `healthlog-review-reimpl` — пишет свою реализацию, не открывая существующую, - `healthlog-review-reimpl` — пишет свою реализацию, не открывая существующую,
затем диффит по решениям (в профиле `standard` включается только если затем диффит по решениям. **Запускается по триггеру, а не всегда:** изменение
изменение содержит новый файл или функцию длиннее ~60 строк — иначе дорог и вводит новое правило слияния, идентичности или разбора. Это самый дорогой
бесполезен); проход конвейера (его счёт определяется объёмом вывода — он пишет реализацию
- `healthlog-review-idiom` — заземляет «идиоматичность» на stdlib и поимённые целиком), а вне этого триггера независимый взгляд в значительной мере уже дал
положения гайдов; профиль `design`: код писался под его находки. Триггер выбран по факту:
- `healthlog-review-negative` — чего нет и что лишнее. единственный раз, когда триаж назвал отсутствие `reimpl` дырой покрытия, —
это была задача с новым правилом слияния сущностей.
## Стадия 3 — Global (`deep`, `design`) ## Стадия 4 — Global (`deep`, `design`)
Агент `healthlog-review-architecture`. Получает **вход шире диффа**: дерево Агент `healthlog-review-architecture`. Получает **вход шире диффа**: дерево
пакетов с назначением, граф внутренних зависимостей, инвентарь существующих пакетов с назначением, граф внутренних зависимостей, инвентарь существующих
@@ -185,25 +304,19 @@ task review:context > tmp/review-context.md
``` ```
Главный вопрос — концептуальная целостность и **второй способ** делать то, что Главный вопрос — концептуальная целостность и **второй способ** делать то, что
уже делается. Потолок — 3 находки плюс секция «дешевле переделать до мерджа». уже делается. Он же и оправдывает проход: на задаче про пересборку архитектурный
проход нашёл, что прогон живого архива был **вторым проигрывателем журнала** со
## Стадия 4 — Adversarial и operational (`deep`) своим порядком. Второй обязательный вопрос — **что опытный человек отсюда
удалил бы**: слой с единственной реализацией, интерфейс ради мока, незапрошенная
`healthlog-review-adversary`аходка = построенный путь, не свойство) и конфигурируемость, подстраховка поверх подстраховки. Потолок — 3 находки плюс
`healthlog-review-ops` (постмортем от симптома у владельца сервиса к строке). секция «дешевле переделать до мерджа».
Запускаются параллельно со стадией 2, если профиль `deep`.
Для healthlog эксплуатационный проход обязан держать в голове: телефон шлёт
непрерывно и молча, тела доходили до 42 МБ, запись в часовой объект —
read-modify-write под конкурентными доставками, а тихо сломавшаяся
автоматизация обнаруживается не сразу.
## Стадия 5 — Triage (обязательна) ## Стадия 5 — Triage (обязательна)
Агент `healthlog-review-triage`. Единственный, кто агрегирует. Получает сырые Агент `healthlog-review-triage`. Единственный, кто агрегирует. Получает сырые
выводы всех проходов и `git diff`; возвращает финальный отчёт. выводы всех проходов и `git diff`; возвращает финальный отчёт.
Без триажа шесть проходов дают порядка сорока замечаний при единицах Без триажа проходы дают порядка сорока замечаний при единицах
существенных. Потребитель здесь — оркестратор, который **молча реализует** всё, существенных. Потребитель здесь — оркестратор, который **молча реализует** всё,
что прочитал: цена нетриажированного отчёта — не потерянное время человека, а что прочитал: цена нетриажированного отчёта — не потерянное время человека, а
разросшийся от вкусовщины код. разросшийся от вкусовщины код.
@@ -220,11 +333,11 @@ read-modify-write под конкурентными доставками, а т
1. `healthlog-review-specs` в режиме «дизайн ДО кода»; 1. `healthlog-review-specs` в режиме «дизайн ДО кода»;
2. `healthlog-review-rubric`, фаза 1 без фазы 2: рубрика на задуманный узел 2. `healthlog-review-rubric`, фаза 1 без фазы 2: рубрика на задуманный узел
становится приёмочными критериями и уезжает в `tasks.md`; становится приёмочными критериями и уезжает в `tasks.md`;
3. `healthlog-review-idiom` по описанию решения (какие конструкции stdlib 3. `healthlog-review-architecture` на предложении: вводит ли change новое
закрывают задачу; не изобретаем ли то, что уже есть); понятие, можно ли выразить существующими — **включая конструкции stdlib**, —
4. `healthlog-review-architecture` на предложении: вводит ли change новое не появляется ли второй способ. Вопрос «не изобретаем ли то, что уже есть в
понятие, можно ли выразить существующими, не появляется ли второй способ; библиотеке» переехал сюда из упразднённого прохода про идиоматичность;
5. вопрос автору дизайна: **«предложи три формы решения и назови компромисс 4. вопрос автору дизайна: **«предложи три формы решения и назови компромисс
каждой»** — если ответ показывает, что рассматривалась одна, это находка. каждой»** — если ответ показывает, что рассматривалась одна, это находка.
Смысл профиля: архитектурная находка на готовом коде стоит переписывания и Смысл профиля: архитектурная находка на готовом коде стоит переписывания и
@@ -115,8 +115,7 @@ healthlog — хранилище данных о здоровье, у котор
Первый чекпоинт ревью-процесса. Вызови Skill **`healthlog-review-pipeline`** с профилем Первый чекпоинт ревью-процесса. Вызови Skill **`healthlog-review-pipeline`** с профилем
`design` и ссылкой на change `<id>`. Он запустит `healthlog-review-specs` (режим `design` и ссылкой на change `<id>`. Он запустит `healthlog-review-specs` (режим
«дизайн/спеки ДО кода»), `healthlog-review-rubric` (фаза 1: приёмочные критерии «дизайн/спеки ДО кода»), `healthlog-review-rubric` (фаза 1: приёмочные критерии
для задуманного узла), `healthlog-review-idiom` и `healthlog-review-architecture` для задуманного узла) и `healthlog-review-architecture` по предложению.
по предложению.
Смысл профиля: архитектурная находка на готовом коде стоит переписывания и Смысл профиля: архитектурная находка на готовом коде стоит переписывания и
потому игнорируется — та же находка здесь стоит абзаца обсуждения. Рубрику из потому игнорируется — та же находка здесь стоит абзаца обсуждения. Рубрику из
@@ -152,18 +151,35 @@ healthlog — хранилище данных о здоровье, у котор
### 7. Ревью кода — Skill `healthlog-review-pipeline` ### 7. Ревью кода — Skill `healthlog-review-pipeline`
Второй чекпоинт. Вызови Skill **`healthlog-review-pipeline`**, дав ссылку на change Второй чекпоинт. Вызови Skill **`healthlog-review-pipeline`**, дав ссылку на change
`<id>`, базу диффа и профиль. Профиль выбирается по факту изменения, а не по `<id>`, базу диффа, профиль **и режим запуска**. Профиль выбирается по факту
ощущению важности (правило — в самом скилле): изменения, а не по ощущению важности (правило — в самом скилле):
- миграция, новый пакет, контракт Read API или MCP, правило слияния точек или - миграция, новый пакет, контракт Read API или MCP, правило слияния точек или
вывод слоя → `deep`; вывод слоя → `deep`;
- иначе меняется поведение, видимое снаружи → `standard`; - иначе меняется поведение, видимое снаружи → `standard`;
- иначе (багфикс, локальная правка, доки) → `quick`. - иначе (багфикс, локальная правка, доки) → `quick`.
**Режим по умолчанию последовательный, и обосновывать его не надо.** Параллельно
гоняем только тогда, когда об этом попросили явно **и назвали набор** — какие
именно проходы или какую стадию. Просьба без набора основанием не считается:
гони последовательно и скажи строкой, что набор не был назван. Причина умолчания
— замеры: `adversary` и `ops` доказывают находки числами (удержание блокировки,
пик кучи, рост `-wal`), а два меряющих прохода на одной машине портят числа друг
другу; находка с испорченным оракулом хуже отсутствующей, потому что выглядит
доказанной. Правило целиком и его оговорки — в самом скилле.
Скилл сам гоняет гейт, нужные проходы и обязательный триаж. Возвращает отчёт с Скилл сам гоняет гейт, нужные проходы и обязательный триаж. Возвращает отчёт с
потолком 7 пунктов, разметкой `Действие: инлайн | развилка` и секцией границ потолком 7 пунктов, разметкой `Действие: инлайн | развилка` и секцией границ
покрытия. покрытия.
**Сверь состав прогона с таблицей профилей в скилле, прежде чем коммитить.**
Пропуск прохода не отличим от прохода без находок: гейт зелёный, спеки сошлись,
отчёт выглядит полным. Единственный, кто мог бы заметить пропуск, — триаж, а он
заполняется тем, что ему подали. Отчёт обязан называть запущенные проходы
**поимённо и с исходом**; непущенный идёт строкой «не запускался» в границы
покрытия. Реестр короткий (4–8 проходов) — сверка стоит одного взгляда, а
молчащий пропуск уже стоил семи находок и отдельной задачи на их дозакрытие.
Отработай так же, как шаг 5: помеченное `инлайн` чини сам и не логируй, Отработай так же, как шаг 5: помеченное `инлайн` чини сам и не логируй,
`развилка` — блокером в беклог (вопрос уже сформулирован триажем, его остаётся `развилка` — блокером в беклог (вопрос уже сформулирован триажем, его остаётся
перенести). После правок — снова `task gate`. перенести). После правок — снова `task gate`.
+14 -4
View File
@@ -58,15 +58,21 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты** пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты**
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по — с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
половина потока), принимаются, хранятся и честно помечаются как неразобранные. разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные.
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет. пересборка воспроизводима и повторный прогон ничего не меняет.
Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API** Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md). токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
пока не отдаются. План в [docs/plan.md](docs/plan.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
@@ -131,6 +137,10 @@ task run
``` ```
curl localhost:8080/healthz curl localhost:8080/healthz
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
``` ```
### Подключение телефона по локальной сети ### Подключение телефона по локальной сети
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"errors"
"log/slog"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// checkpointInterval — как часто разбирается журнал WAL.
//
// Автоматический чекпойнт SQLite остаётся первой линией и срабатывает по концу
// записи; этот тик закрывает случай, которого тот не закрывает по построению —
// запись прекратилась, а журнал остался неразобранным. Поток пачечный, ночью
// телефон молчит часами, поэтому минута против пяти неразличима по эффекту;
// минута взята потому, что с ней своевременен признак «журнал не разбирается»,
// и потому, что это тот же ритм, что у тика воркера свёртки. Тот же период
// берёт Litestream, у которого задача ровно та же.
const checkpointInterval = time.Minute
// walGrowth — во сколько раз обязан вырасти неразобранный журнал, чтобы о нём
// сказали второй раз.
//
// Признак заводится ради состояния, которое САМО НЕ ПРОХОДИТ: вечный читатель
// (в Go чаще всего — незакрытый `sql.Rows`) держит снимок до конца жизни
// процесса. Строка на каждый тик дала бы 1440 одинаковых `WARN` в сутки, и
// владелец перестал бы их читать раньше, чем кончится диск. Поэтому вторая
// строка пишется, только когда стало вдвое хуже.
const walGrowth = 2
// keepWAL разбирает журнал WAL, пока сервис работает.
//
// Живёт в бинаре, а не в хранилище, и это осознанная асимметрия с воркером
// свёртки: у воркера есть доменный исход (доставка свёрнута), а здесь только
// жизненный цикл процесса и строка владельцу. Чтобы завести цикл в `store`,
// пришлось бы внести туда логгер — первый в пакете, который сегодня не логирует
// вовсе и все исходы отдаёт возвратом. Интерпретация чисел при этом осталась в
// хранилище (`Checkpoint.Stuck`): семантика тройки `busy/log/checkpointed`
// принадлежит SQLite, а не тому, кто её печатает.
//
// Период параметром, а не константой внутри: тот же шов, что `Worker.Pass` у
// свёртки, и по той же причине — иначе проверка «цикл переживает отказ» ждала
// бы по минуте на тик. Конфигурируемостью это не является: вызов один, и он
// называет константу.
//
// Контекст один, и работа идёт на нём же — в отличие от свёртки, которая
// сворачивает на отвязанном. Прерванный чекпойнт ничего не теряет: перенос
// страниц идемпотентен, исхода разбора он не пишет, а следующий старт возьмёт
// журнал с того же места. Зато остановка не ждёт переноса полусотни мегабайт в
// бюджете, который делится с приёмом и воркером.
func keepWAL(ctx context.Context, st *store.Store, log *slog.Logger, every time.Duration) {
log = log.With("capability", "wal")
ticker := time.NewTicker(every)
defer ticker.Stop()
var watch walWatch
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
ck, err := st.CheckpointWAL(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Штатная остановка не отказ: чекпойнт прерван ею же. ERROR о
// ней обесценил бы уровень, по которому вмешиваются, — и делал
// бы это на каждом `task restart`.
//
// Различаем по САМОЙ ошибке, а не по `ctx.Err()`: настоящий
// отказ базы, случившийся в тот же тик, что и сигнал остановки,
// иначе подавлялся бы как штатный — то есть терялся бы ровно
// тогда, когда владелец смотрит в логи.
return
}
// Отказ не прекращает цикл: обслуживание, умершее от временного
// отказа базы, молча перестало бы разбирать журнал до конца жизни
// процесса — а видно это было бы только по свободному месту.
log.ErrorContext(ctx, "wal checkpoint failed", "error", err)
continue
}
switch watch.see(ck) {
case walStuck:
// Адресат — владелец, событие «может стать проблемой»: журнал
// растёт, и лечится это не кодом. Значений из данных в записи нет —
// только счётчики страниц.
log.WarnContext(ctx, "wal checkpoint did not advance",
"log_pages", ck.Log,
"checkpointed_pages", ck.Checkpointed)
case walRecovered:
// Возврат к норме — событие, и сказать о нём надо: молчание иначе
// неотличимо от «сервис перестал проверять».
log.InfoContext(ctx, "wal checkpoint caught up", "log_pages", ck.Log)
}
}
}
// walSay — что сказать владельцу по исходу очередного чекпойнта.
type walSay int
const (
walSilent walSay = iota
walStuck
walRecovered
)
// walWatch решает, когда о неразобранном журнале говорить. Отдельно от цикла,
// потому что это единственная его часть, у которой есть исход: решение зависит
// от предыдущих тиков, а проверять его ожиданием минут нельзя.
type walWatch struct {
// warnedAt — размер журнала, о котором уже сказано. Ноль означает
// «состояние нормальное». Свойство разговора с владельцем, а не базы,
// поэтому живёт здесь, а не в хранилище.
warnedAt int
}
func (w *walWatch) see(ck store.Checkpoint) walSay {
switch {
case !ck.Known():
// Исход не измерен (чекпойнт не взял блокировку). Молчим и НЕ трогаем
// накопленное: иначе занятый тик посреди беды прочитался бы как
// выздоровление, сбросил бы подавитель и вернул те самые 1440 строк в
// сутки, против которых он заведён.
return walSilent
case ck.Stuck() && (w.warnedAt == 0 || ck.Log >= w.warnedAt*walGrowth):
w.warnedAt = ck.Log
return walStuck
case ck.Complete() && w.warnedAt != 0:
// Именно `Complete`, а не «порог перестал срабатывать»: журнал, упавший
// ниже порога, но так и не перенесённый, — это всё ещё удерживаемый
// снимок. Строка «догнали» при нуле перенесённых страниц утверждала бы
// то, чего никто не проверял.
w.warnedAt = 0
return walRecovered
default:
return walSilent
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Решение «сказать ли владельцу» проверяется таблицей, а не ожиданием минут:
// состояние копится по тикам, и без отдельной точки его пришлось бы проверять
// прогоном цикла.
func TestКогдаГоворитьОНеразобранномЖурнале(t *testing.T) {
t.Parallel()
const over = 100000 // заведомо больше порога, выраженного в страницах
stuck := store.Checkpoint{Log: over, Checkpointed: 0, PageSize: 4096}
worse := store.Checkpoint{Log: over * 4, Checkpointed: 0, PageSize: 4096}
slightlyWorse := store.Checkpoint{Log: over + 1, Checkpointed: 0, PageSize: 4096}
fine := store.Checkpoint{Log: 12, Checkpointed: 12, PageSize: 4096}
// Занятый чекпойнт: исход не измерен, `-1` вместо чисел.
unknown := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
var w walWatch
cases := []struct {
name string
in store.Checkpoint
want walSay
}{
{"первый застрявший чекпойнт", stuck, walStuck},
{"то же состояние — молчим", stuck, walSilent},
{"чуть хуже — всё ещё молчим", slightlyWorse, walSilent},
{"занятый тик посреди беды молчит", unknown, walSilent},
{"и не сбрасывает накопленное", stuck, walSilent},
{"стало заметно хуже", worse, walStuck},
{"разобрался — говорим о возврате", fine, walRecovered},
{"норма держится — молчим", fine, walSilent},
{"застрял снова", stuck, walStuck},
}
for _, c := range cases {
if got := w.see(c.in); got != c.want {
t.Errorf("%s: сказано %v, ждали %v", c.name, got, c.want)
}
}
}
// Цикл обязан пережить отказ базы: обслуживание, умершее от временного отказа,
// молча перестало бы разбирать журнал до конца жизни процесса.
func TestЦиклЧекпойнтаПереживаетОтказ(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
// Закрытая база — самый простой источник устойчивого отказа чекпойнта.
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
seen := &lines{}
done := make(chan struct{})
go func() {
defer close(done)
keepWAL(ctx, st, slog.New(seen), time.Millisecond)
}()
// Даём циклу натолкнуться на отказ много раз подряд.
time.Sleep(50 * time.Millisecond)
select {
case <-done:
t.Fatal("цикл вышел сам, не дождавшись отмены")
default:
}
// Отказ обязан быть виден: молча не разбирающийся журнал обнаруживается
// только по свободному месту.
if !seen.has("wal checkpoint failed") {
t.Error("отказ чекпойнта не оставил записи владельцу")
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("цикл не вышел по отмене")
}
}
// Отмена — единственный законный повод выйти, и выйти надо сразу: горутина
// ждётся в общем бюджете остановки вместе с воркером свёртки.
func TestЦиклЧекпойнтаВыходитПоОтмене(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
keepWAL(ctx, st, slog.New(slog.DiscardHandler), time.Millisecond)
}()
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("цикл не вышел по отмене")
}
}
// Ветка «фоновые горутины не уложились в бюджет» — последняя защита инварианта
// «доставка либо свёрнута целиком, либо остаётся pending». Прогоном сервиса её
// не проверить: бюджет тридцать секунд, а заставить воркер зависнуть нечем.
func TestОжиданиеФоновыхГорутин(t *testing.T) {
t.Parallel()
closed := make(chan struct{})
close(closed)
if !waitBackground(context.Background(), closed, slog.New(slog.DiscardHandler)) {
t.Error("вышедшие горутины не дождались")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
seen := &lines{}
if waitBackground(ctx, make(chan struct{}), slog.New(seen)) {
t.Error("зависшие горутины объявлены вышедшими — база закрылась бы из-под них")
}
if !seen.has("shutdown budget exceeded") {
t.Error("превышение бюджета осталось без строки владельцу")
}
}
// lines — slog.Handler, копящий сообщения: проверяется факт записи, не данные.
type lines struct {
mu sync.Mutex
msg []string
}
func (l *lines) Enabled(context.Context, slog.Level) bool { return true }
func (l *lines) Handle(_ context.Context, rec slog.Record) error {
l.mu.Lock()
defer l.mu.Unlock()
l.msg = append(l.msg, rec.Message)
return nil
}
func (l *lines) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *lines) WithGroup(string) slog.Handler { return l }
func (l *lines) has(msg string) bool {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.msg {
if m == msg {
return true
}
}
return false
}
+63 -15
View File
@@ -9,10 +9,12 @@ import (
"net" "net"
"net/http" "net/http"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"time" "time"
"git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/config" "git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/httpapi" "git.vakhrushev.me/av/healthlog/internal/httpapi"
@@ -45,6 +47,31 @@ func runServe(args []string) error {
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil) return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
} }
// waitBackground ждёт выхода фоновых горутин и говорит, дождался ли.
//
// Отдельной функцией потому, что это единственная ветка остановки, у которой
// есть исход, и проверить её прогоном сервиса нельзя: бюджет — тридцать секунд,
// а заставить воркер зависнуть по требованию нечем.
//
// Не дождались — база НЕ закрывается: её транзакцию свернёт выход процесса, и
// доставка останется `pending`, то есть будет подобрана следующим стартом.
// Закрытая из-под воркера, она дала бы ERROR по доставке, с которой всё в
// порядке.
//
// Этап в записи называется общим именем, а не воркером свёртки: ждём мы двоих,
// и назвать виновным одного из них значило бы угадать. Чекпойнт при этом
// выходит по отмене немедленно, так что практически это всё тот же воркер, — но
// лог не должен утверждать того, чего не проверял.
func waitBackground(shutdownCtx context.Context, done <-chan struct{}, log *slog.Logger) bool {
select {
case <-done:
return true
case <-shutdownCtx.Done():
log.Warn("shutdown budget exceeded", "stage", "background")
return false
}
}
// serve поднимает сервис и ведёт его до отмены контекста. // serve поднимает сервис и ведёт его до отмены контекста.
// //
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный // Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
@@ -78,6 +105,13 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
if len(cfg.Auth.WriteTokens) == 0 { if len(cfg.Auth.WriteTokens) == 0 {
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст") log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
} }
// Цена у двух контуров разная, и это сказано вслух: открытый приём означает
// мусор во входе, открытое чтение — выгрузку истории здоровья любому, кто
// нашёл порт. Пока сервис живёт в доверенной сети, это осознанный выбор;
// перед выкладкой наружу список обязан быть непуст.
if len(cfg.Auth.ReadTokens) == 0 {
log.Warn("read auth disabled", "reason", "auth.read_tokens пуст")
}
// Воркер и приём делят одну свёртку: приём её только будит, сворачивает // Воркер и приём делят одну свёртку: приём её только будит, сворачивает
// воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика // воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика
@@ -87,8 +121,10 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
srv := &http.Server{ srv := &http.Server{
Handler: httpapi.New(httpapi.Options{ Handler: httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, worker.Notify, log), Ingest: ingest.New(arch, st, worker.Notify, log),
Catalog: catalog.New(st, log),
Log: log, Log: log,
WriteTokens: cfg.Auth.WriteTokens, WriteTokens: cfg.Auth.WriteTokens,
ReadTokens: cfg.Auth.ReadTokens,
MaxBodyMB: cfg.Ingest.MaxBodyMB, MaxBodyMB: cfg.Ingest.MaxBodyMB,
// Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО // Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО
// вызова обработчика и потому покрывает чтение тела, обрывая // вызова обработчика и потому покрывает чтение тела, обрывая
@@ -109,23 +145,41 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err) return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
} }
workerCtx, stopWorker := context.WithCancel(context.Background()) // Обе фоновые горутины живут на одном контексте и ждутся вместе. Вместе —
defer stopWorker() // потому что база закрывается ПОСЛЕ выхода обеих: закрытая из-под воркера,
workerDone := make(chan struct{}) // она даёт ERROR по доставке, с которой всё в порядке, а из-под чекпойнта —
go func() { // отказ обслуживания на ровном месте.
defer close(workerDone) bgCtx, stopBackground := context.WithCancel(context.Background())
defer stopBackground()
var bg sync.WaitGroup
bg.Go(func() {
// Первый проход воркера и есть подбор неразобранного при старте: // Первый проход воркера и есть подбор неразобранного при старте:
// отдельного кода для него нет намеренно. // отдельного кода для него нет намеренно.
worker.Run(workerCtx) worker.Run(bgCtx)
})
bg.Go(func() {
keepWAL(bgCtx, st, log, checkpointInterval)
})
backgroundDone := make(chan struct{})
go func() {
bg.Wait()
close(backgroundDone)
}() }()
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
// Параметры обслуживания журнала — в той же строке, а не отдельной:
// горутина, которую забыли запустить, иначе неотличима от здоровой
// ровно до того дня, когда журнал упрётся в диск. Ноль новых строк, обе
// константы проверяемы глазами.
log.Info("server started", log.Info("server started",
"addr", ln.Addr().String(), "addr", ln.Addr().String(),
"db_path", cfg.Storage.DBPath, "db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(), "archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB) "max_body_mb", cfg.Ingest.MaxBodyMB,
"wal_checkpoint_sec", int64(checkpointInterval.Seconds()),
"wal_limit_mb", store.JournalSizeLimitMB)
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("serve: %w", err) errCh <- fmt.Errorf("serve: %w", err)
@@ -162,15 +216,9 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
} }
} }
stopWorker() stopBackground()
select { if waitBackground(shutdownCtx, backgroundDone, log) {
case <-workerDone:
closeStore() closeStore()
case <-shutdownCtx.Done():
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
// выход процесса, и доставка останется `pending` — то есть будет
// подобрана следующим стартом.
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
} }
return serveErr return serveErr
} }
+2
View File
@@ -13,6 +13,8 @@ write_timeout = "30s" # прочих маршрутов; приём держ
[auth] [auth]
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
# Чтение открыто так же, как приём, но цена другая: это выгрузка истории
# здоровья. Годится только для доверенной локальной сети.
read_tokens = [] read_tokens = []
[storage] [storage]
+7 -2
View File
@@ -19,9 +19,14 @@ write_timeout = "30s" # на отправку ответа прочих ма
# Health Auto Export умеет слать произвольные заголовки — токен задаётся в # Health Auto Export умеет слать произвольные заголовки — токен задаётся в
# настройках автоматизации. # настройках автоматизации.
# ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети; # ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети;
# сервис предупреждает об этом на старте записью `write auth disabled`. # сервис предупреждает об этом на старте записями `write auth disabled` и
# `read auth disabled`.
#
# Цена у контуров РАЗНАЯ, и это стоит помнить: открытый приём означает мусор во
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
write_tokens = [] # токены на приём данных write_tokens = [] # токены на приём данных
read_tokens = [] # токены на чтение (read API появится позже) read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics`
[storage] [storage]
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней # ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
+334 -7
View File
@@ -215,6 +215,7 @@ HRV); у накопительных — только `date`. Поэтому то
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | | `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты | | `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `catalog` | каталог разрезов и измерение рода агрегации |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | | `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API | | `httpapi` | приём и read API |
@@ -518,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 Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
@@ -616,7 +743,8 @@ hour метки выровнены на час heart_rate 00:00:00
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance` процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
в метрах складывать нельзя, а `walking_running_distance` в километрах можно в метрах складывать нельзя, а `walking_running_distance` в километрах можно
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и (находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
агрегация по метрике не предлагается вовсе. агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение
рода агрегации».
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
@@ -798,6 +926,140 @@ hour метки выровнены на час heart_rate 00:00:00
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой — выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным. значит он и станет известен точно, вместо того чтобы быть угаданным.
### Измерение рода агрегации
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
минутного слоя с часовым. Правило целиком:
```
час пригоден, если
у метрики есть объекты обоих слоёв за этот час
час не позже текущего времени плюс час
единицы обоих объектов совпадают
часовой объект несёт ровно одну точку, и она несёт значение
метка этой точки совпадает с началом часа
у минутного объекта не меньше двух точек со значением
сумма минутных отличима от их среднего
вердикт пригодного часа
часовое ≈ сумма минутных → cumulative
часовое ≈ среднее минутных → instant
иначе → свидетельства нет
вердикт метрики
≥3 согласных часа и ни одного противоречащего → род
иначе → unknown
```
Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных,
9 мгновенных, 15 неизвестных, **противоречащих часов ноль**. Каталог собирается
за 68 мс.
**Горизонт обязателен, и это условие корректности, а не защита от вредителя.**
Час объекта берётся из метки в теле доставки, а тело не наше: без верхней
границы одна доставка с метками в будущем занимает окно целиком и подменяет
измеренный род — путь построен и прогнан, мгновенная метрика объявлялась
накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся
`WARN`: сбитые часы телефона и чужое тело в приёме лечатся не кодом.
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
минутным слоем и в `count/hour` часовым даёт в полном часе
`часовое = 60 · среднее = сумма`, то есть уверенный ложный `cumulative`.
Единогласие такого случая не ловит: противоречия нет, есть молчание.
Каждая часть правила стоит своей причины.
**Различимость суммы и среднего — не украшение.** В часе, где все значения нули,
сумма равна среднему, и «сходится с суммой» выполняется тождественно: без этого
условия `walking_asymmetry_percentage` давала 4 часа «накопительная» против
3 «мгновенная», причём конфликт целиком состоял из нулевых часов.
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
который покрывают минутные точки того же объекта.
**Единогласие, а не большинство.** Противоречащий час означает, что одна из
гипотез для метрики ложна; большинство голосов объявляло бы род при известном
контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется
`WARN`: род — свойство, на котором Read API строит арифметику года.
**Порог в три часа** — потому что один совпавший час остаётся свидетельством
одного часа. Цена измерена: порог уводит в `unknown` метрики с единственным
согласным часом.
**Допуск сравнения — относительный, `1e-9`, и один на все три сравнения.**
Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий
обе гипотезы, и его исход определил бы порядок веток кода. Величина названа
числом, потому что от неё зависят счётчики основания в ответе: вердикты
одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у
`heart_rate` при этом меняется вдвое. Абсолютного порога нет: около нуля
относительное сравнение вырождается в сторону «не сходится», то есть даёт
«свидетельства нет», а не ложный род.
**Родов два, а не четыре.** HealthKit различает `cumulative`,
`discreteArithmetic`, `discreteTemporallyWeighted` (пульс) и
`discreteEquivalentContinuousLevel` (аудиоэкспозиция). Взять весь словарь
напрашивалось и отвергнуто измерением: часовой слой HAE считается
арифметически, а не по Apple. Прямое свидетельство — `environmental_audio_exposure`,
которую Apple усредняет логарифмически: её часовое значение сходится с обычным
арифметическим средним минутных. Стили, которые в наших данных ничем не
проявляются, можно было бы только разметить руками — то есть вернуться к тому,
от чего уходит вся конструкция.
**Род нигде не хранится**, а считается на запрос по окну в 48 самых свежих
общих часов. Хранимое значение было бы вторым производным состоянием рядом с
витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень
непереносимого пересборкой и объяснять, на каком составе данных оно снято, —
причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть
функция витрины, а витрина — функция журнала.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Поэтому каталог отдаёт род **вместе с основанием** — сколько часов сравнено,
сколько пригодно, сколько согласны и противоречат, на каких границах окна.
**Второй предел названный вслух: окно измеряется в общих часах, а не в часах
календаря.** Выключи минутную автоматизацию — множество общих часов перестаёт
пополняться, и окно замирает на последних сорока восьми, когда она ещё работала.
Род продолжает объявляться, и единственный след этого — `last_hour` в ответе.
Календарного ограничения нет намеренно: оно уводило бы в `unknown` редкие
метрики, у которых общие часы копятся месяцами, — то есть лечило бы честный
случай ценой другого честного.
#### Как это решают другие и почему не подошло
Prior art здесь обширный, и весь он про **объявление** рода, а не про измерение.
- **HealthKit** зашивает `HKQuantityAggregationStyle` в тип метрики, а
`HKStatistics` возвращает `nil` на свёртку, не отвечающую стилю. Второе взято
как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
- **Home Assistant** получает `state_class` от интеграции и при его смене
требует **удалить** долгосрочную статистику вручную. Взято признание, что
смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить
некому.
- **Graphite** выводит `aggregationMethod` регуляркой по имени метрики.
Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не
работает на именах HAE вовсе.
- **Prometheus и остальные** принимают тип от отправителя; заголовок HAE врёт
уже про слой, оснований верить ему про род нет. Детекция сброса счётчика
(`rate`, `total_increasing`) отвечает на другой вопрос — «был ли рестарт у
известного счётчика», — и к данным Apple неприменима: монотонного накопителя в
них нет.
- **`xFilesFactor`** (Graphite) и **`xff`** (RRDtool) — доля заполненности, ниже
которой свёртка не делается. Измерению порог не нужен: у него две
конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у
`step_count` 41 час пригоден и 24 дали вердикт — остальные и есть неполные).
Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite
задаёт долю **обязательно известных** (0.5 при роллапе и 0 при рендере),
RRDtool — долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины
выглядят как «0.5», означая противоположное. Решение принимает задача Read API.
Готовой практики вывода рода **из данных** не нашлось ни одной: у всех
перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на
входе, — и все за неё платят (Prometheus теряет тип на remote write, Home
Assistant требует ручного удаления статистики). Мы платим измерением.
### Категориальные значения ### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами: HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
@@ -1085,20 +1347,84 @@ GET /healthz
за какой период: за какой период:
```json ```json
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant", {"metric": "heart_rate", "units": ["count/min"],
"aggregation": {"style": "instant", "hours": 48, "compared": 48,
"agreeing": 20, "conflicting": 0,
"first_hour": "2026-07-31T09:00:00Z",
"last_hour": "2026-08-02T14:00:00Z"},
"layers": [ "layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078}, {"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203} {"layer": "raw", "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
]} ]}
``` ```
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него `aggregation.style` — измеренный род (`cumulative` / `instant` / `unknown`), от
зависит, что вообще можно спросить. него зависит, что вообще можно спросить. Рядом лежит **основание**: сколько
общих часов попало в окно, сколько из них оказалось пригодными, сколько дали
преобладающий вердикт и сколько противоречили. Одного числа не хватало —
«часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе»
разные события, и различать их клиент обязан без второго запроса. Поле названо
`style`, а не `kind`: `kind` в проекте уже занят родом секции записи.
`units` — множество: единицы на живом потоке не менялись ни разу (находка 48),
но одна форма поля для обоих случаев честнее строки, которая при расхождении
молча выберет одно из двух. На слой при этом приходится ровно один элемент
`layers`.
Границы слоя — метки **первой и последней точки**, включительно; `first_hour` и
`last_hour`**ярлыки часов** окна измерения. Имена разные потому, что разная
семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на
час, заметной только расхождением сумм.
Границы слоя — это границы **данных, а не обещание покрытия**: внутри диапазона
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
запрошенного диапазона, а не на каталожную пару границ.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
границе периода нельзя: ряд поедет незаметно для клиента. границе периода нельзя: ряд поедет незаметно для клиента.
### Условный запрос
Ресурсы чтения отвечают `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` не выполняет измерения и потому не пишет
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
опросом они становятся функцией смены версии витрины, а не числа запросов;
состояние при этом не теряется — следующая доставка меняет версию, ответ
собирается, и предупреждение пишется.
### Свёртка и размер ответа ### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром: Запросов к метрике ровно два, и это один запрос с необязательным параметром:
@@ -1126,7 +1452,8 @@ GET /healthz
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` Свёртка применяет род из каталога: `cumulative` — сумма, `instant`
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются `bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его
полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются
из нижнего слоя HAE — только из `minute`, `hour` или `sample`. из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
### Форма ответа ### Форма ответа
+2 -1
View File
@@ -20,7 +20,7 @@
## блокеры ## блокеры
## высокий ## высокий
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
@@ -47,6 +47,7 @@
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит - [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны - [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе - [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
## низкий ## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -0,0 +1,49 @@
# Остановка и миграция: раздельные бюджеты и следы в логе
**Приоритет:** средний
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
`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` (архив).
+48 -1
View File
@@ -26,5 +26,52 @@
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
видно `layer`, `bucket` и `aggregation`. видно `layer`, `bucket` и `aggregation`.
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API». **Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
Каталог и род агрегации сделаны (change `2026-08-02-katalog-i-rod-agregacii`), и
измерению порог заполненности не понадобился: у него две конкурирующие гипотезы,
и неполный час не сходится ни с одной сам собой. Свёртке в ответе он нужен, а
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «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-теги, а транспорт владеет только
обёрткой: переименование поля в домене меняет публичный контракт без касания
`httpapi`. Держит это один байтовый тест непустого ответа. Либо объявить в
`architecture.md`, что типы чтения и есть форма провода для всех транспортов
(HTTP и MCP отдают её байт в байт), либо завести DTO в транспорте — но выбрать до
того, как образец скопирует эта задача.
**Клиент обязан смотреть на границы окна измерения.** Род метрики измерен по
48 самым свежим ОБЩИМ часам, а не по последним 48 часам календаря: если минутная
автоматизация HAE выключена, множество общих часов не пополняется и окно
замирает. Род при этом продолжает объявляться, и единственный след — `last_hour`
в ответе. Правило выбора свёртки в Read API обязано это учитывать (или явно
объявить, что не учитывает).
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
план → шаг «Read API».
-30
View File
@@ -1,30 +0,0 @@
# Измеренный род агрегации и каталог разрезов
**Приоритет:** высокий
Решено (вариант «б» груминга): свёртка живёт в ответе, но род метрики
**измеряется**, а не размечается руками. Форма точки рода не выдаёт —
`Avg`/`Min`/`Max` есть только у `heart_rate`, всё остальное приходит в `qty`
(находка 40). Единицы дают процентов девяносто и ломаются на краях.
Метод: одна метрика лежит в минутном и часовом разрезе одновременно. Часовое
значение сходится с суммой минутных — накопительная; со средним — мгновенная;
данных не хватило — `unknown`, и свёртка по такой метрике не предлагается вовсе.
Жёсткое правило: накопительные метрики никогда не сворачиваются из нижнего слоя
HAE. Он не сэмплы, а посекундная развёртка (находка 34), сумма по нему завышена.
Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с
диапазонами, а род проставлен измерением на живой истории.
От этой задачи зависит ещё одно решение: тай-брейк при равной полноте точек.
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему. Остальное правило слияния уже сделано — структурная часть
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
победителя при РАВНОЙ полноте.
Связано: `docs/architecture.md` → «Слои гранулярности», план → шаг «Каталог и род агрегации».
+25
View File
@@ -32,3 +32,28 @@
Активное уведомление — отдельная задача, здесь только факт. Активное уведомление — отдельная задача, здесь только факт.
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за
другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль
событий. Сюда же вторая половина: пять разных причин непригодности часа
(две точки у часового объекта, невыровненная метка, нет числа, мало минутных,
неразличимость) схлопнуты в одну разность `hours compared`, поэтому «HAE
переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно
живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при
непустом окне.
**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
корреляция есть (`delivery_id`), у чтения аналога нет.
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
@@ -0,0 +1,92 @@
# Тай-брейк при равной полноте точек
**Приоритет:** высокий
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
файла и есть выбранный вариант.
Что важно не потерять при реализации: правило обязано остаться **тотальным**
числа у точки нет, значит откат на порядок канонических форм, — и обязано
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
род есть функция витрины, и правило слияния, читающее собственную выдачу,
повторяет дефект наследования слоя «из будущего» (`docs/review-journal.md`,
2026-08-01).
Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан
**измениться** (иначе правило не сработало), а число столкновений с равной
полнотой — остаться прежним.
## Что решить
Какое правило выбирает победителя, когда по одним координатам приехали две точки
с **равными** наборами содержательных полей и разными значениями. Структурная
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
разряд.
Сегодня это порядок канонических форм, и он измеримо смещён: из 1912 случаев, где
сравнение чисел определено, лексикографический порядок берёт **меньшее** значение
в 1847 — 96% (находка 49). Столкновений с равной полнотой 1916 из 444 256
координат, то есть 0.43% координат.
## Что стало известно
Задача «Измеренный род агрегации и каталог разрезов» закрыла посылку, ради
которой тай-брейк откладывали: род метрик теперь **измерен**, а не угадан
(находка 53). Четыре из шести метрик, где тай-брейк системно берёт меньшее
(`step_count`, `walking_running_distance`, `active_energy`,
`basal_energy_burned`), измерены как **накопительные** — там «меньшее» это
систематический недосчёт порядка 0.4% координат, ровно тот, что HAE досчитывает
задним числом (находка 10). Самая крупная группа, `heart_rate`, измерена как
**мгновенная**, и там выбор безразличен: это пересэмплирование, а не досчёт.
И тем же измерением закрылся напрашивавшийся ответ: **сделать тай-брейк
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
результат слияния, и правило слияния, читающее собственную выдачу, повторяет
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
журнала (`docs/review-journal.md`, 2026-08-01, наследование слоя «из будущего»).
## Варианты и цена
**а. Оставить порядок канонических форм.** Цена: систематический недосчёт 0.4%
координат у накопительных метрик, невидимый до сверки с родным экспортом Apple,
то есть месяцами. Плюс: ноль работы, правило остаётся структурным и не знает
ничего о значениях.
**б. Брать бо́льшее значение точки.** Правильно для накопительных (досчёт растёт,
находка 10, и набор полей у версий тренировки ни разу не уменьшался) и безвредно
для мгновенных (пересэмплирование). Цена: слияние перестаёт быть структурным —
оно начинает знать, какое поле точки несёт число (`hae.PointValue` уже есть).
Метрика, у которой «большее» неверно, в потоке не наблюдалась, но и не
исключена; правило приходится делать тотальным (нет числа — откат на порядок
канонических форм), то есть в нём появляется вторая ветка.
**в. Провенанс у точки и тай-брейк по позиции в журнале** — как у сущностей.
Цена: колонка провенанса на точку (или на объект) и рост объёма нижнего слоя;
плюс это не работает для столкновений **внутри одной доставки**, где
`received_at` общий, а таких четверть (находка 47: 33 столкновения внутри
доставки на эпизодах сна). То есть вариант не самодостаточен и всё равно требует
второго разряда.
## Что заблокировано
Ничего срочного: сегодняшнее правило детерминировано и воспроизводимо, витрина
остаётся свёрткой журнала. Блокирован только сам недосчёт — он копится молча.
Сверить его величину можно будет после `healthlog import`: родной экспорт Apple
даст независимый эталон по тем же периодам.
## Рекомендация
**Вариант б.** Он чинит измеренное смещение там, где оно есть, и не трогает
там, где его нет; цена — одна ветка в правиле слияния и признание, что слияние
знает про число точки (а оно уже знает — `hae.PointValue` живёт в разборе). От
варианта «а» отличается тем, что перестаёт систематически терять данные;
от «в» — тем, что не требует ни колонки, ни решения для внутридоставочных
столкновений.
Проверять на прогоне живого архива: отпечаток витрины обязан измениться (иначе
правило не сработало), а число столкновений с равной полнотой — остаться прежним.
Связано: `docs/architecture.md` → «Разрешение столкновений», находки 10, 47, 49,
53.
+8
View File
@@ -7,6 +7,14 @@
правильно, но это же делает выезд наружу опасным: одна забытая настройка правильно, но это же делает выезд наружу опасным: одна забытая настройка
открывает историю здоровья всему интернету. открывает историю здоровья всему интернету.
**Контуров теперь два, а не один.** С появлением каталога
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
предупреждает на старте обоими сообщениями (`write auth disabled`,
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
старта нет, и это решение осталось здесь.
Решается перед деплоем, не раньше — так договорились. Решается перед деплоем, не раньше — так договорились.
Шаги: Шаги:
+6
View File
@@ -161,3 +161,9 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел. решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).
+10
View File
@@ -103,6 +103,16 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
лишний уровень косвенности через rowid ни разу не нужен. лишний уровень косвенности через rowid ни разу не нужен.
Индекс `bucket_catalog` (`metric, layer, hour_utc, first_ts, last_ts, points,
units`) — **покрывающий**, и это следствие той же формы таблицы: у `WITHOUT
ROWID` строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
ключа, поэтому агрегат «какие слои есть у метрики и за какой период» без индекса
тащил бы страницы содержимого — сотни мегабайт чтения на запрос каталога при
260 тысячах объектов за год. По нему же идёт поиск часов, за которые у метрики
есть объекты сразу в двух слоях. Цена — около 60 байт на объект и одна вставка в
дерево на запись; платит её только настоящее изменение, потому что при совпавшем
хеше объект не переписывается вовсе.
**Идентичность точки внутри объекта** — координаты **Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу. `метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При `source` в ключ не входит: он нестабилен и переписывается задним числом. При
+61
View File
@@ -1725,6 +1725,67 @@ apple_stand_time 14
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
2 записи; повторное проигрывание дало тот же отпечаток. 2 записи; повторное проигрывание дало тот же отпечаток.
## 53. Род агрегации измерен: 16 метрик из 31, противоречий ноль
Правило из находки 40 доведено до кода и прогнано на всём архиве (123 доставки,
31 метрика, витрина 2342 объекта). Сверка идёт по парам «минутный объект —
часовой объект за тот же час»; час участвует, только если у часового объекта
ровно одна точка со значением на границе часа, у минутного не меньше двух точек,
а сумма минутных отличима от их среднего.
| исход | метрик |
|---|---|
| `cumulative` | 7 |
| `instant` | 9 |
| `unknown` | 15 |
```
cumulative active_energy, basal_energy_burned, step_count,
walking_running_distance, apple_stand_time, apple_exercise_time,
time_in_daylight
instant heart_rate, respiratory_rate, blood_oxygen_saturation,
environmental_audio_exposure, walking_speed, walking_step_length,
walking_double_support_percentage, walking_asymmetry_percentage,
stair_speed_up
```
**Противоречащих часов ноль на всём корпусе** — ни у одной метрики свидетельства
не разошлись. Это и есть главный результат: правило не «чаще всего работает», а
не дало ни одного контрпримера.
### Что выяснилось по дороге
**Нулевой час обязан отбрасываться, иначе правило конфликтует само с собой.**
Первый прогон дал у `walking_asymmetry_percentage` 4 часа «накопительная» против
3 «мгновенная». Разбор: в часе, где все значения нули, сумма равна среднему, и
проверка «сходится с суммой» выполняется тождественно. Условие «сумма отличима
от среднего» убирает весь конфликт.
**Часовой слой HAE считается арифметически, а не по Apple.** HealthKit относит
`environmental_audio_exposure` к логарифмическому усреднению по энергии, а пульс
— к среднему, взвешенному по длительности. На наших данных часовое значение
аудиоэкспозиции сходится с обычным арифметическим средним минутных в 59 часах из
62, а у пульса — точно в 29 часах из 63 и с точностью 0.1% в 49. Значит четыре
стиля агрегации HealthKit в потоке ничем не различимы, и родов ровно два.
**Допуск сравнения на вердикты не влияет, а на счётчики влияет вдвое.** Прогон
сеткой: при относительном допуске от `1e-9` до `1e-3` роды всех метрик
одинаковы; число согласных часов у `heart_rate` при этом меняется с 29 на 49, у
`step_count` — с 25 на 35. Взят строгий `1e-9`: канонизация округляет числа до
12 значащих цифр, то есть всё крупнее `1e-12` представлением не объясняется.
**Окно в 48 часов обходится дешевле, чем кажется, но редкие метрики уводит в
`unknown`.** Полный обход всех 696 пар часов занимал 123 мс, окно даёт 68 мс и
перестаёт расти вместе с журналом. Плата: у `physical_effort` за всю историю
было 5 согласных часов, а в последних 48 — только 2, и метрика уходит в
`unknown`. Это честный исход: свидетельств в свежем окне действительно мало.
**Неполные часы видны в основании и ничего не ломают.** У `step_count` из 48
часов окна пригодны 41, а вердикт дали 24 — остальные не сошлись ни с суммой, ни
со средним, потому что минутный слой за них неполон. Отдельного порога
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.
## Инструмент ## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
+7 -2
View File
@@ -24,7 +24,12 @@
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind` Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind`
половина потока — перестали лежать неразобранными. От разбора остался словарь половина потока — перестали лежать неразобранными. От разбора остался словарь
категориальных значений; дальше — каталог и род агрегации. категориальных значений.
**Род агрегации измерен**: сверка минутного слоя с часовым разложила метрики
живого корпуса на накопительные и мгновенные, не сойдясь ни на одной. Каталог
разрезов отдаётся первым маршрутом чтения — дальше Read API, которому теперь
есть на чём строить свёртку.
Разведка закончена: правило вывода слоя, модель идентичности и формы точки Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в [local-research.md](local-research.md). проверены на живом потоке, выводы — в [local-research.md](local-research.md).
@@ -35,7 +40,7 @@
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети** - [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`, - [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
`reindex` — сделано; словарь категориальных значений — нет. `reindex` — сделано; словарь категориальных значений — нет.
- [ ] **4. Каталог и род агрегации.** - [x] **4. Каталог и род агрегации.**
- [ ] **5. Read API.** - [ ] **5. Read API.**
- [ ] **6. Самоописание.** - [ ] **6. Самоописание.**
- [ ] **7. MCP.** - [ ] **7. MCP.**
+97
View File
@@ -110,3 +110,100 @@
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
сработали ровно так, как задуманы, — их просто не позвали. сработали ровно так, как задуманы, — их просто не позвали.
## 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`, а не ищет в сыром
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
а десять стоили бы дороже самой находки.
## 2026-08-02 — состав конвейера сужен: 11 проходов до 6–9
Не промах, а решение по итогам пяти задач подряд. Записано здесь, потому что
именно здесь лежит цена непоймания: если что-то теперь проскочит, первый вопрос
будет «не тот ли это класс, который мы перестали проверять».
- **Повод:** профиль `deep` стоял на всех пяти задачах сессии и гонял 11
проходов на коде плюс 4 на дизайне — порядка полутора миллионов токенов на
задачу. Ревью, а не написание кода, стало основной статьёй расхода.
- **На чём основано:** поимённая атрибуция находок надёжна только для
дозапуска трёх проходов на `f8200f7` — там оркестратор запускал их сам.
В двух циклах, которые вели сабагенты, находки перечислены без указания
прохода, и это ограничение вывода названо здесь честно.
- **Что убрано и почему:**
- `negative`**удалён**. За сессию ни одной именной находки; блокер про
откат релиза он нашёл дублем с `ops`, то есть заплатил триажу
дедупликацией. Два его живых вопроса переселены: «хватит ли сигналов
владельцу, когда поток оборвётся ночью» — в `ops`, вопрос 7; «что опытный
человек отсюда удалил бы» — в `architecture`, вопрос 5.
- `rubric`**только в `design`**. Его же 14 свойств из design-прогона
ложатся приёмочными критериями в `tasks.md`; судить код по критерию, под
который он писался, — корреляция по построению.
- `reimpl`**по триггеру** «новое правило слияния, идентичности или
разбора». Самый дорогой проход конвейера; единственный раз, когда триаж
назвал его отсутствие дырой покрытия, — это была задача с новым правилом
слияния сущностей, то есть ровно триггерный случай.
- **Что переставлено, и это важнее сокращения:** `adversary` и `ops` были в
`deep`-только, а `standard` гонял четыре самых слабых generative-прохода.
То есть профиль, которым закрывается большинство задач, запускал ровно тех,
кто ничего не принёс, и не запускал тех, кто принёс почти всё. Оба переехали
в `standard`. Это одновременно дешевле и качественнее.
- **Что чуть не убрали по ошибке:** `idiom` был в списке на удаление как
«вкусовщина». Отменено фактом: в задаче про цену читающего маршрута он нашёл,
что `-1 >= -1` читается как «журнал разобран целиком», и **воспроизвёл**
1492 тика из 5502. Плюс три эксперимента на дизайне `razbor-metrik-v-obekty`.
Вывод, который стоит помнить: этот проход зарабатывает **экспериментами
против поведения stdlib и драйвера**, а не цитатами из гайдов, — и потому у
него есть внешний оракул. Оценка «не всплыл поимённо ни разу» была верна по
имевшимся данным и неверна по существу.
- **Что мы сознательно перестали проверять:** класс «чего нет в зрелой
реализации такого узла» вне профиля `design`, и «пять вопросов второго
инженера» как отдельная постановка. Обратимость этого класса высокая: он
портит форму кода и полноту наблюдаемости, а не данные. Если проскочит
дефект этого класса — запись сюда и пересмотр решения.
- **Побочная выгода, ради которой стоило резать отдельно:** реестр из 69
проходов сверяется взглядом. Промах 2026-08-02 (запись выше) был молчащим
пропуском трёх проходов из одиннадцати; на коротком списке требование
«перечисли запущенные проходы поимённо и с исходом» наконец выполнимо.
## 2026-08-02 — `idiom` тоже упразднён, класс переселён
Решение владельца, принятое после того, как оркестратор привёл доводы против
удаления (находка на чекпойнте WAL, воспроизведённая: 1492 тика из 5502) и они
были выслушаны. Записано отдельной строкой, потому что довод был, и если класс
проскочит — искать надо здесь.
- **Что переселено, а не выброшено.** Проход зарабатывал экспериментами против
поведения stdlib и драйвера, и именно эта способность перенесена поимённо:
- «поведение библиотеки, драйвера и `PRAGMA` измеряется, а не вычитывается из
документации; что возвращается в **вырожденном** случае и отличим ли этот
ответ от штатного» — в `ops`, обязательный вопрос 8, вместе с прецедентом
`-1 >= -1` и оговоркой про `data_version` как свойство соединения;
- «не изобретаем ли то, что уже есть в библиотеке» — в `architecture`,
вопрос 1, с перечнем конструкций stdlib: своя абстракция, повторяющая форму
существующей, — находка того же класса, что и второй способ делать одно и
то же.
- **Что действительно потеряно.** Поимённая сверка с положениями Effective Go,
Go Code Review Comments, Go Proverbs и стайлгайдов Uber и Google. Различение
«идиоматично» против «распространено» больше не задаётся никем: `architecture`
спрашивает про форму решения, `ops` — про поведение под нагрузкой, но ни один
не спросит «в Go так не пишут». Класс обратимый — портит форму кода, не
данные, — но он теперь не покрыт вовсе, и это надо признавать в границах
покрытия, а не считать проверенным.
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3.
Было 11 на коде и 4 на дизайне.
+568
View File
@@ -0,0 +1,568 @@
// Package catalog — каталог разрезов и измеренный род агрегации.
//
// Отвечает на два вопроса потребителя: «что у тебя вообще есть» (метрики,
// единицы, слои с границами и числом точек) и «какая свёртка по этой метрике
// осмысленна» (род агрегации). Оба ответа производны от витрины и ничего в неё
// не пишут.
//
// Род **измеряется**, а не размечается. Форма точки его не выдаёт: `Avg`/`Min`/
// `Max` есть только у `heart_rate`, заведомо мгновенные `walking_speed` и
// `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги (находка
// 40); заголовок доставки про род молчит; единицы дают процентов девяносто и
// ломаются на краях. Зато витрина содержит собственную сверку: одна метрика
// лежит в минутном и часовом разрезе одновременно, и часовое значение либо
// равно сумме минутных, либо их среднему.
//
// Ни один известный проект род не измеряет — HealthKit зашивает его в тип
// метрики, Home Assistant получает `state_class` от интеграции, Graphite
// выводит регуляркой по имени, Prometheus принимает от отправителя. У всех у
// них есть привилегия, которой нет у нас: поставщик объявляет тип на входе.
package catalog
import (
"context"
"log/slog"
"math"
"sort"
"time"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Style — род агрегации метрики.
//
// Нулевое значение — `unknown`, и это не заглушка: «род не измерен» и есть
// честное состояние по умолчанию, а забытое присваивание не имеет права
// выглядеть как измеренный род.
type Style int
// Роды агрегации. Их два, а не четыре, и это следствие измерения, а не
// упрощения. HealthKit различает `cumulative`, `discreteArithmetic`,
// `discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
// (аудиоэкспозиция) — но часовой слой HAE считается арифметически, а не по
// Apple: у `environmental_audio_exposure`, которую Apple усредняет
// логарифмически, часовое значение сошлось с обычным арифметическим средним
// минутных в 59 часах из 62. Стили, которые в наших данных ничем не
// проявляются, можно было бы только разметить руками — то есть вернуться к
// тому, от чего уходит вся конструкция.
const (
Unknown Style = iota
Cumulative
Instant
)
func (s Style) String() string {
switch s {
case Cumulative:
return "cumulative"
case Instant:
return "instant"
default:
return "unknown"
}
}
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
// словаре нет.
func (s Style) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
const (
// Window — сколько самых свежих общих часов метрики участвует в сверке.
// Ограничивает стоимость: полный обход рос бы вместе с журналом, а окно
// держит работу в пределах 2×48 объектов на метрику. Измерено, что на живом
// корпусе окно даёт те же вердикты, что и полный обход.
Window = 48
// MinAgreeing — сколько согласных часов нужно, чтобы объявить род. Один
// совпавший час остаётся свидетельством одного часа, а на этом роде потом
// суммируют год. Цена порога измерена: он уводит в `unknown` ровно одну
// метрику корпуса, у которой согласный час был единственным.
MinAgreeing = 3
// coarsePoints и minFinePoints — структурные условия пригодности часа. Они
// же уходят в хранилище предварительным отбором: содержимое заведомо
// непригодного часа разжимать незачем.
coarsePoints = 1
minFinePoints = 2
// horizonSlack — насколько метка часа может опережать текущее время и всё
// ещё считаться свидетельством.
//
// Час объекта берётся из метки в теле доставки, а тело мы не контролируем:
// без верхней границы одна доставка с метками в будущем занимает окно
// целиком и подменяет измеренный род метрики (построено и прогнано:
// мгновенная метрика объявлялась накопительной при нуле противоречащих
// часов). Запас — на расхождение часов телефона и сервера; данные из
// будущего сверх него свидетельством не являются.
horizonSlack = time.Hour
// maxUnitsReported — сколько различных единиц метрики попадает в ответ.
//
// Единицы приходят из тела дословно и ничем не ограничены, а число
// различных значений равно числу объектов метрики: сотня доставок с разными
// строками единиц раздувает одну запись каталога на десятки килобайт.
// Событие невозможное по наблюдениям (единицы не менялись ни разу) и
// поэтому не имеющее естественного потолка — потолок ставится здесь.
maxUnitsReported = 8
// maxMetricInLog — предел длины имени метрики в записи лога. Тот же предел и
// та же причина, что у координат столкновения в хранилище: имя приходит из
// тела дословно при пределе приёма в 64 МиБ, а запись повторяется на каждый
// запрос каталога.
maxMetricInLog = 64
// tolerance — относительный допуск ВСЕХ сравнений измерения.
//
// Величина названа числом, потому что от неё зависят счётчики основания:
// вердикты метрик на живом корпусе одинаковы при допуске от 1e-9 до 1e-3, а
// число согласных часов у `heart_rate` при этом меняется с 29 на 49.
// Взято строгое: канонизация содержимого округляет числа до 12 значащих
// цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9
// оставляет три порядка запаса и остаётся на шесть порядков строже любого
// содержательного расхождения — сумма и среднее при n ≥ 2 различаются не
// меньше чем вдвое.
tolerance = 1e-9
)
// closeEnough — ЕДИНСТВЕННЫЙ предикат сравнения чисел в измерении.
//
// Один на все три сравнения намеренно. Напиши «различимость суммы и среднего»
// точным неравенством, а «сходимость с гипотезой» — с допуском, и появится час,
// подтверждающий обе гипотезы сразу; его исход молча определил бы порядок веток
// `if`. При одном предикате такой час невыразим.
//
// Допуск относительный, абсолютного порога нет намеренно: второй константы,
// которую пришлось бы объяснять, задача не заводит. Цена названа вслух: при
// обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка
// различимости в verdictOf. Полагаться здесь на «около нуля не сходится»
// нельзя — ровно наоборот.
func closeEnough(a, b float64) bool {
if a == b {
return true
}
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
}
func clipMetric(metric string) string {
if len(metric) <= maxMetricInLog {
return metric
}
return metric[:maxMetricInLog] + "…"
}
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
// одной гипотезой.
//
// Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного»
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
}
// Aggregation — род вместе с основанием.
type Aggregation struct {
Style Style `json:"style"`
Basis
}
// LayerRange — разрез метрики в ответе каталога.
//
// Границы — метки первой и последней точки слоя, включительно, и это границы
// ДАННЫХ, а не обещание покрытия: внутри диапазона законно есть дыры. Числа
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
}
// Metric — запись каталога.
type Metric struct {
Metric string `json:"metric"`
// Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух.
Units []string `json:"units"`
Aggregation Aggregation `json:"aggregation"`
Layers []LayerRange `json:"layers"`
}
// Snapshot — каталог вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
// целиком, просто без условной метки, ровно как до появления условного запроса.
//
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
type Snapshot struct {
Version string
Metrics []Metric
}
// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
//
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
// способна перевернуть измеренный род. Построено и прогнано: та же версия
// витрины, `cumulative` против `unknown`, ноль коммитов между.
//
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
func (s *Service) Version(ctx context.Context) (string, error) {
version, err := s.store.StateVersion(ctx)
if err != nil {
// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
// без метки условный запрос выключается для всех потребителей, а
// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
// разработчик: владельцу об этом скажет `/stats`, когда появится.
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", err
}
return stamp(version, store.Now().Add(horizonSlack)), nil
}
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
func stamp(version string, horizon time.Time) string {
if version == "" {
return ""
}
return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
}
// Service собирает каталог по витрине.
type Service struct {
store *store.Store
log *slog.Logger
}
// New собирает сервис каталога.
func New(st *store.Store, log *slog.Logger) *Service {
return &Service{store: st, log: log}
}
// Metrics отдаёт каталог: разрезы всех метрик и измеренный род каждой.
//
// Род нигде не хранится и считается заново на каждый запрос. Хранимое значение
// было бы вторым производным состоянием рядом с витриной — его пришлось бы
// пересчитывать после каждой свёртки, переносить или не переносить пересборкой
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
// функция журнала, и устаревать в нём нечему.
// Ответ подписывается версией витрины: она нужна условному запросу, и снимает
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack)
var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
return err
})
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
// ответ и второй раз её не пишет.
//
// Отмена снаружи и занятость базы означают «не сделано», а не «не
// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
// давать владельцу ERROR — иначе единственный канал, по которому видно
// настоящий сбой хранилища, забивается штатными событиями. Правило и его
// определение живут в store и читаются уже третьим местом.
if store.Transient(err) {
s.log.DebugContext(ctx, "catalog interrupted", "capability", "query", "error", err)
} else {
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
}
return Snapshot{}, err
}
out := make([]Metric, 0, len(snap.Layers))
for _, group := range groupLayers(snap.Layers) {
style, basis := Measure(snap.Pairs[group.metric])
// Единственный чекпоинт этой границы: род — свойство, на котором Read
// API строит арифметику года, и его смена не имеет права проходить
// молча. Уровень WARN, потому что адресат — владелец, а лечится это
// настройкой автоматизаций HAE, а не кодом. Значений точек в записи нет:
// данные о здоровье чувствительнее токенов. Имя метрики обрезано — оно
// приходит из тела дословно, а запись повторяется на каждый запрос.
if basis.Conflicting > 0 {
s.log.WarnContext(ctx, "aggregation style conflict",
"capability", "query",
"metric", clipMetric(group.metric),
"hours", basis.Hours,
"compared", basis.Compared,
"agreeing", basis.Agreeing,
"conflicting", basis.Conflicting)
}
// Данные, помеченные будущим, в измерение не попадают вовсе — но молчать
// о них нельзя: это либо сбитые часы телефона, либо чужое тело в приёме,
// и оба случая лечатся не кодом. Видно это по разрезам, а не по окну:
// окно такие часы уже отбросило.
if to := group.latest(); to.After(horizon) {
s.log.WarnContext(ctx, "future data",
"capability", "query",
"metric", clipMetric(group.metric),
"last_ts", store.FormatTime(to),
"horizon", store.FormatTime(horizon))
}
out = append(out, Metric{
Metric: group.metric,
Units: group.units,
Aggregation: Aggregation{Style: style, Basis: basis},
Layers: group.layers,
})
}
if version == "" {
// Витрина изменилась, пока ответ собирался (или версию не прочитать).
// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
// плотным потоком доставок так может уходить каждый ответ, и тогда
// механизм не окупается вовсе.
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
}
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
}
type metricGroup struct {
metric string
units []string
layers []LayerRange
unitSet map[string]bool
byLayer map[string]int
}
// latest — самая поздняя метка данных метрики по всем её слоям.
func (g metricGroup) latest() time.Time {
var out time.Time
for _, l := range g.layers {
if l.To.After(out) {
out = l.To
}
}
return out
}
// groupLayers схлопывает строки выборки в записи каталога.
//
// Схлопывание поручено явно, потому что выборка группируется ВМЕСТЕ с
// единицами: у метрики, чьи объекты разошлись единицами, на один слой придут две
// строки. Оставь это на самотёк — клиент получит два элемента с одинаковым
// `layer` ровно в тот единственный день, ради которого единицы и сделаны
// множеством. Различие при этом не теряется: оно видно множеством единиц
// метрики.
func groupLayers(rows []store.LayerRange) []metricGroup {
var out []metricGroup
var cur *metricGroup
for _, r := range rows {
// Указатель, а не сравнение имени с пустой строкой: пустое имя — законное
// значение колонки, и сентинел выбрасывал бы такую метрику из каталога
// молча. Каталог отвечает на вопрос «что у тебя вообще есть»; терять на
// нём то, что в витрине лежит, нельзя.
if cur == nil || r.Metric != cur.metric {
if cur != nil {
out = append(out, *cur)
}
cur = &metricGroup{metric: r.Metric, byLayer: map[string]int{}, unitSet: map[string]bool{}}
}
if r.Units != "" {
cur.unitSet[r.Units] = true
}
i, ok := cur.byLayer[r.Layer]
if !ok {
cur.layers = append(cur.layers, LayerRange{
Layer: r.Layer, From: r.From, To: r.To, Points: r.Points,
})
cur.byLayer[r.Layer] = len(cur.layers) - 1
continue
}
l := &cur.layers[i]
if r.From.Before(l.From) {
l.From = r.From
}
if r.To.After(l.To) {
l.To = r.To
}
l.Points += r.Points
}
if cur != nil {
out = append(out, *cur)
}
for i := range out {
out[i].units = sortedKeys(out[i].unitSet)
}
return out
}
// sortedKeys отдаёт множество строк отсортированным и обрезанным по потолку:
// число различных единиц у метрики равно числу её объектов, и без потолка одна
// запись каталога растёт вместе с витриной.
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
if len(out) > maxUnitsReported {
out = out[:maxUnitsReported]
}
return out
}
// Measure выводит род метрики сверкой минутного и часового слоёв.
//
// Час ПРИГОДЕН, когда у часового объекта ровно одна точка со значением и её
// метка совпадает с началом часа, у минутного не меньше двух точек со значением,
// а сумма минутных отличима от их среднего.
//
// Требование выравнивания часовой метки закрывает зоны с неполночасовым
// смещением: слой выводится по выравниванию метки в исходной зоне, а объект
// адресуется часом UTC, поэтому в зоне +0530 часовая точка описывает не тот
// интервал, который покрывают минутные точки того же объекта.
//
// Требование различимости обязательно: в часе, где все значения нули, сумма
// равна среднему, и совпадение с любой из гипотез не значит ничего. Без него
// `walking_asymmetry_percentage` на живом корпусе давала 4 часа «накопительная»
// против 3 «мгновенная» — конфликт из одних нулевых часов.
//
// Вердикт метрики — не меньше трёх согласных часов и НИ ОДНОГО противоречащего.
// Единогласие, а не большинство: противоречащий час означает, что одна из
// гипотез для этой метрики ложна, и объявлять род при известном контрпримере
// нельзя.
func Measure(pairs []store.HourPair) (Style, Basis) {
basis := Basis{Hours: len(pairs)}
if len(pairs) == 0 {
return Unknown, basis
}
// Часы приходят от свежих к старым; границы окна отдаём по возрастанию.
first, last := pairs[len(pairs)-1].Hour, pairs[0].Hour
basis.FirstHour, basis.LastHour = &first, &last
var cumulative, instant int
for _, p := range pairs {
verdict, ok := verdictOf(p)
if !ok {
continue
}
basis.Compared++
switch verdict {
case Cumulative:
cumulative++
case Instant:
instant++
}
}
// Согласные — часы преобладающей гипотезы, противоречащие — часы другой.
// Разложение одно и то же независимо от того, объявлен род или нет: при
// объявленном роде преобладающая гипотеза им и является, а противоречащих
// ноль по определению правила. Иначе `agreeing` пришлось бы толковать
// по-разному в двух ветках, и клиент читал бы одно поле двумя способами.
basis.Agreeing, basis.Conflicting = cumulative, instant
if instant > cumulative {
basis.Agreeing, basis.Conflicting = instant, cumulative
}
if basis.Conflicting > 0 || basis.Agreeing < MinAgreeing {
return Unknown, basis
}
if cumulative > instant {
return Cumulative, basis
}
return Instant, basis
}
// verdictOf оценивает один час. Второй возврат — был ли час пригоден.
func verdictOf(p store.HourPair) (Style, bool) {
// Единицы обеих сторон обязаны совпасть. Иначе сверка сравнивает величины
// разного масштаба: мгновенная метрика, приехавшая в `count/min` минутным
// слоем и в `count/hour` часовым, даёт `часовое = 60 · среднее = сумма` в
// полном часе — то есть УВЕРЕННЫЙ ложный `cumulative` при нуле
// противоречащих часов. Правило единогласия этот случай не ловит по
// построению: противоречия нет, есть молчание.
if p.FineUnits != p.CoarseUnits {
return Unknown, false
}
if len(p.Coarse) != 1 {
return Unknown, false
}
if !p.Coarse[0].Start.Equal(p.Hour) {
return Unknown, false
}
coarse, ok := hae.PointValue(p.Coarse[0].Raw)
if !ok {
return Unknown, false
}
sum, n := sumFine(p.Fine)
if n < 2 {
return Unknown, false
}
mean := sum / float64(n)
if closeEnough(sum, mean) {
return Unknown, false
}
switch {
case closeEnough(coarse, sum):
return Cumulative, true
case closeEnough(coarse, mean):
return Instant, true
default:
return Unknown, true
}
}
// sumFine складывает значения минутных точек в порядке возрастания метки, чтобы
// вердикт не зависел от порядка точек внутри объекта.
func sumFine(points []store.Point) (float64, int) {
ordered := make([]store.Point, len(points))
copy(ordered, points)
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].Start.Before(ordered[j].Start)
})
var sum float64
n := 0
for _, p := range ordered {
v, ok := hae.PointValue(p.Raw)
if !ok {
continue
}
sum += v
n++
}
return sum, n
}
+522
View File
@@ -0,0 +1,522 @@
package catalog_test
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func openStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func service(t *testing.T, st *store.Store) *catalog.Service {
t.Helper()
return catalog.New(st, slog.New(slog.DiscardHandler))
}
// logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем
// не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным
// гейтом, а владелец о смене рода не узнает.
type logged struct {
records []slog.Record
}
func (h *logged) Enabled(context.Context, slog.Level) bool { return true }
func (h *logged) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *logged) WithGroup(string) slog.Handler { return h }
func (h *logged) Handle(_ context.Context, r slog.Record) error {
h.records = append(h.records, r.Clone())
return nil
}
func (h *logged) find(msg string) (slog.Record, bool) {
for _, r := range h.records {
if r.Message == msg {
return r, true
}
}
return slog.Record{}, false
}
func attr(r slog.Record, key string) string {
out := ""
r.Attrs(func(a slog.Attr) bool {
if a.Key == key {
out = a.Value.String()
return false
}
return true
})
return out
}
func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint {
return store.IncomingPoint{
Metric: metric,
Layer: layer,
Units: units,
Point: store.Point{Start: at, End: at, Raw: qty(v)},
}
}
func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "delivery"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// метрику кладём в оба слоя за n часов: часовая точка на границе часа, три
// минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним.
func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) {
t.Helper()
var points []store.IncomingPoint
for i := range hours {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
coarse := 2.0
if sum {
coarse = 6.0
}
points = append(points, incoming(metric, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
}
func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric {
t.Helper()
for _, m := range metrics {
if m.Metric == name {
return m
}
}
t.Fatalf("метрики %q в каталоге нет", name)
return catalog.Metric{}
}
func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 5, true)
fill(t, st, "heart_rate", 5, false)
// Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет.
merge(t, st, []store.IncomingPoint{
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
})
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
steps := find(t, metrics, "step_count")
if steps.Aggregation.Style != catalog.Cumulative {
t.Errorf("step_count: род %v", steps.Aggregation.Style)
}
if len(steps.Units) != 1 || steps.Units[0] != "count" {
t.Errorf("step_count: единицы %v", steps.Units)
}
if len(steps.Layers) != 2 {
t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers))
}
hr := find(t, metrics, "heart_rate")
if hr.Aggregation.Style != catalog.Instant {
t.Errorf("heart_rate: род %v", hr.Aggregation.Style)
}
sleep := find(t, metrics, "sleep_analysis")
if sleep.Aggregation.Style != catalog.Unknown {
t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style)
}
if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil {
t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis)
}
if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" {
t.Errorf("sleep_analysis: слои %+v", sleep.Layers)
}
}
// Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет
// `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое.
func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 5 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("active_energy", "hour", "kJ", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("active_energy", "raw",
"kJ", h.Add(time.Duration(m)*time.Second), v))
}
}
merge(t, st, points)
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "active_energy")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v, ждали unknown", m.Aggregation.Style)
}
if m.Aggregation.Hours != 0 {
t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours)
}
}
func TestКаталогОграничиваетОкно(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", catalog.Window+7, true)
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if m.Aggregation.Hours != catalog.Window {
t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window)
}
// Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже
// первого часа истории.
first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) {
t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour)
}
}
// Единицы, разошедшиеся между объектами, показываются множеством, а слой
// остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы
// половину диапазона молча.
func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("walking_running_distance", "minute", "km", base, 1),
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
})
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_running_distance")
if len(m.Units) != 2 {
t.Errorf("единицы %v, ждали оба значения", m.Units)
}
if len(m.Layers) != 1 {
t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers)
}
if m.Layers[0].Points != 2 {
t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points)
}
if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) {
t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To)
}
}
func TestКаталогПустойВитриныПуст(t *testing.T) {
t.Parallel()
metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 0 {
t.Errorf("метрик %d, ждали ноль", len(metrics))
}
}
// Род нигде не хранится: новая доставка меняет его в следующем же ответе, без
// перезапуска и без пересчёта чего бы то ни было.
func TestКаталогПересчитываетРодНаКаждыйЗапрос(t *testing.T) {
t.Parallel()
st := openStore(t)
svc := service(t, st)
ctx := context.Background()
fill(t, st, "step_count", 2, true)
before, err := metricsOf(ctx, svc)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, before, "step_count").Aggregation.Style != catalog.Unknown {
t.Fatal("двух часов не хватает на вердикт — ждали unknown")
}
fill(t, st, "step_count", 5, true)
after, err := metricsOf(ctx, svc)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative {
t.Errorf("после доставки род %v, ждали cumulative", got)
}
}
// Час объекта берётся из метки в теле доставки, а тело не наше. Без верхней
// границы окна одна доставка с метками в будущем вытесняет всю настоящую
// историю метрики и подменяет измеренный род: мгновенная объявлялась
// накопительной при нуле противоречащих часов.
func TestКаталогНеИзмеряетПоБудущимЧасам(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant
// Будущее: столько же часов «суммой», сколько влезает в окно целиком.
var future []store.IncomingPoint
base := store.Now().Add(72 * time.Hour).Truncate(time.Hour)
for i := range catalog.Window {
h := base.Add(time.Duration(i) * time.Hour)
future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6))
for m, v := range []float64{1, 2, 3} {
future = append(future, incoming("blood_oxygen_saturation", "minute", "count",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, future)
handler := &logged{}
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "blood_oxygen_saturation")
if m.Aggregation.Style != catalog.Instant {
t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style)
}
if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) {
t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour)
}
if _, ok := handler.find("future data"); !ok {
t.Error("данные из будущего есть, а предупреждения владельцу нет")
}
}
// Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в
// count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть
// уверенный ложный cumulative при нуле противоречащих часов.
func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 6 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("walking_speed", "minute", "count/min",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_speed")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style)
}
if m.Aggregation.Compared != 0 {
t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared)
}
}
// Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос
// «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя.
func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("", "minute", "count", base, 1),
incoming("step_count", "minute", "count", base, 2),
})
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics)
}
if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 {
t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0])
}
}
func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) {
t.Parallel()
st := openStore(t)
long := strings.Repeat("метрика", 200)
var points []store.IncomingPoint
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
for i := range 6 {
h := base.Add(time.Duration(i) * time.Hour)
coarse := 6.0 // сумма
if i >= 4 {
coarse = 2.0 // среднее — противоречие
}
points = append(points, incoming(long, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
handler := &logged{}
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, metrics, long).Aggregation.Style != catalog.Unknown {
t.Error("противоречие обязано гасить род")
}
rec, ok := handler.find("aggregation style conflict")
if !ok {
t.Fatal("противоречие есть, а предупреждения владельцу нет")
}
if rec.Level != slog.LevelWarn {
t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level)
}
if got := attr(rec, "metric"); len(got) > 128 {
t.Errorf("имя метрики в логе не обрезано: %d байт", len(got))
}
if attr(rec, "conflicting") == "" {
t.Error("в записи нет чисел основания — по ней нечего разбирать")
}
}
// Число различных единиц у метрики равно числу её объектов: без потолка одна
// запись каталога растёт вместе с витриной.
func TestКаталогОграничиваетЧислоЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i := range 40 {
points = append(points, incoming("step_count", "minute",
fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1))
}
merge(t, st, points)
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if len(m.Units) == 0 || len(m.Units) > 16 {
t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units))
}
if len(m.Layers) != 1 {
t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers))
}
}
// Отказ хранилища логируется доменной границей один раз и уходит наверх: у
// транспорта своей записи нет, он только переводит ошибку в ответ.
func TestКаталогСообщаетОбОтказеХранилища(t *testing.T) {
t.Parallel()
st := openStore(t)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
handler := &logged{}
if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог на закрытой базе собрался")
}
rec, ok := handler.find("catalog failed")
if !ok {
t.Fatal("отказ не записан на доменной границе")
}
if rec.Level != slog.LevelError {
t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level)
}
}
// Отмена снаружи — «не сделано», а не «не выходит»: она не имеет права давать
// владельцу ERROR, иначе единственный канал настоящих сбоев забивается
// клиентами, оборвавшими запрос по своему тайм-ауту.
func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 3, true)
ctx, cancel := context.WithCancel(context.Background())
cancel()
handler := &logged{}
if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог собрался на отменённом контексте")
}
if _, ok := handler.find("catalog failed"); ok {
t.Error("отмена снаружи записана как сбой сервиса")
}
if _, ok := handler.find("catalog interrupted"); !ok {
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
}
}
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}
// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
// поставить в `ETag`, и условный запрос не работает вовсе.
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
t.Parallel()
st := openStore(t)
snap, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if snap.Version == "" {
t.Error("каталог собран на стоящей витрине и остался без версии")
}
}
+292
View File
@@ -0,0 +1,292 @@
package catalog_test
import (
"encoding/json"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func hour(n int) time.Time {
return time.Date(2026, 8, 1, n, 0, 0, 0, time.UTC).UTC()
}
// pair собирает час: одна часовая точка на границе часа и минутные точки внутри.
func pair(h time.Time, coarse float64, fine ...float64) store.HourPair {
p := store.HourPair{
Hour: h,
Coarse: []store.Point{{Start: h, End: h, Raw: qty(coarse)}},
}
for i, v := range fine {
at := h.Add(time.Duration(i) * time.Minute)
p.Fine = append(p.Fine, store.Point{Start: at, End: at, Raw: qty(v)})
}
return p
}
func qty(v float64) json.RawMessage {
b, err := json.Marshal(map[string]float64{"qty": v})
if err != nil {
panic(err)
}
return b
}
// window собирает окно из одинаковых по устройству часов, от свежих к старым —
// в том же порядке, в каком их отдаёт хранилище.
func window(n int, f func(h time.Time) store.HourPair) []store.HourPair {
out := make([]store.HourPair, 0, n)
for i := n - 1; i >= 0; i-- {
out = append(out, f(hour(i)))
}
return out
}
func TestMeasureНакопительная(t *testing.T) {
t.Parallel()
pairs := window(4, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Cumulative {
t.Fatalf("род: получили %v, ждали cumulative", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 0 || basis.Compared != 4 || basis.Hours != 4 {
t.Errorf("основание: %+v", basis)
}
if basis.FirstHour == nil || !basis.FirstHour.Equal(hour(0)) {
t.Errorf("начало окна: %v", basis.FirstHour)
}
if basis.LastHour == nil || !basis.LastHour.Equal(hour(3)) {
t.Errorf("конец окна: %v", basis.LastHour)
}
}
func TestMeasureМгновенная(t *testing.T) {
t.Parallel()
pairs := window(4, func(h time.Time) store.HourPair {
return pair(h, 2, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Instant {
t.Fatalf("род: получили %v, ждали instant", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 0 {
t.Errorf("основание: %+v", basis)
}
}
// Нулевой час различить гипотезы не может: сумма равна среднему. Без этого
// фильтра `walking_asymmetry_percentage` на живом корпусе давала конфликт из
// одних нулевых часов.
func TestMeasureНулевойЧасНеСвидетельствует(t *testing.T) {
t.Parallel()
pairs := window(5, func(h time.Time) store.HourPair {
return pair(h, 0, 0, 0, 0)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown", style)
}
if basis.Compared != 0 || basis.Hours != 5 {
t.Errorf("основание: %+v — нулевые часы обязаны быть непригодны", basis)
}
}
func TestMeasureПротиворечиеГаситРод(t *testing.T) {
t.Parallel()
pairs := []store.HourPair{
pair(hour(4), 6, 1, 2, 3), // сумма
pair(hour(3), 6, 1, 2, 3), // сумма
pair(hour(2), 6, 1, 2, 3), // сумма
pair(hour(1), 2, 1, 2, 3), // среднее
pair(hour(0), 12, 1, 2, 3), // ни то ни то
}
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown при противоречии", style)
}
if basis.Agreeing != 3 || basis.Conflicting != 1 {
t.Errorf("основание: %+v", basis)
}
if basis.Compared != 5 {
t.Errorf("пригодных часов: %d, ждали 5", basis.Compared)
}
}
func TestMeasureМалоСвидетельств(t *testing.T) {
t.Parallel()
pairs := window(2, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown при двух согласных часах", style)
}
if basis.Agreeing != 2 {
t.Errorf("согласных: %d", basis.Agreeing)
}
}
func TestMeasureОбщихЧасовНет(t *testing.T) {
t.Parallel()
style, basis := catalog.Measure(nil)
if style != catalog.Unknown {
t.Fatalf("род: получили %v", style)
}
if basis.Hours != 0 || basis.FirstHour != nil || basis.LastHour != nil {
t.Errorf("основание: %+v — границ окна быть не должно", basis)
}
}
func TestMeasureНепригодныеЧасы(t *testing.T) {
t.Parallel()
двеЧасовые := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse = append(p.Coarse, store.Point{Start: h, End: h.Add(time.Hour), Raw: qty(6)})
return p
}
однаМинутная := func(h time.Time) store.HourPair {
return pair(h, 1, 1)
}
// Метка часовой точки на середине часа UTC: так выглядит часовой слой в
// зоне с получасовым смещением, и сравнивать его с минутными точками того
// же объекта нельзя — они покрывают другой интервал.
сдвинутая := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse[0].Start = h.Add(30 * time.Minute)
return p
}
безЧисла := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse[0].Raw = json.RawMessage(`{"date":"…"}`)
return p
}
cases := map[string]func(time.Time) store.HourPair{
"часовой объект несёт две точки": двеЧасовые,
"минутный объект несёт одну": однаМинутная,
"часовая метка не выровнена": сдвинутая,
"часовая точка без числа": безЧисла,
}
for name, mk := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
style, basis := catalog.Measure(window(5, mk))
if style != catalog.Unknown {
t.Errorf("род: получили %v, ждали unknown", style)
}
if basis.Compared != 0 {
t.Errorf("пригодных часов: %d, ждали 0 (основание %+v)", basis.Compared, basis)
}
if basis.Hours != 5 {
t.Errorf("часов окна: %d, ждали 5", basis.Hours)
}
})
}
}
// Вердикт обязан быть функцией состава, а не порядка точек внутри объекта:
// порядок ключей и элементов в теле HAE нестабилен (находка 2).
func TestMeasureНеЗависитОтПорядкаТочек(t *testing.T) {
t.Parallel()
forward := window(4, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
backward := window(4, func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Fine[0], p.Fine[2] = p.Fine[2], p.Fine[0]
return p
})
s1, b1 := catalog.Measure(forward)
s2, b2 := catalog.Measure(backward)
// Границы окна сравниваются значением: в основании они указатели, чтобы
// «окна не было» отличалось от нулевой метки в ответе.
same := s1 == s2 &&
b1.Hours == b2.Hours && b1.Compared == b2.Compared &&
b1.Agreeing == b2.Agreeing && b1.Conflicting == b2.Conflicting &&
b1.FirstHour.Equal(*b2.FirstHour) && b1.LastHour.Equal(*b2.LastHour)
if !same {
t.Errorf("перестановка точек изменила исход: %v %+v против %v %+v", s1, b1, s2, b2)
}
}
// Точка без числа в сумму не входит и числа точек часа не увеличивает: час из
// одной значащей точки и одной пустой различить гипотезы не может.
func TestMeasureТочкаБезЧислаНеСчитается(t *testing.T) {
t.Parallel()
pairs := window(5, func(h time.Time) store.HourPair {
p := pair(h, 1, 1)
p.Fine = append(p.Fine, store.Point{
Start: h.Add(time.Minute), End: h.Add(time.Minute),
Raw: json.RawMessage(`{"source":"часы"}`),
})
return p
})
_, basis := catalog.Measure(pairs)
if basis.Compared != 0 {
t.Errorf("пригодных часов: %d, ждали 0", basis.Compared)
}
}
// Перевес мгновенной гипотезы над накопительной: разложение основания одно на
// обе ветки, и `agreeing` всегда относится к преобладающему вердикту.
func TestMeasureПротиворечиеСПеревесомМгновенной(t *testing.T) {
t.Parallel()
pairs := []store.HourPair{
pair(hour(4), 2, 1, 2, 3),
pair(hour(3), 2, 1, 2, 3),
pair(hour(2), 2, 1, 2, 3),
pair(hour(1), 2, 1, 2, 3),
pair(hour(0), 6, 1, 2, 3),
}
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 1 {
t.Errorf("основание: %+v — согласные обязаны быть у преобладающей гипотезы", basis)
}
}
func TestStyleСловарь(t *testing.T) {
t.Parallel()
cases := map[catalog.Style]string{
catalog.Cumulative: `"cumulative"`,
catalog.Instant: `"instant"`,
catalog.Unknown: `"unknown"`,
catalog.Style(42): `"unknown"`,
}
for style, want := range cases {
got, err := json.Marshal(style)
if err != nil {
t.Fatalf("сериализация %v: %v", style, err)
}
if string(got) != want {
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package catalog
import (
"testing"
"time"
)
// Ответ каталога есть функция снимка И горизонта измерения: метка из будущего,
// лежащая в витрине, въезжает в окно сама, с ходом часов и без единого коммита.
// Построено враждебным проходом: та же версия витрины, `cumulative` против
// `unknown`. Значит горизонт обязан входить в метку — иначе клиент с
// `If-None-Match` получит `304` на изменившийся ответ.
func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
t.Parallel()
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) {
t.Error("версия не изменилась при сдвиге горизонта на два часа")
}
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор меняется ровно при переходе через час.
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) {
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
}
if stamp("", at) != "" {
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
}
}
+12 -2
View File
@@ -176,9 +176,19 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
t.Errorf("координаты объекта в записи не те: %q", at) t.Errorf("координаты объекта в записи не те: %q", at)
} }
// И ни одного значения точки: данные о здоровье чувствительнее токенов. // И ни одного значения точки: данные о здоровье чувствительнее токенов.
//
// Метка времени из проверки исключается, и это не поблажка: она содержит
// доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз
// на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять
// надо запись без служебного поля, которое значений нести не может.
delete(rec, "time")
clean, err := json.Marshal(rec)
if err != nil {
t.Fatalf("запись лога не сериализуется: %v", err)
}
for _, secret := range []string{"5.1", "До еды"} { for _, secret := range []string{"5.1", "До еды"} {
if strings.Contains(buf.String(), secret) { if strings.Contains(string(clean), secret) {
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String()) t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean)
} }
} }
} }
+73
View File
@@ -0,0 +1,73 @@
package hae
import (
"bytes"
"encoding/json"
)
// PointValue достаёт число точки: `qty`, а при его отсутствии — `Avg`.
//
// Единственное место в проекте, знающее, какое поле точки HAE несёт число.
// Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
// (находка 40), и без второго кандидата самая частая метрика потока не
// измерялась бы вовсе.
//
// Это чтение, а не интерпретация: значение никуда не пишется и ничего не
// подменяет. Форма точки при этом рода метрики не выдаёт — род измеряется
// сверкой слоёв, а не выводится отсюда.
//
// **Ноль — значение.** Словарь пустоты из `canon` сюда не применяется и
// применяться не должен: там ноль объявлен пустотой, чтобы точка без измерений
// не вытесняла настоящее измерение при столкновении координат, — вопрос совсем
// другой. Взяв его, измерение не увидело бы точки `{"qty":0}`, час выпал бы из
// счётчиков ещё до правила различимости, и проверка «нулевой час свидетельством
// не является» зеленела бы по неверной причине.
//
// **Значением считается только JSON-число.** Строка `"72.5"` — не число, хотя
// `json.Number` её принимает (проверено): такой формы поток не приносил, и
// прочитать её как измерение значило бы угадать за источник. Отсутствие ключа и
// `null` — тоже «нет значения»; при этом `qty: null` не мешает прочитать `Avg`,
// а `qty` не того типа мешает: форма точки поменялась, и догадываться не о чем.
func PointValue(raw json.RawMessage) (float64, bool) {
var p struct {
Qty *json.RawMessage `json:"qty"`
Avg *json.RawMessage `json:"Avg"`
}
// Указатели, а не значения: `null` обязан отличаться от нуля, и в этой форме
// он приходит нулевым указателем.
if err := json.Unmarshal(raw, &p); err != nil {
return 0, false
}
if p.Qty != nil {
return jsonNumber(*p.Qty)
}
if p.Avg != nil {
return jsonNumber(*p.Avg)
}
return 0, false
}
// jsonNumber разбирает значение поля, требуя, чтобы это было JSON-число.
//
// Проверка первого байта нужна потому, что `json.Unmarshal` в `float64`
// отвергает строку, но `json.Number` — принимает; полагаться на тип-приёмник
// значило бы получить разное поведение от невидимой детали.
//
// Бесконечность значением не считается: `1e400` разбирается с ошибкой
// диапазона, и проглоти её — `+Inf` отравил бы и сумму, и среднее всего часа, а
// видно это было бы лишь тем, что род перестал определяться. Отдельной проверки
// на `Inf`/`NaN` после разбора нет намеренно: их литералов в JSON не бывает, а
// число вне диапазона `float64` даёт ошибку, а не бесконечность.
func jsonNumber(raw json.RawMessage) (float64, bool) {
b := bytes.TrimSpace(raw)
if len(b) == 0 || (b[0] != '-' && (b[0] < '0' || b[0] > '9')) {
return 0, false
}
var v float64
if err := json.Unmarshal(b, &v); err != nil {
return 0, false
}
return v, true
}
+96
View File
@@ -0,0 +1,96 @@
package hae_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
func TestPointValueФормыТочки(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want float64
ok bool
}{
{"обычная точка", `{"qty":72.5,"date":"2026-07-31 12:00:00 +0300"}`, 72.5, true},
{"пульс без qty", `{"Min":60,"Avg":70.25,"Max":80}`, 70.25, true},
{"qty впереди Avg", `{"qty":1,"Avg":9}`, 1, true},
{"ноль — значение", `{"qty":0}`, 0, true},
{"отрицательное значение", `{"qty":-1.5}`, -1.5, true},
{"нет числовых полей", `{"date":"…","source":"часы"}`, 0, false},
{"qty равен null", `{"qty":null,"Avg":3}`, 3, true},
{"qty строкой", `{"qty":"72.5"}`, 0, false},
{"qty объектом", `{"qty":{"value":1}}`, 0, false},
{"переполнение float64", `{"qty":1e400}`, 0, false},
{"точка не объект", `[1,2,3]`, 0, false},
{"пустой объект", `{}`, 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got, ok := hae.PointValue(json.RawMessage(c.raw))
if ok != c.ok {
t.Fatalf("наличие значения: получили %v, ждали %v", ok, c.ok)
}
if ok && got != c.want {
t.Errorf("значение: получили %v, ждали %v", got, c.want)
}
})
}
}
// Формы точки берутся из реальных пакетов: документация формата тонкая и
// местами расходится с тем, что приложение шлёт (docs/local-research.md).
func TestPointValueНаРеальныхПакетах(t *testing.T) {
t.Parallel()
for _, name := range []string{"minute.json", "hour.json", "raw.json"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
var doc struct {
Data struct {
Metrics []struct {
Name string `json:"name"`
Data []json.RawMessage `json:"data"`
} `json:"metrics"`
} `json:"data"`
}
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("разбор фикстуры: %v", err)
}
total, valued := 0, 0
for _, m := range doc.Data.Metrics {
for _, raw := range m.Data {
total++
if _, ok := hae.PointValue(raw); ok {
valued++
}
}
}
if total == 0 {
t.Fatal("в фикстуре нет точек — проверять нечего")
}
// Утверждается свойство, а не число: корпус фикстур пополняется, и
// константа протухла бы молча. Значение несёт подавляющее
// большинство точек — если вдруг перестанет, это видно сразу.
if valued*2 < total {
t.Errorf("значение прочиталось лишь у %d точек из %d", valued, total)
}
t.Logf("точек %d, со значением %d", total, valued)
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"net/http"
"git.vakhrushev.me/av/healthlog/internal/catalog"
)
// catalogResponse — оболочка ответа каталога.
//
// Объект, а не голый массив: список метрик — не единственное, что каталогу
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
type catalogResponse struct {
Metrics []catalog.Metric `json:"metrics"`
}
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
//
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
// будет канонизированная форма запроса.
const scopeMetrics = "metrics"
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
// 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 {
// Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
// в нём имена колонок и форма запроса.
writeError(w, http.StatusInternalServerError, "каталог не собрался")
return
}
// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
}
+211
View File
@@ -0,0 +1,211 @@
package httpapi_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
// увидел бы — а клиент увидел бы сразу.
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
h, _, _ := newAPITokens(t, nil, nil)
rec := getCatalog(t, h, "")
if rec.Code != http.StatusOK {
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
}
}
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: "vo2_max", Layer: "raw", Units: "ml/(kg·min)",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":42}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
body := getCatalog(t, h, "").Body.String()
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
if !strings.Contains(body, want) {
t.Errorf("в ответе нет %s: %s", want, body)
}
}
}
func TestКаталогТребуетТокенЧтения(t *testing.T) {
write := []string{"write-token"}
read := []string{"read-token"}
cases := []struct {
name string
auth string
want int
}{
{"без заголовка", "", http.StatusUnauthorized},
{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
{"голое значение без схемы", "read-token", http.StatusUnauthorized},
{"чужой токен", "Bearer nope", http.StatusUnauthorized},
{"токен чтения", "Bearer read-token", http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h, _, _ := newAPITokens(t, write, read)
rec := getCatalog(t, h, c.auth)
if rec.Code != c.want {
t.Errorf("статус %d, ждали %d (тело %s)", rec.Code, c.want, rec.Body.String())
}
})
}
}
// Пустой список токенов чтения = проверка выключена. Это симметрия с приёмом, и
// цена её названа в конфиге: открытое чтение — выгрузка истории здоровья.
func TestКаталогБезТокеновОтдаётся(t *testing.T) {
h, _, _ := newAPITokens(t, []string{"write-token"}, nil)
if rec := getCatalog(t, h, ""); rec.Code != http.StatusOK {
t.Errorf("статус %d, ждали 200", rec.Code)
}
}
// Токен чтения — такой же секрет, как токен приёма: посланный на маршрут приёма
// заголовком с произвольным именем, он не имеет права осесть в учёте доставки.
func TestТокенЧтенияНеОседаетВУчётеДоставки(t *testing.T) {
h, st, _ := newAPITokens(t, nil, []string{"read-token"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Whatever", "read-token")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("статус %d", rec.Code)
}
d, err := st.LastDelivery(context.Background())
if err != nil {
t.Fatalf("доставка: %v", err)
}
if strings.Contains(d.Headers, "read-token") {
t.Errorf("токен чтения сохранён в заголовках доставки: %s", d.Headers)
}
}
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
// поля меняет публичный контракт без единого касания транспорта; страж у него
// один — этот литерал.
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i := range 4 {
at := base.Add(time.Duration(i) * time.Hour)
points = append(points, store.IncomingPoint{
Metric: "step_count", Layer: "hour", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":6}`)},
})
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
ts := at.Add(time.Duration(m) * time.Minute)
points = append(points, store.IncomingPoint{
Metric: "step_count", Layer: "minute", Units: "count",
Point: store.Point{Start: ts, End: ts, Raw: json.RawMessage(v)},
})
}
}
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
want := `{"metrics":[{"metric":"step_count","units":["count"],` +
`"aggregation":{"style":"cumulative","hours":4,"compared":4,"agreeing":4,` +
`"conflicting":0,"first_hour":"2026-06-01T00:00:00Z","last_hour":"2026-06-01T03:00:00Z"},` +
`"layers":[{"layer":"hour","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:00:00Z","points":4},` +
`{"layer":"minute","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:02:00Z","points":12}]}]}`
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
t.Errorf("форма ответа изменилась:\n получили %s\n ждали %s", got, want)
}
}
// Два запроса подряд на неизменившейся витрине совпадают побайтово: порядок
// метрик и слоёв держится `ORDER BY` в чужом пакете, и снятие сортировки
// «раз индекс и так отсортирован» проявилось бы у клиента, а не в тестах.
func TestКаталогПовторяетсяПобайтово(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i, metric := range []string{"step_count", "heart_rate", "active_energy"} {
for _, layer := range []string{"minute", "hour", "raw"} {
at := base.Add(time.Duration(i) * time.Hour)
points = append(points, store.IncomingPoint{
Metric: metric, Layer: layer, Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
})
}
}
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
first := getCatalog(t, h, "").Body.String()
second := getCatalog(t, h, "").Body.String()
if first != second {
t.Errorf("два ответа на неизменившейся витрине разошлись:\n %s\n %s", first, second)
}
if !strings.Contains(first, `"metric":"active_energy"`) {
t.Fatalf("в ответе нет ожидаемых метрик: %s", first)
}
}
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
// наружу не уходит — в нём имена колонок и форма запроса.
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
rec := getCatalog(t, h, "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("статус %d, ждали 500", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
t.Errorf("наружу уехали внутренности: %s", body)
}
}
+150
View File
@@ -0,0 +1,150 @@
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)
}
}
+159
View File
@@ -0,0 +1,159 @@
package httpapi_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func conditionalGet(t *testing.T, h http.Handler, auth, cond string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
if cond != "" {
req.Header.Set("If-None-Match", cond)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func point(t *testing.T, st *store.Store, metric string, at time.Time) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: metric, Layer: "minute", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те
// же, а повтор с этой меткой не собирает снимка вовсе.
func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
first := getCatalog(t, h, "")
tag := first.Header().Get("ETag")
if tag == "" {
t.Fatal("ответ ушёл без метки")
}
if got := first.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got)
}
second := getCatalog(t, h, "")
if got := second.Header().Get("ETag"); got != tag {
t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got)
}
if first.Body.String() != second.Body.String() {
t.Error("два ответа на неизменившейся витрине разошлись байтами")
}
cond := conditionalGet(t, h, "", tag)
if cond.Code != http.StatusNotModified {
t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String())
}
if cond.Body.Len() != 0 {
t.Errorf("у 304 есть тело: %q", cond.Body.String())
}
if got := cond.Header().Get("ETag"); got != tag {
t.Errorf("метка на 304 — %q, ждали %q", got, tag)
}
if got := cond.Header().Get("Content-Type"); got != "" {
t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got)
}
}
// Свёртка записала объект — метка обязана смениться, иначе клиент останется на
// устаревшем ответе навсегда.
func TestКаталогМенялсяПослеЗаписи(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
point(t, st, "step_count", at)
tag := getCatalog(t, h, "").Header().Get("ETag")
point(t, st, "heart_rate", at)
rec := conditionalGet(t, h, "", tag)
if rec.Code != http.StatusOK {
t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code)
}
if got := rec.Header().Get("ETag"); got == tag {
t.Error("метка не изменилась после записи в витрину")
}
}
// Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет
// и отказом не является.
func TestКаталогФормыУсловия(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
cases := []struct {
name string
cond string
want int
}{
{"звёздочка", "*", http.StatusNotModified},
{"мусор", "не-метка", http.StatusOK},
{"чужая метка", `W/"gen-нет"`, http.StatusOK},
}
for _, c := range cases {
if got := conditionalGet(t, h, "", c.cond).Code; got != c.want {
t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want)
}
}
}
// Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние
// витрины тому, кому она не открыта.
func TestУсловныйЗапросБезТокена(t *testing.T) {
read := []string{"read-token"}
h, st, _ := newAPITokens(t, nil, read)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag")
if tag == "" {
t.Fatal("ответ ушёл без метки")
}
for _, cond := range []string{tag, "*"} {
if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized {
t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got)
}
}
}
// Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул
// внешний: каталог при сборке пишет владельцу предупреждение о данных из
// будущего, и его отсутствие означает, что сборки не было.
func TestУсловныйОтветНеСобираетКаталог(t *testing.T) {
h, st, _, seen := newAPILogged(t, nil, nil)
// Метка из будущего: при каждой сборке каталога она даёт `WARN`.
point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour))
tag := getCatalog(t, h, "").Header().Get("ETag")
if !seen.has("future data") {
t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден")
}
seen.reset()
if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified {
t.Fatalf("статус %d, ждали 304", got)
}
if seen.has("future data") {
t.Error("условный ответ собрал каталог: снимок открыт зря")
}
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"net/http/httptest"
"testing"
)
// Сравнение меток слабое, звёздочка совпадает с любой существующей меткой, а
// мусор условия не выполняет — и это не отказ: клиент, приславший кривой
// заголовок, получает данные, а не `400`.
func TestУсловиеЗапроса(t *testing.T) {
t.Parallel()
const tag = `W/"metrics.gen-7"`
cases := []struct {
name string
header []string
tag string
want bool
}{
{"заголовка нет", nil, tag, false},
{"пустая строка", []string{""}, tag, false},
{"та же метка", []string{tag}, tag, true},
{"та же метка без W/", []string{`"metrics.gen-7"`}, tag, true},
{"чужая метка", []string{`W/"metrics.gen-8"`}, tag, false},
{"метка другого ресурса", []string{`W/"points.gen-7"`}, tag, false},
{"список, метка вторая", []string{`W/"metrics.gen-1", W/"metrics.gen-7"`}, tag, true},
{"две строки заголовка", []string{`W/"metrics.gen-1"`, `W/"metrics.gen-7"`}, tag, true},
{"запятая внутри метки", []string{`W/"points.a,b-7"`}, `W/"points.a,b-7"`, true},
{"метка без закрывающей кавычки", []string{`W/"metrics.gen-7`}, tag, false},
{"звёздочка", []string{"*"}, tag, true},
{"звёздочка без метки", []string{"*"}, "", false},
{"мусор", []string{"metrics.gen-7"}, tag, false},
{"мусор со звёздочкой внутри", []string{`"*"`}, tag, false},
{"метки нет", []string{tag}, "", false},
}
for _, c := range cases {
if got := notModified(c.header, c.tag); got != c.want {
t.Errorf("%s: %v, ждали %v", c.name, got, c.want)
}
}
}
// Пустая версия метки не даёт: подписать ответ нечем, и притворяться нельзя.
func TestМеткаИзВерсии(t *testing.T) {
t.Parallel()
if got := etag("metrics", ""); got != "" {
t.Errorf("пустая версия дала метку %q", got)
}
if got := etag("metrics", "gen-7"); got != `W/"metrics.gen-7"` {
t.Errorf("метка %q, ждали слабую с областью", got)
}
if etag("metrics", "gen-7") == etag("points", "gen-7") {
t.Error("метки разных ресурсов совпали — 304 отдал бы чужие данные")
}
}
// Подписать нечем — заголовка нет вовсе. Пустой `ETag:` синтаксически невалиден,
// и что с ним сделает посредник, не определено ничем.
func TestОтветБезВерсииНеНесётМетки(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
setReadHeaders(rec, "")
if _, ok := rec.Header()["Etag"]; ok {
t.Errorf("ответ без версии несёт метку %q", rec.Header().Get("ETag"))
}
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Errorf("правило кеширования %q — оно не зависит от наличия метки", got)
}
}
+32 -13
View File
@@ -14,14 +14,17 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/ingest"
) )
// Options — зависимости и настройки транспорта. // Options — зависимости и настройки транспорта.
type Options struct { type Options struct {
Ingest *ingest.Service Ingest *ingest.Service
Catalog *catalog.Service
Log *slog.Logger Log *slog.Logger
WriteTokens []string WriteTokens []string
ReadTokens []string
MaxBodyMB int MaxBodyMB int
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела // IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout // вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
@@ -31,8 +34,10 @@ type Options struct {
type api struct { type api struct {
ingest *ingest.Service ingest *ingest.Service
catalog *catalog.Service
log *slog.Logger log *slog.Logger
writeTokens []string writeTokens []string
readTokens []string
maxBody int64 maxBody int64
ingestBudget time.Duration ingestBudget time.Duration
} }
@@ -41,8 +46,10 @@ type api struct {
func New(o Options) http.Handler { func New(o Options) http.Handler {
a := &api{ a := &api{
ingest: o.Ingest, ingest: o.Ingest,
catalog: o.Catalog,
log: o.Log, log: o.Log,
writeTokens: o.WriteTokens, writeTokens: o.WriteTokens,
readTokens: o.ReadTokens,
maxBody: int64(o.MaxBodyMB) << 20, maxBody: int64(o.MaxBodyMB) << 20,
ingestBudget: o.IngestWriteBudget, ingestBudget: o.IngestWriteBudget,
} }
@@ -53,7 +60,8 @@ func New(o Options) http.Handler {
r.Get("/healthz", a.handleHealthz) r.Get("/healthz", a.handleHealthz)
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
r.With(a.requireWriteToken).Post("/ingest", a.handleIngest) r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
}) })
return r return r
} }
@@ -64,21 +72,32 @@ func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"status":"ok"}`)) _, _ = w.Write([]byte(`{"status":"ok"}`))
} }
// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка // requireToken проверяет токен контура. Пустой список токенов = проверка
// выключена: локальный запуск в доверенной сети. О выключенной проверке // выключена: локальный запуск в доверенной сети. О выключенной проверке
// сервис предупреждает на старте. // сервис предупреждает на старте.
func (a *api) requireWriteToken(next http.Handler) http.Handler { //
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
if len(a.writeTokens) == 0 { // одним полем и несла бы три решения сразу — сравнение за постоянное время,
// «пустой список = выключено» и текст 401; правка любого из них в одном месте
// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения
// данных о здоровье.
//
// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не
// открывает. Схема строгая — токеном считается только значение после `Bearer `.
func requireToken(tokens []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(tokens) == 0 {
next.ServeHTTP(w, r)
return
}
if !tokenAllowed(bearer(r), tokens) {
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
return
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return })
} }
if !tokenAllowed(bearer(r), a.writeTokens) {
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
return
}
next.ServeHTTP(w, r)
})
} }
func bearer(r *http.Request) string { func bearer(r *http.Request) string {
+63 -2
View File
@@ -10,10 +10,12 @@ import (
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/httpapi" "git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/store" "git.vakhrushev.me/av/healthlog/internal/store"
@@ -284,6 +286,25 @@ func TestПриёмРаботаетНаТранспортеБезДедлайн
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) { func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
t.Helper() t.Helper()
h, st, _ := newAPITokens(t, writeTokens, nil)
return h, st
}
// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не
// переписывать два десятка вызовов ради одного параметра.
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
t.Helper()
h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
return h, st, cat
}
// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
// не наблюдением: единственный внешний признак того, что каталог собирался, —
// его предупреждения владельцу.
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
t.Helper()
dir := t.TempDir() dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db")) st, err := store.Open(filepath.Join(dir, "healthlog.db"))
@@ -297,15 +318,55 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
t.Fatalf("archive.New: %v", err) t.Fatalf("archive.New: %v", err)
} }
log := slog.New(slog.DiscardHandler) seen := &records{}
log := slog.New(seen)
cat := catalog.New(st, log)
h := httpapi.New(httpapi.Options{ h := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, nil, log), Ingest: ingest.New(arch, st, nil, log),
Catalog: cat,
Log: log, Log: log,
WriteTokens: writeTokens, WriteTokens: writeTokens,
ReadTokens: readTokens,
MaxBodyMB: 1, MaxBodyMB: 1,
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет, // Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
// и это ровно тот транспорт, на котором приём обязан продолжать работать. // и это ровно тот транспорт, на котором приём обязан продолжать работать.
IngestWriteBudget: time.Minute, IngestWriteBudget: time.Minute,
}) })
return h, st return h, st, cat, seen
}
// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
type records struct {
mu sync.Mutex
msg []string
}
func (r *records) Enabled(context.Context, slog.Level) bool { return true }
func (r *records) Handle(_ context.Context, rec slog.Record) error {
r.mu.Lock()
defer r.mu.Unlock()
r.msg = append(r.msg, rec.Message)
return nil
}
func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
func (r *records) WithGroup(string) slog.Handler { return r }
func (r *records) has(msg string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, m := range r.msg {
if m == msg {
return true
}
}
return false
}
func (r *records) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.msg = nil
} }
+11 -1
View File
@@ -103,11 +103,21 @@ var secretHeaders = map[string]bool{
// redacted подменяет значение, которое оказалось секретом. // redacted подменяет значение, которое оказалось секретом.
const redacted = "[redacted]" const redacted = "[redacted]"
// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
// токеном, всё равно какого контура.
func (a *api) isSecretValue(v string) bool {
return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
}
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты. // safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
// //
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение, // Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
// совпавшее с настроенным токеном, подменяется — токен можно положить в // совпавшее с настроенным токеном, подменяется — токен можно положить в
// заголовок с произвольным именем, и угадать его мы не можем. // заголовок с произвольным именем, и угадать его мы не можем.
//
// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен
// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе
// доставок навсегда.
func (a *api) safeHeaders(r *http.Request) map[string][]string { func (a *api) safeHeaders(r *http.Request) map[string][]string {
out := make(map[string][]string, len(r.Header)+1) out := make(map[string][]string, len(r.Header)+1)
for name, values := range r.Header { for name, values := range r.Header {
@@ -117,7 +127,7 @@ func (a *api) safeHeaders(r *http.Request) map[string][]string {
} }
safe := make([]string, len(values)) safe := make([]string, len(values))
for i, v := range values { for i, v := range values {
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) { if a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) {
safe[i] = redacted safe[i] = redacted
continue continue
} }
+79
View File
@@ -6,12 +6,16 @@ import (
"context" "context"
"flag" "flag"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/ident" "git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store" "git.vakhrushev.me/av/healthlog/internal/store"
) )
@@ -132,6 +136,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем. // Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint) t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
measureStyles(t, dst)
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним // Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
// `date` лежит до трёх записей (docs/local-research.md, находка 47). // `date` лежит до трёх записей (docs/local-research.md, находка 47).
// //
@@ -151,6 +157,72 @@ func TestReplayЖивогоАрхива(t *testing.T) {
t.Logf("координат сна %d, различных меток %d", coords, labels) t.Logf("координат сна %d, различных меток %d", coords, labels)
} }
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
// живого архива, — единственное место, где правило проверяется на настоящем
// потоке, а не на фикстурах.
//
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
// живого архива в гейт не входит, так что константа, производная от размера
// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные
// числа печатаются.
func measureStyles(t *testing.T, st *store.Store) {
t.Helper()
started := time.Now()
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
elapsed := time.Since(started)
byStyle := map[catalog.Style]int{}
for _, m := range metrics {
byStyle[m.Aggregation.Style]++
// Главное свойство правила: свидетельства единогласны. Противоречие —
// событие для разбора, а не отказ прогона, поэтому оно печатается с
// координатами и валит тест: пока его нет, посылка «род измерим» верна.
if m.Aggregation.Conflicting > 0 {
t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись",
m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing)
}
// Род измеряется только сверкой минутного слоя с часовым. Метрика с
// объявленным родом обязана иметь оба слоя: иначе он выведен из
// нижнего, а нижний слой HAE — посекундная развёртка, и его сумма
// завышена.
if m.Aggregation.Style == catalog.Unknown {
continue
}
var hasMinute, hasHour bool
for _, l := range m.Layers {
hasMinute = hasMinute || l.Layer == string(hae.LayerMinute)
hasHour = hasHour || l.Layer == string(hae.LayerHour)
}
if !hasMinute || !hasHour {
t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers)
}
}
// Роды разошлись: правило различает накопительные и мгновенные, а не
// сваливает всё в одну кучу и не отвечает `unknown` на весь корпус.
if byStyle[catalog.Cumulative] == 0 {
t.Error("накопительных метрик не нашлось — правило не различает роды")
}
if byStyle[catalog.Instant] == 0 {
t.Error("мгновенных метрик не нашлось — правило не различает роды")
}
t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d",
len(metrics), elapsed.Round(time.Millisecond),
byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown])
for _, m := range metrics {
t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d",
m.Metric, m.Aggregation.Style, m.Aggregation.Hours,
m.Aggregation.Compared, m.Aggregation.Agreeing)
}
}
// countSleepKeys возвращает число различных координат записей сна и число // countSleepKeys возвращает число различных координат записей сна и число
// различных меток начала. Разница между ними и есть то, что теряет ключ по // различных меток начала. Разница между ними и есть то, что теряет ключ по
// метке. // метке.
@@ -219,3 +291,10 @@ func gunzip(t *testing.T, body []byte) []byte {
} }
return out return out
} }
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}
+292
View File
@@ -0,0 +1,292 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// Запросы каталога вынесены константами: по ним же проверяется план выполнения.
// Обе выборки обязаны отвечать по покрывающему индексу `bucket_catalog`, не
// касаясь строк таблицы — вместе со строкой пришло бы и сжатое содержимое.
const (
layerRangesQuery = `
SELECT metric, layer, units, min(first_ts), max(last_ts), sum(points)
FROM bucket
GROUP BY metric, layer, units
ORDER BY metric, layer, units`
// Общие часы: самые свежие часы, за которые у метрики есть объекты обоих
// слоёв. Возвращаются вместе с числом точек и единицами каждой стороны —
// эти колонки лежат в том же покрывающем индексе, а стоят они того, чтобы
// решать пригодность часа НЕ разжимая содержимое.
//
// Горизонт (`f.hour_utc <= ?`) обязателен. Час объекта берётся из метки в
// теле доставки, а тело мы не контролируем: без верхней границы одна
// доставка с метками в будущем занимает всё окно и подменяет измеренный род
// метрики. Свидетельство из будущего свидетельством не является.
commonHoursQuery = `
SELECT f.hour_utc, f.points, f.units, c.points, c.units
FROM bucket f
JOIN bucket c ON c.metric = ? AND c.layer = ? AND c.hour_utc = f.hour_utc
WHERE f.metric = ? AND f.layer = ? AND f.hour_utc <= ?
ORDER BY f.hour_utc DESC
LIMIT ?`
)
// hourPairsQuery строит выборку объектов окна: ОДИН запрос на любое число
// часов. Отдельной функцией потому, что число подстановок переменное, а форма
// «один запрос независимо от размера окна» — проверяемое свойство, а не
// намерение.
func hourPairsQuery(hours int) string {
return `
SELECT layer, hour_utc, payload FROM bucket
WHERE metric = ? AND layer IN (?, ?) AND hour_utc IN (` +
strings.TrimSuffix(strings.Repeat("?,", hours), ",") + `)`
}
// LayerRange — один разрез метрики: слой, единицы, границы данных, число точек.
//
// Границы — это границы ДАННЫХ, а не обещание покрытия: внутри диапазона законно
// есть дыры (часы без доставок, периоды, чьи верхние слои не пережили
// пересборку). Единицы входят в разрез потому, что группировка идёт вместе с
// ними: расхождение единиц у объектов одной метрики обязано быть видно строкой,
// а не выбираться молча.
type LayerRange struct {
Metric string
Layer string
Units string
From time.Time
To time.Time
Points int
}
// HourPair — час, за который у метрики есть объекты обоих слоёв сразу.
// Именно на таких часах и держится измерение рода агрегации.
//
// Точки заполнены не всегда: содержимое читается только у часов, прошедших
// предварительный отбор по учётным колонкам (см. CatalogWindow). У остальных
// известны число точек и единицы — этого хватает, чтобы признать час
// непригодным, не разжимая ни байта.
type HourPair struct {
Hour time.Time
FinePoints int
FineUnits string
Fine []Point
CoarsePoints int
CoarseUnits string
Coarse []Point
}
// CatalogWindow — что считать окном измерения. Все числа задаёт вызывающий:
// правило измерения принадлежит домену, хранилище лишь выбирает по нему строки.
type CatalogWindow struct {
// Fine и Coarse — мелкий и крупный слои сверки.
Fine, Coarse string
// Hours — сколько самых свежих общих часов брать.
Hours int
// Horizon — верхняя граница: часы позже неё в окно не входят.
Horizon time.Time
// CoarsePoints — сколько точек обязан нести объект крупного слоя, чтобы час
// стоило читать. MinFinePoints — сколько минимум обязан нести мелкий.
// Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: он экономит
// разжатие заведомо непригодных часов, а решение принимает домен.
CoarsePoints int
MinFinePoints int
}
// CatalogSnapshot — весь вход каталога, снятый ОДНОЙ транзакцией чтения.
//
// Единый снимок здесь не аккуратность. Приём идёт непрерывно, и фоновая свёртка
// пишет в витрину во время запроса: разрезы, снятые до её коммита, и объекты
// окна, прочитанные после, дали бы ответ, внутренне противоречивый и
// неотличимый от обычного свежего. Тот же довод записан у отпечатка витрины, и
// второй его экземпляр разошёлся бы с первым молча.
type CatalogSnapshot struct {
// Layers — разрезы всех метрик, упорядоченные по метрике, слою и единицам.
Layers []LayerRange
// Pairs — по метрике её общие часы, от САМЫХ СВЕЖИХ к старым. Метрики, у
// которой нет объектов обоих слоёв, в карте нет вовсе.
Pairs map[string][]HourPair
}
// ReadCatalog снимает вход каталога: разрезы всех метрик и объекты окна.
//
// Число обращений к базе — `1 + 2×метрик` и от размера окна НЕ зависит: объекты
// окна читаются пакетом, одним запросом на метрику. Чтение по объекту за раз
// давало бы под сотню обращений на метрику, каждое своей транзакцией.
//
// Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.
// Разжимать всё подряд означало бы платить памятью за часы, чей вердикт заранее
// известен: замер на раздутой витрине давал 109 МиБ аллокаций при нуле
// пригодных часов.
func (s *Store) ReadCatalog(ctx context.Context, w CatalogWindow) (CatalogSnapshot, error) {
out := CatalogSnapshot{Pairs: make(map[string][]HourPair)}
switch {
case w.Hours <= 0:
return CatalogSnapshot{}, fmt.Errorf("окно каталога: часов должно быть больше нуля, задано %d", w.Hours)
case w.Fine == w.Coarse:
return CatalogSnapshot{}, fmt.Errorf("окно каталога: слои сверки совпадают (%q)", w.Fine)
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return CatalogSnapshot{}, fmt.Errorf("begin read tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
out.Layers, err = readLayerRanges(ctx, tx)
if err != nil {
return CatalogSnapshot{}, err
}
for _, metric := range metricsWithBothLayers(out.Layers, w.Fine, w.Coarse) {
pairs, err := commonHours(ctx, tx, metric, w)
if err != nil {
return CatalogSnapshot{}, err
}
if len(pairs) == 0 {
continue
}
if err := readHourPairs(ctx, tx, metric, w, pairs); err != nil {
return CatalogSnapshot{}, err
}
out.Pairs[metric] = pairs
}
return out, nil
}
// readLayerRanges отвечает по покрывающему индексу: содержимое объектов ради
// границ и счётчиков не разжимается и даже не читается.
func readLayerRanges(ctx context.Context, tx *sql.Tx) ([]LayerRange, error) {
rows, err := tx.QueryContext(ctx, layerRangesQuery)
if err != nil {
return nil, fmt.Errorf("select layer ranges: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]LayerRange, 0, 64)
for rows.Next() {
var r LayerRange
var from, to string
if err := rows.Scan(&r.Metric, &r.Layer, &r.Units, &from, &to, &r.Points); err != nil {
return nil, fmt.Errorf("scan layer range: %w", err)
}
if r.From, err = ParseTime(from); err != nil {
return nil, err
}
if r.To, err = ParseTime(to); err != nil {
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select layer ranges: %w", err)
}
return out, nil
}
// metricsWithBothLayers отбирает метрики, у которых есть оба слоя. Без отбора
// пришлось бы спрашивать общие часы у метрик, где второго слоя заведомо нет, —
// два лишних запроса на каждую.
func metricsWithBothLayers(layers []LayerRange, fine, coarse string) []string {
seen := make(map[string]map[string]bool, len(layers))
order := make([]string, 0, len(layers))
for _, l := range layers {
set, ok := seen[l.Metric]
if !ok {
set = map[string]bool{}
seen[l.Metric] = set
order = append(order, l.Metric)
}
set[l.Layer] = true
}
out := make([]string, 0, len(order))
for _, m := range order {
if seen[m][fine] && seen[m][coarse] {
out = append(out, m)
}
}
return out
}
// commonHours возвращает самые свежие часы окна, от свежих к старым, вместе с
// учётными колонками обеих сторон. Содержимого не читает.
func commonHours(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow) ([]HourPair, error) {
rows, err := tx.QueryContext(ctx, commonHoursQuery,
metric, w.Coarse, metric, w.Fine, FormatTime(w.Horizon), w.Hours)
if err != nil {
return nil, fmt.Errorf("select common hours: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]HourPair, 0, w.Hours)
for rows.Next() {
var p HourPair
var raw string
if err := rows.Scan(&raw, &p.FinePoints, &p.FineUnits, &p.CoarsePoints, &p.CoarseUnits); err != nil {
return nil, fmt.Errorf("scan common hour: %w", err)
}
if p.Hour, err = ParseTime(raw); err != nil {
return nil, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select common hours: %w", err)
}
return out, nil
}
// readHourPairs дочитывает содержимое объектов — ОДНИМ запросом и только у
// часов, прошедших отбор по учётным колонкам.
func readHourPairs(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow, pairs []HourPair) error {
at := make(map[string]int, len(pairs))
args := make([]any, 0, len(pairs)+3)
args = append(args, metric, w.Fine, w.Coarse)
for i := range pairs {
if pairs[i].CoarsePoints != w.CoarsePoints || pairs[i].FinePoints < w.MinFinePoints {
continue
}
key := FormatTime(pairs[i].Hour)
at[key] = i
args = append(args, key)
}
if len(at) == 0 {
return nil
}
rows, err := tx.QueryContext(ctx, hourPairsQuery(len(at)), args...)
if err != nil {
return fmt.Errorf("select hour pairs: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var layer, hour string
var payload []byte
if err := rows.Scan(&layer, &hour, &payload); err != nil {
return fmt.Errorf("scan hour pair: %w", err)
}
i, ok := at[hour]
if !ok {
continue
}
points, err := decodePayload(payload)
if err != nil {
return err
}
if layer == w.Fine {
pairs[i].Fine = points
} else {
pairs[i].Coarse = points
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("select hour pairs: %w", err)
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"context"
"path/filepath"
"strings"
"testing"
)
// План выполнения — единственный оракул требования «каталог не читает
// содержимого объектов». `bucket` объявлена WITHOUT ROWID, то есть строка
// целиком, вместе со сжатым payload, живёт в дереве первичного ключа: обход по
// нему тащил бы страницы содержимого. Проверяется, что обе выборки идут по
// ПОКРЫВАЮЩЕМУ индексу — по нему таблица не открывается вовсе.
func TestПланЗапросовКаталогаИдётПоИндексу(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
cases := []struct {
name string
sql string
args []any
}{
{"разрезы метрик", layerRangesQuery, nil},
{"общие часы двух слоёв", commonHoursQuery,
[]any{"step_count", "hour", "step_count", "minute", "2026-08-02T00:00:00Z", 48}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
plan := explain(t, st, c.sql, c.args...)
t.Logf("план: %s", plan)
if !strings.Contains(plan, "COVERING INDEX bucket_catalog") {
t.Errorf("выборка не идёт по покрывающему индексу:\n%s", plan)
}
// Обращение к самой таблице выглядит в плане как SCAN/SEARCH bucket
// без слова INDEX — именно оно и тащило бы страницы содержимого.
for _, line := range strings.Split(plan, "\n") {
if strings.Contains(line, "bucket") && !strings.Contains(line, "INDEX") {
t.Errorf("строка плана читает таблицу, а не индекс: %q", line)
}
}
})
}
}
func explain(t *testing.T, st *Store, query string, args ...any) string {
t.Helper()
rows, err := st.db.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
if err != nil {
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
t.Fatalf("разбор плана: %v", err)
}
out = append(out, detail)
}
if err := rows.Err(); err != nil {
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
}
if len(out) == 0 {
t.Fatal("план пуст — проверять нечего")
}
return strings.Join(out, "\n")
}
// Свойство, ради которого выборка объектов окна вынесена отдельной функцией:
// сколько бы часов ни было в окне, запрос ОДИН. Чтение по объекту за раз давало
// бы под сотню обращений на метрику и столько же снимков витрины.
func TestВыборкаОкнаОстаётсяОднимЗапросом(t *testing.T) {
t.Parallel()
for _, hours := range []int{1, 2, 48, 200} {
q := hourPairsQuery(hours)
if n := strings.Count(q, ";"); n != 0 {
t.Errorf("часов %d: в запросе %d разделителей — это уже не один запрос", hours, n)
}
// Три подстановки на метрику и слои плюс по одной на час.
if got, want := strings.Count(q, "?"), hours+3; got != want {
t.Errorf("часов %d: подстановок %d, ждали %d", hours, got, want)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package store_test
import (
"context"
"encoding/json"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func window(hours int, horizon time.Time) store.CatalogWindow {
return store.CatalogWindow{
Fine: "minute", Coarse: "hour",
Hours: hours, Horizon: horizon,
CoarsePoints: 1, MinFinePoints: 2,
}
}
// hourly кладёт метрику в оба слоя за `hours` часов начиная с `base`.
func hourly(t *testing.T, st *store.Store, metric, units string, base time.Time, hours int) {
t.Helper()
var in []store.IncomingPoint
for i := range hours {
h := base.Add(time.Duration(i) * time.Hour)
in = append(in, store.IncomingPoint{
Metric: metric, Layer: "hour", Units: units,
Point: store.Point{Start: h, End: h, Raw: json.RawMessage(`{"qty":6}`)},
})
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
at := h.Add(time.Duration(m) * time.Minute)
in = append(in, store.IncomingPoint{
Metric: metric, Layer: "minute", Units: units,
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)},
})
}
}
if _, err := st.Merge(context.Background(), store.Incoming{Points: in},
store.DeliveryRef{ID: "delivery"}); err != nil {
t.Fatalf("слияние: %v", err)
}
}
func TestReadCatalogОтдаётРазрезыИОкно(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
hourly(t, st, "step_count", "count", base, 3)
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(snap.Layers) != 2 {
t.Fatalf("разрезов %d, ждали 2: %+v", len(snap.Layers), snap.Layers)
}
pairs := snap.Pairs["step_count"]
if len(pairs) != 3 {
t.Fatalf("общих часов %d, ждали 3", len(pairs))
}
// От свежих к старым — на этот порядок опирается и окно, и границы основания.
if !pairs[0].Hour.After(pairs[len(pairs)-1].Hour) {
t.Errorf("часы пришли не от свежих к старым: %v", pairs[0].Hour)
}
if len(pairs[0].Coarse) != 1 || len(pairs[0].Fine) != 3 {
t.Errorf("содержимое пары не прочитано: %+v", pairs[0])
}
if pairs[0].FineUnits != "count" || pairs[0].CoarseUnits != "count" {
t.Errorf("единицы сторон не прочитаны: %+v", pairs[0])
}
}
// Час объекта берётся из метки в теле доставки. Без горизонта одна доставка с
// метками в будущем занимает окно целиком и подменяет измеренный род.
func TestReadCatalogОтсекаетБудущиеЧасы(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
hourly(t, st, "step_count", "count", base, 2)
hourly(t, st, "step_count", "count", ts(t, "2099-01-01T00:00:00Z"), 5)
horizon := base.Add(24 * time.Hour)
snap, err := st.ReadCatalog(t.Context(), window(48, horizon))
if err != nil {
t.Fatalf("каталог: %v", err)
}
pairs := snap.Pairs["step_count"]
if len(pairs) != 2 {
t.Fatalf("часов в окне %d, ждали 2: будущее не отсечено", len(pairs))
}
for _, p := range pairs {
if p.Hour.After(horizon) {
t.Errorf("час %v позже горизонта %v", p.Hour, horizon)
}
}
}
// Окно ограничено сверху и берёт самые свежие часы.
func TestReadCatalogОграничиваетОкно(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
hourly(t, st, "step_count", "count", base, 10)
snap, err := st.ReadCatalog(t.Context(), window(4, base.Add(48*time.Hour)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
pairs := snap.Pairs["step_count"]
if len(pairs) != 4 {
t.Fatalf("часов %d, ждали 4", len(pairs))
}
if !pairs[0].Hour.Equal(base.Add(9 * time.Hour)) {
t.Errorf("самый свежий час %v, ждали %v", pairs[0].Hour, base.Add(9*time.Hour))
}
}
// Содержимое читается только у часов, прошедших отбор по учётным колонкам:
// разжимать заведомо непригодный час незачем, а знать о нём — обязательно.
func TestReadCatalogНеЧитаетСодержимоеНепригодныхЧасов(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
in := []store.IncomingPoint{
// Часовой объект с двумя точками: час непригоден.
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
Point: store.Point{Start: base, End: base, Raw: json.RawMessage(`{"qty":1}`)}},
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
Point: store.Point{Start: base, End: base.Add(time.Hour), Raw: json.RawMessage(`{"qty":1}`)}},
}
for m, v := range []string{`{"qty":1}`, `{"qty":2}`} {
at := base.Add(time.Duration(m) * time.Minute)
in = append(in, store.IncomingPoint{Metric: "apple_stand_hour", Layer: "minute", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)}})
}
if _, err := st.Merge(t.Context(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
pairs := snap.Pairs["apple_stand_hour"]
if len(pairs) != 1 {
t.Fatalf("часов %d, ждали 1", len(pairs))
}
if pairs[0].CoarsePoints != 2 || pairs[0].FinePoints != 2 {
t.Errorf("учётные колонки не прочитаны: %+v", pairs[0])
}
if pairs[0].Coarse != nil || pairs[0].Fine != nil {
t.Errorf("содержимое непригодного часа разжато напрасно: %+v", pairs[0])
}
}
func TestReadCatalogОтвергаетНеверноеОкно(t *testing.T) {
t.Parallel()
st := open(t)
cases := map[string]store.CatalogWindow{
"нулевое окно": {Fine: "minute", Coarse: "hour", Hours: 0},
"слои совпадают": {Fine: "minute", Coarse: "minute", Hours: 48},
"окно отрицательно": {Fine: "minute", Coarse: "hour", Hours: -1},
}
for name, w := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := st.ReadCatalog(context.Background(), w); err == nil {
t.Error("окно вне контракта принято без ошибки")
}
})
}
}
+9
View File
@@ -45,3 +45,12 @@ func Transient(err error) bool {
} }
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy) return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
} }
// ErrClosed — хранилище закрыто, и заводить новые соединения к нему поздно.
//
// Отдельная ошибка, а не общая: запрос версии витрины и остановка сервиса идут
// в разных горутинах, и запрос, успевший в это окно, обязан получить отказ, а
// не открыть базу заново. Соединение, открытое после закрытия пула, оставило бы
// последним себя — SQLite не сделал бы финальный чекпойнт, и рядом с базой
// остался бы неразобранный `-wal`.
var ErrClosed = errors.New("хранилище закрыто")
@@ -0,0 +1,26 @@
-- +goose Up
-- Каталог разрезов отвечает по учётным колонкам объекта, а не по его
-- содержимому. Без индекса это невозможно: `bucket` объявлена `WITHOUT ROWID`,
-- то есть строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
-- ключа. Агрегат по всем строкам тащил бы за собой страницы содержимого — при
-- 260 тысячах объектов за год это сотни мегабайт чтения на каждый запрос
-- каталога, притом что сам ответ несёт три десятка строк.
--
-- Индекс ПОКРЫВАЮЩИЙ: в нём есть всё, что спрашивает каталог, поэтому обращения
-- к самой таблице не будет вовсе. Порядок колонок задан двумя запросами:
--
-- 1. разрезы метрики — GROUP BY metric, layer (+ units, чтобы расхождение
-- единиц было видно строкой, а не выбиралось молча);
-- 2. общие часы двух слоёв — обход hour_utc по убыванию внутри (metric,
-- layer), поэтому hour_utc стоит третьим и до колонок значений.
--
-- Первичный ключ (metric, layer, hour_utc) в индекс дописывается самим SQLite:
-- у таблицы `WITHOUT ROWID` строка адресуется им.
--
-- Цена — около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на
-- запись объекта. Платит её только настоящее изменение: широкий проход, у
-- которого сошёлся хеш содержимого, объект не переписывает вовсе.
CREATE INDEX bucket_catalog ON bucket (metric, layer, hour_utc, first_ts, last_ts, points, units);
-- +goose Down
DROP INDEX bucket_catalog;
+27
View File
@@ -23,6 +23,10 @@ var migrationsFS embed.FS
// Store — доступ к витрине. // Store — доступ к витрине.
type Store struct { type Store struct {
db *sqlx.DB db *sqlx.DB
// probe — закреплённое соединение версии витрины, заводится по первому
// запросу версии. См. version.go: значение `data_version` локально для
// соединения, и брать его из пула нельзя.
probe versionProbe
} }
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции. // Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
@@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64,
// Close закрывает соединение с БД. // Close закрывает соединение с БД.
func (s *Store) Close() error { func (s *Store) Close() error {
// Щуп версии закрывается сам и раньше пула: закреплённое соединение
// переживает `db.Close()` и продолжает отвечать на запросы — проверено.
s.closeProbe()
if err := s.db.Close(); err != nil { if err := s.db.Close(); err != nil {
return fmt.Errorf("close sqlite: %w", err) return fmt.Errorf("close sqlite: %w", err)
} }
return nil 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 для параллельного чтения во время // dsn собирает строку подключения: WAL для параллельного чтения во время
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала. // записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
// //
@@ -166,11 +190,14 @@ func (s *Store) Close() error {
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого // запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных // busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
// слияния из 800 против 800 из 800. // слияния из 800 против 800 из 800.
//
// `journal_size_limit` — см. константу выше.
func dsn(path string) string { func dsn(path string) string {
q := url.Values{} q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(on)") q.Add("_pragma", "foreign_keys(on)")
q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit))
q.Add("_txlock", "immediate") q.Add("_txlock", "immediate")
return "file:" + path + "?" + q.Encode() return "file:" + path + "?" + q.Encode()
} }
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"sync"
"git.vakhrushev.me/av/healthlog/internal/ident"
)
// dataVersionQuery — счётчик коммитов, видимых соединению.
//
// SQLite меняет его, когда изменения в базу закоммитило ДРУГОЕ соединение, и
// оставляет неизменным для коммитов самого соединения. Отсюда требование к
// щупу: он ничего не пишет.
const dataVersionQuery = `PRAGMA data_version`
// versionProbe — закреплённое соединение, с которого читается версия витрины.
//
// Соединение своё, а не из пула, по измеренной причине: значение `data_version`
// локально для соединения. На одном и том же состоянии базы два соединения
// одного пула отвечают разными числами, а любое СВЕЖЕЕ соединение отвечает
// одним и тем же значением независимо от содержимого базы. Версия из пула
// поэтому давала бы не только ложную инвалидацию (разные метки на
// неизменившейся витрине — не страшно), но и одинаковые метки на разных
// состояниях — то есть `304` на изменившиеся данные.
//
// Поколение выдаётся соединению и меняется вместе с ним. Без него метка не
// переживала бы рестарт: счётчик после переоткрытия начинается заново, и одно и
// то же значение до и после означало бы разные состояния витрины. Побочная
// выгода: поколение меняется и при выкатке нового бинаря, так что смена ФОРМЫ
// ответа при неизменившихся данных тоже обнуляет метки клиентов.
//
// Мьютекс здесь не только ради поля: `sql.Conn` не предназначен для
// одновременного использования из нескольких горутин, а запросов чтения бывает
// сколько угодно. Запрос при этом мгновенный — очереди на нём не образуется.
// На щупе выполняется РОВНО ОДИН вид запроса и только через
// `QueryRowContext(...).Scan(...)`. `QueryContext` и `BeginTx` на нём не
// зовутся никогда: незакрытые `Rows` или открытая транзакция удержали бы
// читающий снимок до конца жизни процесса — а щуп единственное долгоживущее
// соединение процесса. Тогда пассивный чекпойнт перестал бы продвигаться
// вовсе, и вторая половина задачи убила бы первую при полностью исправном
// обслуживании. Заодно повисли бы все читающие запросы: щуп у них общий.
type versionProbe struct {
mu sync.Mutex
conn *sql.Conn
generation string
// closed — хранилище закрыто, щупа больше не будет. Без флага гонка
// «запрос версии против Close» воскресила бы соединение уже после закрытия
// пула, и последнее соединение к базе осталось бы открытым: SQLite не
// сделал бы финальный чекпойнт, а рядом с базой остался бы `-wal`.
closed bool
}
// StateVersion отдаёт версию витрины: метку, которая меняется при любом
// коммите в базу и не меняется, пока коммитов не было.
//
// Имя не называет PRAGMA намеренно: метка это пара «поколение + счётчик», а не
// голое значение `data_version`, и область её сравнимости задаёт хранилище, а
// не SQLite. Со «версией схемы» (ErrSchemaMismatch) она не пересекается ничем.
//
// Равные метки означают, что между их снятием в базу никто ничего не записал, —
// на этом и держится условный запрос читающих маршрутов. Обратное неверно:
// метка меняется от любой записи, включая учёт доставки, витрину не менявшей.
// Это ложная инвалидация, то есть безопасная сторона.
func (s *Store) StateVersion(ctx context.Context) (string, error) {
s.probe.mu.Lock()
defer s.probe.mu.Unlock()
// Две попытки, а не цикл: единственная восстановимая беда — умершее
// соединение, и лечится она ровно одним пересозданием. Повторять дальше
// значило бы ходить по кругу за отказом, который не в соединении.
var lastErr error
for range 2 {
conn, generation, err := s.probe.acquire(ctx, s.db.DB)
if err != nil {
return "", err
}
var counter int64
err = conn.QueryRowContext(ctx, dataVersionQuery).Scan(&counter)
if err == nil {
return generation + "-" + strconv.FormatInt(counter, 10), nil
}
lastErr = err
// Непригодность соединения — это ТОЛЬКО `ErrConnDone`, и список узок
// намеренно. Измерено на этом драйвере: отмена контекста запроса щуп не
// убивает — следующий запрос на нём проходит; непригодным соединение
// становится после явного закрытия. Считать смертью щупа любую ошибку
// нельзя: занятость базы и обрыв запроса клиентом (обычные события, для
// которых в проекте заведён `Transient`) меняли бы поколение, и все
// потребители получали бы полный ответ вместо `304` — то есть механизм
// схлопывался бы ровно под нагрузкой, ради которой заведён.
if !errors.Is(err, sql.ErrConnDone) {
break
}
// Вместе с соединением выбрасывается и поколение: переиспользовать его
// нельзя — счётчик у нового соединения начнётся заново, и старая метка
// совпала бы с новой на другом состоянии.
s.probe.release()
}
return "", fmt.Errorf("read data version: %w", lastErr)
}
// VersionedRead выполняет чтение и отдаёт версию витрины, которой это чтение
// подписано. Пустая версия означает «подписать нечем» — не отказ.
//
// Правило живёт здесь, в одном экземпляре, потому что нарушить его можно ровно
// одним способом и этот способ опасен: версия, снятая ПОСЛЕ чтения, пометила бы
// устаревший снимок свежей меткой и заперла бы клиента на нём навсегда. Версия,
// снятая только ДО, допускает два разных ответа под одной меткой. Поэтому проба
// делается дважды, а метка выдаётся, только если между пробами в базу никто не
// коммитил.
//
// Read API точек и MCP заявлены потребителями той же машинерии: вторая её
// реализация «по образцу» отличалась бы от первой ровно на этот порядок, и ни
// один тест каталога этого не увидел бы.
//
// Отказ пробы версией не является и запрос не роняет: читающий маршрут
// деградирует до полного ответа, а не до отказа. Настоящий отказ базы всплывёт
// самим чтением, которое идёт следом, и будет назван один раз им.
func (s *Store) VersionedRead(ctx context.Context, read func(context.Context) error) (string, error) {
before, probeErr := s.StateVersion(ctx)
if err := read(ctx); err != nil {
return "", err
}
if probeErr != nil {
return "", nil
}
after, err := s.StateVersion(ctx)
if err != nil || after != before {
return "", nil
}
return before, nil
}
// acquire отдаёт закреплённое соединение, заводя его при первом обращении.
// Вызывается под мьютексом.
//
// Лениво, а не при открытии базы: щуп нужен читающим маршрутам, а `reindex` и
// утилиты учёта открывают ту же базу и версию не спрашивают ни разу.
func (p *versionProbe) acquire(ctx context.Context, db *sql.DB) (*sql.Conn, string, error) {
if p.conn != nil {
return p.conn, p.generation, nil
}
if p.closed {
return nil, "", ErrClosed
}
conn, err := db.Conn(ctx)
if err != nil {
return nil, "", fmt.Errorf("pin version probe connection: %w", err)
}
p.conn = conn
p.generation = ident.NewID()
return p.conn, p.generation, nil
}
// release закрывает закреплённое соединение и забывает поколение.
// Вызывается под мьютексом.
func (p *versionProbe) release() {
if p.conn == nil {
return
}
// Ошибка закрытия непригодного соединения ничего не меняет: следующий
// заход возьмёт новое.
_ = p.conn.Close()
p.conn = nil
p.generation = ""
}
// closeProbe закрывает щуп. Отдельно от пула и ДО него: закреплённое
// соединение переживает `db.Close()` и продолжает отвечать на запросы
// (проверено), то есть само по себе не закрывается ничем.
func (s *Store) closeProbe() {
s.probe.mu.Lock()
defer s.probe.mu.Unlock()
s.probe.closed = true
s.probe.release()
}
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
// Смерть щупа — не гипотеза: `sql.Conn`, закрытый кем угодно, отвечает
// `ErrConnDone` навсегда. Без пересоздания читающий контур остался бы без
// условного запроса до конца жизни процесса; с пересозданием обязано смениться
// и поколение — счётчик у нового соединения начинается заново, и старая метка
// совпала бы с новой на другом состоянии витрины.
func TestЩупПересоздаётсяСНовымПоколением(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
before, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
// Ровно то, что делает непригодным настоящее соединение: явное закрытие.
st.probe.mu.Lock()
_ = st.probe.conn.Close()
st.probe.mu.Unlock()
after, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия после смерти щупа: %v", err)
}
if generationOf(before) == generationOf(after) {
t.Errorf("поколение переиспользовано: %q → %q", before, after)
}
}
func generationOf(version string) string {
return strings.SplitN(version, "-", 2)[0]
}
// Отказ пробы — не отказ чтения: маршрут обязан деградировать до полного
// ответа, а не до `500`. Ветка исполняется только когда проба не удалась, а
// чтение прошло, и другого способа туда попасть нет.
func TestVersionedReadПриОтказеПробыОтдаётЧтениеБезВерсии(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
// Щуп закрыт, а база — нет: проба отказывает, чтение проходит.
st.closeProbe()
read := false
version, err := st.VersionedRead(t.Context(), func(context.Context) error {
read = true
return nil
})
if err != nil {
t.Fatalf("чтение подменено отказом пробы: %v", err)
}
if !read {
t.Error("чтение не выполнено")
}
if version != "" {
t.Errorf("ответ подписан версией %q, хотя проба отказала", version)
}
}
// Пул закрыт, а флаг «закрыто» не взведён: щупа нет и завести его нечем.
// Отдельная ветка от ErrClosed, и она обязана быть отказом, а не пустой
// версией — иначе отказ базы выглядел бы как «версии нет».
func TestВерсияОтказываетКогдаПулЗакрыт(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if err := st.db.Close(); err != nil {
t.Fatalf("закрытие пула: %v", err)
}
if _, err := st.StateVersion(t.Context()); err == nil {
t.Error("версия снята с закрытого пула")
}
}
// Запрос версии против закрытия хранилища: ровно та гонка, ради которой заведён
// флаг «закрыто». Проверяется под `-race`; исход законен любой, кроме
// воскресшего соединения — его ловит требование «после Close версия отказывает».
func TestВерсияПротивЗакрытия(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
const n = 8
done := make(chan struct{}, n)
for range n {
go func() {
_, _ = st.StateVersion(context.Background())
done <- struct{}{}
}()
}
_ = st.Close()
for range n {
<-done
}
if _, err := st.StateVersion(context.Background()); !errors.Is(err, ErrClosed) {
t.Errorf("после закрытия версия отвечает %v, ждали %v", err, ErrClosed)
}
}
// Оборванный клиентом запрос — обычное событие, и смертью щупа он быть не
// имеет права: иначе каждый такой обрыв менял бы поколение и обнулял метки всех
// потребителей, то есть механизм схлопывался бы под нагрузкой.
func TestОборванныйЗапросНеМеняетПоколение(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
before, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
dead, cancel := context.WithCancel(context.Background())
cancel()
if _, err := st.StateVersion(dead); err == nil {
t.Fatal("запрос на отменённом контексте прошёл — тест проверяет не то")
}
after, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия после обрыва: %v", err)
}
if generationOf(before) != generationOf(after) {
t.Errorf("обрыв запроса сменил поколение: %q → %q", before, after)
}
}
// Предел файла журнала — строка в DSN, и её пропажу не заметит ни один тест
// поведения: журнал просто останется на пике навсегда.
func TestПределЖурналаЗадан(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
var limit int64
if err := st.db.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").Scan(&limit); err != nil {
t.Fatalf("предел журнала: %v", err)
}
if limit != journalSizeLimit {
t.Errorf("предел журнала %d, ждали %d", limit, journalSizeLimit)
}
}
+222
View File
@@ -0,0 +1,222 @@
package store_test
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func version(t *testing.T, st *store.Store) string {
t.Helper()
v, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
if v == "" {
t.Fatal("версия витрины пуста")
}
return v
}
// Точка в витрину, чтобы у версии было от чего измениться.
func writePoint(t *testing.T, st *store.Store, metric string, at time.Time) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: metric, Layer: "minute", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// Главное свойство: без коммитов метка не двигается. На нём держится `304`.
func TestВерсияНеМеняетсяБезЗаписи(t *testing.T) {
t.Parallel()
st := open(t)
first := version(t, st)
// Читающие запросы версию двигать не имеют права: иначе условный запрос не
// сработал бы ни разу — каждый ответ каталога сам бы себя и обесценивал.
if _, err := st.ReadCatalog(t.Context(), window(48, time.Now())); err != nil {
t.Fatalf("каталог: %v", err)
}
if got := version(t, st); got != first {
t.Errorf("версия сдвинулась без записи: %q → %q", first, got)
}
}
// Запись из пула — то есть с ЧУЖОГО для щупа соединения — обязана быть видна.
// Это ровно случай фоновой свёртки: она пишет, пока читающий маршрут отвечает.
func TestВерсияМеняетсяПослеЗаписи(t *testing.T) {
t.Parallel()
st := open(t)
before := version(t, st)
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
after := version(t, st)
if after == before {
t.Errorf("версия не изменилась после записи: %q", before)
}
if strings.SplitN(before, "-", 2)[0] != strings.SplitN(after, "-", 2)[0] {
t.Errorf("поколение сменилось без пересоздания щупа: %q → %q", before, after)
}
}
// Счётчик `data_version` после переоткрытия базы начинается заново, и одно и то
// же значение до и после означало бы разные состояния витрины. Метку от этого
// спасает поколение — проверяем на одном и том же файле.
func TestВерсияНеПовторяетсяПослеПереоткрытия(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
first, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
before := version(t, first)
writePoint(t, first, "step_count", ts(t, "2026-06-01T10:00:00Z"))
if err := first.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
second, err := store.Open(path)
if err != nil {
t.Fatalf("повторное открытие базы: %v", err)
}
t.Cleanup(func() { _ = second.Close() })
if after := version(t, second); after == before {
t.Errorf("версия совпала через переоткрытие: %q — клиент получил бы 304 на изменившиеся данные", before)
}
}
// Версия читается конкурентно: `sql.Conn` одновременного использования не
// допускает, и без защиты это гонка, а не редкий отказ.
func TestВерсияЧитаетсяКонкурентно(t *testing.T) {
t.Parallel()
st := open(t)
const n = 16
errs := make(chan error, n)
for range n {
go func() {
_, err := st.StateVersion(context.Background())
errs <- err
}()
}
for range n {
if err := <-errs; err != nil {
t.Fatalf("версия витрины: %v", err)
}
}
}
// Подпись ответа: пока витрина стоит, чтение подписывается версией.
func TestVersionedReadПодписываетТихоеЧтение(t *testing.T) {
t.Parallel()
st := open(t)
called := false
v, err := st.VersionedRead(t.Context(), func(context.Context) error {
called = true
return nil
})
if err != nil {
t.Fatalf("чтение с версией: %v", err)
}
if !called {
t.Fatal("чтение не выполнено")
}
if v == "" {
t.Error("тихое чтение осталось без версии")
}
}
// Единственная ветка, ради которой проба делается дважды: витрина изменилась,
// пока ответ собирался. Метки быть не должно — иначе два разных ответа уехали
// бы под одной, а подписать снимок версией, снятой ПОСЛЕ него, значило бы
// запереть клиента на устаревшем ответе навсегда.
func TestVersionedReadНеПодписываетИзменившеесяЧтение(t *testing.T) {
t.Parallel()
st := open(t)
v, err := st.VersionedRead(t.Context(), func(ctx context.Context) error {
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
return nil
})
if err != nil {
t.Fatalf("чтение с версией: %v", err)
}
if v != "" {
t.Errorf("ответ подписан версией %q, хотя витрина изменилась при сборке", v)
}
}
// Отказ самого чтения версией не подменяется: это отказ операции, и он обязан
// дойти до вызывающего.
func TestVersionedReadВозвращаетОтказЧтения(t *testing.T) {
t.Parallel()
st := open(t)
want := errors.New("чтение не вышло")
if _, err := st.VersionedRead(t.Context(), func(context.Context) error { return want }); !errors.Is(err, want) {
t.Errorf("ошибка %v, ждали %v", err, want)
}
}
// После закрытия хранилища щуп не воскресает: соединение, открытое позже
// закрытия пула, осталось бы последним, и SQLite не сделал бы финальный
// чекпойнт.
func TestВерсияПослеЗакрытияОтказывает(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
if _, err := st.StateVersion(context.Background()); !errors.Is(err, store.ErrClosed) {
t.Errorf("ошибка %v, ждали %v", err, store.ErrClosed)
}
}
// Закрытие хранилища обязано оставить базу без журнала: финальный чекпойнт
// делает SQLite при закрытии ПОСЛЕДНЕГО соединения, а щуп его переживает.
// Незакрытый щуп означал бы `-wal` рядом с базой — и подмену базы пересборкой
// без хвоста записей.
func TestЗакрытиеНеОставляетЖурнала(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if _, err := st.StateVersion(t.Context()); err != nil {
t.Fatalf("версия витрины: %v", err)
}
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
if _, err := os.Stat(path + "-wal"); !os.IsNotExist(err) {
t.Errorf("рядом с базой остался журнал: %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"context"
"fmt"
)
// checkpointQuery — пассивный чекпойнт журнала WAL.
//
// Режим PASSIVE, а не TRUNCATE или RESTART, и причина измерена: TRUNCATE
// двигает `data_version`, то есть каждый чекпойнт обнулял бы условный запрос у
// всех потребителей. PASSIVE не двигает его даже перенося 12502 страницы.
// Второй довод известнее: PASSIVE ничего не ждёт — ни читателей, ни писателей.
const checkpointQuery = `PRAGMA wal_checkpoint(PASSIVE)`
// pageSizeQuery — размер страницы базы. Свойство ФАЙЛА, зафиксированное при его
// создании, а не настройка соединения: журнал считается в страницах, а предел
// файла назван в байтах, и без этого числа их не связать.
const pageSizeQuery = `PRAGMA page_size`
// Checkpoint — исход одного чекпойнта: сколько страниц лежало в журнале и
// сколько из них перенесено в базу.
//
// Отдаётся без интерпретации: решение, считать ли это бедой, принимает не
// хранилище. Знать при этом надо обе величины, а не одну — признаком служит
// именно их расхождение.
type Checkpoint struct {
// Busy — SQLite не смог взять блокировку чекпойнта. Признаком беды флаг НЕ
// является: измерено `busy=0` при 6256 страницах в журнале и пяти
// перенесённых — обработчик занятости в пассивном режиме не зовётся, и
// «не продвинулись» флагом не выражается.
//
// Зато он выражает другое, и это обязано читаться: при `busy=1` SQLite
// отдаёт `log = checkpointed = -1`, то есть исход НЕ ИЗМЕРЕН. Воспроизведено
// шестью соединениями, чекпойнтящими один файл: 200 ответов `(1, -1, -1)`
// против 40 измеренных.
Busy bool
// Log — страниц в журнале, Checkpointed — из них перенесено в базу.
// Равенство означает, что журнал разобран целиком. Отрицательные значения
// означают «не измерено» (см. Busy) и на шкале страниц не сравниваются.
Log int
Checkpointed int
// PageSize — размер страницы этой базы в байтах. Ноль означает **«не
// измерено»** и нулём не является: без него страницы не перевести в байты,
// и признак беды молчит, а не гадает.
PageSize int
}
// Known отвечает, измерен ли исход вообще.
//
// SQLite отдаёт `-1` там, где ответа нет: чекпойнт не взял блокировку либо
// журнала не существует. Внутриполосный признак («-1 на шкале страниц») —
// ровно тот приём, который Effective Go называет неуклюжим, и он опасен
// буквально: `-1 >= -1` истинно, то есть незамеренный исход читался бы как
// «журнал разобран целиком», а владельцу уходила бы строка о выздоровлении
// посреди болезни, с числом, которого не бывает.
func (c Checkpoint) Known() bool { return !c.Busy && c.Log >= 0 && c.Checkpointed >= 0 }
// Complete отвечает, разобран ли журнал целиком. Неизмеренный исход
// разобранным не считается — «не знаем» и «разобран» разные ответы.
//
// Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
// ошибки при этом не возвращает — растущий файл единственный след. Поэтому
// «журнал не разбирается» выражается здесь, а не флагом занятости.
func (c Checkpoint) Complete() bool { return c.Known() && c.Checkpointed >= c.Log }
// Stuck отвечает, перестал ли журнал разбираться: неразобранного накопилось
// больше, чем держит верхняя граница файла, и перенести это не вышло.
//
// Порог не своё число: это тот же `journal_size_limit`, только журнал считается
// в страницах, а предел назван в байтах. Одна величина в двух ролях (предел
// возвращает файл, порог сообщает, что вернуть его не выходит) — двумя
// константами они разъехались бы молча, сделав признак либо недостижимым, либо
// шумным.
//
// Размер страницы берётся у самой базы, а не предполагается: он фиксируется при
// создании файла, и база, созданная чужим инструментом с другим умолчанием,
// сместила бы порог в разы. Неизвестен — предикат молчит: гадать о пороге хуже,
// чем не сказать.
//
// Предикат живёт в хранилище, а не у вызывающего: семантика тройки
// `busy/log/checkpointed` принадлежит SQLite, и второй её экземпляр разошёлся
// бы с первым молча.
func (c Checkpoint) Stuck() bool {
if !c.Known() || c.PageSize <= 0 {
return false
}
return !c.Complete() && c.Log*c.PageSize >= journalSizeLimit
}
// CheckpointWAL переносит страницы журнала в базу и говорит, сколько удалось.
//
// Автоматический чекпойнт SQLite (`wal_autocheckpoint`, 1000 страниц) остаётся
// первой линией и отключать его незачем; этот вызов страхует случай, которого
// автоматический не закрывает по построению — запись прекратилась, а журнал
// остался неразобранным. Поток пачечный: ночью телефон молчит часами.
func (s *Store) CheckpointWAL(ctx context.Context) (Checkpoint, error) {
var busy, logPages, checkpointed int
if err := s.db.QueryRowContext(ctx, checkpointQuery).Scan(&busy, &logPages, &checkpointed); err != nil {
return Checkpoint{}, fmt.Errorf("wal checkpoint: %w", err)
}
// Отказ ЭТОГО запроса отказом чекпойнта не является: страницы уже
// перенесены, и объявить это провалом значило бы отправить владельца искать
// беду в чекпойнте. Неизвестный размер страницы предикат и так трактует как
// «не измерено» и молчит.
var pageSize int
if err := s.db.QueryRowContext(ctx, pageSizeQuery).Scan(&pageSize); err != nil {
pageSize = 0
}
return Checkpoint{Busy: busy != 0, Log: logPages, Checkpointed: checkpointed, PageSize: pageSize}, nil
}
+198
View File
@@ -0,0 +1,198 @@
package store_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite" // второе подключение к тому же файлу мимо store
"git.vakhrushev.me/av/healthlog/internal/store"
)
func checkpoint(t *testing.T, st *store.Store) store.Checkpoint {
t.Helper()
ck, err := st.CheckpointWAL(t.Context())
if err != nil {
t.Fatalf("чекпойнт: %v", err)
}
return ck
}
// Штатный случай: писателей нет, читателей нет — журнал разбирается целиком.
// Это и есть работа, которой автоматический чекпойнт не делает: он срабатывает
// по концу записи, а ночью телефон молчит часами.
func TestЧекпойнтРазбираетЖурналВТишине(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 20 {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("журнал разобран не целиком: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
if ck.Stuck() {
t.Errorf("разобранный журнал объявлен застрявшим: %+v", ck)
}
}
// Щуп версии — единственное долгоживущее соединение процесса, и он ходит в базу
// дважды на каждый читающий запрос. Останься за ним открытая читающая
// транзакция — пассивный чекпойнт перестал бы продвигаться навсегда, и вторая
// половина задачи убила бы первую при полностью исправном обслуживании.
func TestЩупНеУдерживаетЧитающийСнимок(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 10 {
if _, err := st.StateVersion(t.Context()); err != nil {
t.Fatalf("версия витрины: %v", err)
}
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("щуп удерживает снимок: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
}
// Удерживаемый читатель — ровно тот случай, ради которого признак и заведён:
// пассивный чекпойнт не идёт дальше его снимка и ОШИБКИ ПРИ ЭТОМ НЕ ВОЗВРАЩАЕТ.
// Проверяем, что признаком служит расхождение чисел, а не флаг занятости.
func TestЧекпойнтПодЧитателемНеПродвигаетсяБезОшибки(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
base := ts(t, "2026-06-01T00:00:00Z")
writePoint(t, st, "step_count", base)
// Читающая транзакция мимо store: она моделирует не наш код, а любого
// читателя, задержавшегося на снимке, — включая забытый `rows.Close()`.
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("открытие второго подключения: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
tx, err := db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
t.Fatalf("читающая транзакция: %v", err)
}
var n int
if err := tx.QueryRowContext(t.Context(), "SELECT count(*) FROM bucket").Scan(&n); err != nil {
t.Fatalf("чтение снимка: %v", err)
}
for i := 1; i < 40; i++ {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
_ = tx.Rollback()
if ck.Busy {
t.Errorf("флаг занятости взведён — признак строится не на нём: %+v", ck)
}
if ck.Complete() {
// Не Skip: на платформе, разбирающей журнал под удерживаемым читателем,
// ломается предпосылка всей задачи, а пропущенный тест выглядит зелёным
// — и вместе с ним молча исчезает единственная защита режима PASSIVE.
t.Fatalf("журнал разобран под удерживаемым читателем — предпосылка задачи не воспроизводится: %+v", ck)
}
if ck.Log <= ck.Checkpointed {
t.Errorf("перенесено не меньше, чем лежит: %+v", ck)
}
}
// Порог молчит на журнале обычного размера: иначе `WARN` шёл бы каждую минуту
// на здоровом сервисе, и уровень, по которому вмешиваются, перестал бы значить
// что-либо.
func TestНебольшойНеразобранныйЖурналНеЗастрял(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 10, Checkpointed: 0, PageSize: 4096}
if ck.Stuck() {
t.Errorf("десять неразобранных страниц объявлены бедой: %+v", ck)
}
if ck.Complete() {
t.Errorf("неразобранный журнал объявлен разобранным: %+v", ck)
}
}
// Занятый чекпойнт отдаёт `busy=1` и `-1` вместо чисел: исход НЕ ИЗМЕРЕН.
// Внутриполосный `-1` опасен буквально — `-1 >= -1` истинно, то есть
// незамеренный тик читался бы как «журнал разобран целиком», и владельцу ушла
// бы строка о выздоровлении посреди болезни.
func TestЗанятыйЧекпойнтНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() {
t.Errorf("занятый чекпойнт объявлен измеренным: %+v", ck)
}
if ck.Complete() {
t.Errorf("незамеренный исход объявлен разобранным журналом: %+v", ck)
}
if ck.Stuck() {
t.Errorf("незамеренный исход объявлен бедой: %+v", ck)
}
}
// Журнала нет вовсе — SQLite отвечает теми же `-1`. Исход тот же: молчим.
func TestОтсутствующийЖурналНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() || ck.Complete() || ck.Stuck() {
t.Errorf("исход без журнала прочитан как измеренный: %+v", ck)
}
}
// Размер страницы — свойство файла, и без него порог не выразить. Неизвестен —
// признак молчит: сместившийся в разы порог хуже, чем его отсутствие.
func TestБезРазмераСтраницыПризнакМолчит(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 1 << 20, Checkpointed: 0}
if ck.Stuck() {
t.Errorf("порог сработал при неизвестном размере страницы: %+v", ck)
}
}
// Размер страницы приходит из базы, а не предполагается кодом.
func TestЧекпойнтНазываетРазмерСтраницы(t *testing.T) {
t.Parallel()
if got := checkpoint(t, open(t)).PageSize; got <= 0 {
t.Errorf("размер страницы %d — порог выразить нечем", got)
}
}
// Отмена контекста не должна превращаться в отказ обслуживания: цикл проверяет
// её сам, а вызов обязан вернуть ошибку, а не молчаливый нулевой исход.
func TestЧекпойнтНаОтменённомКонтексте(t *testing.T) {
t.Parallel()
st := open(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := st.CheckpointWAL(ctx); err == nil {
t.Error("чекпойнт на отменённом контексте прошёл успешно")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,443 @@
## Context
Каталог (`GET /api/v1/metrics`) — первый и пока единственный читающий маршрут.
Он живёт в одном процессе с приёмом и фоновой свёрткой, и три измерения ревью
показали, что цена чтения ложится на приём:
- снимок каталога держит разжатые точки окна по всем метрикам сразу: 693 мс и
+153 МиБ живой кучи на враждебном запросе (20 метрик × 8 часов × 5000 точек);
- непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
около 7 МБ/с без верхней границы, тогда как тот же писатель без читателей
стабилизируется на 4 МБ;
- самый частый запрос трёх потребителей (агент-медик, трекер, игра) — повтор
неизменившегося, а условного запроса нет.
Решение по задаче принято до её начала: **вариант (г) + вариант (в)**. Предел
размера ответа и собственный дедлайн маршрута (вариант «а») отложены до Read
API точек, где предел всё равно проектируется; потоковое измерение по метрике
(«б») не берётся, пока счётчик не заговорит; кеш ответа («д») — последним.
Всё, что ниже, измерено на стенде этой машины тем же драйвером
(`modernc.org/sqlite`) и с тем же набором PRAGMA, что у сервиса. Числа приведены
там, где от них зависит решение.
## Goals / Non-Goals
**Goals:**
- WAL перестаёт расти без верхней границы; когда он всё же растёт, это видно
владельцу строкой лога, а не только `df`.
- Повторный запрос неизменившегося каталога не открывает снимок витрины вовсе.
- `ETag` честен: **равная метка ⟹ тот же ответ**. Ответ есть функция снимка и
горизонта измерения, и в метку входят оба (решения 7а и 7в).
- Машинерия одна на все читающие маршруты: точки и MCP берут её готовой.
- Горутина чекпойнта дренируется осознанно, как воркер свёртки.
**Non-Goals:**
- Предел числа метрик и точек в ответе, пагинация, собственный дедлайн
маршрута — это Read API точек.
- Потоковое измерение рода по метрике: меняет форму границы `store`/`catalog`
ради случая, которого живой поток не производит.
- Кеш ответа: третье представление того же факта и новое место, где можно
ошибиться молча.
- Конфигурируемость периода чекпойнта и порогов: ни одного основания выбирать
их снаружи сегодня нет.
## Decisions
### 1. Чекпойнт по таймеру нужен не вместо автоматического, а рядом с ним
`wal_autocheckpoint` включён по умолчанию (измерено: 1000 страниц) и запускает
пассивный чекпойнт **по концу записи**. Отсюда дыра: всплеск, раздувший WAL,
оставляет его неразобранным до следующей записи, а поток пачечный по природе —
ночью телефон молчит часами. Таймер закрывает ровно этот случай: страницы
возвращаются в базу вскоре после того, как читатели ушли, а не при следующей
доставке.
Отвергнуто **«полагаться на автоматический чекпойнт»**: он не срабатывает без
записи, то есть именно в том состоянии, ради которого таймер и заводится.
Отвергнут **`TRUNCATE`** — и по измеренной причине, а не по осторожности: он
двигает `data_version` (3 → 4 на пустом ходу), то есть каждый тик обнулял бы
`ETag` у всех потребителей. `PASSIVE` не двигает его даже при переносе 12502
страниц (3 → 3). Это же измерение объясняет, почему две части задачи вообще
уживаются в одном процессе.
**Период — минута**, тот же, что у тика воркера свёртки и у чекпойнта
Litestream. Он ничего не решает в
момент всплеска (пока читатели держат снимок, чекпойнт бессилен по построению),
и решает всё в тишине: минута против пяти неразличима по эффекту, но делает
признак «журнал не разбирается» своевременным. Холостой чекпойнт стоит одного
запроса на пустом журнале.
### 2. Признак — не `busy`, а «перенесено меньше, чем лежит»
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя, и
**ошибки при этом нет**. Измерено под удерживаемым читателем: `busy=0`,
`log=6256`, `checkpointed=5` — то есть флаг занятости молчит, а журнал растёт.
Значит признак строится на паре чисел: страниц в журнале больше порога **и**
перенесено меньше, чем лежало.
Порог назван в байтах (64 МиБ) и переводится в страницы размером страницы самой
базы — он свойство файла, и чужое умолчание сместило бы признак в разы.
Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
собственного измерения: суточный поток даёт около 23 МБ архива, то есть журнал
такого размера означает не всплеск, а удерживаемый снимок. С пределом тела
приёма связи нет, хотя порядок и совпадает: журнал растёт от чтения, а не от
размера доставки.
`WARN`, а не `ERROR`: адресат — владелец, событие «может стать проблемой»
(диск), лечится оно не кодом.
**Частота строки решается отдельно от порога.** Признак заведён ради состояния,
которое само не проходит: вечный читатель (в Go чаще всего — незакрытый
`sql.Rows`) держит снимок до конца жизни процесса. Строка на каждый тик дала бы
1440 одинаковых записей в сутки — фон, а не сигнал. Поэтому строка пишется при
входе в состояние и повторяется, только когда журнал вырос вдвое; возврат к
норме — отдельная строка `INFO`, потому что тишина иначе неотличима от «сервис
перестал проверять».
### 3. `journal_size_limit` — потому что пассивный чекпойнт файл не укорачивает
Измерено: после успешного чекпойнта (`log=12502`, `checkpointed=12502`) файл
остаётся 51 МБ — страницы переиспользуются, но диск не возвращается. С
`journal_size_limit=64 МиБ` первая же запись после чекпойнта усекает файл до
предела (проверено: 51 МБ → 8 МиБ при лимите 8 МиБ), и `data_version` при этом
двигает сама запись, а не усечение.
Это не второй механизм для одной цели: чекпойнт возвращает **страницы**, лимит
возвращает **файл**. Без первого второй никогда не срабатывает, без второго
пик, случившийся однажды, остаётся на диске навсегда.
**Предела РОСТА это не даёт, и говорить иначе нельзя.** Измерено там же: под
удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит действует
только после полного чекпойнта. То есть ровно в сценарии задачи
(перекрывающиеся читатели, 7 МБ/с) верхней границы у диска по-прежнему нет, и
единственный исход — `WARN` владельцу. Аварийный клапан, который на это ставит
Litestream (блокирующий `TRUNCATE` по порогу размера), не берётся по измеренной
причине: он двигает `data_version` и ждёт читателей. Понадобится — станет
отдельной задачей, и её ценой будет обнуление меток.
Порог `WARN` и лимит файла — **одно число**: 64 МиБ, выраженное там в
страницах. Двумя константами они разъехались бы молча, сделав признак либо
недостижимым, либо шумным.
### 4. `data_version` сравним только в пределах одного соединения — отсюда щуп
Два измерения, каждое из которых убивает наивную реализацию:
- **значения разных соединений одного пула несравнимы**: на одном и том же
состоянии базы `c1=5`, `c2=3`, а любое свежее соединение отвечает `2`
независимо от содержимого базы;
- **своя запись значение не двигает**, чужая двигает.
Отсюда следствие, которое и есть главный риск варианта (в): если брать
`data_version` из пула, два запроса на разных соединениях дают разные метки на
неизменившейся витрине (ложная инвалидация — не страшно), но и **одинаковые
метки на разных состояниях** (свежие соединения всегда отвечают `2` — вот это
уже выдача устаревшего под видом свежего).
Поэтому версия читается с **одного закреплённого соединения-щупа**
(`sql.Conn`), которое ничего больше не делает и потому никогда не двигает
собственную версию. Щуп берётся по первому запросу версии; если он становится
непригодным, он пересоздаётся, и вместе с ним меняется **поколение**.
**Что считать смертью щупа — измерено, а не предположено.** Первая редакция
этого дизайна называла причиной отмену контекста; проверка на драйвере проекта
показала обратное: запрос, оборванный отменой, возвращает `context.Canceled`, а
следующий запрос на том же соединении проходит. Непригодным `sql.Conn`
становится только после закрытия (`sql.ErrConnDone`). Различие не
академическое: считай система смертью щупа любую ошибку — каждый клиент,
оборвавший запрос по своему тайм-ауту, менял бы поколение, и все три
потребителя получали бы полный ответ вместо `304`. Механизм схлопывался бы ровно
под нагрузкой, ради которой заведён.
**Инвариант щупа записан одной строкой:** на нём выполняется ровно один вид
запроса и только через `QueryRowContext(...).Scan(...)`; `QueryContext` и
`BeginTx` не зовутся никогда. Незакрытые `Rows` на единственном долгоживущем
соединении процесса удержали бы читающий снимок навсегда — чекпойнт перестал бы
продвигаться, и метка убила бы обслуживание при полностью исправном
обслуживании. Проверяется это оракулом: после серии снятий версии пассивный
чекпойнт обязан перенести журнал целиком.
**Закрытие щупа входит в контракт, а не в реализацию.** Измерено: закреплённое
соединение переживает `db.Close()` и продолжает отвечать. Значит без явного
закрытия последнего соединения к базе не наступает вовсе — SQLite не делает
финальный чекпойнт, рядом с базой остаётся `-wal`, и пересборка, переносящая
один файл базы, теряет хвост записей молча. Отсюда же флаг «закрыто»: запрос
версии, успевший в окно между закрытием щупа и закрытием пула, не имеет права
открыть соединение заново.
Отвергнут **`FileControlDataVersion`** из `modernc.org/sqlite` (обёртка над
`SQLITE_FCNTL_DATA_VERSION`): он отражает и коммиты собственного соединения,
то есть снимает требование «щуп ничего не пишет». Цена — доступ через
`(*sql.Conn).Raw` и приведение к интерфейсу драйвера в самом чувствительном
месте ради инварианта, который держится одним небольшим файлом и проверяется
тестом. Взято простое; если щуп когда-нибудь начнёт писать, замена — три
строки.
Отвергнут **счётчик изменений со страницы 1** (`SQLITE_DBPAGE`) — по названной
чужой причине: в режиме WAL он инкрементируется не на каждой транзакции, потому
что страница 1 в журнал не попадает, если в ней самой ничего не поменялось.
### 5. Метка — пара «поколение + счётчик»
Счётчик `data_version` не переживает переоткрытия: свежее соединение всегда
отвечает `2`. Значит после рестарта метка `2` означала бы совсем другое
состояние, чем метка `2` до него, и клиент со старым `ETag` получил бы `304` на
изменившиеся данные — единственный по-настоящему опасный исход всей задачи.
Поэтому метка это `"<поколение>-<счётчик>"`, где поколение — ULID, выданный при
получении соединения-щупа.
Побочная выгода названа вслух: поколение меняется и при выкатке новой версии
бинаря, то есть смена **формы** ответа при неизменившихся данных тоже
инвалидирует метку. Цена — один полный ответ каждому потребителю после
рестарта.
### 6. Метка снимается до и после сборки ответа
Версия щупа снимается **дважды**: до открытия снимка и после его закрытия.
Совпали — метка выставляется; разошлись — ответ уходит **без `ETag`**.
Причина ровно в обещании сильной метки. Если снять версию только до сборки, то
коммит, случившийся во время сборки, даёт ответ более свежий, чем его метка, —
и два ответа с одной меткой могут различаться байтами. Устаревания это не даёт
(следующий запрос увидит другую версию и получит `200`), но обещание «равная
метка ⟹ те же байты» перестаёт быть верным, а на нём держится весь смысл
`304`.
Снимать версию **после** сборки и выставлять её нельзя категорически: это
пометило бы старый снимок новой версией, то есть заперло бы клиента на
устаревшем ответе навсегда — ровно тот единственный исход, которого нельзя
допускать.
Цена ветки названа и измерена: под непрерывной свёрткой каталог перестаёт
отдавать `ETag` вовсе — при коммите раз в 60 мс и сборке каталога 97 мс
подписано 0 ответов из 15. То есть на время разбора задолженности (рестарт,
широкий проход) условный запрос выключается сам. Это деградация в безопасную
сторону — ровно сегодняшнее поведение, — и повтор сборки ради второй попытки не
берётся: он удваивает самое дорогое чтение ровно в тот момент, когда база
занята записью. На установившемся потоке цена мала: сборка 45 мс против
доставки раз в пять минут.
След у этого состояния есть, хотя и не для владельца: каталог пишет `DEBUG`,
когда ответ уходит неподписанным. Владельческий канал — `/stats`, и пункт про
долю подписанных ответов внесён в его задачу тем же изменением.
### 7. Снимок каталога версией не подписывается изнутри хранилища
Напрашивалось снимать версию внутри той же читающей транзакции, что и снимок:
там она равна версии снимка точно, без второй пробы. Отвергнуто ценой: это
требует, чтобы снимок каталога шёл по тому же закреплённому соединению, то есть
**все читающие запросы выстроились бы в очередь по одному**. Головная блокировка
на 693 мс у одного потребителя означала бы ожидание у двух других, и это уже
предел маршрута — то самое, что задача откладывает до Read API точек.
### 7а. Метка слабая (`W/`), и причина названа числом другого рода
Ответ каталога есть функция не только снимка, но и **горизонта измерения**
(`Now() + час`), а горизонт едет вместе с часами. На нормальных данных это
ничего не меняет: окно берёт самые свежие общие часы, и пока в витрине нет
меток из будущего, ход часов ответ не двигает. Но метки из будущего в витрине
возможны (сбитые часы телефона, чужое тело в приёме) — и тогда ответ меняется
без единого коммита.
Поэтому метка **слабая**: `W/"<поколение>-<счётчик>"`. Это же предписывает
общая практика для меток, построенных из состояния БД, а не из байтов ответа, и
`If-None-Match` сравнивает метки слабо в любом случае — на `304` форма не
влияет. Остаток был назван вслух — и оказался больше, чем звучал: враждебный проход
построил и прогнал путь, где та же версия витрины даёт `cumulative` против
`unknown` при нуле коммитов между. Слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое. Поэтому горизонт вошёл в метку
(решение 7в), и остаток закрыт, а не назван.
Побочное следствие, которое иначе было бы неверным: `WARN` о данных из будущего
пишется при сборке ответа, а `304` сборки не делает. С горизонтом в метке полный
ответ случается не реже раза в час на потребителя — значит и предупреждение
тоже. Без горизонта потребитель на условном опросе гасил бы его насовсем.
### 7в. Горизонт входит в метку, огрублённый до часа
Метка ответа каталога — `версия витрины . час горизонта`. Огрубление точное, а
не приблизительное: метки объектов лежат ровно на часах (проверено отдельно,
включая зоны с неполночасовым смещением), поэтому отбор `hour_utc <= горизонт`
меняется ровно при переходе горизонта через час. Цена — один полный ответ в час
на потребителя при неизменившейся витрине; сборка стоит 45 мс.
Отсюда же следует, что версию ответа спрашивает **домен, а не хранилище**:
`catalog.Service.Version` знает, что в ответ входит горизонт, а транспорт не
знает и знать не должен. Read API точек ответит на том же месте своей версией,
включающей канонизированную форму запроса.
### 7б. Читающий маршрут деградирует до полного ответа, а не до отказа
Версия не читается — ответ уходит `200` без метки. Это правило названо отдельно,
потому что естественная реализация даёт обратное: `DataVersion` возвращает
ошибку, транспорт переводит ошибку домена в `500`, и маршрут, работавший до
задачи, перестаёт работать из-за машинерии, вся ценность которой — экономия.
Отказ пробы поэтому глушится с комментарием: настоящий отказ базы всплывёт
сборкой каталога, идущей следом, и будет назван ею один раз.
### 8. Условный запрос — помощник транспорта, а не свойство каталога
`If-None-Match` разбирается и метка сравнивается в `httpapi` одним помощником:
точки и MCP получат его готовым. Сравнение слабое (`W/"x"` совпадает с `"x"`) —
так предписывает HTTP для `If-None-Match`; `*` совпадает с любой существующей
меткой.
Источник версии передаётся транспорту **функцией** (`store.StateVersion`) — той
же формой, что и `worker.Notify` у приёма: транспорт не получает доступа к
хранилищу целиком ради одного числа.
Три вещи, которые помощник обязан делать по HTTP и которые легко не сделать:
неразбираемое условие даёт `200`, а не `400` (клиент, приславший мусор, получает
данные); `*` совпадает с любой **существующей** меткой, а при её отсутствии
условие не выполнено; `304` уходит без представленческих заголовков — так же,
как их снимает `writeNotModified` в `net/http/fs.go`. Заголовок читается всеми
строками (`Header.Values`), а не первой: `If-None-Match` клиент вправе прислать
несколькими.
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
валидатора эвристическое кеширование посредником было маловероятным; с меткой
ответ становится штатно кешируемым, а при выключенной проверке токенов в запросе
нет и `Authorization`, на который опирается запрет для разделяемых кешей.
**`HEAD` маршрут не обслуживает, и это оставлено как было.** Роутер регистрирует
только `GET`, так что `HEAD /api/v1/metrics` отвечает `405` — и отвечал им до
задачи. Самый дешёвый способ спросить «изменилось ли» у клиента при этом есть:
условный `GET`, который на совпавшей метке не собирает ответа вовсе. Заводить
`HEAD` вместе с условным запросом значило бы расширять контракт маршрута
мимоходом; вопрос принадлежит Read API точек, где маршрутов станет пять.
### 8а. Остановка: обе фоновые горутины ждутся вместе
Форма ожидания названа, потому что наивное добавление второго канала в
существующий `select` закрыло бы базу по выходу **любой** из двух горутин — а
закрытая из-под воркера база даёт `ERROR` по доставке, с которой всё в порядке.
Обе горутины идут в один `sync.WaitGroup`, канал закрывается после `Wait`, и
`select` против бюджета остановки остаётся один.
Цена названа: запись о превышении бюджета больше не обвиняет воркер свёртки
поимённо — ждут двоих, и назвать виновным одного из них было бы догадкой.
Практически это всё тот же воркер (чекпойнт выходит по отмене немедленно), но
лог не должен утверждать того, чего не проверял.
Чекпойнт при этом идёт на контексте цикла, а не на отвязанном, — в отличие от
свёртки. Причина в том, что терять ему нечего: перенос страниц идемпотентен,
исхода разбора он не пишет, а следующий старт возьмёт журнал с того же места.
Зато остановка не ждёт переноса полусотни мегабайт в бюджете, который делится с
приёмом и воркером. Прерывание по отмене отказом не считается и в лог не идёт —
иначе каждый `task restart` писал бы владельцу об отказе обслуживания.
### 9. Что где живёт
- `store.DataVersion(ctx)` — щуп, поколение, пересоздание. Хранилище владеет
соединениями, и знание про `data_version` принадлежит ему.
- `store.CheckpointWAL(ctx)` — один PRAGMA, тройка чисел наружу. Решение, что с
ними делать, принимает не хранилище.
- цикл чекпойнта — `cmd/healthlog`, рядом с запуском воркера: это забота
жизненного цикла процесса, а не хранилища, и остановка у него общая с
остальными горутинами. Асимметрия с воркером свёртки (тот живёт в
`internal/replay`) названа вслух: у воркера есть доменный исход, у чекпойнта —
только строка владельцу, а чтобы поселить цикл в `store`, пришлось бы внести
туда логгер, первый в пакете. Интерпретация чисел при этом осталась в
хранилище (`Checkpoint.Stuck`).
- `Store.VersionedRead` — двойная проба вокруг чтения. В хранилище, а не в
каталоге: правило «версией, снятой после чтения, не подписывать» обязано
существовать в одном экземпляре, потому что нарушить его можно ровно одним
способом, и точки с MCP заявлены потребителями той же машинерии.
- `catalog.Service.Metrics` возвращает снимок вместе с версией — то есть
каталог решает, чем подписан его ответ, но не как это делается.
Имена доменные, а не по PRAGMA: `StateVersion`, а не `DataVersion`. Метка это
пара «поколение + счётчик», область её сравнимости задаёт хранилище, и читатель,
знающий SQLite, не должен ждать от метода голого значения `data_version`.
### 10. Как это решают другие
Обе части задачи — общепринятая практика, и брались они готовыми.
**Чекпойнт.** Документация SQLite (`wal.html`, разделы 3.1, 3.2 и 6) называет
ровно наш случай: при перекрывающихся читателях, среди которых всегда есть
активный, «чекпойнты не смогут завершиться, и файл WAL будет расти без границы»;
режим `PASSIVE` «делает столько, сколько может, не мешая другим соединениям, и
может не дойти до конца»; чекпойнт «обычно не укорачивает файл, если не задан
`journal_size_limit`». Механика признака взята из `wal_checkpoint_v2`:
полнота — это `checkpointed == log`, а `busy` в пассивном режиме не значит
ничего, потому что обработчик занятости в нём не зовётся вовсе. Это же
объясняет измеренное `busy=0` при пяти перенесённых страницах из 6256.
**Litestream** ближе всех по форме: интервал чекпойнта — **минута**, режим
`PASSIVE`, а блокирующий `TRUNCATE` — аварийный клапан по порогу размера, а не
шаг расписания. Отсюда взят период. Не взят его же совет отключать
`wal_autocheckpoint`: он продиктован тем, что Litestream владеет чекпойнтами
целиком, а у нас автоматический чекпойнт — первая линия, таймер лишь страхует
тишину. Не взят подход **rqlite** (всегда `TRUNCATE`, ожидание читателя до
250 мс): он продиктован требованием нулевого WAL для снапшота Raft, которого у
нас нет. Прикладные гайды по SQLite в проде (Django/`dj-lite`, «SQLite in
production») из всего этого ставят одно — `journal_size_limit` порядка 2664 МБ;
взято 64 МиБ.
Отдельная чужая находка, объясняющая, ради чего признак вообще заводится: в Go
самый частый источник вечного читателя — незакрытый `sql.Rows`, который держит
читающую транзакцию до конца жизни процесса и останавливает чекпойнты навсегда.
Это не гипотетический риск для нас: читающих запросов в проекте становится
больше с каждой задачей Read API, и `WARN` про неразобранный журнал — ровно тот
сигнал, который такую утечку показывает.
**Условный запрос.** Формулировка `pragma.html#pragma_data_version` взята
дословно и определила конструкцию: значение «локальное свойство каждого
соединения», сравнивать осмысленно «только значения одного соединения в разные
моменты», и оно «не меняется для коммитов того же соединения». Рекомендация
держать для наблюдения **отдельное соединение** взята из ответа сопровождающего
SQLite на форуме; там же названа и наша проблема рестарта («версия, полученная
следующим соединением, может быть несравнима»), которую и закрывает поколение.
Отвергнут **хеш файла базы** (так делает Datasette в неизменяемом режиме,
отдавая кусок SHA-256 в URL и год кеша): наша база пишется непрерывно, и
неизменяемого режима у неё не бывает. Взята оттуда одна мысль — маркер,
посчитанный один раз при старте, законен, и именно ей является поколение.
Отвергнут **собственный счётчик версии в таблице**: он переживает рестарт, но
это второе производное состояние рядом с витриной и лишняя запись на каждый
коммит — тот же довод, по которому в проекте не хранится измеренный род.
## Risks / Trade-offs
- **Соединение-щуп умерло, и версия перестала сравниваться** → пересоздание с
новым поколением: клиенты получают по одному полному ответу, устаревшего не
получает никто. Молчаливого варианта (переиспользовать поколение) не
существует — это и был бы опасный исход.
- **Чекпойнт конкурирует с приёмом**`PASSIVE` не ждёт ни читателей, ни
писателей: измерено при одновременной записи — `busy=0`, чекпойнт переносит
то, что может, и выходит. Ошибка чекпойнта прохода не прекращает и цикл не
убивает: логируется и ждётся следующий тик.
- **Занятость чекпойнта, держащаяся тиками подряд, немая**: незамеренный тик
ничего не говорит и ничего не меняет — правильно поодиночке, но серия таких
тиков означает растущий журнал при полном молчании. Счётчик подряд идущих
неизмеренных тиков не заводится: поток пачечный, писатель занимает блокировку
чекпойнта секундами, а тик — минутный, так что серия маловероятна. Если
окажется иначе, это увидит `/stats`.
- **Порог 64 МиБ выбран без живого профиля** → он назван числом в одном месте,
и признак сформулирован условием («страниц больше порога **и** перенесено
меньше»), а не утверждением о нагрузке.
- **Удерживающееся состояние даёт строку в минуту** → строка пишется при входе
в состояние и повторяется, только когда журнал вырос вдвое; возврат к норме —
отдельная строка. Иначе вечный читатель дал бы 1440 одинаковых `WARN` в
сутки, и владелец перестал бы их читать раньше, чем кончится диск.
- **Условный опрос гасит предупреждения каталога** (данные из будущего,
противоречащий род): они пишутся при сборке ответа, а `304` сборки не делает.
Названо в спеке каталога следствием, а не умолчано: состояние не исчезает —
следующая доставка меняет версию, и ответ соберётся.
- **`ETag` меняется чаще, чем меняется каталог**: `data_version` двигает любая
запись в базу, включая учёт доставки, не менявшей витрину. Это ложная
инвалидация, то есть безопасная сторона; обратной (метка та же, данные
другие) конструкция не допускает по построению.
- **Память маршрута остаётся без потолка** — вариант «а» отложен намеренно.
Условный запрос снимает большую часть читающих транзакций, но враждебный
первый запрос стоит столько же, сколько стоил. Это записано в задаче Read API
точек, а не забыто.
## Open Questions
Нет: развилки закрыты решением по задаче и измерениями выше.
@@ -0,0 +1,66 @@
## Why
Первый читающий маршрут (`GET /api/v1/metrics`) обошёлся дороже, чем выглядел:
два прохода ревью измерили 693 мс и +153 МиБ живой кучи на враждебном запросе,
а непрерывная запись вместе с четырьмя читающими транзакциями внахлёст дала
рост `-wal` около 7 МБ/с без верхней границы (40 МБ за пять секунд). Приём
живёт в том же процессе, и обе цены платит он: OOM убивает приём, а доставка,
не попавшая в архив, телефоном не переприсылается. Третье проявление той же
причины — повтор: спека каталога уже требует побайтового совпадения двух
ответов на неизменившейся витрине, то есть ресурс по построению пригоден для
условного запроса, а `ETag` не выставляется вовсе.
Задача берётся **перед** Read API точек намеренно: тот строится поверх этой же
машинерии, и решать один вопрос трижды (каталог, точки, MCP) нельзя.
## What Changes
- **Периодический чекпойнт WAL.** Рядом с воркером свёртки живёт горутина,
которая раз в минуту выполняет `PRAGMA wal_checkpoint(PASSIVE)` и
останавливается дренированием, как воркер. Автоматический чекпойнт SQLite
срабатывает только по концу записи, поэтому WAL, раздутый всплеском, остаётся
неразобранным до следующей доставки — а ночью телефон молчит часами.
- **Наблюдаемость непродвинувшегося чекпойнта.** Пассивный чекпойнт не идёт
дальше снимка самого старого активного читателя и **ошибки при этом не
возвращает**: измерено — `busy=0`, `log=6256`, `checkpointed=5`. Значит
единственный различимый признак — «страниц в журнале много, перенесено
меньше», и именно он идёт в `WARN` владельцу.
- **Названный предел файла журнала.** `journal_size_limit` в строке
подключения: пассивный чекпойнт возвращает страницы в базу, но файл оставляет
на пике (измерено: 51 МБ до и после успешного чекпойнта на 12502 страницы).
Роста это не ограничивает — усечение делает первая запись после полного
чекпойнта, — и так и сказано в спеке.
- **Версия витрины и условный запрос.** Хранилище отдаёт версию витрины по
`PRAGMA data_version`, каталог выставляет `ETag`, а на `If-None-Match` с
непротухшей версией отвечает `304` **не открывая снимок вовсе**.
- **Не делается** (отложено): предел размера ответа и собственный дедлайн
маршрута — их проектирует Read API точек; потоковое измерение по метрике;
кеш ответа; `HEAD` на маршруте каталога.
## Capabilities
### New Capabilities
Новых нет: обе части ложатся на существующие домены.
### Modified Capabilities
- `storage`: добавляется **версия витрины** (признак изменения «в базу никто не
коммитил»; монотонной она не является) и **обслуживание WAL** (чекпойнт по
таймеру, признак непродвижения, остановка дренированием).
- `catalog`: добавляется **условный запрос**`ETag` на ответе каталога и
`304` на `If-None-Match`, связанный с уже существующим требованием
побайтового совпадения двух ответов на неизменившейся витрине.
## Impact
- `internal/store` — закреплённое соединение-щуп для `data_version`, метод
чекпойнта WAL, `journal_size_limit` в DSN.
- `internal/catalog` — снимок каталога уезжает вместе с версией витрины.
- `internal/httpapi` — общий помощник условного запроса (им же будут
пользоваться точки и MCP), `ETag`/`304` на маршруте каталога.
- `cmd/healthlog/serve.go` — горутина чекпойнта и её дренирование в общем
бюджете остановки.
- Схема базы **не меняется**: миграции нет.
- Контракт приёма не меняется. Контракт чтения расширяется совместимо: клиент,
не присылающий `If-None-Match`, получает ровно то же, что и сегодня.
@@ -0,0 +1,139 @@
## ADDED Requirements
### Requirement: Каталог отвечает на условный запрос
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
условного запроса в том, что самый частый запрос потребителя — повтор
неизменившегося — не стоит ничего.
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое, на нём Read API строит арифметику
года.
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
потребителя при неизменившейся витрине.
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
в любом случае.
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
условно в следующий раз.
**Метка действительна только в пределах одного ресурса, и область действия
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
задачи, ведущий к выдаче не тех данных.
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
что и до появления условного запроса.
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
кеширование посредником было маловероятным; с меткой ответ становится штатно
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
сохранить любой прокси на пути.
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
отказ:
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
`304`;
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
становятся функцией смены версии витрины, а не числа запросов. Состояние при
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
трёх потребителей значило бы обесценить уровень.
#### Scenario: Повтор на неизменившейся витрине
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
менялась
- **THEN** ответ — `304` без тела, с той же меткой
#### Scenario: Витрина изменилась
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
- **THEN** ответ — `200` с полным каталогом и новой меткой
#### Scenario: Горизонт сдвинулся
- **GIVEN** витрина не менялась
- **WHEN** горизонт измерения перешёл через час
- **THEN** метка отличается от прежней
#### Scenario: Метка другого ресурса
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
- **WHEN** совпадает версия витрины
- **THEN** условие не выполнено, и ответ — `200`
#### Scenario: Клиент не спрашивает условно
- **WHEN** каталог запрашивается без `If-None-Match`
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
#### Scenario: Две метки на неизменившейся витрине совпадают
- **GIVEN** витрина не менялась между двумя запросами
- **WHEN** каталог запрошен дважды
- **THEN** метки совпадают, и тела ответов совпадают побайтово
#### Scenario: Звёздочка в условии
- **WHEN** каталог запрашивается с `If-None-Match: *`
- **THEN** ответ — `304` с текущей меткой
#### Scenario: Условие нечитаемо
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
#### Scenario: Условный запрос без токена чтения
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
- **THEN** ответ — `401`, а не `304`
#### Scenario: Витрина изменилась во время сборки ответа
- **GIVEN** между снятием версии до и после сборки в базу был коммит
- **WHEN** ответ сформирован
- **THEN** он уходит с полным телом и без заголовка `ETag`
#### Scenario: Версия витрины недоступна
- **GIVEN** версию витрины прочитать не удалось
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
#### Scenario: Условный ответ не собирает каталог
- **GIVEN** в витрине лежат данные, помеченные будущим
- **WHEN** каталог отвечает `304` по совпавшей метке
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
@@ -0,0 +1,279 @@
## ADDED Requirements
### Requirement: Хранилище отдаёт версию витрины
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
предназначена условному запросу читающих маршрутов: равные метки означают, что
между их снятием в базу никто ничего не записал.
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
поколение — идентификатор, выданный тому соединению, с которого счётчик
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
гарантируется только неравенство.
Обе части обязательны, и каждая закрывает измеренный отказ:
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
два соединения одного пула отвечают разными числами, а любое свежее
соединение отвечает одним и тем же значением независимо от содержимого базы.
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
ничем другим не занято: собственная запись соединения его версию не двигает,
и щуп, участвующий в записи, молчал бы о собственных изменениях.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
поэтому одно и то же значение до и после означает разные состояния витрины.
Без поколения клиент со старой меткой получал бы «не изменилось» на
изменившиеся данные — единственный по-настоящему опасный исход условного
запроса.
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
долгоживущее соединение процесса — становится тем самым вечным читателем,
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
журнала при полностью исправном обслуживании.
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
заведено.
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
допускается только при отказе, означающем закрытое соединение; отмена запроса
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
пересборка, переносящая один файл базы, теряет хвост записей молча.
#### Scenario: Витрина не менялась
- **GIVEN** после первого запроса версии в базу никто не писал
- **WHEN** версия запрашивается второй раз
- **THEN** обе версии совпадают
#### Scenario: Свёртка записала объект
- **GIVEN** версия витрины снята
- **WHEN** фоновая свёртка закоммитила изменения в витрину
- **THEN** следующая снятая версия отличается от прежней
#### Scenario: База переоткрыта
- **GIVEN** версия витрины снята, база закрыта и открыта заново
- **WHEN** версия снимается снова на том же файле
- **THEN** она отличается от снятой до переоткрытия
#### Scenario: Соединение-щуп стало непригодным
- **GIVEN** соединение, с которого читается счётчик, закрыто
- **WHEN** версия запрашивается снова
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
#### Scenario: Запрос версии оборван клиентом
- **GIVEN** запрос версии отменён контекстом
- **WHEN** версия запрашивается следующим запросом
- **THEN** поколение остаётся прежним
#### Scenario: Щуп не мешает разбирать журнал
- **GIVEN** версия снималась много раз подряд
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
- **THEN** журнал перенесён целиком
#### Scenario: Хранилище закрыто
- **GIVEN** хранилище закрыто
- **WHEN** запрашивается версия витрины
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
базой не остаётся файла журнала
### Requirement: Чтение подписывается версией только целиком
Система SHALL снимать версию витрины **до и после** чтения, которое ею
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
снятая только ДО, допускает два разных ответа под одной меткой.
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
потребителями той же машинерии, и вторая её реализация «по образцу»
отличалась бы от первой ровно на этот порядок — а тест первой этого не
увидел бы.
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
полного ответа, а не до отказа.
#### Scenario: Витрина стояла всё время чтения
- **WHEN** чтение выполнено и обе пробы дали одну версию
- **THEN** версия отдана
#### Scenario: Витрина изменилась во время чтения
- **GIVEN** между пробами в базу закоммитили
- **THEN** версии нет, а результат чтения отдан целиком
#### Scenario: Само чтение отказало
- **WHEN** чтение вернуло ошибку
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
ровно в измеренном случае удерживаемого читателя.
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
разы. Неизвестен — признак молчит.
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
его не выходит), и двумя разошедшимися константами признак стал бы либо
недостижимым, либо шумным — молча.
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
перестал проверять».
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
чекпойнт его не укоротил; усечение делает первая запись после полного
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
`WARN` владельцу.
#### Scenario: Журнал разбирается в тишине
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
- **WHEN** проходит период чекпойнта
- **THEN** страницы перенесены в базу без единой новой записи
#### Scenario: Читатель держит снимок
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
- **WHEN** выполняется пассивный чекпойнт
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
флаг занятости остаётся снятым
#### Scenario: Чекпойнт не взял блокировку
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
накопленное состояние не меняется
#### Scenario: Журнал невелик
- **GIVEN** в журнале меньше страниц, чем названный порог
- **WHEN** чекпойнт не смог перенести всё
- **THEN** строка `WARN` не пишется
#### Scenario: Состояние держится
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** следующий чекпойнт застаёт журнал того же размера
- **THEN** строка не повторяется
#### Scenario: Журнал разобрался
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
- **THEN** о возврате к норме сказано один раз
#### Scenario: Журнал стал мал, но не перенесён
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
размер ниже порога — нет
#### Scenario: Чекпойнт отказал
- **GIVEN** очередной чекпойнт вернул ошибку
- **WHEN** наступает следующий период
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
### Requirement: Обслуживание журнала останавливается дренированием
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
получать отмену и MUST быть дождана вместе с воркером свёртки — база
закрывается только после выхода **обеих**. Обрывать её выходом процесса
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
того же места. Прерывание по отмене отказом MUST NOT считаться.
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
проверено: ждут двоих, и назвать виновным одного из них — догадка.
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
последнего соединения к базе SQLite делает его само. Условие названо в
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
последнего соединения не наступает вовсе.
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
#### Scenario: Сервис останавливается
- **WHEN** сервис получает сигнал остановки
- **THEN** горутина чекпойнта завершается до закрытия базы
#### Scenario: Чекпойнт идёт в момент остановки
- **GIVEN** чекпойнт выполняется, когда пришла отмена
- **WHEN** он прерывается
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
#### Scenario: Бюджет остановки исчерпан
- **GIVEN** фоновые горутины не вышли в бюджет
- **WHEN** сервис завершается
- **THEN** база не закрывается, а запись лога называет этап, не указывая
виновной горутины
@@ -0,0 +1,66 @@
## 1. Версия витрины в хранилище
- [x] 1.1 Соединение-щуп: `sql.Conn`, взятый по первому запросу версии,
поколение (ULID через `internal/ident`), пересоздание с новым поколением
только при `sql.ErrConnDone` — обстоятельства поколение не меняют
- [x] 1.2 `Store.StateVersion(ctx)``PRAGMA data_version` со щупа, метка вида
`<поколение>-<счётчик>`; `Store.VersionedRead` — двойная проба вокруг
чтения; щуп закрывается раньше пула в `Close` и не воскресает после него
- [x] 1.3 Тесты: неизменившаяся база даёт ту же версию; запись из пула её
двигает; переоткрытие базы даёт другую версию; щуп пересоздаётся с новым
поколением; после `Close` версия отказывает и `-wal` рядом не остаётся;
щуп не удерживает читающий снимок
## 2. Обслуживание WAL
- [x] 2.1 `journal_size_limit` в DSN рабочего подключения, с причиной в
комментарии (пассивный чекпойнт файл не укорачивает)
- [x] 2.2 `Store.CheckpointWAL(ctx)``PRAGMA wal_checkpoint(PASSIVE)`,
наружу тройка чисел (busy, log, checkpointed) без интерпретации
- [x] 2.3 Цикл чекпойнта в `cmd/healthlog`: тик в минуту, `WARN` при
«страниц больше порога и перенесено меньше», отказ не убивает цикл
- [x] 2.4 Запуск и дренирование в `serve`: горутина ждётся в общем бюджете
остановки, отдельного чекпойнта на выходе нет
- [x] 2.5 Тесты: чекпойнт переносит страницы в тишине; удерживаемый читатель
даёт `checkpointed < log` без ошибки; порог молчит на малом журнале;
цикл выходит по отмене
## 3. Условный запрос в транспорте
- [x] 3.1 Помощник `httpapi`: разбор `If-None-Match` (список, `W/`, `*`),
слабое сравнение, `304` без тела — общий для будущих читающих маршрутов
- [x] 3.2 Источник версии передаётся транспорту функцией (как `worker.Notify`),
проверка токена чтения остаётся раньше условия
- [x] 3.3 Тесты помощника на формах заголовка: пусто, список, `W/`, `*`,
мусор
## 4. Каталог отдаёт версию
- [x] 4.1 `catalog.Service.Metrics` возвращает снимок вместе с версией:
проба до, сборка, проба после; расхождение — версии нет
- [x] 4.2 `handleMetrics`: `ETag` из версии, `304` по `If-None-Match` без
открытия снимка, ответ без `ETag` при расхождении проб
- [x] 4.3 Тесты: два ответа подряд — одна метка и одинаковые байты; после
свёртки метка другая; `304` не открывает снимок; `401` раньше `304`
## 5. Приёмочные критерии (рубрика ревью дизайна)
- [x] 5.1 Равная метка ⟹ побайтово равный ответ (кроме горизонта — назван в
дизайне); обратное направление ошибок не допускается ни в одном тесте
- [x] 5.2 Ни один новый лог не несёт значений точек, имён метрик без обрезки
и секретов; уровень выбран по адресату
- [x] 5.3 `task gate` зелёный; `task verify:archive` сходится (обслуживание
WAL и версия не меняют витрину)
- [x] 5.4 Поведенческая проверка на своём стенде из исходников (отдельный
каталог данных, рабочий контейнер не трогаем): `curl` дважды даёт
`304`, после доставки — `200`
## 6. Документация
- [x] 6.1 `docs/architecture.md`: версия витрины, условный запрос, обслуживание
WAL, отвергнутые чужие решения с причинами
- [x] 6.2 `README.md`: строка про условный запрос в примерах чтения
- [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
первого запроса, готовая машинерия условного запроса) перенесён в
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,453 @@
## Context
Витрина уже держит одну метрику в нескольких слоях одновременно
(`sample`/`raw`/`minute`/`hour`/`day`) и своей агрегации при записи не делает.
Read API обязан уметь свести ряд к запрошенной сетке — но правильная свёртка
зависит от рода метрики, а рода в потоке нет:
- **форма точки его не выдаёт**`Avg`/`Min`/`Max` есть только у `heart_rate`,
заведомо мгновенные `walking_speed` и `blood_oxygen_saturation` приходят в
`qty` ровно так же, как шаги (находка 40);
- **заголовок доставки не описывает данные**`automation-aggregation`
принимает значение `Default` у трёх разных режимов (находка 31);
- **единицы дают процентов девяносто и ломаются на краях**
`six_minute_walking_test_distance` в метрах складывать нельзя, а
`walking_running_distance` в километрах можно.
Зато витрина содержит собственную сверку: у одной метрики есть и минутный, и
часовой разрез за один и тот же час, и они приходят из разных автоматизаций.
Часовое значение либо равно сумме минутных, либо их среднему — и это
наблюдаемое различие.
Prior art по этому вопросу богат, но весь он про **объявление** рода, а не про
измерение: HealthKit зашивает `HKQuantityAggregationStyle` в тип метрики, Home
Assistant получает `state_class` от интеграции, Graphite выводит
`aggregationMethod` регуляркой по имени метрики, Prometheus/Datadog/Splunk
принимают тип от отправителя. Прецедента вывода рода из значений нет ни одного —
и паспорт проекта предупреждал об этом заранее.
## Goals / Non-Goals
**Goals:**
- Род метрики (`cumulative` / `instant` / `unknown`) выводится измерением на
накопленных данных, без ручной разметки и без списка имён в коде.
- Потребитель одним запросом узнаёт, что в хранилище есть: метрики, единицы,
слои с диапазонами и числом точек, род и основание, на котором он измерен.
- Каталог отдаёт наблюдаемое состояние витрины и ничего не досчитывает: период,
у которого верхние слои не пересобираемы, честно объявляет то, что есть.
- Стоимость каталога не растёт вместе с историей.
**Non-Goals:**
- **Свёртка ряда в ответе.** Каталог отвечает «какая свёртка осмысленна», сама
свёртка — задача Read API.
- **Правило неполного ведра** (`xFilesFactor`). Оно нужно свёртке, а не
измерению — см. решение 6.
- **Тай-брейк при равной полноте точек.** Вынут блокером — решение 8.
- **Каталог сущностей** (`workout`, `record`). Тренировки и записи адресуются
своим `id`, слоёв у них нет; их перечисление приезжает вместе с их же
маршрутами Read API.
- **Хранение измеренного рода.** Решение 2.
## Decisions
### 1. Род измеряется двумя конкурирующими гипотезами и единогласием
Для каждого часа, за который у метрики есть **и** минутный, **и** часовой
объект:
```
час пригоден, если
час не позже текущего времени плюс час
единицы обоих объектов совпадают
часовой объект несёт ровно одну точку, и она несёт значение
метка этой точки совпадает с началом часа
у минутного не меньше двух точек со значением
сумма минутных и их среднее сами различимы
вердикт пригодного часа:
часовое ≈ сумма минутных → cumulative
часовое ≈ среднее минутных → instant
иначе → час свидетельства не даёт
```
Вердикт метрики: **не меньше трёх согласных часов и ни одного противоречащего**.
Иначе — `unknown`, и свёртка по метрике не предлагается вовсе. Наличие
противоречащих часов пишется `WARN`: род — свойство, на котором Read API строит
арифметику года, и его смена не имеет права проходить молча.
**Все три сравнения — один предикат с одним допуском**, относительным, величиной
`1e-9`. Это не аккуратность, а устранение целого класса: напиши «различимость»
точным неравенством, а «сходимость» с допуском — появится час, подтверждающий
обе гипотезы сразу, и его исход молча определит порядок веток `if`. При одном
предикате такой час невыразим.
Величина названа числом, потому что от неё зависят счётчики основания в ответе.
Измерено на живом корпусе: вердикты метрик одинаковы при допуске от `1e-9` до
`1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49 —
то есть выбор не влияет на вывод, но влияет на то, что мы о нём сообщаем. Взято
строгое: канонизация содержимого округляет числа до 12 значащих цифр, значит всё
крупнее `1e-12` представлением не объясняется; `1e-9` оставляет три порядка
запаса и остаётся на шесть порядков строже любого содержательного расхождения —
сумма и среднее при `n ≥ 2` различаются не меньше чем вдвое.
Абсолютного порога нет намеренно: второй константы, которую пришлось бы
объяснять, задача не заводит. Цена названа вслух — при обоих нулях предикат
истинен, и от нулевого часа защищает не он, а проверка различимости. Полагаться
на «около нуля не сходится» нельзя: там ровно наоборот.
**Горизонт закрывает подделку и сбитые часы.** Час объекта берётся из метки в
теле доставки, а тело не наше: без верхней границы одна доставка с метками в
будущем занимает окно целиком и подменяет измеренный род. Путь построен и
прогнан — мгновенная метрика объявлялась накопительной при нуле противоречащих
часов, то есть с виду безупречным основанием. Часы позже `now + час` в окно не
входят, а сам факт данных из будущего пишется `WARN`: сбитые часы телефона и
чужое тело в приёме лечатся не кодом.
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
минутным слоем и в `count/hour` часовым даёт в полном часе
`часовое = 60 · среднее = сумма` — уверенный ложный `cumulative`, которого
правило единогласия не ловит по построению: противоречия нет, есть молчание.
Единицы лежат в том же покрывающем индексе, так что проверка не стоит ничего.
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
который покрывают минутные точки того же объекта. Сравнивать их нельзя. Условие
стоит копейки, а закрывает класс целиком — на корпусе одной зоны его не
воспроизвести, и потому оно и записано правилом, а не оставлено на «когда
поедем».
Измерено на живом архиве (123 доставки, 31 метрика, окно — все общие часы):
| исход | метрик |
|---|---|
| `cumulative` | 7 (`active_energy`, `basal_energy_burned`, `step_count`, `walking_running_distance`, `apple_stand_time`, `apple_exercise_time`, `time_in_daylight`) |
| `instant` | 9 (`heart_rate`, `respiratory_rate`, `blood_oxygen_saturation`, `environmental_audio_exposure`, `walking_speed`, `walking_step_length`, `walking_double_support_percentage`, `walking_asymmetry_percentage`, `stair_speed_up`) |
| `unknown` | 15 |
| **противоречащих часов** | **0 на всём корпусе** |
Числа сняты при допуске `1e-9` и окне в 48 часов. Полный обход всей истории дал
бы `instant` ещё и `physical_effort` (5 согласных часов за всё время против 2 в
свежем окне) — окно честно уводит редкие метрики в `unknown`, и это то же
правило, а не издержка.
Три части правила стоят каждая своей причины.
**Фильтр различимости — не украшение.** Без него
`walking_asymmetry_percentage` давала 4 часа «накопительная» против 3
«мгновенная»: в нулевом часе сумма равна среднему, и «сходится с суммой»
выполняется тождественно. Час, в котором гипотезы неразличимы, свидетельством не
является.
**Порог в три часа.** Один совпавший час — свидетельство одного часа, а на роде
потом суммируют год. Цена измерена: порог уводит в `unknown` ровно одну метрику
(`headphone_audio_exposure`, один согласный час).
**Единогласие, а не большинство.** Противоречие означает, что одна из гипотез
ложна для этой метрики; большинство голосов позволило бы объявить род при
известном контрпримере. Измеренная цена этого решения — ноль: конфликтов нет.
Альтернативы отвергнуты:
- **Разметка руками** (так делают все, кроме нас) — она и есть то, от чего
задача уходит: список из сотни метрик Apple, который устареет в день
появления новой.
- **Вывод по имени метрики** (Graphite, `pattern = \.count$`) — противоречит
инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
- **Вывод по единицам** — ломается на краях (находка 40).
- **Заголовок доставки** — врёт уже про слой, оснований верить про род нет.
- **Детекция сброса счётчика** (Prometheus `rate`, Home Assistant
`total_increasing` с допуском 10%) — отвечает на другой вопрос: «был ли
рестарт у известного счётчика», а не «счётчик ли это». К данным Apple
неприменима: монотонного накопителя в них нет, накопительная метрика приходит
уже поинтервальными значениями.
### 2. Родов два, а не четыре — потому что больше нечем измерить
HealthKit различает четыре стиля: `cumulative`, `discreteArithmetic`,
`discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
(аудиоэкспозиция, логарифмическое усреднение по энергии). Взять весь словарь
напрашивалось — и отвергнуто **измерением**: часовой слой HAE считается
арифметически, а не по Apple.
Прямое свидетельство даёт `environmental_audio_exposure`: Apple усредняет её
логарифмически, а часовое значение HAE сошлось с обычным арифметическим средним
минутных в 59 часах из 62. У `heart_rate`, который Apple взвешивает по
длительности, часовое значение сходится с арифметическим средним точно в 29
часах из 63 и с точностью 0.1% — в 49; с суммой не сошлось ни разу.
Значит четвёртый и третий стили в наших данных ничем не проявляются, и ввести
их можно было бы только разметкой руками — то есть тем, от чего задача уходит.
Правило проекта прежнее: **род, который нечем измерить, не объявляется.**
Появится источник, различающий больше родов (родной экспорт Apple несёт
интервалы сэмплов), — словарь расширится тем же измерением.
### 3. Род не хранится, а считается на запрос по ограниченному окну
Хранить измеренный род означало бы завести **второе производное состояние**
рядом с витриной: колонку, которую надо пересчитывать после каждой свёртки,
переносить или не переносить пересборкой (перечень в `architecture.md`),
мигрировать и объяснять, на каком составе данных она измерена. Цена ошибки
здесь — молчаливая: устаревшее значение выглядит ровно как свежее.
Считанный на запрос род — по построению функция текущей витрины, а витрина есть
функция журнала. Устареть нечему.
Плата — стоимость чтения, и она ограничена **окном в 48 самых свежих общих
часов** метрики. Измерено на живом корпусе: полное измерение по всем 696 парам
часов всех 31 метрики — 123 мс; окно даёт тот же результат и не даёт стоимости
расти вместе с историей (за год окно ограничивает работу 1488 парами вместо
270 тысяч).
Кеш в памяти сознательно не заводится: он вводит третье представление того же
факта, а вопрос его инвалидации («изменился ли хоть один объект окна») стоит
дороже самого измерения. Появится профиль нагрузки, показывающий обратное, —
кеш добавится с числом в руках.
### 4. В измерении участвуют только `minute` и `hour`
Нижний слой HAE — посекундная развёртка настоящих сэмплов с инфляцией 2.4×
(находка 20) и до 478× у базального обмена (находка 34); его сумма завышена, и
в сверке он не сходится. Слой `sample` из родного экспорта в витрине пока пуст,
а его точки несут собственные интервалы — их сверка с часовым слоем это другая
задача (`healthlog import`).
`day` в измерении не участвует: суточная сводка сна — не разрез часов, а другая
схема под тем же именем (находка 38).
### 5. Значение точки — `qty`, а при его отсутствии `Avg`
Единственное место, знающее, какое поле точки HAE несёт число, — пакет `hae`.
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
(находка 40), и без второго кандидата самая важная метрика потока не измерялась
бы вовсе. Точка, не несущая ни того, ни другого, в сумму не входит и число
точек часа не увеличивает.
Это чтение, а не интерпретация: значение никуда не пишется и ничего не
подменяет.
**Ноль — значение.** Словарь пустоты из `canon` сюда не годится и применяться не
должен: там ноль объявлен пустотой, чтобы точка без измерений не вытесняла
настоящее измерение при столкновении координат, — вопрос другой. Взяв его,
измерение не увидело бы точки `{"qty":0}`, час выпал бы из счётчиков ещё до
правила различимости, и сценарий «нулевой час свидетельством не является»
позеленел бы по неверной причине. Поэтому разбор здесь свой: `*json.Number` для
обоих полей, отсутствие ключа и `null` — «нет значения», ноль — значение.
**Бесконечность — не значение.** `json.Number("1e400").Float64()` возвращает
`+Inf` вместе с `ErrRange`; проглоченная ошибка отравила бы и сумму, и среднее
всего часа. Значение, не разобравшееся в конечное число, считается
неприсланным.
### 6. `xFilesFactor` здесь не нужен, и это сказано вслух
Graphite и RRDtool закрывают вопрос «что делать со свёрткой неполного ведра»
долей заполненности: ниже порога — не число, а пусто. Паспорт называл это
готовым ответом на вопрос, который у нас ещё не задан.
Измерению порог не нужен, потому что у него **две конкурирующие гипотезы**, а
не одна: неполный минутный час не сходится ни с суммой, ни со средним и
свидетельства не даёт сам собой. Это видно в измерении — у `step_count` 25
часов согласны и 17 не дали ничего; ровно эти 17 и есть неполные часы.
Свёртке в ответе порог понадобится, и вместе с ним — выбор полярности:
Graphite `xFilesFactor` задаёт долю **обязательно известных** (умолчание 0.5 при
роллапе и 0 при рендере — один параметр с двумя умолчаниями), RRDtool `xff`
долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины будут
выглядеть как «0.5», означая противоположное. Решение и его полярность
принимает задача Read API; здесь оно названо, чтобы не решалось дважды.
### 7. Разрезы отвечают по индексу, окно читается пакетом, ответ — из одного снимка
Границу надо назвать точно, иначе она запрещает то, ради чего задача есть:
**не разжимается содержимое ради разрезов и границ; объекты окна измерения
разжимаются обязательно** — сумма минутных значений иначе невычислима. Разжатых
объектов не больше `2 × 48` на метрику.
Диапазоны и число точек лежат учётными колонками объекта (`first_ts`,
`last_ts`, `points`), но `bucket` — таблица `WITHOUT ROWID`, то есть строка
целиком, вместе с `payload`, живёт в самом дереве первичного ключа. Обход всех
строк ради агрегата тащил бы за собой страницы сжатого содержимого: при
260 тысячах объектов за год это сотни мегабайт на каждый запрос каталога.
Поэтому миграция `00009` заводит **покрывающий индекс**
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`: и
агрегат разрезов, и поиск общих часов двух слоёв читают только его. Цена —
около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на запись
объекта.
Данных индекс не меняет, поэтому в перечне того, что не переносит пересборка,
ему места нет.
**Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.**
Число точек и единицы обеих сторон лежат в покрывающем индексе, а условия
пригодности «у крупного слоя ровно одна точка, у мелкого не меньше двух»
проверяются по ним. Замер на раздутой витрине: 109 МиБ аллокаций при нуле
пригодных часов — вся работа шла до того, как выяснялось, что вердикта не будет.
Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: числа задаёт домен,
хранилище лишь выбирает по ним строки.
**Объекты окна берутся пакетом и в одной транзакции чтения со всем остальным.**
Существующий `Store.Bucket` открывает собственную read-only транзакцию на каждый
вызов: окно в 48 часов дало бы под сотню транзакций на метрику, а ответ
собрался бы из смеси снимков — разрезы одного состояния витрины, род другого,
причём под непрерывным приёмом и неотличимо от обычного свежего ответа. Проект
уже записал это рассуждение у отпечатка витрины, и второй раз оно разошлось бы
молча.
Поэтому хранилище отдаёт каталогу **один снимок**: агрегат разрезов, общие часы
каждой метрики и объекты её окна — за одну транзакцию чтения, двумя запросами на
метрику плюс один общий. Число обращений к базе перестаёт зависеть от размера
окна. В WAL длинная транзакция чтения писателей не блокирует, а измеренные
130 мс на живом корпусе — цена, которую видно.
### 8. Тай-брейк при равной полноте точек не трогаем — вынут блокером
Задача обещала доделать его «по каталогу», и измерение действительно
подтвердило посылку: четыре из шести метрик, где тай-брейк системно берёт
меньшее значение (находка 49), измерены как накопительные — то есть там это
недосчёт, а у `heart_rate` (самая крупная группа) род мгновенный, и выбор
безразличен.
Но сделать тай-брейк зависящим от **измеренного** рода нельзя: род есть функция
витрины, витрина — результат слияния, и правило слияния, читающее собственную
выдачу, повторяет ровно тот дефект, на котором свёртка уже переставала быть
функцией префикса журнала (`docs/review-journal.md`, 2026-08-01). Остаются
варианты, не зависящие от рода, и выбор между ними — развилка с ценой; она
уходит блокером вместе с измеренным основанием.
### 9. Каталог живёт под токеном чтения
`GET /api/v1/metrics` — первый маршрут, который отдаёт данные наружу, поэтому
здесь же появляется проверка `auth.read_tokens`. Правило то же, что у приёма:
пустой список означает выключенную проверку, и о ней сервис предупреждает на
старте. Токен приёма каталог не открывает — раздельность контуров объявлена
архитектурой, и «пишущий умеет читать» её бы отменило.
Цена симметрии названа вслух, потому что она несимметрична: у приёма открытый
контур означает мусор во входе, у чтения — выгрузку истории здоровья любому, кто
нашёл порт. Отказ старта при пустом списке рассматривался и не взят здесь:
сегодня оба образца конфига в репозитории идут с пустыми списками сознательно
(доверенная локальная сеть), и такой отказ сломал бы `task up` до правки
конфигов, заведя асимметрию с приёмом, которую пришлось бы объяснять. Вопрос
принадлежит задаче об управлении секретами — он там уже стоит, и эта задача
добавляет ему второй контур, а не заводит третье место для того же решения.
Проверка **одна на оба контура**, параметризованная списком: копия отличалась бы
одним полем и несла бы три решения сразу — сравнение за постоянное время,
«пустой список = выключено» и текст 401, — правка любого из них в одном месте не
дала бы ни ошибки компиляции, ни красного теста.
Схема строгая: токеном считается только `Authorization: Bearer <значение>`.
Снисходительности к голому значению у приёма нет и не было; заводить её на
контуре чтения, клиенты которого свои, тем более не за чем.
Отдельно — **редакция заголовков**: сохраняемые заголовки доставки чистятся
подстановкой по списку токенов, и сегодня в этом списке только токены приёма.
Токен чтения, посланный заголовком с произвольным именем, осел бы в базе; список
становится общим.
### 10. Форма ответа
```json
{"metrics": [
{"metric": "step_count",
"units": ["count"],
"aggregation": {"style": "cumulative",
"hours": 48, "compared": 40, "agreeing": 25, "conflicting": 0,
"first_hour": "2026-07-31T09:00:00Z",
"last_hour": "2026-08-02T14:00:00Z"},
"layers": [
{"layer": "minute", "from": "2026-07-30T21:48:00Z",
"to": "2026-08-02T14:59:00Z", "points": 1102},
{"layer": "raw", "from": "…", "to": "…", "points": 25636}]}]}
```
- `units`**массив**: единицы метрики на живом потоке не менялись ни разу
(находка 48), но одна форма поля для обоих случаев честнее строки, которая при
расхождении молча выберет одно из двух. Та же форма, что у самоописания. На
слой при этом приходится ровно один элемент `layers`: строки выборки,
разошедшиеся единицами, схлопываются в общий диапазон и общую сумму точек, а
различие видно множеством единиц метрики. Не поручить это схлопывание явно
значило бы отдать клиенту два элемента с одинаковым `layer` в тот единственный
день, ради которого `units` и сделали массивом.
- `aggregation` — объект, а не строка: он несёт **основание**, и числа в нём
подобраны так, чтобы их разности были осмысленны. `hours` — сколько общих
часов попало в окно, `compared` — сколько из них оказалось пригодными,
`agreeing` и `conflicting` — вердикты пригодных. `hours compared` — часы,
отброшенные проверкой пригодности; `compared agreeing conflicting` — часы,
не сошедшиеся ни с одной гипотезой. Одного числа не хватало: «часов было 48, а
пригодным не оказалось ни одного» и «часов не было вовсе» — разные события.
- Поле называется `style`, а не `kind`: слово `kind` в проекте уже занято родом
секции записи (`record.kind`), и два смысла под одним именем в одном API — это
сноска в документации навсегда. `style` — слово HealthKit
(`HKQuantityAggregationStyle`) для ровно этого понятия.
- Значения рода остаются `cumulative` / `instant` / `unknown`. `cumulative`
совпадает со словарём HealthKit; `instant` не совпадает ни с чьим (у Apple
`discrete`, у Prometheus `gauge`, у Home Assistant `measurement`) — и взят
сознательно: `discrete` описывает **природу сэмпла**, а мы называем то, что
измерили, — свёртку средним. Архитектура пользуется словом «мгновенная» с
самого начала, и менять словарь ради чужого сходства значило бы переименовать
понятие, не изменив его.
- `first_hour`/`last_hour` вместо `from`/`to` — потому что это **ярлыки часов**,
а не метки данных: у слоя `to` — метка последней точки (`…14:59:00Z`), у окна
— начало последнего часа окна (`…14:00:00Z`), включая непригодные. Одно имя для двух
семантик в одном ответе стоило бы клиенту ошибки на час, заметной только
расхождением сумм.
- `layers` — только то, что есть. Числа часовых объектов в ответе нет: объект —
деталь хранения, клиент про него не знает.
- Поля присутствуют всегда, в том числе со значением `null`: клиент не должен
выводить смысл из наличия или отсутствия ключа. Это требует внимания к
нулевым значениям Go: nil-срез сериализуется в `null`, а нулевой `time.Time`
в правдоподобную метку `0001-01-01T00:00:00Z`, неотличимую от данных. Поэтому
срезы конструируются пустыми, границы окна — указателями, а приёмочный тест
сравнивает **байты** ответа с литералом, а не разобранную структуру с
разобранной.
- Порядок метрик и слоёв детерминирован: два ответа на неизменившейся витрине
обязаны совпасть побайтово, иначе «повторный запрос не опирается на прошлый»
нечем проверить.
## Risks / Trade-offs
- **Род измеряется по свежему окну, а метрика могла его сменить в прошлом**
окно и его границы отдаются в ответе (`from`/`to`, `compared`), то есть род
объявлен вместе с периодом, на котором измерен. Так же поступает Home
Assistant, признавая смену `state_class` разрушительным событием, а не
уточнением поля.
- **Метрика приходит только в одном слое — род не измерить никогда** → штатный
`unknown` с `compared: 0`. Сегодня это 14 метрик из 31, в том числе
`sleep_analysis` и `heart_rate_variability`. Лечится не кодом, а второй
автоматизацией HAE на том же наборе метрик.
- **Стоимость каталога растёт с числом метрик** (~4 мс на метрику на живом
корпусе) → окно ограничивает вклад каждой; при сотне метрик это порядка
полусекунды. Число измерено и попадёт в отчёт; кеш заводится по профилю
нагрузки, а не заранее.
- **Часовой слой HAE — тоже досчитываемое задним числом значение** (находка 10)
→ свежайший час окна может быть неполным и вердикта не дать. На исход это не
влияет: неполный час просто не свидетельствует, а окно в 48 часов заведомо
содержит устоявшиеся.
- **Каталог метрик не говорит о невосстановимости `stateOfMind`** — секция живёт
в `record`, а не в метриках, и её единственный источник это доставки HAE
(находка 46). Граница названа: за это отвечает ретеншен и перечень
непокрытого, а не каталог разрезов.
- **Покрывающий индекс удорожает запись объекта** → одна вставка в дерево на
объект; широкий проход, у которого хеш сошёлся, объект не переписывает вовсе,
поэтому цену платят только настоящие изменения.
- **Род дребезжит вместе со скользящим окном**: час, въехавший в окно, может
сменить `cumulative` на `unknown` без единой новой доставки за спрошенный
период, и для агента это выглядит поломкой сервиса → следствие принято вслух и
снабжено двумя средствами. Первое — основание измерения в ответе: клиент
видит, что изменилось и почему. Второе — `WARN` при появлении противоречащих
часов: событие адресовано владельцу, потому что лечится оно настройкой
автоматизаций HAE, а не кодом. Смягчать правило долей согласных вместо
единогласия отвергнуто: это объявление рода при известном контрпримере.
- **Пустой список токенов чтения открывает историю здоровья** → предупреждение на
старте и запись цены в образцах конфига; отказ старта рассмотрен и оставлен
задаче об управлении секретами (решение 9). До выкладки наружу это домашняя
сеть, после — блокирующее условие деплоя, и оно уже записано там.
- **Каталог — первая ручка, где повторный запрос стоит заметного CPU** (порядка
4 мс на метрику) → предела на размер ответа и тайм-аута у него нет, потому что
и то, и другое — правило Read API, которое пишется следующей задачей вместе с
остальными его маршрутами. Названо, чтобы не оказалось забытым.
@@ -0,0 +1,59 @@
## Why
Read API обязан уметь сворачивать метрику к запрошенной сетке, а род свёртки
(сумма или среднее) HAE не присылает: `Avg`/`Min`/`Max` есть только у
`heart_rate`, всё остальное приходит в `qty` (находка 40), заголовок доставки
про род молчит, а единицы врут на краях. Просуммировать мгновенную метрику или
сложить нижний слой HAE значит завысить ответ втрое — поэтому род измеряется
**до** Read API, а не угадывается внутри него.
Второй пробел того же размера: потребитель не может спросить «что у тебя вообще
есть». Слои и их диапазоны — часть контракта (после пересборки старый период
законно теряет верхние слои), и узнать их сегодня можно только через sqlite на
хосте.
## What Changes
- **Род агрегации измеряется сверкой минутного и часового слоёв между собой**:
часовое значение сходится с суммой минутных — метрика накопительная, с
арифметическим средним — мгновенная, ни с тем ни с другим или свидетельства
противоречат — `unknown`. Измерено на живом архиве (123 доставки, 31 метрика):
17 метрик классифицируются, 14 остаются `unknown`, **противоречивых
свидетельств ноль**.
- **Нижний слой (`raw`) в измерении не участвует и не суммируется никогда** — он
посекундная развёртка, а не сэмплы (находка 34).
- **Новая ручка `GET /api/v1/metrics`** — каталог: по каждой метрике единицы,
измеренный род с основанием измерения и список слоёв с диапазонами и числом
точек. Первый маршрут под токеном чтения; появляется проверка этого токена.
- **Род нигде не хранится**: он производен от витрины и считается на запрос по
ограниченному окну. Ни новой колонки, ни строки в перечне того, что не
переносит пересборка.
- Схема получает **только индекс** (`00009`): разрезы и границы обязаны
отвечать, не разжимая содержимое объектов. Само измерение содержимое читает —
иначе сумму минутных значений не получить, — но не больше `2 × 48` объектов на
метрику и одной транзакцией чтения на весь ответ.
- Тай-брейк при равной полноте точек **в этой задаче не меняется** — вынут
блокером: род измеряется из витрины, а витрина есть результат слияния, и
правило слияния, читающее собственную выдачу, повторяет дефект вывода слоя из
журнала ревью.
## Capabilities
### New Capabilities
- `catalog`: каталог разрезов и измеренный род агрегации — что за метрики есть,
в каких слоях, за какие периоды и какая свёртка по ним осмысленна.
### Modified Capabilities
- `storage`: каталог отвечает по учётным полям объекта, не разжимая `payload`;
отсюда требование к стоимости выборки разрезов.
## Impact
- Новый пакет `internal/catalog` — измерение рода и сборка каталога.
- `internal/store` — выборка разрезов метрики и общих часов двух слоёв.
- `internal/hae` — единственное место, знающее, какое поле точки несёт число.
- `internal/httpapi` — маршрут каталога и проверка токена чтения.
- `internal/store/migrations/00009_bucket_catalog.sql` — покрывающий индекс.
- Документация: `architecture.md` (метод измерения и отвергнутые чужие решения),
`database.md` (индекс), `local-research.md` (находка о результате измерения),
`config.example.toml` (read_tokens перестали быть заделом на будущее).
@@ -0,0 +1,517 @@
## ADDED Requirements
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
единицы и слои с границами данных и числом точек. Каталог MUST показывать
только то, что в витрине есть: досчитывать отсутствующий слой,
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
считать каталожную пару границ доказательством непрерывности.
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
число в ответ не идёт.
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
списке.
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
есть», нельзя ничего.
Единицы отдаются **множеством различных значений** метрики, отсортированным и
ограниченным потолком (пустые в множество не входят):
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
метрики.
#### Scenario: Метрика лежит в нескольких слоях
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
точек
#### Scenario: Слоя за период не осталось
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
`sample`
- **WHEN** запрашивается каталог
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
#### Scenario: Внутри диапазона слоя есть дыра
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
ними нет ни одного
- **WHEN** запрашивается каталог
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
каталог не утверждает, что данные есть за весь этот период
#### Scenario: Единицы метрики разошлись
- **GIVEN** объекты одной метрики несут разные единицы
- **WHEN** запрашивается каталог
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
элементом списка с объединённым диапазоном и суммой точек
#### Scenario: Метрика приехала без имени
- **GIVEN** в витрине есть объекты метрики с пустым именем
- **WHEN** запрашивается каталог
- **THEN** метрика присутствует в ответе со своими слоями
#### Scenario: Единиц у метрики стало неправдоподобно много
- **GIVEN** объекты метрики несут десятки различных строк единиц
- **WHEN** запрашивается каталог
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
элементом
#### Scenario: Витрина пуста
- **WHEN** в витрине нет ни одного объекта
- **THEN** каталог отдаёт пустой список метрик, а не отказ
### Requirement: Форма ответа каталога
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
`aggregation` и `layers`; элемент `layers``layer`, `from`, `to`, `points`;
объект `aggregation``style`, `hours`, `compared`, `agreeing`, `conflicting`,
`first_hour`, `last_hour`.
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
неотличима от настоящей.
Семантика границ различна, поэтому имена различны:
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
- `first_hour`/`last_hour`**ярлыки часов**, первого и последнего часа окна
измерения, включительно.
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
на одинаковом состоянии витрины совпадали побайтово.
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
разных смысла под одним именем в одном API — вечная сноска.
#### Scenario: Пустая витрина отдаётся пустым списком
- **WHEN** каталог запрашивается на пустой витрине
- **THEN** тело ответа — `{"metrics":[]}`
#### Scenario: Род не измерен
- **WHEN** у метрики нет общих часов двух слоёв
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
`last_hour` равны `null`
#### Scenario: Два запроса подряд дают один ответ
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
- **THEN** тела ответов совпадают побайтово
### Requirement: Число точки берётся из одного объявленного поля
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
значение поля `Avg`, и MUST NOT выводить число из других полей.
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
Значение, которое не разбирается как конечное число (строка, `null`, объект,
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
отравляет и сумму, и среднее всего часа.
Точка без числа в сумму не входит и число точек часа не увеличивает.
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
не того типа означает, что форма точки изменилась, и догадываться о числе не о
чем: точка считается не несущей значения целиком.
#### Scenario: Точка несёт только qty
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
- **THEN** её число равно `72.5`
#### Scenario: Точка несёт Min/Avg/Max без qty
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
- **THEN** её число равно значению `Avg`
#### Scenario: Нулевое значение остаётся значением
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
- **THEN** её число равно нулю, и точка считается несущей значение
#### Scenario: Значение не разбирается как конечное число
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
- **THEN** точка считается не несущей значения и в сумму не входит
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
имени метрики, единицам, форме точки или заголовку доставки.
Час **пригоден** для сверки, когда выполнено всё:
- у метрики есть объекты обоих слоёв за этот час;
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
- единицы обоих объектов совпадают;
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
совпадает с началом часа;
- у минутного объекта не меньше двух точек со значением;
- сумма минутных значений **отличима** от их среднего.
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
нуле противоречащих часов. Правило единогласия этот случай не ловит по
построению: противоречия нет, есть молчание.
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
целиком, а не «по той, у которой есть значение».
Требование выравнивания часовой метки закрывает зоны с неполночасовым
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
интервал, который покрывают минутные точки того же объекта. Сравнивать их
нельзя, и такой час свидетельства не даёт.
Требование различимости обязательно: в часе, где все значения нули, сумма равна
среднему, и совпадение с любой из гипотез не значит ничего.
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
невыразим по построению, и исход не зависит от порядка веток.
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
чем вдвое).
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
ложный род.
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
вердикт не зависел от порядка точек внутри объекта.
#### Scenario: Часовое значение равно сумме минутных
- **GIVEN** у метрики есть минутный и часовой объекты за один час
- **WHEN** часовое значение сходится с суммой минутных значений
- **THEN** метрика получает род `cumulative`
#### Scenario: Часовое значение равно среднему минутных
- **WHEN** часовое значение сходится со средним минутных значений
- **THEN** метрика получает род `instant`
#### Scenario: Нулевой час свидетельством не является
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
- **WHEN** измеряется род
- **THEN** этот час непригоден и в подсчёт согласных не идёт
#### Scenario: Час лежит в будущем
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
времени
- **WHEN** измеряется род
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
истории, и владельцу пишется предупреждение
#### Scenario: Единицы слоёв разошлись
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Часовой объект несёт две точки
- **GIVEN** у метрики за час есть часовой объект с двумя точками
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Минутный объект несёт одну точку
- **GIVEN** минутный объект часа несёт единственную точку
- **WHEN** измеряется род
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
#### Scenario: Метка часовой точки не выровнена на начало часа
- **GIVEN** часовая точка стоит на середине часа UTC
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Форма точки на исход не влияет
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
### Requirement: Род объявляется только при единогласном свидетельстве
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
именем метрики и числами основания, без значений точек: род — свойство, на
котором Read API строит арифметику года, и его смена не имеет права проходить
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
шума правило не создаёт.
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
остаётся свидетельством одного часа.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Клиент, которому это важно, различает случаи по основанию измерения — оно
отдаётся вместе с родом.
#### Scenario: Свидетельства противоречат
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
`instant`
- **WHEN** измеряется род
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
и пишется `WARN` с именем метрики
#### Scenario: Свидетельств мало
- **WHEN** согласных часов меньше трёх
- **THEN** род равен `unknown`
#### Scenario: Второго слоя нет вовсе
- **WHEN** метрика лежит только в одном слое
- **THEN** род равен `unknown`, а число часов окна равно нулю
### Requirement: Нижний слой в измерении не участвует
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
использовать в сверке слои `raw`, `sample` и `day`.
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
другая схема под тем же именем, а не разрез часов.
#### Scenario: Метрика есть только в нижнем слое
- **WHEN** у метрики есть объекты только в слое `raw`
- **THEN** род равен `unknown`
#### Scenario: Нижний слой не подменяет минутный
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
- **WHEN** измеряется род
- **THEN** сверка не выполняется и род равен `unknown`
#### Scenario: Метрика лежит только в суточном слое
- **WHEN** у метрики есть объекты только слоя `day`
- **THEN** слой объявлен в каталоге, а род равен `unknown`
### Requirement: Каталог отдаёт основание измерения, а не только вывод
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
иметь возможность отличить «свидетельств не было» от «свидетельства
противоречат», не делая второго запроса.
Числа определены так, что их разность осмысленна:
- `hours` — сколько общих часов двух слоёв попало в окно;
- `compared` — сколько из них оказалось **пригодными**;
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
объявленном роде это он и есть);
- `conflicting` — сколько дали другой.
Разложение одно и то же независимо от того, объявлен род или нет: иначе
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
одно поле двумя способами.
Разность `compared agreeing conflicting` — часы, не сошедшиеся ни с одной
гипотезой; разность `hours compared` — часы, отброшенные проверкой
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
данные молчат» там, где ни один час не был пригоден вовсе.
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
всей историей.
#### Scenario: Род измерен
- **WHEN** метрика получила род `cumulative`
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
противоречащих и границы окна
#### Scenario: Часы были, но ни один не пригоден
- **WHEN** все часы окна отброшены проверкой пригодности
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
### Requirement: Окно измерения ограничено сорока восемью часами
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
расти вместе с журналом.
Число названо в спеке, а не оставлено реализации, по той же причине, что и
порог согласных часов: от него зависят счётчики основания в ответе.
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
издержка, а то же правило: свидетельств в свежем окне действительно мало.
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
правило пригодности часа.
#### Scenario: История длиннее окна
- **GIVEN** у метрики общих часов больше сорока восьми
- **WHEN** измеряется род
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
сорока восьми
### Requirement: Измеренный род нигде не сохраняется
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
ни колонкой, ни кешем.
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
витрины, а витрина — функция журнала, и устаревать в нём нечему.
#### Scenario: Новая доставка меняет род без перезапуска
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
запрашивается снова
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
### Requirement: Каталог читается одним снимком витрины
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
объекты окна измерения MUST читаться в одной транзакции чтения.
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
внутренне противоречивый и неотличимый от обычного свежего.
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
константой на метрику и не зависеть от размера окна: чтение объектов окна по
одному даёт тысячи обращений там, где хватает двух на метрику.
#### Scenario: Доставка приезжает во время сборки каталога
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
- **WHEN** ответ сформирован
- **THEN** он целиком описывает одно состояние витрины
#### Scenario: Размер окна не умножает число запросов
- **WHEN** окно измерения увеличено
- **THEN** число обращений к хранилищу на метрику не меняется
### Requirement: Каталог доступен по токену чтения
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
схемой `Bearer`; значение без этой схемы токеном не считается.
Пустой список токенов чтения означает выключенную проверку, и о выключенной
проверке сервис предупреждает на старте — тем же способом, что о выключенной
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
секретами; здесь фиксируется, что предупреждение существует и адресовано
владельцу.
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
обратное тоже неверно.
#### Scenario: Запрос без токена при заданном списке
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** ответ — 401, и данные не отдаются
#### Scenario: Токен приёма каталога не открывает
- **GIVEN** заданы разные списки токенов приёма и чтения
- **WHEN** каталог запрашивается с токеном приёма
- **THEN** ответ — 401
#### Scenario: Токен без схемы Bearer
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
значение токена без слова `Bearer`
- **THEN** ответ — 401
#### Scenario: Проверка выключена
- **GIVEN** список токенов чтения пуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** каталог отдаётся
#### Scenario: О выключенной проверке предупреждают на старте
- **GIVEN** список токенов чтения пуст
- **WHEN** сервис стартует
- **THEN** в логе появляется предупреждение владельцу
#### Scenario: Токен чтения не оседает в учёте доставки
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
именем
- **WHEN** доставка учтена
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Перечисление разрезов не читает содержимое объектов
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
которые у метрики есть объекты сразу в двух слоях.
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
значения точек, то есть разжать содержимое объектов окна, и требование его не
касается — иначе оно запрещало бы то, ради чего каталог существует.
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
ответ несёт три десятка строк.
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
#### Scenario: Разрезы метрики за длинную историю
- **GIVEN** в витрине объекты за многие месяцы
- **WHEN** запрашиваются слои метрики с границами и числом точек
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
#### Scenario: Общие часы двух слоёв
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
в минутном, и в часовом слое
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
часов
### Requirement: Объекты перечисленных часов читаются пакетом
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
метрики **одним запросом**, а не по объекту за раз.
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
разных снимков витрины под непрерывным приёмом.
#### Scenario: Окно из многих часов
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
- **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов
@@ -0,0 +1,133 @@
## 1. Схема
- [x] 1.1 Миграция `00009_bucket_catalog.sql` — покрывающий индекс
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`
- [x] 1.2 Обновить `docs/database.md`: индекс и зачем он
## 2. Хранилище
- [x] 2.1 `store.ReadCatalog(ctx, store.CatalogWindow)` — весь вход каталога
**одной транзакцией чтения**: разрезы всех метрик, общие часы пары слоёв
по каждой метрике, объекты окна обоих слоёв
- [x] 2.2 Разрезы — одним запросом `metric, layer, units, min(first_ts),
max(last_ts), sum(points)`, группировка вместе с единицами (расхождение
видно, а не выбирается молча)
- [x] 2.3 Общие часы — самые свежие часы с объектами обоих слоёв, от свежих к
старым, не больше `window`
- [x] 2.4 Объекты окна — **пакетом**, один запрос на метрику на оба слоя
(`hour_utc IN (…)`), а не по объекту за раз
- [x] 2.5 Тест: разрезы и общие часы отвечают по индексу (`EXPLAIN QUERY PLAN`
не содержит обхода таблицы)
- [x] 2.6 Тест: число обращений к базе на метрику не зависит от размера окна
- [x] 2.7 Тест: `CommonHours` отдаёт не больше `window` и именно свежие часы
## 3. Значение точки
- [x] 3.1 `hae.PointValue(raw)` — число точки: `qty`, при его отсутствии `Avg`;
разбор через `*json.Number`, **ноль — значение**, отсутствие ключа и
`null` — нет значения, нечисловое и не влезающее в `float64` (`ErrRange`,
`±Inf`) — нет значения
- [x] 3.2 В док-комментарии сказать, что словарь пустоты `canon` сюда не
применяется, и почему
- [x] 3.3 Тесты на реальных формах точки из `testdata`: `heart_rate` с
`Min`/`Avg`/`Max` без `qty`, `{"qty":0}`, точка без числового поля,
`1e400`
## 4. Измерение рода
- [x] 4.1 Пакет `internal/catalog`: тип рода (`cumulative`/`instant`/`unknown`,
пустое значение невыразимо) и основание измерения
(`hours`/`compared`/`agreeing`/`conflicting`/границы окна)
- [x] 4.2 Именованная константа допуска `1e-9` с измеренной ценой в комментарии
и **один** предикат `close(a, b)`, которым выражены все три сравнения
- [x] 4.3 Пригодность часа: ровно одна точка со значением у часового объекта,
её метка совпадает с началом часа, ≥2 точек со значением у минутного,
сумма отличима от среднего
- [x] 4.4 Сумма минутных значений считается в порядке возрастания метки
- [x] 4.5 Правило метрики: ≥3 согласных и 0 противоречащих, иначе `unknown`
- [x] 4.6 Окно 48 самых свежих общих часов
- [x] 4.7 Сборка каталога: разрезы из снимка + род из измерения; строки слоя,
разошедшиеся единицами, схлопываются в один элемент, множество единиц
метрики отсортировано
- [x] 4.8 Чекпоинт `WARN` при `conflicting > 0`: имя метрики и числа основания,
без значений точек
- [x] 4.9 Тесты: накопительная, мгновенная, нулевой час, двухточечный часовой
объект, одноточечный минутный, невыровненная часовая метка, противоречие,
единственный слой, только слой `day`, история длиннее окна, разошедшиеся
единицы, идемпотентность двух вызовов
## 5. HTTP
- [x] 5.1 Проверка токена **одна на оба контура**, параметризованная списком;
схема строгая (`Bearer`), пустой список = выключено
- [x] 5.2 Предупреждение на старте о выключенной проверке чтения
- [x] 5.3 Редакция сохраняемых заголовков доставки чистит токены **обоих**
контуров
- [x] 5.4 `GET /api/v1/metrics` — форма ответа из дизайна: `style`, `hours`,
`compared`, `agreeing`, `conflicting`, `first_hour`, `last_hour`; срезы
пустые, а не nil; границы окна — указатели; порядок детерминирован
- [x] 5.5 Тесты: 401 без токена, 401 с токеном приёма, 401 с голым значением без
схемы, отдача при выключенной проверке, пустая витрина — **сравнением
байтов** ответа с литералом
- [x] 5.6 `config.example.toml` и `config.docker.toml`: `read_tokens` перестал
быть заделом на будущее, цена пустого списка названа комментарием
## 6. Проверка на живом архиве
- [x] 6.1 Прогон измерения в `internal/replay/archive_test.go`: свойства, а не
числа — конфликтующих свидетельств ноль; накопительные и мгновенные
метрики разошлись по родам; ни одна метрика не измерена по слою `raw`
- [x] 6.2 Печать измеренного рода по метрикам и стоимости каталога в `t.Logf`
- [x] 6.3 `task verify:archive` зелёный, отпечаток витрины не изменился
## 7. Документация и беклог
- [x] 7.1 `docs/architecture.md`: метод измерения, окно, порог, допуск, почему
род не хранится, почему родов два, а не четыре, где нужен `xFilesFactor` и
какой у него подвох с полярностью
- [x] 7.2 `docs/architecture.md`: форма каталога приведена к реализованной
- [x] 7.3 `docs/local-research.md`: находка с результатом измерения на живом
корпусе
- [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами,
ценой и рекомендацией
- [x] 7.5 Пометка в `docs/backlog/read-api-tochki.md`: порог неполного ведра,
его полярность и предел размера ответа решаются там
- [x] 7.6 Пометка в `docs/backlog/upravlenie-sekretami.md`: контуров теперь два
- [x] 7.7 Убрать задачу из беклога, обновить индекс
## 8. Дозакрыто по ревью кода
- [x] 8.0 Горизонт окна: часы позже `now + час` в сверку не входят, данные из
будущего пишутся `WARN`
- [x] 8.0 Единицы обеих сторон обязаны совпасть — иначе уверенный ложный род
- [x] 8.0 Содержимое разжимается только у часов, прошедших отбор по учётным
колонкам
- [x] 8.0 Имя метрики в логе обрезано, множество единиц ограничено потолком
- [x] 8.0 Метрика с пустым именем показывается, а не выбрасывается сентинелом
- [x] 8.0 Отмена снаружи не пишется как сбой сервиса
- [x] 8.0 Байтовый тест непустого ответа и повтора запроса
## 9. Приёмочные критерии (рубрика ревью предложения)
- [x] 9.1 Вердикт — чистая функция состояния витрины и окна: не зависит от
порядка строк SQL, порядка точек в объекте и момента вызова
- [x] 9.2 Допуск назван величиной, один предикат на все сравнения, поведение
около нуля объявлено
- [x] 9.3 Вырожденные свидетельства исключены явно, кворум назван числом, ниже
кворума исход — `unknown`, а не умолчание
- [x] 9.4 `unknown` — исход первого класса, и его причины различимы клиентом без
второго запроса
- [x] 9.5 Измерение ничего не пишет и не кешируется скрытно
- [x] 9.6 Стоимость ответа ограничена сверху и по числу запросов, и по числу
прочитанных страниц; не растёт вместе с историей
- [x] 9.7 HTTP-контракт полон: только `GET`, пустая витрина — 200 с пустым
списком, авторизация до работы, токены и значения здоровья не в логах
выше `DEBUG`
- [x] 9.8 Ответ самоописателен: словарь слоёв тот же, что везде; границы
объявляют, что метят; расхождение единиц показано, а не выбрано молча
- [x] 9.9 Каталог отдаёт наблюдаемое, а не досчитанное; деталь хранения наружу
не протекает
- [x] 9.10 Нижний слой в сверке не участвует; `source` в измерение не входит
- [x] 9.11 Смена вердикта наблюдаема чекпоинтом
- [x] 9.12 Правило часа и правило метрики тестируются без БД; на живом архиве
проверяются свойства, а не числа
+664
View File
@@ -0,0 +1,664 @@
# catalog Specification
## Purpose
Отвечает потребителю на два вопроса: «что у тебя вообще есть» — метрики,
единицы, слои с границами данных и числом точек — и «какая свёртка по этой
метрике осмысленна». Второй ответ **измеряется** сверкой минутного слоя с
часовым, а не размечается руками: HAE рода не шлёт, и всё, что можно было бы
объявить, пришлось бы угадать. Род неизвестен — свёртка не предлагается вовсе.
## Requirements
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
единицы и слои с границами данных и числом точек. Каталог MUST показывать
только то, что в витрине есть: досчитывать отсутствующий слой,
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
считать каталожную пару границ доказательством непрерывности.
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
число в ответ не идёт.
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
списке.
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
есть», нельзя ничего.
Единицы отдаются **множеством различных значений** метрики, отсортированным и
ограниченным потолком (пустые в множество не входят):
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
метрики.
#### Scenario: Метрика лежит в нескольких слоях
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
точек
#### Scenario: Слоя за период не осталось
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
`sample`
- **WHEN** запрашивается каталог
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
#### Scenario: Внутри диапазона слоя есть дыра
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
ними нет ни одного
- **WHEN** запрашивается каталог
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
каталог не утверждает, что данные есть за весь этот период
#### Scenario: Единицы метрики разошлись
- **GIVEN** объекты одной метрики несут разные единицы
- **WHEN** запрашивается каталог
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
элементом списка с объединённым диапазоном и суммой точек
#### Scenario: Метрика приехала без имени
- **GIVEN** в витрине есть объекты метрики с пустым именем
- **WHEN** запрашивается каталог
- **THEN** метрика присутствует в ответе со своими слоями
#### Scenario: Единиц у метрики стало неправдоподобно много
- **GIVEN** объекты метрики несут десятки различных строк единиц
- **WHEN** запрашивается каталог
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
элементом
#### Scenario: Витрина пуста
- **WHEN** в витрине нет ни одного объекта
- **THEN** каталог отдаёт пустой список метрик, а не отказ
### Requirement: Форма ответа каталога
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
`aggregation` и `layers`; элемент `layers``layer`, `from`, `to`, `points`;
объект `aggregation``style`, `hours`, `compared`, `agreeing`, `conflicting`,
`first_hour`, `last_hour`.
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
неотличима от настоящей.
Семантика границ различна, поэтому имена различны:
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
- `first_hour`/`last_hour`**ярлыки часов**, первого и последнего часа окна
измерения, включительно.
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
на одинаковом состоянии витрины совпадали побайтово.
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
разных смысла под одним именем в одном API — вечная сноска.
#### Scenario: Пустая витрина отдаётся пустым списком
- **WHEN** каталог запрашивается на пустой витрине
- **THEN** тело ответа — `{"metrics":[]}`
#### Scenario: Род не измерен
- **WHEN** у метрики нет общих часов двух слоёв
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
`last_hour` равны `null`
#### Scenario: Два запроса подряд дают один ответ
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
- **THEN** тела ответов совпадают побайтово
### Requirement: Число точки берётся из одного объявленного поля
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
значение поля `Avg`, и MUST NOT выводить число из других полей.
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
Значение, которое не разбирается как конечное число (строка, `null`, объект,
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
отравляет и сумму, и среднее всего часа.
Точка без числа в сумму не входит и число точек часа не увеличивает.
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
не того типа означает, что форма точки изменилась, и догадываться о числе не о
чем: точка считается не несущей значения целиком.
#### Scenario: Точка несёт только qty
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
- **THEN** её число равно `72.5`
#### Scenario: Точка несёт Min/Avg/Max без qty
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
- **THEN** её число равно значению `Avg`
#### Scenario: Нулевое значение остаётся значением
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
- **THEN** её число равно нулю, и точка считается несущей значение
#### Scenario: Значение не разбирается как конечное число
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
- **THEN** точка считается не несущей значения и в сумму не входит
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
имени метрики, единицам, форме точки или заголовку доставки.
Час **пригоден** для сверки, когда выполнено всё:
- у метрики есть объекты обоих слоёв за этот час;
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
- единицы обоих объектов совпадают;
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
совпадает с началом часа;
- у минутного объекта не меньше двух точек со значением;
- сумма минутных значений **отличима** от их среднего.
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
нуле противоречащих часов. Правило единогласия этот случай не ловит по
построению: противоречия нет, есть молчание.
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
целиком, а не «по той, у которой есть значение».
Требование выравнивания часовой метки закрывает зоны с неполночасовым
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
интервал, который покрывают минутные точки того же объекта. Сравнивать их
нельзя, и такой час свидетельства не даёт.
Требование различимости обязательно: в часе, где все значения нули, сумма равна
среднему, и совпадение с любой из гипотез не значит ничего.
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
невыразим по построению, и исход не зависит от порядка веток.
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
чем вдвое).
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
ложный род.
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
вердикт не зависел от порядка точек внутри объекта.
#### Scenario: Часовое значение равно сумме минутных
- **GIVEN** у метрики есть минутный и часовой объекты за один час
- **WHEN** часовое значение сходится с суммой минутных значений
- **THEN** метрика получает род `cumulative`
#### Scenario: Часовое значение равно среднему минутных
- **WHEN** часовое значение сходится со средним минутных значений
- **THEN** метрика получает род `instant`
#### Scenario: Нулевой час свидетельством не является
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
- **WHEN** измеряется род
- **THEN** этот час непригоден и в подсчёт согласных не идёт
#### Scenario: Час лежит в будущем
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
времени
- **WHEN** измеряется род
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
истории, и владельцу пишется предупреждение
#### Scenario: Единицы слоёв разошлись
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Часовой объект несёт две точки
- **GIVEN** у метрики за час есть часовой объект с двумя точками
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Минутный объект несёт одну точку
- **GIVEN** минутный объект часа несёт единственную точку
- **WHEN** измеряется род
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
#### Scenario: Метка часовой точки не выровнена на начало часа
- **GIVEN** часовая точка стоит на середине часа UTC
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Форма точки на исход не влияет
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
### Requirement: Род объявляется только при единогласном свидетельстве
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
именем метрики и числами основания, без значений точек: род — свойство, на
котором Read API строит арифметику года, и его смена не имеет права проходить
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
шума правило не создаёт.
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
остаётся свидетельством одного часа.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Клиент, которому это важно, различает случаи по основанию измерения — оно
отдаётся вместе с родом.
#### Scenario: Свидетельства противоречат
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
`instant`
- **WHEN** измеряется род
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
и пишется `WARN` с именем метрики
#### Scenario: Свидетельств мало
- **WHEN** согласных часов меньше трёх
- **THEN** род равен `unknown`
#### Scenario: Второго слоя нет вовсе
- **WHEN** метрика лежит только в одном слое
- **THEN** род равен `unknown`, а число часов окна равно нулю
### Requirement: Нижний слой в измерении не участвует
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
использовать в сверке слои `raw`, `sample` и `day`.
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
другая схема под тем же именем, а не разрез часов.
#### Scenario: Метрика есть только в нижнем слое
- **WHEN** у метрики есть объекты только в слое `raw`
- **THEN** род равен `unknown`
#### Scenario: Нижний слой не подменяет минутный
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
- **WHEN** измеряется род
- **THEN** сверка не выполняется и род равен `unknown`
#### Scenario: Метрика лежит только в суточном слое
- **WHEN** у метрики есть объекты только слоя `day`
- **THEN** слой объявлен в каталоге, а род равен `unknown`
### Requirement: Каталог отдаёт основание измерения, а не только вывод
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
иметь возможность отличить «свидетельств не было» от «свидетельства
противоречат», не делая второго запроса.
Числа определены так, что их разность осмысленна:
- `hours` — сколько общих часов двух слоёв попало в окно;
- `compared` — сколько из них оказалось **пригодными**;
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
объявленном роде это он и есть);
- `conflicting` — сколько дали другой.
Разложение одно и то же независимо от того, объявлен род или нет: иначе
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
одно поле двумя способами.
Разность `compared agreeing conflicting` — часы, не сошедшиеся ни с одной
гипотезой; разность `hours compared` — часы, отброшенные проверкой
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
данные молчат» там, где ни один час не был пригоден вовсе.
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
всей историей.
#### Scenario: Род измерен
- **WHEN** метрика получила род `cumulative`
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
противоречащих и границы окна
#### Scenario: Часы были, но ни один не пригоден
- **WHEN** все часы окна отброшены проверкой пригодности
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
### Requirement: Окно измерения ограничено сорока восемью часами
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
расти вместе с журналом.
Число названо в спеке, а не оставлено реализации, по той же причине, что и
порог согласных часов: от него зависят счётчики основания в ответе.
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
издержка, а то же правило: свидетельств в свежем окне действительно мало.
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
правило пригодности часа.
#### Scenario: История длиннее окна
- **GIVEN** у метрики общих часов больше сорока восьми
- **WHEN** измеряется род
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
сорока восьми
### Requirement: Измеренный род нигде не сохраняется
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
ни колонкой, ни кешем.
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
витрины, а витрина — функция журнала, и устаревать в нём нечему.
#### Scenario: Новая доставка меняет род без перезапуска
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
запрашивается снова
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
### Requirement: Каталог читается одним снимком витрины
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
объекты окна измерения MUST читаться в одной транзакции чтения.
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
внутренне противоречивый и неотличимый от обычного свежего.
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
константой на метрику и не зависеть от размера окна: чтение объектов окна по
одному даёт тысячи обращений там, где хватает двух на метрику.
#### Scenario: Доставка приезжает во время сборки каталога
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
- **WHEN** ответ сформирован
- **THEN** он целиком описывает одно состояние витрины
#### Scenario: Размер окна не умножает число запросов
- **WHEN** окно измерения увеличено
- **THEN** число обращений к хранилищу на метрику не меняется
### Requirement: Каталог доступен по токену чтения
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
схемой `Bearer`; значение без этой схемы токеном не считается.
Пустой список токенов чтения означает выключенную проверку, и о выключенной
проверке сервис предупреждает на старте — тем же способом, что о выключенной
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
секретами; здесь фиксируется, что предупреждение существует и адресовано
владельцу.
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
обратное тоже неверно.
#### Scenario: Запрос без токена при заданном списке
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** ответ — 401, и данные не отдаются
#### Scenario: Токен приёма каталога не открывает
- **GIVEN** заданы разные списки токенов приёма и чтения
- **WHEN** каталог запрашивается с токеном приёма
- **THEN** ответ — 401
#### Scenario: Токен без схемы Bearer
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
значение токена без слова `Bearer`
- **THEN** ответ — 401
#### Scenario: Проверка выключена
- **GIVEN** список токенов чтения пуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** каталог отдаётся
#### Scenario: О выключенной проверке предупреждают на старте
- **GIVEN** список токенов чтения пуст
- **WHEN** сервис стартует
- **THEN** в логе появляется предупреждение владельцу
#### Scenario: Токен чтения не оседает в учёте доставки
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
именем
- **WHEN** доставка учтена
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
### Requirement: Каталог отвечает на условный запрос
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
условного запроса в том, что самый частый запрос потребителя — повтор
неизменившегося — не стоит ничего.
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое, на нём Read API строит арифметику
года.
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
потребителя при неизменившейся витрине.
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
в любом случае.
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
условно в следующий раз.
**Метка действительна только в пределах одного ресурса, и область действия
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
задачи, ведущий к выдаче не тех данных.
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
что и до появления условного запроса.
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
кеширование посредником было маловероятным; с меткой ответ становится штатно
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
сохранить любой прокси на пути.
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
отказ:
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
`304`;
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
становятся функцией смены версии витрины, а не числа запросов. Состояние при
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
трёх потребителей значило бы обесценить уровень.
#### Scenario: Повтор на неизменившейся витрине
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
менялась
- **THEN** ответ — `304` без тела, с той же меткой
#### Scenario: Витрина изменилась
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
- **THEN** ответ — `200` с полным каталогом и новой меткой
#### Scenario: Горизонт сдвинулся
- **GIVEN** витрина не менялась
- **WHEN** горизонт измерения перешёл через час
- **THEN** метка отличается от прежней
#### Scenario: Метка другого ресурса
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
- **WHEN** совпадает версия витрины
- **THEN** условие не выполнено, и ответ — `200`
#### Scenario: Клиент не спрашивает условно
- **WHEN** каталог запрашивается без `If-None-Match`
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
#### Scenario: Две метки на неизменившейся витрине совпадают
- **GIVEN** витрина не менялась между двумя запросами
- **WHEN** каталог запрошен дважды
- **THEN** метки совпадают, и тела ответов совпадают побайтово
#### Scenario: Звёздочка в условии
- **WHEN** каталог запрашивается с `If-None-Match: *`
- **THEN** ответ — `304` с текущей меткой
#### Scenario: Условие нечитаемо
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
#### Scenario: Условный запрос без токена чтения
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
- **THEN** ответ — `401`, а не `304`
#### Scenario: Витрина изменилась во время сборки ответа
- **GIVEN** между снятием версии до и после сборки в базу был коммит
- **WHEN** ответ сформирован
- **THEN** он уходит с полным телом и без заголовка `ETag`
#### Scenario: Версия витрины недоступна
- **GIVEN** версию витрины прочитать не удалось
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
#### Scenario: Условный ответ не собирает каталог
- **GIVEN** в витрине лежат данные, помеченные будущим
- **WHEN** каталог отвечает `304` по совпавшей метке
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
+326
View File
@@ -1009,3 +1009,329 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
пор не пересворачивалась пор не пересворачивалась
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю - **THEN** её число пропущенных сущностей отсутствует, а не равно нулю
### Requirement: Перечисление разрезов не читает содержимое объектов
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
которые у метрики есть объекты сразу в двух слоях.
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
значения точек, то есть разжать содержимое объектов окна, и требование его не
касается — иначе оно запрещало бы то, ради чего каталог существует.
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
ответ несёт три десятка строк.
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
#### Scenario: Разрезы метрики за длинную историю
- **GIVEN** в витрине объекты за многие месяцы
- **WHEN** запрашиваются слои метрики с границами и числом точек
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
#### Scenario: Общие часы двух слоёв
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
в минутном, и в часовом слое
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
часов
### Requirement: Объекты перечисленных часов читаются пакетом
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
метрики **одним запросом**, а не по объекту за раз.
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
разных снимков витрины под непрерывным приёмом.
#### Scenario: Окно из многих часов
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
- **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов
### Requirement: Хранилище отдаёт версию витрины
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
предназначена условному запросу читающих маршрутов: равные метки означают, что
между их снятием в базу никто ничего не записал.
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
поколение — идентификатор, выданный тому соединению, с которого счётчик
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
гарантируется только неравенство.
Обе части обязательны, и каждая закрывает измеренный отказ:
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
два соединения одного пула отвечают разными числами, а любое свежее
соединение отвечает одним и тем же значением независимо от содержимого базы.
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
ничем другим не занято: собственная запись соединения его версию не двигает,
и щуп, участвующий в записи, молчал бы о собственных изменениях.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
поэтому одно и то же значение до и после означает разные состояния витрины.
Без поколения клиент со старой меткой получал бы «не изменилось» на
изменившиеся данные — единственный по-настоящему опасный исход условного
запроса.
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
долгоживущее соединение процесса — становится тем самым вечным читателем,
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
журнала при полностью исправном обслуживании.
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
заведено.
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
допускается только при отказе, означающем закрытое соединение; отмена запроса
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
пересборка, переносящая один файл базы, теряет хвост записей молча.
#### Scenario: Витрина не менялась
- **GIVEN** после первого запроса версии в базу никто не писал
- **WHEN** версия запрашивается второй раз
- **THEN** обе версии совпадают
#### Scenario: Свёртка записала объект
- **GIVEN** версия витрины снята
- **WHEN** фоновая свёртка закоммитила изменения в витрину
- **THEN** следующая снятая версия отличается от прежней
#### Scenario: База переоткрыта
- **GIVEN** версия витрины снята, база закрыта и открыта заново
- **WHEN** версия снимается снова на том же файле
- **THEN** она отличается от снятой до переоткрытия
#### Scenario: Соединение-щуп стало непригодным
- **GIVEN** соединение, с которого читается счётчик, закрыто
- **WHEN** версия запрашивается снова
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
#### Scenario: Запрос версии оборван клиентом
- **GIVEN** запрос версии отменён контекстом
- **WHEN** версия запрашивается следующим запросом
- **THEN** поколение остаётся прежним
#### Scenario: Щуп не мешает разбирать журнал
- **GIVEN** версия снималась много раз подряд
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
- **THEN** журнал перенесён целиком
#### Scenario: Хранилище закрыто
- **GIVEN** хранилище закрыто
- **WHEN** запрашивается версия витрины
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
базой не остаётся файла журнала
### Requirement: Чтение подписывается версией только целиком
Система SHALL снимать версию витрины **до и после** чтения, которое ею
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
снятая только ДО, допускает два разных ответа под одной меткой.
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
потребителями той же машинерии, и вторая её реализация «по образцу»
отличалась бы от первой ровно на этот порядок — а тест первой этого не
увидел бы.
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
полного ответа, а не до отказа.
#### Scenario: Витрина стояла всё время чтения
- **WHEN** чтение выполнено и обе пробы дали одну версию
- **THEN** версия отдана
#### Scenario: Витрина изменилась во время чтения
- **GIVEN** между пробами в базу закоммитили
- **THEN** версии нет, а результат чтения отдан целиком
#### Scenario: Само чтение отказало
- **WHEN** чтение вернуло ошибку
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
ровно в измеренном случае удерживаемого читателя.
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
разы. Неизвестен — признак молчит.
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
его не выходит), и двумя разошедшимися константами признак стал бы либо
недостижимым, либо шумным — молча.
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
перестал проверять».
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
чекпойнт его не укоротил; усечение делает первая запись после полного
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
`WARN` владельцу.
#### Scenario: Журнал разбирается в тишине
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
- **WHEN** проходит период чекпойнта
- **THEN** страницы перенесены в базу без единой новой записи
#### Scenario: Читатель держит снимок
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
- **WHEN** выполняется пассивный чекпойнт
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
флаг занятости остаётся снятым
#### Scenario: Чекпойнт не взял блокировку
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
накопленное состояние не меняется
#### Scenario: Журнал невелик
- **GIVEN** в журнале меньше страниц, чем названный порог
- **WHEN** чекпойнт не смог перенести всё
- **THEN** строка `WARN` не пишется
#### Scenario: Состояние держится
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** следующий чекпойнт застаёт журнал того же размера
- **THEN** строка не повторяется
#### Scenario: Журнал разобрался
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
- **THEN** о возврате к норме сказано один раз
#### Scenario: Журнал стал мал, но не перенесён
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
размер ниже порога — нет
#### Scenario: Чекпойнт отказал
- **GIVEN** очередной чекпойнт вернул ошибку
- **WHEN** наступает следующий период
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
### Requirement: Обслуживание журнала останавливается дренированием
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
получать отмену и MUST быть дождана вместе с воркером свёртки — база
закрывается только после выхода **обеих**. Обрывать её выходом процесса
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
того же места. Прерывание по отмене отказом MUST NOT считаться.
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
проверено: ждут двоих, и назвать виновным одного из них — догадка.
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
последнего соединения к базе SQLite делает его само. Условие названо в
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
последнего соединения не наступает вовсе.
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
#### Scenario: Сервис останавливается
- **WHEN** сервис получает сигнал остановки
- **THEN** горутина чекпойнта завершается до закрытия базы
#### Scenario: Чекпойнт идёт в момент остановки
- **GIVEN** чекпойнт выполняется, когда пришла отмена
- **WHEN** он прерывается
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
#### Scenario: Бюджет остановки исчерпан
- **GIVEN** фоновые горутины не вышли в бюджет
- **WHEN** сервис завершается
- **THEN** база не закрывается, а запись лога называет этап, не указывая
виновной горутины