Compare commits
8
Commits
98e0772ec5
...
7e6d63415e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6d63415e
|
||
|
|
58cf5c07d8
|
||
|
|
9ad1deeb01
|
||
|
|
33cf1b7bae
|
||
|
|
8db2ec7ff4
|
||
|
|
6b729bbd2f
|
||
|
|
28d974e45d
|
||
|
|
03edf1087d
|
@@ -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. **Что опытный человек отсюда удалил бы.** Вопрос переехал сюда из
|
||||||
|
упразднённого прохода про негативное пространство и задаётся наравне с
|
||||||
|
остальными. Ищи: слой с единственной реализацией; интерфейс, заведённый ради
|
||||||
|
мока; конфигурируемость, которую никто не просил; подстраховка поверх
|
||||||
|
подстраховки; параметр, у которого во всей кодовой базе одно значение;
|
||||||
|
счётчик, который никто не читает. Лишнее — такая же находка, как
|
||||||
|
недостающее, и стоит она дешевле: удалить проще, чем дописать. Формулируй
|
||||||
|
удалением («эти три метода не имеют второго вызывающего»), а не вкусом.
|
||||||
|
|
||||||
## Потолок и отдельная секция
|
## Потолок и отдельная секция
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
Если видишь такое — не выводи находкой; максимум упомяни строкой в границах
|
Если видишь такое — не выводи находкой; максимум упомяни строкой в границах
|
||||||
|
|||||||
@@ -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` запускать можно. Код не редактируй.
|
|
||||||
@@ -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
|
|
||||||
- проверено: <какие узлы, с чем сравнивалась зрелость>
|
|
||||||
- не проверялось и почему: ...
|
|
||||||
- принципиально недоступно этому проходу: сознательность пропусков, история инцидентов, ошибки в написанном коде
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
Только чтение. Код не редактируй. Не предлагай удалять то, на что ссылается
|
|
||||||
дельта-спека, — это находка в спеку и всегда развилка. Не предлагай удалять
|
|
||||||
дословность хранения точки как «избыточность»: на ней держится срок жизни
|
|
||||||
данных.
|
|
||||||
@@ -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 — своя реализация. Существующую открывать ЗАПРЕЩЕНО
|
||||||
|
|
||||||
Тебе дают: требования из дельта-спеки, сигнатуры соседей, с которыми узел
|
Тебе дают: требования из дельта-спеки, сигнатуры соседей, с которыми узел
|
||||||
|
|||||||
@@ -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 | 7–8 |
|
||||||
| `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`.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Подключение телефона по локальной сети
|
### Подключение телефона по локальной сети
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ write_timeout = "30s" # прочих маршрутов; приём держ
|
|||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
||||||
|
# Чтение открыто так же, как приём, но цена другая: это выгрузка истории
|
||||||
|
# здоровья. Годится только для доверенной локальной сети.
|
||||||
read_tokens = []
|
read_tokens = []
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
|
|||||||
+7
-2
@@ -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
@@ -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`.
|
||||||
|
|
||||||
### Форма ответа
|
### Форма ответа
|
||||||
|
|||||||
@@ -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` (архив).
|
||||||
@@ -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».
|
||||||
|
|
||||||
|
|||||||
@@ -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` → «Слои гранулярности», план → шаг «Каталог и род агрегации».
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
правильно, но это же делает выезд наружу опасным: одна забытая настройка
|
правильно, но это же делает выезд наружу опасным: одна забытая настройка
|
||||||
открывает историю здоровья всему интернету.
|
открывает историю здоровья всему интернету.
|
||||||
|
|
||||||
|
**Контуров теперь два, а не один.** С появлением каталога
|
||||||
|
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
|
||||||
|
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
|
||||||
|
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
|
||||||
|
предупреждает на старте обоими сообщениями (`write auth disabled`,
|
||||||
|
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
|
||||||
|
старта нет, и это решение осталось здесь.
|
||||||
|
|
||||||
Решается перед деплоем, не раньше — так договорились.
|
Решается перед деплоем, не раньше — так договорились.
|
||||||
|
|
||||||
Шаги:
|
Шаги:
|
||||||
|
|||||||
@@ -161,3 +161,9 @@
|
|||||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||||
решение о судьбе тел.
|
решение о судьбе тел.
|
||||||
|
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||||
|
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||||
|
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||||
|
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
|
||||||
|
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||||
|
запросов, координаты объектов).
|
||||||
|
|||||||
@@ -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` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||||
|
|||||||
@@ -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
@@ -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.**
|
||||||
|
|||||||
@@ -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`, и «пять вопросов второго
|
||||||
|
инженера» как отдельная постановка. Обратимость этого класса высокая: он
|
||||||
|
портит форму кода и полноту наблюдаемости, а не данные. Если проскочит
|
||||||
|
дефект этого класса — запись сюда и пересмотр решения.
|
||||||
|
- **Побочная выгода, ради которой стоило резать отдельно:** реестр из 6–9
|
||||||
|
проходов сверяется взглядом. Промах 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` 7–8, `design` 3.
|
||||||
|
Было 11 на коде и 4 на дизайне.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("каталог собран на стоящей витрине и остался без версии")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("условный ответ собрал каталог: снимок открыт зря")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
//
|
||||||
|
// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
|
||||||
|
// одним полем и несла бы три решения сразу — сравнение за постоянное время,
|
||||||
|
// «пустой список = выключено» и текст 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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(a.writeTokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !tokenAllowed(bearer(r), a.writeTokens) {
|
if !tokenAllowed(bearer(r), tokens) {
|
||||||
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
|
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bearer(r *http.Request) string {
|
func bearer(r *http.Request) string {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ¬used, &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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("окно вне контракта принято без ошибки")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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` порядка 26–64 МБ;
|
||||||
|
взято 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`, получает ровно то же, что и сегодня.
|
||||||
+139
@@ -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** предупреждение владельцу не пишется, потому что измерения не было
|
||||||
+279
@@ -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 Правило часа и правило метрики тестируются без БД; на живом архиве
|
||||||
|
проверяются свойства, а не числа
|
||||||
@@ -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** предупреждение владельцу не пишется, потому что измерения не было
|
||||||
|
|
||||||
@@ -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** база не закрывается, а запись лога называет этап, не указывая
|
||||||
|
виновной горутины
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user