Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -18,9 +18,10 @@
|
||||
либо берётся, либо отвергается с названной причиной.
|
||||
|
||||
## блокеры
|
||||
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Сегодняшний порядок канонических форм берёт меньшее значение в 96% случаев — для накопительных это систематический недосчёт
|
||||
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Один запрос каталога способен выесть память процесса и раздуть WAL — а OOM здесь стоит доставок, которых телефон не перешлёт
|
||||
|
||||
## высокий
|
||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
@@ -47,6 +48,7 @@
|
||||
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
||||
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||
|
||||
## низкий
|
||||
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Цена первого читающего маршрута: память, WAL и повторный опрос
|
||||
|
||||
**Приоритет:** блокеры
|
||||
|
||||
## Что решить
|
||||
|
||||
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
|
||||
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
|
||||
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
|
||||
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
|
||||
трижды нельзя.
|
||||
|
||||
Три измеренных проявления одной причины.
|
||||
|
||||
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
|
||||
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
|
||||
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
|
||||
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
|
||||
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
|
||||
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
|
||||
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
|
||||
переприсылается.
|
||||
|
||||
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
|
||||
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
||||
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
|
||||
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
|
||||
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
|
||||
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
|
||||
вызывается нигде.
|
||||
|
||||
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
|
||||
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
|
||||
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
|
||||
игра), и самый частый их запрос — повтор неизменившегося.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
|
||||
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
|
||||
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
|
||||
чтения усложняется на первой же ручке.
|
||||
|
||||
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
|
||||
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
|
||||
хранилище перестаёт возвращать снимок значением и начинает отдавать его
|
||||
последовательно (итератор или колбэк) — то есть меняется форма границы
|
||||
`store`/`catalog`, ради случая, которого живой поток пока не производит.
|
||||
|
||||
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
|
||||
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
|
||||
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
|
||||
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
|
||||
|
||||
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
|
||||
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
|
||||
остаются.
|
||||
|
||||
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
|
||||
представление того же факта, и его инвалидация становится новым местом, где можно
|
||||
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
|
||||
|
||||
## Что заблокировано
|
||||
|
||||
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
|
||||
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
|
||||
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
|
||||
непрерывным.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
|
||||
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
|
||||
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
|
||||
большую часть читающих транзакций, и делает это без кеша ответа.
|
||||
|
||||
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
|
||||
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
|
||||
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
|
||||
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
|
||||
|
||||
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
|
||||
`stats-nablyudaemost.md`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Остановка и миграция: раздельные бюджеты и следы в логе
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
|
||||
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
|
||||
`GET /api/v1/metrics` — первый обработчик, способный законно работать заметное
|
||||
время.
|
||||
|
||||
**Бюджет остановки один на оба этапа.** `shutdownCtx` в `runServe` передаётся и
|
||||
в `srv.Shutdown`, и в ожидание фонового воркера. `Shutdown` ждёт, пока
|
||||
обработчики вернутся; контексты обработчиков он при этом не отменяет
|
||||
(`BaseContext` не задан), так что долгий запрос каталога может съесть бюджет
|
||||
целиком. Дальше `select` видит два готовых случая и выбирает равновероятно: база
|
||||
закрывается или нет от запуска к запуску, а в лог уходит
|
||||
`shutdown budget exceeded stage=fold-worker` — обвинение воркеру, который бюджета
|
||||
не превышал. Цена именно в диагнозе: этот `WARN` означает «доставка осталась
|
||||
`pending`, данные под вопросом», и ложное срабатывание обесценивает настоящее.
|
||||
|
||||
Чинится двумя движениями: собственный `context.WithTimeout` второму этапу вместо
|
||||
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
|
||||
чтобы долгий запрос об остановке узнавал.
|
||||
|
||||
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
|
||||
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
|
||||
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
|
||||
долго, владелец не отличит «ещё мигрирует» от «зависло» и от «упало»: тишина
|
||||
одинакова во всех трёх случаях. Плюс `migrate` работает на `context.Background()`,
|
||||
то есть `SIGTERM` она не видит и ждать придётся 30-секундного `SIGKILL`.
|
||||
|
||||
Порчи данных при этом нет: goose оборачивает миграцию в транзакцию, обрыв
|
||||
откатывает её целиком, и следующий старт повторяет с нуля. Замер на синтетической
|
||||
копии годового объёма (260 тысяч объектов, 483 МБ): `CREATE INDEX` миграции
|
||||
`00009` — 297 мс тёплым кешем. То есть сегодня окно тишины — доли секунды;
|
||||
опасность в том, что оно растёт вместе с витриной незаметно.
|
||||
|
||||
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
|
||||
старта видно, что миграции накатывались и сколько это заняло.
|
||||
|
||||
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
|
||||
`cena-chitayushchego-marshruta.md`.
|
||||
@@ -26,5 +26,36 @@
|
||||
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||
видно `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`, иначе через полгода два места кода поймут поле по-разному.
|
||||
|
||||
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
|
||||
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
|
||||
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
|
||||
станет первым его потребителем.
|
||||
|
||||
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
|
||||
типы `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,19 @@
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
|
||||
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
|
||||
переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за
|
||||
другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль
|
||||
событий. Сюда же вторая половина: пять разных причин непригодности часа
|
||||
(две точки у часового объекта, невыровненная метка, нет числа, мало минутных,
|
||||
неразличимость) схлопнуты в одну разность `hours − compared`, поэтому «HAE
|
||||
переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно
|
||||
живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при
|
||||
непустом окне.
|
||||
|
||||
**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора
|
||||
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
|
||||
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
|
||||
корреляция есть (`delivery_id`), у чтения аналога нет.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Тай-брейк при равной полноте точек
|
||||
|
||||
**Приоритет:** блокеры
|
||||
|
||||
## Что решить
|
||||
|
||||
Какое правило выбирает победителя, когда по одним координатам приехали две точки
|
||||
с **равными** наборами содержательных полей и разными значениями. Структурная
|
||||
часть правила слияния закрыта (`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`), образцы конфига цену называют комментарием — но отказа
|
||||
старта нет, и это решение осталось здесь.
|
||||
|
||||
Решается перед деплоем, не раньше — так договорились.
|
||||
|
||||
Шаги:
|
||||
|
||||
Reference in New Issue
Block a user