остальные конвенции переведены на формальный язык

- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
av
2026-07-25 19:17:32 +03:00
parent 7701a28df1
commit 31d0620f55
11 changed files with 2404 additions and 811 deletions
+144 -47
View File
@@ -1,63 +1,160 @@
---
status: рекомендуемая
---
# Время
Один формат времени на всё приложение: хранение, логи, API, обмен с
внешними системами. Разные форматы в разных слоях — источник ошибок,
которые всплывают через полгода на границе перехода на летнее время.
Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. Форма записи —
`common/language.md`.
## Формат
## Область действия
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
Лексикографическая сортировка равна хронологии только среди строк
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
created_at` по текстовому полю обязан давать порядок событий.
- Разные носители могут иметь разную точность: строки БД и строки лога
между собой никогда не сравниваются. Требование — не «одна точность на
приложение», а «внутри колонки и внутри потока логов ширина одна».
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
логах, ни в JSON API.
Конвенция описывает фиксацию **свершившихся моментов** — того, что уже
произошло и попало в базу, лог или ответ API. Планирование будущих событий —
отдельный случай: там хранят локальное время плюс имя зоны, потому что
правила зон меняются в промежутке между планированием и наступлением. Пока
такой сущности нет, правил для неё в файле нет.
## Генерирует приложение, а не хранилище
## Правила
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
должна падать громко, а не тихо получать значение от БД — иначе
расходятся источник времени (сервер БД) и его формат.
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
## Длительность — не метка времени
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами.
Измерение длительности операции — отдельная величина: число (обычно
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
время в формате выше. Засекает её тот слой, который делает вызов.
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
полгода, на переходе на летнее время, когда реальное смещение перестаёт
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
**Интервал измеряется монотонными часами процесса**, а не вычитанием
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
дать отрицательную длительность. Из этого следует, что источник меток
времени и источник интервалов — разные, даже если оба называются «часы».
### R2. Ширина строки фиксируется на каждый носитель
## Зоны
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает.
Единственное место, где появляется не-UTC, — **отображение пользователю**.
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
`UTC`. На хранение, сортировку и логи она не влияет.
**Почему.** Лексикографическая сортировка совпадает с хронологией только
среди строк одинаковой длины: `…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); календарная
логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано -->
<!-- /local -->