Files
healthlog/docs/conventions.md
T
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

170 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Конвенции кода
Как пишем код (How), а не что система делает (What — в
[architecture.md](architecture.md)). Перенесено из jellybit и сжато под
масштаб этого проекта.
## Язык
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — **английский**.
## Ошибки
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
работали сквозь слои. `%v` — только когда причину сознательно не
раскрываем.
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
смысл, не повторяя нижний.
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows`
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
ошибки. Не плодим типы там, где хватает sentinel.
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
`errors.Join`.
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
`recover` — на верхней границе HTTP-обработчика.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
## Логи
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
Сбор и ротацию делает окружение.
- `msg` — короткая константа в нижнем регистре, категория события
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
префикс сообщения.
- **Уровень — это адресат, а не громкость поломки:**
| Уровень | Кому | Примеры |
|---|---|---|
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
переводит ошибку в ответ и не логирует повторно.
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
- Время в логах — UTC, RFC 3339 с долями секунды.
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
При сомнении логируем факт наличия, не значение.
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
и с обрезкой по длине.
- **Текст ошибки разбора не содержит значений из входа** — только род токена
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
разбора не узнает.
## Конфигурация
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
файла под `0600`.
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
только её. Конфиг неизменяем — смена параметров означает рестарт.
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
`--config=path`.
- `config.example.toml` коммитим как единый самодокументируемый справочник:
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
допустимых значений и в каких единицах. Секретные поля — пустые.
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
выход с ненулевым кодом. Не стартуем «наполовину».
## База данных и идентификаторы
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
невалидный id — 404 без похода в БД.
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout`
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
в двух секциях затёр бы одну запись другой молча).
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
тело.
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
она становится меткой касания, и запрос «что изменилось с момента X» получает
столько ложных изменений, сколько раз источник переприслал то же самое (у
тренировки — двадцать шесть).
- **Новая производная от разбора колонка в момент появления вносится в перечень
того, что пересборка не переносит.** Перечень — единственное место, где это
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
прогона.
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
падать громко.
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [architecture.md](architecture.md) тем же
изменением.
## Тесты
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
коммутативность и молчит про ассоциативность, а сломаться правило может
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза.
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
равной формой ловит другое: неединственный минимум, при котором победителем
оказывается просто первый в срезе, то есть порядок элементов на проводе.
- **Изменение правила разбора или слияния сопровождается замером на живом
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).