docs: документация переведена на канон av-dev-pm
- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги переименованы с транслита на английские, 85 ссылок поправлены - conventions.md разобран в docs/conventions/, local-research.md — в docs/research/, review-journal.md — в docs/review.md с разделом настройки конвейера; заведены security.md, adr/ и .pm.json - шаг docs.py check добавлен в task gate; поведение в architecture.md помечено девятью маркерами долга, database.md получил настройки с числовым значением
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Беклог
|
||||
|
||||
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
|
||||
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
|
||||
план — то, подо что берут. Порядка внутри секции нет: «что делать
|
||||
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
|
||||
|
||||
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
|
||||
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
|
||||
человека, а его следы — вопросами в файлах задач.
|
||||
|
||||
## ядро
|
||||
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
|
||||
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
|
||||
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
||||
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
|
||||
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
|
||||
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
|
||||
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
|
||||
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
|
||||
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
|
||||
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
- [Проверка секций, которых поток ещё не приносил](items/unseen-sections-check.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
|
||||
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](items/read-api-points.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [Словарь категориальных значений → коды HealthKit](items/categorical-value-dictionary.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
||||
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
|
||||
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
|
||||
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
|
||||
## инфра
|
||||
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
|
||||
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
|
||||
- [Счётчики слияния переживают ротацию логов](items/merge-counters-in-db.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- [Остановка и миграция: раздельные бюджеты и следы в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||
- [Чем откатывать релиз после наката миграции](items/release-rollback-after-migration.md) — Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
|
||||
- [Ретеншен сырого архива](items/raw-archive-retention.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
|
||||
- [Наблюдаемость: /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [Умолчания конфига указывают на прежнюю раскладку](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
|
||||
- [Управление токенами и секретами](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
|
||||
@@ -0,0 +1,45 @@
|
||||
# План
|
||||
|
||||
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
|
||||
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
|
||||
В первой секции («порядок») очередь значима и обосновывается
|
||||
прозой; в остальных порядка нет — это тематические цели.
|
||||
|
||||
## Что уже пройдено
|
||||
|
||||
Каркас и приём без разбора закрыты. Метрики, тренировки и записи со своими `id`
|
||||
разбираются и ложатся в часовые объекты. `reindex` проигрывает журнал в свежую
|
||||
витрину, отпечатки сравниваются, повторный прогон ничего не меняет. Род
|
||||
агрегации **измерен**: сверка минутного слоя с часовым разложила метрики живого
|
||||
корпуса на накопительные и мгновенные, не сойдясь ни на одной, и каталог
|
||||
разрезов отдаётся первым маршрутом чтения. Разведка закончена — правило вывода
|
||||
слоя, модель идентичности и формы точки проверены на живом потоке
|
||||
([research/apple-health.md](../research/apple-health.md)).
|
||||
|
||||
Эти звенья целями не заведены: закрытая цель записи не оставляет, ей хватает
|
||||
коммита и спеки.
|
||||
|
||||
## Почему в таком порядке
|
||||
|
||||
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
|
||||
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
|
||||
нижний слой значит завысить втрое. Это звено уже закрыто.
|
||||
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
|
||||
экспорта не написан, помечать что-либо устаревшим не на основании чего.
|
||||
- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит
|
||||
вызовы в те же обработчики; переводить пока нечего.
|
||||
|
||||
## порядок
|
||||
- [[goal] Разбор и хранилище](items/parsing-and-storage.md) — Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
|
||||
- [[goal] Read API](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [[goal] Самоописание](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [[goal] MCP](items/mcp.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [[goal] Импорт родного экспорта Apple](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [[goal] Устаревание нижнего слоя](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [[goal] Наблюдаемость](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [[goal] Деплой](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
|
||||
|
||||
## темы
|
||||
- [[goal] Прочность слияния и идентичности](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
|
||||
- [[goal] Журнал и пересборка](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
|
||||
- [[goal] Пределы и поведение под объёмом](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
|
||||
@@ -0,0 +1,10 @@
|
||||
# Ушедшее без реализации
|
||||
|
||||
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
|
||||
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
|
||||
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
|
||||
|
||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Была секция: высокий.
|
||||
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры.
|
||||
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Спринт
|
||||
|
||||
Спринта нет. Цель называет человек, набор собирает агент:
|
||||
`tasks.py sprint start --goal <слаг>`.
|
||||
|
||||
## Набор
|
||||
@@ -0,0 +1,43 @@
|
||||
# Импорт родного экспорта Apple Health
|
||||
|
||||
**Секция:** ядро · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут · **Теги:** goal:native-export-import
|
||||
|
||||
Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт
|
||||
посекундную развёртку, а не измерения (находка 34). Полная история и точные
|
||||
сэмплы лежат в zip родного экспорта.
|
||||
|
||||
Это же основание для устаревания нижнего слоя и единственный способ поднять
|
||||
историю глубже недели: дыра старше недели проходами синхронизации не чинится.
|
||||
|
||||
Формат разобран на девяти экспортах за 5.5 лет (находки 42–45), гадать не
|
||||
придётся:
|
||||
|
||||
- **версии 11 → 13 → 14**, на 14 стоит больше года; за всё время **ни один тип
|
||||
записи не исчез**, только добавлялись. Значит незнакомый тип — новый тип, а
|
||||
не сломанный парсер: падать на нём нельзя;
|
||||
- **`Correlation`** — обёртка из двух записей, ею приезжает давление. Появилась
|
||||
только в 2026 году. Парсер по одним `<Record>` разберёт давление как две
|
||||
несвязанные метрики и потеряет их парность;
|
||||
- **`WorkoutStatistics`** внутри тренировки — с 2024 года;
|
||||
- **имя файла локализовано**: `экспорт.xml`, не `export.xml` — так во всех
|
||||
девяти архивах;
|
||||
- **DTD расходится с данными** (в v11 у `<Me>` на атрибут больше объявленного)
|
||||
— валидировать документ его же DTD нельзя;
|
||||
- объём: 1.6 ГБ XML и 3.6 млн записей в свежем экспорте — только потоковый
|
||||
разбор, документ целиком в память не влезет.
|
||||
|
||||
Шаги:
|
||||
- потоковый разбор `экспорт.xml` в слой `sample`, включая `Correlation`;
|
||||
- `HeartRateVariabilityMetadataList` с `InstantaneousBeatsPerMinute` — это
|
||||
тот же `heartbeatSeries`, что в HAE (находка 39), 1.25 млн ударов;
|
||||
- маршруты GPX и ЭКГ отдельными файлами — их в XML нет;
|
||||
- заливка кусками по годам, идемпотентно: повторный импорт того же архива не
|
||||
должен ничего менять;
|
||||
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
|
||||
|
||||
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
|
||||
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
|
||||
периодов.
|
||||
|
||||
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
|
||||
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Словарь категориальных значений → коды HealthKit
|
||||
|
||||
**Секция:** ядро · **Хук:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить · **Теги:** goal:parsing-and-storage
|
||||
|
||||
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
|
||||
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
|
||||
кодами (`HKCategoryValueSleepAnalysisAsleepREM`) — источники несопоставимы
|
||||
(находка 37).
|
||||
|
||||
Три следствия, и третье решающее: клиент угадывает словарь; смена языка
|
||||
телефона молча расколет историю; сверить покрытие экспортом нечем — а на этой
|
||||
сверке стоит устаревание нижнего слоя.
|
||||
|
||||
Решение (вариант «б»): строка хранится **дословно**, рядом кладётся выведенный
|
||||
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
|
||||
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
|
||||
|
||||
**Словарь фаз сна уже выведен** сопоставлением потока с экспортом за тот же
|
||||
период (находка 43) — составлять руками не нужно:
|
||||
|
||||
```
|
||||
Основная → AsleepCore Бодрствование → Awake БДГ → AsleepREM
|
||||
Глубокий → AsleepDeep В кровати → InBed Во сне → AsleepUnspecified
|
||||
```
|
||||
|
||||
Тем же способом добираются `heart_rate.context` и типы тренировок.
|
||||
|
||||
Осложнение, всплывшее на истории экспортов: **коды тоже не вечны.** Одни и те
|
||||
же записи сна приезжают как `…Asleep` в экспорте 2021 года и как
|
||||
`…AsleepUnspecified` в экспорте 2026-го: Apple переименовала значение и
|
||||
переписывает историю при выгрузке (находка 43). Значит словарь должен
|
||||
переживать переименование самих кодов, иначе после обновления iOS история
|
||||
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
|
||||
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
|
||||
|
||||
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
|
||||
`/stats` показывает строки, для которых кода ещё нет.
|
||||
|
||||
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Умолчания конфига указывают на прежнюю раскладку
|
||||
|
||||
**Секция:** инфра · **Хук:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка · **Теги:** goal:deploy
|
||||
|
||||
Данные переехали в `./data` (база + сырой архив, он же том контейнера), а
|
||||
умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`.
|
||||
|
||||
Отказ тихий и оттого неприятный: `task run` без `config.toml` не падает, а
|
||||
заводит **пустую** базу в корне репозитория рядом с настоящей. Дальше человек
|
||||
смотрит на пустое хранилище и делает неверный вывод о том, что поток сломан.
|
||||
|
||||
Чинится одной строкой, но требует прогона гейта: шаг `config-samples` следит,
|
||||
чтобы `config.example.toml` и `config.docker.toml` не разъехались со структурой.
|
||||
|
||||
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
|
||||
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Data-миграции не отбирают строки по обрезаемым спискам
|
||||
|
||||
**Секция:** ядро · **Хук:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`).
|
||||
|
||||
## Оракул: механизм доказан, дефект пока пустой
|
||||
|
||||
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
|
||||
секции стоит в `uncovered_sections`:
|
||||
|
||||
```sql
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
WHERE json_each.value IN ('workouts', 'stateOfMind'))
|
||||
```
|
||||
|
||||
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
|
||||
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
|
||||
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
|
||||
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
|
||||
секцией `ecg` за ними даёт список без `ecg`.
|
||||
|
||||
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
|
||||
(`docs/research/apple-health.md`, находка 50), секций восемь, тела с 32 незнакомыми
|
||||
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
|
||||
слепую зону, а к тому времени причину никто не вспомнит.
|
||||
|
||||
## Что делать
|
||||
|
||||
Записать принцип и выбрать форму отбора:
|
||||
|
||||
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
|
||||
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
|
||||
например «эту доставку смотрел разбор старше версии N».
|
||||
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
|
||||
означать безусловное пересворачивание — доставка, у которой список обрезан,
|
||||
про своё покрытие ничего достоверного не говорит.
|
||||
- Кандидат в `docs/conventions/README.md` (раздел про миграции), если форма отбора
|
||||
окажется общей.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Проверка секций, которых поток ещё не приносил](unseen-sections-check.md) —
|
||||
именно она следующей сделает секцию покрытой и напишет такую миграцию.
|
||||
@@ -0,0 +1,19 @@
|
||||
# [idea] Что считать сутками при смене часового пояса
|
||||
|
||||
**Секция:** ядро · **Хук:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено · **Теги:** goal:read-api
|
||||
|
||||
«Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с
|
||||
офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день
|
||||
по локальному времени в момент измерения, по текущей зоне телефона или по
|
||||
фиксированной зоне пользователя — три разных числа.
|
||||
|
||||
Apple эту неоднозначность не решает, а перекладывает: в экспорте у записи есть
|
||||
и время, и офсет. Мы храним так же — значит выбор всплывает ровно в момент
|
||||
свёртки.
|
||||
|
||||
Почему идея, а не задача: непонятно, что должно стать наблюдаемо иначе. Нужно
|
||||
решить, чей это выбор — сервера (одна зона в конфиге), клиента (параметр
|
||||
запроса) или обоих (умолчание плюс переопределение).
|
||||
|
||||
Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз
|
||||
поездки со сменой зоны.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Предел на размер и число заголовков доставки
|
||||
|
||||
**Секция:** ядро · **Хук:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним · **Теги:** goal:limits-and-load
|
||||
|
||||
У тела доставки предел есть (`max_body`), у заголовков — нет ни одного:
|
||||
`MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком,
|
||||
сколько бы их ни пришло. В лог они с недавних пор обрезаются, в базу — нет.
|
||||
|
||||
Сегодня отправитель один и он свой, поэтому дефект спит. Просыпается он
|
||||
**вместе с [деплоем](deploy-rivendell.md)**: у приёма, торчащего наружу,
|
||||
отправитель перестаёт быть своим по определению. Оценка сверху при доставке раз
|
||||
в пять минут — сотни мегабайт в сутки в таблице, которую никто не подчищает; а
|
||||
растёт вместе с ней и стоимость пересборки, которая учёт материализует целиком.
|
||||
|
||||
Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел
|
||||
на то, что уходит в колонку. Разумно делать одной правкой с
|
||||
[управлением токенами](token-and-secret-management.md) — оба пункта про одно и то же:
|
||||
приём перестаёт доверять тому, кто с ним говорит.
|
||||
|
||||
Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда.
|
||||
Отказ по превышению обязан наступать **до** записи тела, а не после, и быть
|
||||
отличим в логе от отказа обстоятельств.
|
||||
|
||||
Готово, когда доставка с заведомо раздутыми заголовками получает внятный отказ,
|
||||
не оставляя следа в базе, а обычная доставка проходит как раньше.
|
||||
|
||||
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
|
||||
@@ -0,0 +1,40 @@
|
||||
# Заголовки доставки в архиве рядом с телом
|
||||
|
||||
**Секция:** ядро · **Хук:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
|
||||
лежит только **тело**: заголовки запроса (`automation-id`,
|
||||
`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут
|
||||
единственной копией — в колонке `delivery.headers`.
|
||||
|
||||
Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex`
|
||||
читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда.
|
||||
Пока база цела, это работает. Если базу потерять, весь журнал становится
|
||||
«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего,
|
||||
заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся
|
||||
никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с
|
||||
оговоркой, которой в инварианте нет.
|
||||
|
||||
Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его
|
||||
заголовками именно потому, что тело без метаданных запроса события не
|
||||
воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и
|
||||
на то, что заголовки лежат рядом текстом, а не в отдельной базе.
|
||||
|
||||
Развилка формы (решать при взятии, не сейчас):
|
||||
|
||||
- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и
|
||||
одна операция, но файл перестаёт быть «телом как пришло»;
|
||||
- файл-спутник `<ulid>.headers.json` — тело остаётся дословным, зато на доставку
|
||||
два файла и два fsync, а атомарность пары надо обеспечивать самому;
|
||||
- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по
|
||||
операциям, но появляется третья сущность.
|
||||
|
||||
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
|
||||
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
|
||||
старые тела без заголовков продолжали читаться.
|
||||
|
||||
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
|
||||
же состояние, что пересборка с базой.
|
||||
|
||||
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
|
||||
`internal/replay`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Деплой на rivendell
|
||||
|
||||
**Секция:** инфра · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома · **Теги:** goal:deploy
|
||||
|
||||
Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только
|
||||
дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но
|
||||
это не то, ради чего заводился всегда доступный VPS.
|
||||
|
||||
Есть готовый образец: jellybit собирает образ локально и отправляет на сервер
|
||||
через `docker save`/`load`, Caddy впереди терминирует TLS. Здесь то же самое.
|
||||
|
||||
Шаги:
|
||||
- сборка образа локально, доставка на rivendell;
|
||||
- Caddy: поддомен приёма и поддомен чтения (плюс MCP на нём же);
|
||||
- тома под `./data`, конфиг с токенами отдельно, права `0600`.
|
||||
|
||||
Готово, когда телефон шлёт на публичный адрес из любой сети, а агент читает по
|
||||
тому же домену.
|
||||
|
||||
Зависит от задачи про секреты: выезжать наружу с выключенной проверкой токенов
|
||||
нельзя.
|
||||
|
||||
Этим же переездом закрывается резервное копирование: на сервере пет-проектов
|
||||
механизм уже готов, своего заводить не нужно (задача `bekap-dannyh` снята,
|
||||
см. `CLOSED.md`). Побочное следствие — до деплоя история существует в одном
|
||||
экземпляре на рабочей машине; это и есть цена ожидания.
|
||||
|
||||
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
|
||||
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
|
||||
непрерывно, и файл под записью копировать нельзя.
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Деплой
|
||||
|
||||
**Секция:** порядок · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
|
||||
|
||||
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
|
||||
|
||||
Выведена из шага 11 плана.
|
||||
|
||||
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
|
||||
названный механизм, а запуск без конфига не заводит базу мимо данных.
|
||||
|
||||
## Завершение
|
||||
|
||||
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
|
||||
а запуск без конфига не заводит базу мимо данных.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Выведенные из данных схемы содержимого
|
||||
|
||||
**Секция:** ядро · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке · **Теги:** goal:self-description
|
||||
|
||||
Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал
|
||||
бы документацию HAE, а не то, что он реально прислал. Схема содержимого
|
||||
**выводится из данных** тем же проходом разбора: незнакомая метрика описывает
|
||||
себя сама, без релиза.
|
||||
|
||||
Схема отдаётся вместе со статистикой — для потребителя она важнее формального
|
||||
типа: сколько точек, первая и последняя метка, единицы, доля присутствия поля.
|
||||
|
||||
Вывод ограничивается по глубине вложенности, иначе схема тренировки с маршрутом
|
||||
разрастётся до размеров самих данных.
|
||||
|
||||
**Глубину вывода для тренировок выбираем по факту**, когда увидим, как приходят
|
||||
маршруты: структура тренировки разнородна, и заранее назначенный предел либо
|
||||
срежет полезное, либо не срежет ничего. Точка маршрута при этом описываться
|
||||
должна — блоб трека не непрозрачен, это массив однотипных объектов.
|
||||
|
||||
Готово, когда клиент по `/api/v1/metrics/{name}/schema` видит поля, их типы и
|
||||
присутствие, не выкачивая выборку.
|
||||
|
||||
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
|
||||
не эта задача, а OpenAPI.
|
||||
@@ -0,0 +1,19 @@
|
||||
# [idea] Отказ от heartbeatSeries
|
||||
|
||||
**Секция:** ядро · **Хук:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе · **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом
|
||||
межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39)
|
||||
ради данных, которых нет ни в одном из планируемых запросов: ни агент, ни
|
||||
трекер, ни игра межударными интервалами не оперируют.
|
||||
|
||||
Отбросить их означало бы нарушить инвариант «точки хранятся дословно» — и это
|
||||
не мелочь: срок жизни сырого архива держится ровно на том, что объект является
|
||||
полной копией. Поэтому вопрос не «выбросить или нет», а «когда цена хранения
|
||||
нижнего слоя станет заметной».
|
||||
|
||||
Почему идея, а не задача: цена пока не измерена в годовом масштабе, а решение
|
||||
необратимо — выброшенные ряды не вернуть иначе как из экспорта Apple, где их
|
||||
может не быть вовсе.
|
||||
|
||||
Связано: `docs/architecture.md` → «Открытые вопросы», находка 39.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Пределы на размер сущности и потоковый расчёт формы
|
||||
|
||||
**Секция:** ядро · **Хук:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе · **Теги:** goal:limits-and-load
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
|
||||
сущности из транзакции и перестала считать каноническую форму дважды. Осталось
|
||||
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
|
||||
считаются материализацией значения целиком.
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
|
||||
на теле в пределах приёма (64 МиБ):
|
||||
|
||||
```
|
||||
тело 40 МиБ → пик HeapAlloc 768.3 МиБ
|
||||
тело 63 МиБ → повторная доставка держит блокировку 5.019 с при busy_timeout 5000
|
||||
```
|
||||
|
||||
При `_txlock=immediate` конкурентный `CreateDelivery` получает `SQLITE_BUSY`,
|
||||
`inTx` повторяет до пяти раз и на исчерпании отдаёт `store.ErrBusy` — приём
|
||||
отвечает 500 по доставке, тело которой уже в архиве. Осиротевшее тело подберёт
|
||||
`reindex`, но узнать о нём можно только из лога.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Предел на размер **одной сущности** и на суммарный размер секции, отдельно
|
||||
от предела тела (64 МиБ). Сегодня одна тренировка законно может занять всё
|
||||
тело целиком. Вход, превышающий предел, обязан отклоняться **до**
|
||||
канонизации, а не после.
|
||||
2. Потоковый расчёт канонической формы и хеша: `canon.Form` разворачивает
|
||||
значение в дерево `any`, из-за чего пик кучи кратен размеру входа (замер даёт
|
||||
множитель около 19×). Хеш считается по потоку; форма нужна целиком только для
|
||||
сравнения, и только когда хеш разошёлся.
|
||||
3. Разбор **сохранённой** версии всё ещё идёт внутри транзакции: её содержимое
|
||||
читается оттуда же. Убрать это можно оптимистичным чтением до транзакции —
|
||||
но только с перепроверкой хеша и провенанса **внутри** транзакции, иначе две
|
||||
конкурентные свёртки одного `id` дадут потерянное обновление и исход снова
|
||||
станет функцией порядка коммитов, а не журнала.
|
||||
|
||||
## Условия, пришедшие из закрывающей задачи
|
||||
|
||||
1. Мягкое чтение заголовка сущности увеличило долю тел, доходящих до
|
||||
канонизации: сущность, которая раньше отсекалась на `json.Unmarshal`
|
||||
заголовка почти бесплатно, теперь разбирается и канонизируется целиком. То
|
||||
есть худший случай по памяти стал достижим на входах, которые до него не
|
||||
доходили, — предел из пункта 1 после этого **обязателен**, а не желателен.
|
||||
|
||||
2. Каноническая форма и множества ключей всех версий доставки теперь
|
||||
**удерживаются** до конца транзакции слияния (раньше считались лениво и на
|
||||
одной доставке из сорока четырёх). Расход стал пропорционален размеру
|
||||
ДОСТАВКИ, а не самой большой её сущности; предел обязан считать суммарный
|
||||
размер секции, а не только одной сущности.
|
||||
|
||||
3. **Потолок на число версий одного ключа в одной доставке.** Выбор победителя
|
||||
квадратичен по числу кандидатов; версии с совпавшей канонической формой
|
||||
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
|
||||
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
|
||||
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
|
||||
вопрос открыт для точек на одной координате: `merge-cost-wide-delivery.md`,
|
||||
пункт 4.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Цена слияния на широкой доставке](merge-cost-wide-delivery.md) —
|
||||
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
|
||||
часа). Задачи делать вместе: половина решения общая — `canon`.
|
||||
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
|
||||
отброшено как предел по конструкции, но условием ложится сюда.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Сущность с id, но неразобранной меткой
|
||||
|
||||
**Секция:** ядро · **Хук:** Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны · **Теги:** goal:parsing-and-storage, question
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
|
||||
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
|
||||
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
|
||||
с неразбираемой меткой по-прежнему пропускается целиком.
|
||||
|
||||
## Что известно
|
||||
|
||||
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
|
||||
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
|
||||
счётчик `SkippedEntityNoTime` растёт.
|
||||
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
|
||||
и ретеншен получает честный ответ «терять есть что». То есть событие больше
|
||||
не молчит — но содержимое всё ещё не хранится.
|
||||
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
|
||||
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
|
||||
задокументирован (`docs/research/apple-health.md`), то есть вход не выдуман.
|
||||
|
||||
## Вопросы
|
||||
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
|
||||
|
||||
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
|
||||
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
|
||||
что делает с такими строками, иначе они молча исчезнут из любого ответа.
|
||||
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
|
||||
когда разбор научится читать формат.
|
||||
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
|
||||
вернёт `reindex`. После включения ретеншена окно становится необратимым.
|
||||
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
|
||||
измерение в колонке, по которой идёт выборка.
|
||||
|
||||
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
|
||||
чтения без читателя проектируется вслепую.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Проверка целостности собранной витрины перед подменой
|
||||
|
||||
**Секция:** ядро · **Хук:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` собирает витрину в отдельный файл и снимает с него
|
||||
отпечаток, а подмену делает человек: остановить сервис, переименовать файл,
|
||||
поднять обратно. Это **единственный необратимый шаг** всей операции — и
|
||||
единственный, перед которым мы ничего не проверяем.
|
||||
|
||||
Отпечаток сегодня снимается на **ещё открытом дескрипторе**: он говорит, что
|
||||
свёртка сошлась, но не говорит, что файл на диске корректен как база SQLite.
|
||||
Между «свёртка сошлась» и «файл цел» помещается всё, о чём предупреждает
|
||||
[How To Corrupt An SQLite Database](https://www.sqlite.org/howtocorrupt.html):
|
||||
оборванный `fsync`, полный диск, ФС, соврала о записи. Человек в этот момент
|
||||
уже удалил рабочую витрину.
|
||||
|
||||
Что делать: после закрытия файла и **до** того, как команда объявит результат
|
||||
годным к подмене, открыть его заново и прогнать `PRAGMA integrity_check`.
|
||||
Не прошёл — команда завершается отказом и прямо говорит, что подменять нечем.
|
||||
|
||||
Дёшево: одна страница кода, один прогон по готовому файлу. Ценно ровно в тот
|
||||
момент, когда всё остальное уже пошло не так.
|
||||
|
||||
Готово, когда `reindex` на заведомо испорченном выходном файле отказывается
|
||||
называть результат годным, а на здоровом — не замедляется заметно.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
|
||||
@@ -0,0 +1,18 @@
|
||||
# [goal] Журнал и пересборка
|
||||
|
||||
**Секция:** темы · **Хук:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
|
||||
|
||||
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
|
||||
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
|
||||
|
||||
В порядок не встаёт: работа приходит находками и растёт вместе с
|
||||
журналом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как расхождение витрины с
|
||||
журналом перестаёт быть молчащим.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
|
||||
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
|
||||
журналом.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Порядок журнала при конкурентных приёмах
|
||||
|
||||
**Секция:** ядро · **Хук:** Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда · **Теги:** goal:journal-and-rebuild, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
||||
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
||||
приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает.
|
||||
Задача берётся после [наблюдаемости](stats-endpoint.md); ниже — исходная
|
||||
постановка блокера, она же ТЗ.
|
||||
|
||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
||||
|
||||
## Вопросы
|
||||
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
|
||||
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
|
||||
коммитом строки проходит запись тела (измерено 184 мс на 62 МиБ) плюс ожидание
|
||||
занятой базы (до пяти секунд, а с повторами транзакции дольше).
|
||||
|
||||
Путь построен и прогнан:
|
||||
|
||||
1. Широкая доставка **A** автоматизации X получает `received_at = T1` и уходит
|
||||
писать тело.
|
||||
2. Узкая доставка **B** той же автоматизации (`T2 > T1`, только `sleep_analysis`,
|
||||
плотных метрик нет) успевает закоммитить строку первой и будит воркер.
|
||||
3. Воркер видит только B, сворачивает её, наследовать слой не от кого →
|
||||
`ErrLayerUnknown` → `failed`.
|
||||
4. `failed` фоновая свёртка не подбирает никогда. Точки B в витрину не попадут.
|
||||
|
||||
Измерено на фикстурах: живой приём даёт `B=failed` и ноль часов
|
||||
`sleep_analysis/minute`; журнальный порядок — `B=parsed` и два часа. То есть
|
||||
живое состояние расходится с тем, что даст `healthlog reindex`, и расхождение
|
||||
молчит: уровень лога у этого исхода `WARN`, такой же, как у штатного «у этой
|
||||
автоматизации плотных метрик не бывает».
|
||||
|
||||
**Это не регресс** — прежде свёртка шла в порядке завершения обработчиков, то
|
||||
есть было хуже. Изменение окно сузило и назвало предел в спеке приёма; вопрос в
|
||||
том, закрывать ли его совсем.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
**а. Резервировать строку учёта в начале `Accept`** (до записи тела), дописывая
|
||||
`raw_path`/`bytes`/`sha256` после. Тогда видимость строки монотонна вместе с
|
||||
`received_at`. Цена: ломается инвариант «тело на диск раньше строки учёта»,
|
||||
заведённый ровно затем, чтобы не было учтённой доставки без данных; появляется
|
||||
новое состояние «строка есть, тела ещё нет», которое обязаны понимать пересборка
|
||||
и ретеншен.
|
||||
|
||||
**б. Откладывать свёртку доставки, пока она не «устоялась»** — не сворачивать
|
||||
моложе N секунд. Цена: задержка N на каждую доставку и произвольное N: окно
|
||||
занятости базы измерено до пяти секунд и зависит от нагрузки, так что N честно
|
||||
не выбрать.
|
||||
|
||||
**в. `ErrLayerUnknown` в живом пути не выводит доставку из очереди** —
|
||||
ограниченное число повторов, потом `failed`. Цена: колонка счётчика попыток
|
||||
(миграция) и политика «сколько попыток достаточно»; зато лечит и прочие случаи
|
||||
«предшественница ещё не доехала». Требует правки спеки хранения («отказ разбора
|
||||
⇒ `failed`»).
|
||||
|
||||
**г. Ничего не делать**, оставив предел названным в спеке. Цена: редкая,
|
||||
молчаливая потеря точек у автоматизаций без плотных метрик; лечится
|
||||
`healthlog reindex` с остановкой сервиса и ручной подменой базы, но узнать о
|
||||
необходимости неоткуда — счётчика `failed` в рантайме нет.
|
||||
|
||||
## Что заблокировано
|
||||
|
||||
Ничего: задача про разнесение ответа и свёртки доведена до конца в объявленных
|
||||
границах, предел записан в спеке приёма. Заблокировано только **закрытие**
|
||||
предела.
|
||||
|
||||
Смежно: пока предел жив, полезно уметь сверять живую витрину с пересборкой —
|
||||
`reindex` уже печатает оба отпечатка, но по расписанию их никто не сравнивает.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**(в)**, но не раньше `/stats`: сперва должно стать видно, сколько доставок
|
||||
числится `failed` и как давно, — иначе повторы будут лечить болезнь, которую
|
||||
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Пределы и поведение под объёмом
|
||||
|
||||
**Секция:** темы · **Хук:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
|
||||
|
||||
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
|
||||
поведение под удерживаемой блокировкой.
|
||||
|
||||
В порядок не встаёт: пределы всплывают замерами, а не планом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Устаревание нижнего слоя
|
||||
|
||||
**Секция:** порядок · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
|
||||
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
|
||||
|
||||
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
|
||||
|
||||
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
|
||||
удалении опирается на колонку, отличающую ноль от «не измерялось».
|
||||
|
||||
## Завершение
|
||||
|
||||
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
|
||||
календарю, и решение об удалении опирается на колонку, отличающую ноль от
|
||||
«не измерялось».
|
||||
@@ -0,0 +1,21 @@
|
||||
# Устаревание нижнего слоя после экспорта
|
||||
|
||||
**Секция:** ядро · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен · **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у
|
||||
минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление
|
||||
по объёму создаёт он один, и ровно там родной экспорт Apple оказывается
|
||||
настоящим надмножеством.
|
||||
|
||||
Два ограничителя, без которых правило опасно:
|
||||
|
||||
- пометка вешается по **загруженному и проверенному** экспорту, а не по
|
||||
сделанному: проверка — непрерывность по дням и сходимость сумм с часовым
|
||||
слоем;
|
||||
- пометка ≠ удаление. Удаление включается только после того, как восстановление
|
||||
из экспорта отработает на живых данных хотя бы раз.
|
||||
|
||||
Приоритет низкий: пока история измеряется днями, экономить нечего. Задача
|
||||
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
|
||||
|
||||
Зависит от импорта экспорта Apple — до него помечать нечем.
|
||||
@@ -0,0 +1,22 @@
|
||||
# MCP-сервер поверх Read API
|
||||
|
||||
**Секция:** ядро · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем · **Теги:** goal:mcp
|
||||
|
||||
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
|
||||
на дату последнего ручного экспорта.
|
||||
|
||||
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
|
||||
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
|
||||
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
|
||||
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
|
||||
|
||||
Инструментов три: каталог разрезов, значения за период, значения с разбивкой.
|
||||
Собственной логики в адаптере нет.
|
||||
|
||||
Правило размера ответа здесь не украшение, а необходимость: у сетевого агента
|
||||
нет способа «посмотреть поближе» иначе, чем повторным вызовом.
|
||||
|
||||
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
|
||||
неделе» без промежуточного кода.
|
||||
|
||||
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] MCP
|
||||
|
||||
**Секция:** порядок · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
|
||||
Агент-медик — первый заказчик проекта — подключается к хранилищу.
|
||||
|
||||
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
|
||||
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
|
||||
нечего.
|
||||
|
||||
Завершена, когда агент читает данные через MCP тем же токеном чтения.
|
||||
|
||||
## Завершение
|
||||
|
||||
Агент читает данные через MCP тем же токеном чтения, и собственной логики
|
||||
адаптер не несёт.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Цена слияния на широкой доставке
|
||||
|
||||
**Секция:** ядро · **Хук:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed · **Теги:** goal:limits-and-load
|
||||
|
||||
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
|
||||
проход и независимая реализация — независимо друг от друга).
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
|
||||
координате:
|
||||
|
||||
```
|
||||
транзакция держалась 5.149 с, аллоцировано 6108 МБ, пик кучи 189 МБ
|
||||
```
|
||||
|
||||
При `busy_timeout` 5000 мс и `_txlock=immediate` параллельная доставка
|
||||
получает `SQLITE_BUSY`, `inTx` повторяет её до пяти раз (каждый повтор —
|
||||
полный пересчёт слияния), и на исчерпании попыток доставка уходит в
|
||||
`parse_status=failed`. Тело при этом в архиве остаётся, но пересборки, которая
|
||||
его подберёт, ещё нет.
|
||||
|
||||
Отдельный вклад, измеренный бенчмарком на часовом объекте нижнего слоя
|
||||
(3 600 точек):
|
||||
|
||||
```
|
||||
HashAll на объект 9.46 мс 6.7 МБ аллокаций
|
||||
Form одной точки 2.2 мкс
|
||||
```
|
||||
|
||||
`hashPoints` пересчитывает каноническую форму ВСЕХ точек часа на каждое
|
||||
слияние, включая неизменившиеся. Утверждение `docs/architecture.md` о
|
||||
дешевизне глубокого прохода («4400 сравнений хеша, почти все сойдутся»)
|
||||
стоимости самих сравнений не учитывает: чтобы сравнить хеш, его надо посчитать.
|
||||
|
||||
Часть цены задачей `pravilo-sliyaniya-tochek` уже снята: `isEmpty` больше не
|
||||
материализует значение (было 410 мс на точку 16.5 МБ), а разбор точки
|
||||
переиспользуется через `canon.Fields`. Осталось структурное.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Держать хеш каждой точки в `payload` рядом с координатами (`storedPoint`
|
||||
уже есть) и пересчитывать форму только для новых и выигравших столкновение
|
||||
— стоимость станет пропорциональна дельте, а не объёму часа.
|
||||
2. Считать `canon.Form` точки один раз на столкновение и переиспользовать в
|
||||
`Equal`, `Relate` и порядке вместо трёх независимых разборов.
|
||||
3. Проверять `ctx.Err()` в цикле по точкам: сейчас дедлайн свёртки неисполним,
|
||||
прервать слияние нечем.
|
||||
4. Оценить потолок числа кандидатов на одной координате: выбор победителя
|
||||
квадратичен по ним, и 119 точек на одну метку — вход, который приём
|
||||
принимает.
|
||||
|
||||
## Связано
|
||||
|
||||
- Разнесение ответа приёма и свёртки **сделано** (архив change
|
||||
`2026-08-02-otvet-i-svyortka`): воркер убрал влияние на время ответа, но не на
|
||||
блокировку записи — длинная транзакция слияния держит её по-прежнему. Заодно
|
||||
оттуда взято главное смягчение: занятость базы больше не выводит доставку из
|
||||
очереди, она остаётся `pending` и пересворачивается. Оракул окна —
|
||||
`task verify:busy`.
|
||||
- Пересборка (`healthlog reindex`) подбирает доставки, ушедшие в `failed` по
|
||||
другим причинам.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Счётчики слияния переживают ротацию логов
|
||||
|
||||
**Секция:** инфра · **Хук:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего · **Теги:** goal:observability
|
||||
|
||||
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
|
||||
негативного пространства, подтверждено эксплуатационным).
|
||||
|
||||
## Что не так
|
||||
|
||||
Решение не реализовывать объединение полей при несравнимых наборах стоит на
|
||||
одном аргументе: «вместо реализации — счётчик, который скажет, если событие
|
||||
наступит». Сказать он может только в одну сторону — строкой `WARN` в stdout
|
||||
контейнера.
|
||||
|
||||
`docker-compose.yml` держит `json-file` с `max-size: 10m, max-file: 3`. При
|
||||
~288 доставках в сутки это порядка кварталов, а ожидаемая частота события —
|
||||
«ни разу за 99 доставок». Штатный сценарий: событие происходит ночью, логи
|
||||
никто не грепал в этот месяц, строка уходит в ротацию, и в системе не остаётся
|
||||
ни одного свидетельства. Ни колонки в `delivery`, ни `/stats`, ни файла.
|
||||
|
||||
То есть обещание «событие будет видно, а не додумано» на практике не
|
||||
выполняется.
|
||||
|
||||
## Что делать
|
||||
|
||||
Положить `overwrites` и `incomparable` в строку `delivery` — миграция плюс
|
||||
запись в `FinishParse`, которая эту строку всё равно трогает. Тогда «было ли
|
||||
когда-нибудь несравнимо» это один `SELECT`, живущий столько же, сколько
|
||||
витрина.
|
||||
|
||||
Заодно стоит решить смежное, найденное тем же проходом: сегодня в логе
|
||||
неразличимы «правило полноты сработало» и «всё ушло в тай-брейк». Два разных
|
||||
состояния мира дают одинаковую картину `overwrites=N, incomparable=0`, то есть
|
||||
отказ правила выглядит как здоровая работа. Отдельный счётчик исходов
|
||||
`Superset`/`Subset` рядом с `Overwrites` это закрывает.
|
||||
|
||||
## Связано
|
||||
|
||||
- [stats-endpoint](stats-endpoint.md) — то же наблюдение нужно и там.
|
||||
- [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о
|
||||
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
|
||||
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Прочность слияния и идентичности
|
||||
|
||||
**Секция:** темы · **Хук:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
|
||||
|
||||
Тема: правила, по которым две версии одних данных превращаются в одну.
|
||||
В порядок не встаёт — работа приходит находками ревью и замерами на
|
||||
живом корпусе.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
|
||||
от порядка на проводе.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
|
||||
версиями перестают зависеть от порядка элементов на проводе.
|
||||
@@ -0,0 +1,21 @@
|
||||
# [idea] Месячный проход по ручным секциям
|
||||
|
||||
**Секция:** ядро · **Хук:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит · **Теги:** goal:parsing-and-storage
|
||||
|
||||
Окно досчёта не единое, и это измеренное различие, а не предположение.
|
||||
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
|
||||
опаздывают на часы, и недельного глубокого прохода им хватает. Ручные записи
|
||||
(`symptoms`, `medications`, `stateOfMind`, `cycleTracking`) заводятся задним
|
||||
числом на недели и месяцы: симптом или приём лекарства можно отметить за
|
||||
прошлую дату.
|
||||
|
||||
Растянуть общий глубокий проход на месяц нельзя: тела запросов доходили до
|
||||
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
|
||||
доставку. Отсюда решение: редкий широкий проход **только по ручным секциям** —
|
||||
их единицы записей, и месячное окно там почти ничего не стоит.
|
||||
|
||||
Почему идея, а не задача: этих секций в живом потоке ещё не было. Заводить,
|
||||
когда они появятся, — иначе проход пишется вслепую и проверяется не на чем.
|
||||
|
||||
Связано: `docs/architecture.md` → «Досчёт задним числом», задача
|
||||
`unseen-sections-check`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# [goal] Импорт родного экспорта Apple
|
||||
|
||||
**Секция:** порядок · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
|
||||
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
|
||||
ложится в хранилище перед проигрыванием хвоста доставок.
|
||||
|
||||
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
|
||||
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
|
||||
|
||||
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
|
||||
импорт того же экспорта ничего не меняет.
|
||||
|
||||
## Завершение
|
||||
|
||||
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
|
||||
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
|
||||
@@ -0,0 +1,20 @@
|
||||
# [idea] NDJSON-поток для больших выборок Read API
|
||||
|
||||
**Секция:** ядро · **Хук:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация · **Теги:** goal:read-api
|
||||
|
||||
Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период
|
||||
это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в
|
||||
сутки, и месяц такого ряда не влезет ни в память клиента, ни в разумный ответ.
|
||||
|
||||
Сейчас проблема закрыта с другой стороны — правилом размера ответа: сервер сам
|
||||
берёт сетку погрубее, когда разбивка не задана, и отвечает ошибкой со списком
|
||||
доступных сеток, когда задана явно. Это защищает агента с ограниченным
|
||||
контекстом, но не помогает клиенту, которому действительно нужен весь ряд —
|
||||
например, разовой выгрузке в другой инструмент.
|
||||
|
||||
Почему идея, а не задача: неизвестно, появится ли такой клиент. Если появится,
|
||||
выбор между NDJSON-потоком и курсорной пагинацией зависит от того, читает он
|
||||
последовательно или с возвратами.
|
||||
|
||||
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
|
||||
`read-api-points`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Наблюдаемость
|
||||
|
||||
**Секция:** порядок · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
|
||||
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
|
||||
молча, и молчание неотличимо от нормы.
|
||||
|
||||
Выведена из шага 10 плана.
|
||||
|
||||
Завершена, когда пропажа потока и расхождение витрины с журналом видны
|
||||
владельцу без чтения логов.
|
||||
|
||||
## Завершение
|
||||
|
||||
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
|
||||
логов и переживают ротацию логов.
|
||||
@@ -0,0 +1,23 @@
|
||||
# OpenAPI-спека и Swagger UI
|
||||
|
||||
**Секция:** ядро · **Хук:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате · **Теги:** goal:read-api
|
||||
|
||||
Потребителей три, и один из них — агент, который читает контракт машиной.
|
||||
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
|
||||
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
|
||||
это OpenAPI.
|
||||
|
||||
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
|
||||
ею и будет OpenAPI-документ, а не собственный формат.
|
||||
|
||||
Шаги:
|
||||
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
|
||||
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
|
||||
он должен работать в локальной сети без интернета);
|
||||
- проверка актуальности спеки в гейте: контракт разъезжается молча.
|
||||
|
||||
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
|
||||
локально и выполняет запрос к живому сервису.
|
||||
|
||||
Развилка на решение: спека пишется руками как источник истины или выводится из
|
||||
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
|
||||
@@ -0,0 +1,19 @@
|
||||
# [idea] Пересекающиеся источники одной метрики
|
||||
|
||||
**Секция:** ядро · **Хук:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь · **Теги:** goal:read-api
|
||||
|
||||
Одну метрику пишут несколько источников: сон — часы и стороннее приложение
|
||||
AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не
|
||||
источник, а множество вкладчиков (`Apple Watch Ultra 3|iPhone (Anton)`), и
|
||||
состав меняется от группировки (находка 36).
|
||||
|
||||
По координатному ключу столкновений почти нет — источники пишут в разные метки.
|
||||
Но по **времени** интервалы пересекаются, и сумма по обоим задвоит ночь сна или
|
||||
дневные шаги.
|
||||
|
||||
Почему идея: неясно, чья это ответственность. Варианты — отдавать как есть и
|
||||
предупреждать в каталоге, выбирать источник по приоритету, отдавать разбивку по
|
||||
источникам отдельным разрезом. Первое честнее всего, третье полезнее всего.
|
||||
|
||||
Для агента-медика вопрос практический: «сколько я спал» не должно давать
|
||||
двойной ответ.
|
||||
@@ -0,0 +1,18 @@
|
||||
# [idea] Выгрузка в parquet отдельной командой
|
||||
|
||||
**Секция:** ядро · **Хук:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно · **Теги:** goal:read-api
|
||||
|
||||
Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой
|
||||
аналитики снаружи, без миграции самого хранилища.
|
||||
|
||||
Контекст решения: DuckDB рассматривался как основное хранилище и отложен —
|
||||
чистого Go-драйвера нет, любой требует cgo, а это стоит нам `CGO_ENABLED=0` и
|
||||
одного статического бинаря. Но дверь при этом осталась открытой: DuckDB читает
|
||||
и parquet, и файл SQLite напрямую. Значит спешить некуда — выгрузка добавляется
|
||||
тогда, когда появится тяжёлый аналитический запрос, а не заранее.
|
||||
|
||||
Почему идея, а не задача: такого запроса пока нет. Ни один из трёх потребителей
|
||||
(агент-медик, трекер, игра) в аналитике по всей истории не нуждается.
|
||||
|
||||
Связано: `docs/architecture.md` → «Открытые вопросы» → «Хранилище под
|
||||
аналитику».
|
||||
@@ -0,0 +1,18 @@
|
||||
# [goal] Разбор и хранилище
|
||||
|
||||
**Секция:** порядок · **Хук:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
|
||||
|
||||
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
|
||||
объекты; тела перестали быть недифференцированной кучей.
|
||||
|
||||
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
|
||||
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
|
||||
поток ещё не приносил.
|
||||
|
||||
Завершена, когда ни одна секция живого потока не числится неразобранной, а
|
||||
категориальные значения имеют стабильный код рядом с переведённой строкой.
|
||||
|
||||
## Завершение
|
||||
|
||||
Ни одна секция живого потока не числится неразобранной, а категориальные
|
||||
значения несут стабильный код рядом с переведённой строкой.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Ретеншен сырого архива
|
||||
|
||||
**Секция:** инфра · **Хук:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
|
||||
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
|
||||
|
||||
**Само правило изменилось.** Экспорт Apple — снапшот всей истории, доставки
|
||||
после его даты — события поверх снапшота, и состояние всегда пересобираемо
|
||||
свёрткой. Значит доставки должны жить **до следующего проверенного экспорта**,
|
||||
а не фиксированные две недели: иначе между концом ретеншена и датой снапшота
|
||||
образуется дыра в журнале, и пересобрать этот отрезок будет нечем.
|
||||
|
||||
Цена измерена: ~23 МБ архива в сутки, то есть ~2 ГБ за квартал между
|
||||
экспортами. Дёшево за возможность пересобрать что угодно.
|
||||
|
||||
Отдельное исключение: `stateOfMind` в экспорт не попадает вовсе (проверено на
|
||||
свежем архиве). Для него доставки — не хвост журнала, а единственный источник,
|
||||
и под общее правило удаления он не подпадает.
|
||||
|
||||
Включать **после** того, как разбор устоится и пересборка докажет, что
|
||||
хранилище действительно восстанавливается: иначе страховка исчезнет раньше, чем
|
||||
перестанет быть нужна.
|
||||
|
||||
Готово, когда удаляются только доставки старше последнего проверенного
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
## Предусловие снова открыто
|
||||
|
||||
Признак «доставка с непокрытой секцией» появился в change
|
||||
`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
|
||||
`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
|
||||
|
||||
Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
|
||||
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
|
||||
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
|
||||
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
|
||||
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
|
||||
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
|
||||
состояния разума навсегда.
|
||||
|
||||
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
|
||||
Варианты:
|
||||
|
||||
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
|
||||
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
|
||||
так же, как `uncovered_sections`, и одним запросом.
|
||||
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
|
||||
Цена та же, но смысл шире и требует решения, что считать единственным
|
||||
источником для будущих секций.
|
||||
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
|
||||
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
|
||||
её **текущей** версии, а копий у записи бывает по 26.
|
||||
|
||||
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
|
||||
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
|
||||
семантику.
|
||||
|
||||
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
|
||||
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
|
||||
соблюдается.
|
||||
|
||||
## Что читать перед удалением тела
|
||||
|
||||
Две колонки учётной записи, и обе обязательны:
|
||||
|
||||
- `uncovered_sections` — непустой список означает, что в теле есть секции,
|
||||
которых разбор не покрывает; удалять нельзя;
|
||||
- `skipped_entities` — число сущностей с собственным `id`, которые разбор не
|
||||
понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят
|
||||
доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не
|
||||
досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело
|
||||
тренировки, маршрута которой нет больше нигде: в экспорте Apple его не
|
||||
существует.
|
||||
|
||||
Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого
|
||||
различия.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Read API: точки, выбор слоя, свёртка по сетке
|
||||
|
||||
**Секция:** ядро · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может · **Теги:** goal:read-api
|
||||
|
||||
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
|
||||
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
|
||||
|
||||
Формы запроса ровно две, и это один запрос с необязательным параметром:
|
||||
`?from&to` — все значения за период (вес, лекарства, симптомы), `?from&to&bucket`
|
||||
— с разбивкой (шаги, энергия).
|
||||
|
||||
Решение по размеру ответа (вариант «б»): разбивка не задана и ответ не влезает —
|
||||
сервер сам берёт сетку погрубее и **называет её в ответе**; разбивка задана явно
|
||||
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
|
||||
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
|
||||
|
||||
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
|
||||
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
|
||||
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
|
||||
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
|
||||
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
|
||||
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
|
||||
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
|
||||
|
||||
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
|
||||
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||
видно `layer`, `bucket` и `aggregation`.
|
||||
|
||||
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
|
||||
Каталог и род агрегации сделаны (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».
|
||||
@@ -0,0 +1,17 @@
|
||||
# [goal] Read API
|
||||
|
||||
**Секция:** порядок · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
|
||||
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
|
||||
|
||||
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
|
||||
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
|
||||
дорого — просуммировать нижний слой значит завысить втрое.
|
||||
|
||||
Завершена, когда любой из трёх потребителей получает точки за период без
|
||||
доступа к файлу базы.
|
||||
|
||||
## Завершение
|
||||
|
||||
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
|
||||
и предел размера ответа объявлен, а не подразумевается.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Сверка живой витрины с пересборкой
|
||||
|
||||
**Секция:** ядро · **Хук:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей —
|
||||
то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение
|
||||
живого состояния с тем, что даёт проигрывание журнала, сегодня обнаруживается
|
||||
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
|
||||
|
||||
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
|
||||
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
|
||||
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
|
||||
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
|
||||
— единственный способ узнать, что он сработал.
|
||||
|
||||
Инвариант «состояние есть свёртка журнала» проверяем ровно этим: пересобрать в
|
||||
отдельный файл (рабочая база не трогается — это уже так и устроено), сверить
|
||||
отпечатки, расхождение — событие, которое видно. Прогон не бесплатный
|
||||
(на квартальном журнале десятки минут), поэтому это регламент, а не фоновая
|
||||
задача сервиса.
|
||||
|
||||
Развилка при взятии: кто запускает — `cron` на хосте рядом с деплоем или сам
|
||||
сервис по расписанию. Первое честнее (пересборка уже сейчас команда, а не
|
||||
режим сервиса), но требует места под второй файл базы.
|
||||
|
||||
Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того,
|
||||
догадался ли человек посмотреть.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
|
||||
[деплой](deploy-rivendell.md).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Пересборка держит весь журнал в памяти
|
||||
|
||||
**Секция:** ядро · **Хук:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` материализует целиком две вещи: учёт доставок из базы и
|
||||
список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на
|
||||
квартальном (~12 тысяч) — терпимо, а дальше растёт линейно и без предела:
|
||||
журнал по определению не подчищается до следующего проверенного экспорта.
|
||||
|
||||
Отдельно к этому примешивается **размер заголовков**: `MaxHeaderBytes` у
|
||||
сервера не задан, то есть верхней границы у колонки `delivery.headers` нет
|
||||
вовсе. Раздутый заголовок множится на число доставок.
|
||||
|
||||
Порог, за которым это перестаёт быть теорией, не измерен — с него и стоит
|
||||
начинать, если задача берётся. Лечится потоковым перечислением журнала
|
||||
(курсор по учёту, обход каталога партиями по суткам) вместо двух срезов в
|
||||
памяти.
|
||||
|
||||
Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с
|
||||
[ретеншеном сырого архива](raw-archive-retention.md): та задача задаёт, где
|
||||
у журнала конец, эта — как его читать, не поднимая целиком.
|
||||
|
||||
Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением
|
||||
памяти, не зависящим от его длины.
|
||||
|
||||
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Чем откатывать релиз после наката миграции
|
||||
|
||||
**Секция:** инфра · **Хук:** Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем · **Теги:** goal:deploy, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
|
||||
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
|
||||
откат = подмена файла. `Down`-блоки миграций при этом честно называются
|
||||
декорацией для локальной разработки, а не аварийным путём: ни один из них не
|
||||
исполнялся ни разу. Осталось решить при взятии — сколько копий держим и где.
|
||||
Задача естественно склеивается с [деплоем](deploy-rivendell.md). Ниже —
|
||||
исходная постановка блокера, она же ТЗ.
|
||||
|
||||
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(проходы `ops` и `negative`, профиль `deep`).
|
||||
|
||||
## Что именно решить
|
||||
|
||||
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
|
||||
отказ на старте. Решение принято владельцем и здесь не пересматривается. Но у
|
||||
него есть следствие, которое до сих пор нигде не было записано:
|
||||
|
||||
**после того как новый бинарь накатил миграцию, возврат старого бинаря приёма
|
||||
не чинит.** Он теперь отказывается стартовать, а понизить схему нечем:
|
||||
|
||||
- подкоманды миграции у бинаря нет (`serve`, `reindex`, `healthcheck`);
|
||||
- `goose` CLI в образ не кладётся;
|
||||
- блоки `-- +goose Down` в миграциях написаны, но ни один тест их не исполняет,
|
||||
и на рабочей базе они не выполнялись ни разу (`DROP COLUMN` в SQLite через
|
||||
`modernc.org/sqlite` не проверялся вовсе);
|
||||
- `restart: unless-stopped` превращает отказ в цикл перезапуска, а телефон всё
|
||||
это время шлёт в закрытый порт и **не перешлёт** потом.
|
||||
|
||||
То есть аварийный путь придётся изобретать в момент аварии, при остановленном
|
||||
приёме. Цена простоя для метрик закрывается широким и глубоким проходами
|
||||
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
|
||||
единственный источник.
|
||||
|
||||
## Вопросы
|
||||
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
|
||||
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
|
||||
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
|
||||
импровизацией.
|
||||
2. **Копия файла базы перед накатом** — entrypoint контейнера делает `cp` рядом,
|
||||
откат = подмена файла. Цена: место (база растёт), плюс правило «сколько копий
|
||||
держим». Зато не требует ни кода, ни доверия к `Down`, а база производна от
|
||||
архива — потеря копии не смертельна.
|
||||
3. **`goose` CLI в образ.** Цена: образ перестаёт быть одним статическим
|
||||
бинарём, появляется вторая точка, знающая про схему.
|
||||
4. **Ничего, но записать вслух**: «понижение схемы не поддерживается, лечение —
|
||||
только выкатка вперёд». Цена: в аварии выбора нет.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
(2) плюс уже сделанная запись из (4). Копия файла — единственный вариант,
|
||||
который не требует доверять непроверенному коду ровно в тот момент, когда
|
||||
проверять некогда; а `Down`-блоки при этом честно называются декорацией для
|
||||
локальной разработки.
|
||||
|
||||
## Что стоит, пока решения нет
|
||||
|
||||
Ничего: страж работает, и это правильно. Стоит только аварийный сценарий —
|
||||
он существует ровно в том виде, в каком описан выше. Строка «понижение схемы не
|
||||
поддерживается» уже записана в `docs/architecture.md` (раздел «Деплой»).
|
||||
@@ -0,0 +1,21 @@
|
||||
# [idea] Человеческие аннотации поверх выведенных схем
|
||||
|
||||
**Секция:** ядро · **Хук:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата · **Теги:** goal:self-description
|
||||
|
||||
Схема содержимого выводится из данных и говорит **форму** — какие поля есть,
|
||||
какого типа, с какой заполненностью. Чего она не говорит — что метрика значит,
|
||||
в каких единицах разумны значения и чем `apple_stand_hour` отличается от
|
||||
`apple_exercise_time`.
|
||||
|
||||
Два пути, и выбор между ними преждевременен:
|
||||
|
||||
- **аннотации поверх выведенных схем** — человеческое описание рядом с
|
||||
машинным выводом, дописывается по мере надобности;
|
||||
- **рукописный каталог метрик** — полнее, но описывал бы документацию HAE, а не
|
||||
то, что он реально прислал.
|
||||
|
||||
Почему идея, а не задача: выбор зависит от того, насколько стабильным окажется
|
||||
формат. Меняться он может только с обновлением Health Auto Export, а это
|
||||
отслеживается — значит ответ придёт сам.
|
||||
|
||||
Связано: `docs/architecture.md` → «Самоописание», задача `derived-content-schemas`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# [idea] Порог sealed: с какого возраста час считается запечатанным
|
||||
|
||||
**Секция:** ядро · **Хук:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта · **Теги:** goal:merge-robustness
|
||||
|
||||
Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова:
|
||||
изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные
|
||||
всё равно сохраняются. Не выбрано одно — **с какого возраста** ставить флаг.
|
||||
|
||||
Почему идея, а не задача: правильный порог выводится из эксплуатации, а не из
|
||||
рассуждения. Наблюдалась глубина досчёта до 22 минут (находка 10), но одного
|
||||
наблюдения мало — ручные секции правятся задним числом на недели, а
|
||||
количественные метрики опаздывают на часы. Ставить порог сейчас значит угадать.
|
||||
|
||||
Что нужно, чтобы стало задачей: статистика `WARN` за несколько недель живого
|
||||
потока и распределение возраста изменённых часов по классам метрик.
|
||||
|
||||
Связано: `docs/architecture.md` → «Часовые объекты метрик».
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Самоописание
|
||||
|
||||
**Секция:** порядок · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
|
||||
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
|
||||
|
||||
Выведена из шага 6 плана.
|
||||
|
||||
Завершена, когда контракт читается машиной, а формы содержимого метрик
|
||||
выведены из данных, а не описаны руками.
|
||||
|
||||
## Завершение
|
||||
|
||||
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
|
||||
описаны руками.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Остановка и миграция: раздельные бюджеты и следы в логе
|
||||
|
||||
**Секция:** инфра · **Хук:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM · **Теги:** goal:deploy
|
||||
|
||||
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
|
||||
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
|
||||
`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` (архив).
|
||||
@@ -0,0 +1,59 @@
|
||||
# Наблюдаемость: /stats
|
||||
|
||||
**Секция:** инфра · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах · **Теги:** goal:observability
|
||||
|
||||
Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора:
|
||||
данные просто перестают приходить, и заметить это можно только по молчанию.
|
||||
Расписание HAE — пожелание, а не гарантия (находка 28), так что молчание
|
||||
случается штатно.
|
||||
|
||||
`/stats` отвечает на «жив ли поток» без чтения логов: последняя доставка по
|
||||
каждой автоматизации, счётчики за сутки, тишина в часах, доля доставок с
|
||||
ошибкой разбора, строки без кода в словаре категориальных значений.
|
||||
|
||||
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
|
||||
когда.
|
||||
|
||||
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
|
||||
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
|
||||
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
|
||||
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
|
||||
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
|
||||
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
|
||||
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
|
||||
|
||||
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
|
||||
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
|
||||
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
|
||||
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
|
||||
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
|
||||
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
|
||||
десятков тысяч доставок.
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
|
||||
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
|
||||
переставил автоматизацию 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,29 @@
|
||||
# Активный алерт «данных нет N часов»
|
||||
|
||||
**Секция:** инфра · **Хук:** Пропажу потока сейчас замечает человек, а не сервис · **Теги:** goal:observability
|
||||
|
||||
Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если
|
||||
туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили.
|
||||
|
||||
Активное уведомление закрывает разрыв: сервис сам сообщает, что данных нет
|
||||
дольше порога. Канал — тот же, что у остальных моих проектов.
|
||||
|
||||
Порог не единый: быстрый проход идёт каждые 5 минут, но ночью телефон
|
||||
заблокирован и тишина штатна (находка 28). Значит порог считается по времени
|
||||
суток или по последней успешной доставке каждой автоматизации отдельно.
|
||||
|
||||
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
|
||||
После деплоя на rivendell поднимется.
|
||||
|
||||
|
||||
## Источник алерта не может жить внутри `serve`
|
||||
|
||||
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
|
||||
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
|
||||
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
|
||||
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
|
||||
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
|
||||
перезапуск хоста или откат деплоя.
|
||||
|
||||
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
|
||||
`negative`).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Тай-брейк при равной полноте точек
|
||||
|
||||
**Секция:** ядро · **Хук:** Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
|
||||
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
|
||||
файла и есть выбранный вариант.
|
||||
|
||||
Что важно не потерять при реализации: правило обязано остаться **тотальным** —
|
||||
числа у точки нет, значит откат на порядок канонических форм, — и обязано
|
||||
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
|
||||
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
|
||||
род есть функция витрины, и правило слияния, читающее собственную выдачу,
|
||||
повторяет дефект наследования слоя «из будущего» (`docs/review.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.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.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Управление токенами и секретами
|
||||
|
||||
**Секция:** инфра · **Хук:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу · **Теги:** goal:deploy
|
||||
|
||||
Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и
|
||||
`config.docker.toml` коммитится без секретов. Для локальной разработки это
|
||||
правильно, но это же делает выезд наружу опасным: одна забытая настройка
|
||||
открывает историю здоровья всему интернету.
|
||||
|
||||
**Контуров теперь два, а не один.** С появлением каталога
|
||||
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
|
||||
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
|
||||
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
|
||||
предупреждает на старте обоими сообщениями (`write auth disabled`,
|
||||
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
|
||||
старта нет, и это решение осталось здесь.
|
||||
|
||||
Решается перед деплоем, не раньше — так договорились.
|
||||
|
||||
Шаги:
|
||||
- раздельные токены приёма и чтения, генерация и хранение вне репозитория;
|
||||
- сервис громко предупреждает на старте, если проверка выключена (уже есть),
|
||||
и **отказывается стартовать**, если адрес прослушивания публичный, а токенов
|
||||
нет;
|
||||
- проверка в гейте, что в коммит не уехал файл с токеном (частично закрыта
|
||||
`gitleaks`).
|
||||
|
||||
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
|
||||
оба контура закрыты разными токенами.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Проверка секций, которых поток ещё не приносил
|
||||
|
||||
**Секция:** ядро · **Хук:** Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую · **Теги:** goal:parsing-and-storage
|
||||
|
||||
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
|
||||
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`, а также вес — а вес
|
||||
агенту-медику нужен наверняка.
|
||||
|
||||
Пользователь настраивает оставшиеся метрики на телефоне, так что данные
|
||||
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
|
||||
разбираются, а не молча падают в `parse_status`.
|
||||
|
||||
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
|
||||
**есть** и в экспорте они присутствуют — `BodyMass` (1127 записей),
|
||||
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
|
||||
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
|
||||
данные», а «доедут ли они через HAE и в какой форме».
|
||||
|
||||
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
|
||||
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
|
||||
устаревание нижнего слоя к нему неприменимо, держим всегда.
|
||||
|
||||
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
|
||||
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
|
||||
смотреть, когда данные появятся.
|
||||
|
||||
Готово, когда каждая новая секция либо разобрана, либо явно описана в
|
||||
`docs/research/apple-health.md` как не пришедшая, и ни одна не числится в ошибках
|
||||
разбора.
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
|
||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт
|
||||
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
||||
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
||||
поток приносил, и сравнение с известным набором закрывает задачу.
|
||||
|
||||
Модель под секции с собственным `id` заложена (change
|
||||
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
|
||||
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
|
||||
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications` —
|
||||
их формы никто не видел, и разбор вслепую сознательно не писался.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Идентичность тренировок при импорте родного экспорта
|
||||
|
||||
**Секция:** ядро · **Хук:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE · **Теги:** goal:native-export-import
|
||||
|
||||
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
|
||||
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
|
||||
тип, источник, даты и статистика. Значит `healthlog import` не сможет сопоставить
|
||||
тренировку из снапшота с той же тренировкой, уже приехавшей от HAE, и они
|
||||
задвоятся.
|
||||
|
||||
Это ровно та дыра, что была у точек, и там её закрыли ключом `start + end`
|
||||
(находка 47): `HKObject.uuid` в выгрузку не попадает, поэтому модель
|
||||
идентичности обязана выражаться через интервал.
|
||||
|
||||
Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку **хешем
|
||||
содержимого** (`hash_id` в sqlite-utils) — ровно потому, что идентификатора в
|
||||
экспорте нет. Нам это не подходит в лоб: у нас половина тренировок уже лежит под
|
||||
настоящим `id`, и хеш содержимого дал бы третий ключ рядом с двумя.
|
||||
|
||||
Варианты, которые надо будет сравнить:
|
||||
|
||||
- **Второй уникальный ключ `(start_utc, end_utc)`** у тренировки: импорт ищет по
|
||||
нему, HAE — по `id`. Цена: индекс и вопрос, что делать при столкновении двух
|
||||
разных тренировок с одним интервалом (бывает ли такое — неизвестно).
|
||||
- **Сопоставление на стадии импорта**, без изменения схемы: импорт читает уже
|
||||
сохранённые тренировки за период и приписывает найденным их `id`. Цена: логика
|
||||
сопоставления живёт в импорте и не проверяется ничем, кроме него.
|
||||
- **Считать тренировки из экспорта отдельным родом** и не сопоставлять вовсе.
|
||||
Цена: потребитель видит две тренировки вместо одной и обязан схлопывать сам —
|
||||
ровно то, чего проект старается не делать.
|
||||
|
||||
Решать до появления формы `healthlog import` значит угадывать: неизвестно,
|
||||
понадобятся ли тренировки из экспорта вообще (у HAE они полнее — с маршрутом и
|
||||
рядами, а в экспорте маршрут лежит отдельными GPX).
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||
`apple-export-import`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
|
||||
|
||||
**Секция:** ядро · **Хук:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом · **Теги:** goal:read-api
|
||||
|
||||
Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая
|
||||
маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура
|
||||
тренировки разнородна и избыточна (сводки дублируют ряды, находка 15), и
|
||||
раскладывать её в таблицы значило бы решить за Apple, что в ней главное.
|
||||
|
||||
Разворачивание маршрута в отдельную таблицу точек имело бы смысл для запросов
|
||||
вида «все пробежки, проходившие через эту область» или «набор высоты по
|
||||
сегментам» — то есть когда маршрут нужен не целиком, а выборочно.
|
||||
|
||||
Почему идея, а не задача: такого клиента нет. Трекер тренировок берёт
|
||||
тренировку целиком одним пакетом, и этого ему достаточно.
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
|
||||
Reference in New Issue
Block a user