--- prefix: GTIM extends: arch/time.md --- # Время: реализация на Go Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — тогда и только тогда, когда написаны заглавными. ## Правила ### GTIM-1. «Сейчас» берётся у слоя хранилища **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего `time.Now().UTC()`, а не из `time.Now()` по коду. **ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она когда-то локальной, и восстановить смещение задним числом не по чему. Тестируемость мотивом **не является**: точка — единственное место, которое придётся превратить в переменную или поле, если однажды понадобится подменять часы, но само по себе оно подмены не даёт. ### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime` **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ получить строку времени и прочитать её обратно. **ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и взаимная обратимость записи и чтения держатся ровно до первого второго layout — а расхождение проявится не на записи, а при сравнении значений, записанных разными местами. ### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий **ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений ровно два, и оба прописаны явно: | № | Исключение | Почему оно не покрывается GTIM-1 | |---|---|---| | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) | **ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. Исключения перечисляются исчерпывающе, потому что каждое из них — само по себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут «починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить сама себе. ### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера **ДОЛЖЕН.** Исключение из GTIM-3 оформляется как `//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в конфигурации линтера для него не заводятся. **ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует исключение путём к файлу, отвязывается при переносе кода и продолжает разрешать `time.Now()` там, где исключения уже нет, — молча. Директива переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый `//nolint` без имени правила глушит на строке все проверки сразу, а без причины неотличим от заглушенного дефекта; обе деградации штатно ловит `nolintlint` (`require-specific`, `require-explanation`) — стандартный способ дисциплинировать директивы в golangci-lint. ### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. **ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому лексикографический порядок совпадает с хронологическим только при одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** целой секунды того же момента (`.` меньше `Z`), то есть ломаются и `ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех сразу. Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды, поэтому `Format` их не выведет. ### GTIM-5. `time.RFC3339Nano` не используется **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит от значения: соседние записи получают разную ширину, и свойство, на котором держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом проходит — отказывает только порядок. ### GTIM-6. Чужой вход нормализуется явно **ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится к каноническому виду явно, а не считается каноническим по факту успешного разбора. **ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует **писатель**, а не читатель; пока писатель один, этого достаточно, но значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и обнаруживается не на записи, а на первой сортировке. Само решение «нормализовать, а не отклонять» — базовое (`TIME-13`); здесь — Go-механика, из-за которой его легко нарушить незаметно. ### GTIM-7. В драйвер передаётся строка, а не `time.Time` **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с собственным layout, который меняется вместе с версией драйвера, а не вместе с конвенцией. ### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr` **ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`: ```go func utcTime(_ []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey { a.Value = slog.TimeValue(a.Value.Time().UTC()) } return a } ``` **ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне процесса — на ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, неверная зона выглядит как совершенно валидное время, а записи из разных мест перестают складываться в одну хронологию с метками хранилища. ### GTIM-9. Точность времени в логах отличается от точности в БД **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не приводится к секундной точности GTIM-4. **ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование одной точности везде. Ширина фиксируется на носитель: три знака в логе — такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не нарушено. Общее у лога и базы одно — зона (GTIM-8). ### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с локальным `//nolint`. **ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким меткам, зависит от подводки часов: перевод назад даёт отрицательную длительность, скачок вперёд — выброс в измерениях, и оба случая невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается как недосмотр и его «чинят». ### GTIM-11. `time/tzdata` импортируется в `main` **ДОЛЖЕН.** База зон вшивается в бинарь. **ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых в минимальном образе нет: отказ происходит в рантайме, на первой же попытке применить зону, — то есть после выкладки, а не на сборке. Импорт именно в `main` держит это решение в одном видимом месте, а не в случайном пакете, откуда его удаляют при чистке зависимостей. ### GTIM-12. Зона отображения применяется только в UI **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах представления, но не в хранимых значениях и не в вычислениях. **ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в вычисления и хранение, она делает уже записанные данные зависимыми от текущего значения настройки: смена зоны задним числом сдвигает границы суток у того, что давно посчитано и сохранено. Календарные вычисления бизнес-логики берут зону явно — как описано в базовом слое. ## Связано - базовый слой — UTC как формат хранения, нормализация чужого входа, явная зона в календарных вычислениях. - конвенция `config` — валидация зоны отображения загрузчиком конфига.