# Время Как приложение записывает моменты и длительности: в каком формате, откуда берётся значение и где появляется не-UTC. Форма записи — `common/language.md`. ## Область действия Конвенция описывает фиксацию **свершившихся моментов** — того, что уже произошло и попало в базу, лог или ответ API. Планирование будущих событий — отдельный случай: там хранят локальное время плюс имя зоны, потому что правила зон меняются в промежутке между планированием и наступлением. Пока такой сущности нет, правил для неё в файле нет. ## Правила ### R1. Единый формат — RFC 3339, UTC, суффикс `Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` — одинаково в хранении, логах, API и обмене с внешними системами. **Почему.** Разные форматы в разных слоях требуют преобразования на каждой границе, а ошибка в таком преобразовании не видна сразу: она всплывает через полгода, на переходе на летнее время, когда реальное смещение перестаёт совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` убирает из данных и смещение, и сам вопрос «в какой зоне это записано». ### R2. Ширина строки фиксируется на каждый носитель **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина строки времени одна и от записи к записи не плавает. **Почему.** Лексикографическая сортировка совпадает с хронологией только среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по текстовому полю обязан давать порядок событий. Плавающая ширина (типичный источник — форматирование, отбрасывающее незначащие нули) ломает порядок не везде, а только на тех парах записей, где дробная часть оказалась короче, — то есть редко, выборочно и невоспроизводимо. ### R3. Точность разных носителей может различаться **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. **Почему.** Квантор в R2 — на носитель, а не на приложение, потому что строки разных носителей между собой не сравниваются: сортировка идёт внутри колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался как «одна точность на всё приложение»: от подгонки формата логов под формат колонки ни одна пара строк не становится сравнимой, зато точность режется до худшего из носителей. ### R4. Локальное время не хранится и не передаётся **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной зоне. **Почему.** Метка без зоны неинтерпретируема вне процесса, который её записал: чтобы понять, какому моменту она соответствует, читателю нужно знать настройки чужой машины на момент записи. И даже зная их, он не разберёт час перехода на зимнее время: этот час идёт дважды, две записи получают одинаковую метку, и порядок между ними не восстанавливается ничем. ### R5. Единая точка получения «сейчас», форматирования и разбора **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает метки; прямые вызовы часов по коду не разбросаны. **Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех меток без исключения, а каждый прямой вызов часов заводит ещё одно место, где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в данных, и обнаруживается, когда испорченных записей уже накопилось. Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`). ### R6. Дефолтов времени в схеме БД нет **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий код: значение появляется, но приходит от сервера БД — то есть с других часов и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка падает громко и чинится в момент написания, а не при разборе расхождения между временем в записи и временем в логе. Правило то же, что для идентификаторов (`arch/db-identifiers.md R2`). ### R7. Длительность — отдельная величина, а не пара меток **ДОЛЖЕН.** Длительность операции записывается числом (обычно миллисекундами) в поле вида `duration_ms`. **Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». Пара меток заставляет каждого потребителя знать, какие именно две из них образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в логе; число сравнивается, агрегируется и попадает в перцентили без этого шага. Кроме того, разность сохранённых меток считается по стенным часам и наследует их дефект (R9). ### R8. Длительность засекает слой, который делает вызов **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. **Почему.** Обе границы операции видит только этот слой: замер уровнем выше приписывает операции чужие накладные расходы, уровнем ниже — теряет часть вызова. В обоих случаях число остаётся правдоподобным и потому не оспаривается, хотя отвечает не на тот вопрос, который к нему задают. ### R9. Момент и интервал берутся с разных часов **ДОЛЖЕН.** Источник зависит от того, что записывается: | № | Величина | Источник | |---|---|---| | R9.1 | момент события | стенные часы через единую точку (R5) | | R9.2 | длительность операции | монотонные часы процесса | **Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для меток: их ноль произволен и не переживает перезапуск процесса, так что вне процесса такое значение ничего не означает. Отсюда следствие, которое легко упустить: источник меток времени и источник интервалов — разные, даже если оба называются «часы». ### R10. Не-UTC существует только на слое отображения **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не проникает в хранение, сортировку и логи. **Почему.** Как только конвертация уходит вглубь, результат вычислений начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт разные группировки, а порядок записей перестаёт быть общим для всех. Ещё хуже, что при конвертации в нескольких слоях её легко выполнить дважды — смещение удваивается, результат остаётся похожим на правду, а найти виновный слой можно только перечитав их все. ### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC` **ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение по умолчанию — `UTC`. **Почему.** Зашитая в код зона превращает переезд или второго пользователя в другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано потому, что оно не притворяется настроенным: показанное время совпадает с тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается как «зону не задали», а не как «где-то потерялось смещение». ### R12. В календарных вычислениях зона указывается явно **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с явно переданной зоной, а не с системной зоной процесса. **Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: расхождение не воспроизводится там, где его заметили, и объясняется средой, а не кодом. Явно переданная зона делает результат функцией от аргументов. Зона по умолчанию здесь та же, что и для отображения (R11); календарная логика, которой нужна другая, получает её тем же явным аргументом. ## Связано - `arch/config.md` — где задаётся зона отображения. - `arch/db-identifiers.md` — то же правило «генерирует приложение» для id.