предложение переработано по итогам ревью дизайна

- три решения опровергнуты экспериментами: повтор при SQLITE_BUSY не сходится
  без _txlock=immediate (242 из 800 против 800 из 800), разбор в map[string]any
  держит 197 МиБ против 54, канонизация без json.Number теряет литерал
- канонизации назначен дом: общий internal/canon вместо hae, иначе импорт
  экспорта Apple потребует второй реализации и хеш-детектор станет бесполезен
- вывод слоя вернул шаг наследования, WARN сравнивается только с надёжным
  заголовком; в bucket возвращены units и границы содержимого
This commit is contained in:
av
2026-08-01 15:16:58 +03:00
parent 65351ab6e7
commit 809153e03f
8 changed files with 394 additions and 76 deletions
+102 -15
View File
@@ -30,6 +30,10 @@
- Словарь категориальных значений: строка пока хранится дословно и без кода.
- Род агрегации и каталог разрезов.
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
- **Хранение эпизодных схем** (поэпизодный `sleep_analysis`): модель их
идентичности вынесена блокером `identichnost-epizodnyh-metrik`. Точки
разбираются и считаются, но не сохраняются; тела в архиве, подберёт
пересборка.
## Decisions
@@ -47,16 +51,67 @@
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
который о HAE ничего не знает.
### Разбор — синхронно в приёме, после записи в архив
**Стратегия декодирования — часть контракта, а не деталь.** Конверт
разбирается в структуру с `Data []json.RawMessage` на метрику; точка
декодируется по одной и сразу отбрасывается. Измерено: разбор тела 42 МиБ в
`map[string]any` удерживает 197 МиБ кучи против 54 МиБ у этой формы. Вместе с
самим телом и удвоением в чтении пик доходит до ~300 МиБ на доставку — при
трёх автоматизациях и неизвестном размере VPS это OOM ровно на пике потока,
когда терять доставки дороже всего.
Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает
архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а
тело из точек — нет.
Сигнатура при этом остаётся `[]byte`: тело уже целиком в памяти после чтения
запроса, `io.Reader` добавил бы второй буфер и ничего не сэкономил, а правило
вывода слоя (≥10 точек по всей доставке) всё равно требует двух проходов.
Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором
доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание,
что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout`
уже пять минут.
### Дом канонизации — общий, а не внутри разбора
Канонизация, полнота точки и хеш живут в **нейтральном** пакете, который
импортируют и `hae`, и `store`. Иначе граница «`store` о HAE не знает»
оставляет их без дома: слияние происходит в `store`, после него хеш надо
пересчитать, а канонизация лежала бы в `hae`.
Альтернатива — вторая реализация канонизации для импорта родного экспорта
Apple — отвергнута: две реализации разойдутся на дребезге последнего разряда
double, и хеш-детектор начнёт видеть изменения там, где их нет. Глубокий
проход из почти бесплатного превратится в перезапись недели на каждом прогоне.
Каноническая форма существует только в момент вычисления хеша. Хранимая форма
— исходные байты точки: числа читаются литералом (`json.Number`), потому что
обход через `float64` теряет `1.0``1` и сдвигает целые больше 2^53, а
невалидный UTF-8 в именах устройств заменяется на U+FFFD. Сортировку ключей
делает `encoding/json`, своей писать не надо; собственным остаётся округление
до двенадцати значащих цифр.
### Разбор — функция от доставки в архиве, а не от тела в памяти
Разбор адресуется **идентификатором доставки**, тело читается из архива. Приём
сворачивает одну доставку, будущий `reindex` — все; код один.
Первая редакция дизайна отвергала это как «асинхронный разбор с очередью»,
которым оно не является: проход по архиву — детерминированная свёртка, ровно
то, чем система объявлена в `docs/architecture.md`
(`состояние = import(снапшот) + replay(доставки)`). Прежняя форма давала два
кода для одной операции — разбор при приёме и будущую пересборку, — и они
разошлись бы на первом же расхождении.
Плата: тело перечитывается с диска сразу после записи. Для 42 МБ это
страничный кэш, то есть несущественно.
### Свёртка вызывается синхронно, сразу после записи в архив
Тело ложится на диск, потом сворачивается — но уже как доставка из архива, а
не как буфер в памяти (см. выше). Отказ свёртки не откатывает архив: журнал
важнее витрины, восстановить точки из тела можно всегда, тело из точек — нет.
Синхронно, а не фоновым воркером: очередь дала бы окно «принято, но не
свёрнуто» при перезапуске, и понадобилось бы отдельное состояние «что
досворачивать». Свёртка одной доставки стоит секунд, а `read_timeout` уже пять
минут.
Работа после записи в архив идёт на контексте, **отвязанном от запроса**
(`context.WithoutCancel` с собственным дедлайном): иначе обрыв соединения
клиентом или Caddy на середине свёртки оставит часть объектов записанной, а
доставку — со статусом, по которому её никто не подберёт.
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
@@ -78,9 +133,17 @@
победил» стирало бы `start`/`end` у уже сохранённой точки.
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
При равной полноте побеждает точка из доставки с большим `received_at` — это
делает свёртку по журналу детерминированной: проигрывание архива обязано дать
то же состояние, что приём в реальном времени.
При **равной** полноте исход обязан быть детерминированным и не зависеть от
порядка доставок. Первая редакция предписывала сравнение по `received_at`
оно неисполнимо: у сохранённой точки нет провенанса, сравнивать не с чем. Хуже,
что четверть доставок несёт столкновения **внутри себя**, где `received_at`
вообще один. Поэтому исход определяется свойством самих значений (порядком
канонических форм), а не порядком событий.
Столкновение с различием содержимого оставляет след — `WARN` и счётчик. Иначе
допущение «меньше полей не значит новее», объявленное риском, не получит ни
одного наблюдения.
### Слой выводится по метрике внутри доставки, а не по доставке целиком
@@ -92,6 +155,13 @@
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
наследует самый мелкий слой среди плотных.
Третий шаг — доставка без плотных метрик вовсе — наследует последний надёжно
выведенный слой той же автоматизации. Первая редакция заменила его заголовком,
и это была регрессия: измерено 2 такие доставки из 89, обе с заголовком
`Default`, который не означает режима. Наследовать нечего и заголовок
ненадёжен — точки не сохраняются, доставка ждёт пересборки; молчаливый `raw`
создал бы призрачный разрез, который поедет в каталог и в выбор слоя Read API.
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
@@ -120,10 +190,27 @@
архива; паника в разборе перехватывается, доставка помечается
`parse_status=failed`, ответ остаётся `200`.
**Конкурентные доставки правят один час** → read-modify-write без блокировки
теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма,
а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор.
Проверяется тестом с параллельной записью в один `hour_utc`.
**Конкурентные доставки правят один час** → read-modify-write теряет точки, и
наивное «транзакция плюс повтор» **измеримо не работает**. Замер на
`modernc.org/sqlite` с DSN проекта, 4 горутины × 200 слияний в одну строку:
```
txlock=deferred без повтора 91 из 800
txlock=deferred с повтором 242 из 800 (31078 повторов)
txlock=immediate без повтора 800 из 800
```
Причина: код отказа — `517` (`SQLITE_BUSY_SNAPSHOT`), и `busy_timeout` его не
покрывает, SQLite возвращает его немедленно. Поэтому: `_txlock=immediate` в
DSN; повтор оборачивает **всю тройку** чтение-слияние-запись, а не только
запись (иначе повтор перезапишет чужие точки уже прочитанным состоянием —
классический lost update); путь «хеш совпал, писать нечего» идёт под
`TxOptions{ReadOnly: true}`, чтобы не сериализоваться на write-lock;
распознавание — `errors.As` на `*sqlite.Error` с кодами 5 и 517, обёрнутое в
`store`, чтобы драйвер не торчал наружу.
Тест обязан быть с настоящей конкуренцией и проверкой суммы: две горутины в
удачном порядке проходят и на сломанной реализации.
**Правило полноты ошибочно для метрики, где меньше полей значит новее**
таких в потоке не наблюдалось, но допущение не доказано. Помечается как