- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
16 KiB
Конвенции кода
Как пишем код (How), а не что система делает (What — в 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 тем же изменением.
Тесты
- Тесты на разбор формата HAE держим на реальных пакетах, сложенных в
testdata(с вычищенными токенами). Документация формата ненадёжна — источником истины служат живые данные. - Проверяем идемпотентность: повторный разбор того же пакета не меняет витрину.
- Где код выбирает между двумя версиями одних данных, тест обязан прогнать обе стороны и хотя бы одну перестановку трёх. Пример на паре доказывает коммутативность и молчит про ассоциативность, а сломаться правило может именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их попарная свёртка дала нетранзитивное отношение победы, из-за которого одна и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого не увидело, ревью кода увидело только перебором троек. Правилом линтера не выражается — отсюда проза.
- В тот же перебор обязана входить версия с содержимым, равным одной из уже присланных, и пара «равная каноническая форма, разные байты». Три версии с разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с равной формой ловит другое: неединственный минимум, при котором победителем оказывается просто первый в срезе, то есть порядок элементов на проводе.
- Изменение правила разбора или слияния сопровождается замером на живом архиве, и ответ «пересворачивать нечего» произносится с числом. Утверждение без числа не отличается от предположения, а цена ошибки здесь — необратимое решение о судьбе тел.
- Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер. Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем
timeи ищем в остатке. Правило общее — таких тестов будет больше (токены, тела запросов, координаты объектов).