Часовые пояса: конвенция для абсолютного времени и производных дат #63

Closed
opened 2026-08-07 17:08:14 +03:00 by ddmitry · 3 comments
Owner

Пояса в ClickHouse — частые грабли, и стенд сейчас стоит на неявном умолчании:
колонки времени объявлены без пояса, а сходится всё лишь потому, что пояс
сервера — UTC. Тикет просит принять конвенцию и записать её, пока dds.* и
витрины не написаны: правило понадобится именно там — воронки, удержание,
«покупки по дням».

Что измерено на стенде

7 августа 2026 года, ClickHouse 26.3.17.56, ods.event после #43. Настоящая
строка модельного дня, колонка UTCEventTime DateTime без пояса:

сервер UTC клиент с session_timezone='Europe/Samara'
колонка показана 2026-05-31 21:24:09 2026-06-01 01:24:09
toDate(UTCEventTime) 2026-05-31 2026-05-31
toDate(UTCEventTime, 'Europe/Samara') 2026-06-01 2026-06-01
EventDate из выгрузки 2026-06-01 2026-06-01

У клиента с чужим поясом глаз и GROUP BY расходятся: в строке видно
первое июня, а группируется она в тридцать первое мая. Документация ClickHouse
это объясняет: у колонки без объявленного пояса на запись действует пояс
сервера и только он, session_timezone на запись не влияет, а на чтение —
влияет (сверено через Context7 7 августа 2026 года).

Расхождение не редкость: toDate(UTCEventTime) != EventDate у 1713 событий из
50 626 модельного дня — 3,4%. Так задуман мир: пояс счётчика UTC+4, EventDate
живёт в нём, UTCEventTime абсолютна (спека генератора, раздел 9).

Развилка

Решение принимается до правок, веером и с записью отклонённого — скилл
brainstorm-with-docs. Что видно сейчас:

  • оставить неявное умолчание: сервер UTC, поясов в типах нет;
  • пинить пояс в типе там, где время абсолютно: DateTime('UTC'),
    DateTime64(3, 'UTC'). Date не трогается — у него пояса нет вовсе;
  • то же плюс правило на производные даты: календарная дата из абсолютного
    времени — только с явно названным поясом, голый toDate по DateTime в
    репозитории не пишется;
  • хранить всё в поясе счётчика — ломает смысл UTCEventTime, назван для
    полноты веера.

Учебная ценность у второго и третьего одна и та же, и она не оборонительная:
явный пояс в DDL — не сторож, а названное намерение. Менти читает
DateTime('UTC') и спрашивает, зачем пояс написан; вопрос этот ровно тот, ради
которого мир сделан с поясом счётчика +4.

Что войдёт после решения

  • Конвенция записана в docs/architecture/storage.md рядом со служебными
    колонками; отклонённые варианты — там же или в ADR.
  • DDL приведён к конвенции: sql/ddl/10-stg-tables.sql,
    sql/ddl/20-ods-tables.sql.
  • Разбор строки получил имя пояса: sql/ddl/30-ods-views.sql. Это не DDL, а
    выражение матвью, и правка там другого рода — третий аргумент 'UTC' у
    parseDateTimeOrNull. Без неё тип колонки чинит только чтение: суффикс Z
    маска сверяет как букву и выбрасывает, поэтому число, которое ляжет в
    колонку, сегодня определяет пояс сервера. Замерено 8 августа 2026 года:
    под session_timezone='Europe/Samara' та же строка даёт 1780262649 без
    аргумента и 1780277049 с 'UTC'.
  • Имя пояса и число смещения связаны явно: в генераторе пояс живёт числом
    (world.COUNTER_TIMEZONE_MINUTES = 240), а toDate требует имя IANA
    (Europe/Samara). Сегодня они эквивалентны — Самара часы не переводит, — но
    разъедутся молча, если однажды поменять одно.
  • Один учебный комментарий у первой колонки с явным поясом: почему он написан.

Критерии приёмки

  • Конвенция принята веером и записана; отклонённые варианты видны.
  • DDL ей соответствует; стенд поднимается с нуля, make check-clickhouse
    зелёный и не медленнее прежнего.
  • Имя пояса и смещение связаны в одном месте.
  • Учебный комментарий на месте.
  • Замер из раздела «что измерено» повторён после правки. Три клетки те же,
    одна названа заранее и обязана измениться: у клиента с чужим поясом
    колонка показана не 2026-06-01 01:24:09, а 2026-05-31 21:24:09.
    Это и есть поставляемое — глаз и GROUP BY перестают спорить.

Границы

  • Постоянных проверок не заводить ни одной — docs/architecture/testing.md.
    Расхождение глаза и GROUP BY учит лабой: Superset и Grafana рендерят в
    своём поясе, и это готовая сцена урока, а не приёмка.
  • Модель мира не трогать: пояс счётчика UTC+4 решён спекой генератора.
  • Date остаётся без пояса — у типа его нет.

Сначала прочитать

  • docs/architecture/storage.md — служебные колонки и конвенции.
  • docs/specs/2026-08-01-generator.md, раздел 9 — пояс счётчика и почему
    toDate(UTCEventTime) не равен EventDate у ночных событий.
  • generator/src/clickstream_generator/world.py — COUNTER_TIMEZONE_MINUTES.
  • sql/ddl/ — что править.

Проверка

  • make clean && make up && make check-clickhouse
  • два SELECT из «что измерено», до и после правки
Пояса в ClickHouse — частые грабли, и стенд сейчас стоит на неявном умолчании: колонки времени объявлены без пояса, а сходится всё лишь потому, что пояс сервера — `UTC`. Тикет просит принять конвенцию и записать её, пока `dds.*` и витрины не написаны: правило понадобится именно там — воронки, удержание, «покупки по дням». ## Что измерено на стенде 7 августа 2026 года, ClickHouse 26.3.17.56, `ods.event` после #43. Настоящая строка модельного дня, колонка `UTCEventTime DateTime` без пояса: | | сервер `UTC` | клиент с `session_timezone='Europe/Samara'` | |---|---|---| | колонка показана | `2026-05-31 21:24:09` | `2026-06-01 01:24:09` | | `toDate(UTCEventTime)` | `2026-05-31` | `2026-05-31` | | `toDate(UTCEventTime, 'Europe/Samara')` | `2026-06-01` | `2026-06-01` | | `EventDate` из выгрузки | `2026-06-01` | `2026-06-01` | У клиента с чужим поясом **глаз и `GROUP BY` расходятся**: в строке видно первое июня, а группируется она в тридцать первое мая. Документация ClickHouse это объясняет: у колонки без объявленного пояса на запись действует пояс сервера и только он, `session_timezone` на запись не влияет, а на чтение — влияет (сверено через Context7 7 августа 2026 года). Расхождение не редкость: `toDate(UTCEventTime) != EventDate` у 1713 событий из 50 626 модельного дня — 3,4%. Так задуман мир: пояс счётчика UTC+4, `EventDate` живёт в нём, `UTCEventTime` абсолютна (спека генератора, раздел 9). ## Развилка Решение принимается до правок, веером и с записью отклонённого — скилл `brainstorm-with-docs`. Что видно сейчас: - оставить неявное умолчание: сервер `UTC`, поясов в типах нет; - пинить пояс в типе там, где время абсолютно: `DateTime('UTC')`, `DateTime64(3, 'UTC')`. `Date` не трогается — у него пояса нет вовсе; - то же плюс правило на производные даты: календарная дата из абсолютного времени — только с явно названным поясом, голый `toDate` по `DateTime` в репозитории не пишется; - хранить всё в поясе счётчика — ломает смысл `UTCEventTime`, назван для полноты веера. Учебная ценность у второго и третьего одна и та же, и она не оборонительная: явный пояс в DDL — не сторож, а названное намерение. Менти читает `DateTime('UTC')` и спрашивает, зачем пояс написан; вопрос этот ровно тот, ради которого мир сделан с поясом счётчика +4. ## Что войдёт после решения - Конвенция записана в docs/architecture/storage.md рядом со служебными колонками; отклонённые варианты — там же или в ADR. - DDL приведён к конвенции: `sql/ddl/10-stg-tables.sql`, `sql/ddl/20-ods-tables.sql`. - Разбор строки получил имя пояса: `sql/ddl/30-ods-views.sql`. Это не DDL, а выражение матвью, и правка там другого рода — третий аргумент `'UTC'` у `parseDateTimeOrNull`. Без неё тип колонки чинит только чтение: суффикс `Z` маска сверяет как букву и выбрасывает, поэтому число, которое ляжет в колонку, сегодня определяет пояс сервера. Замерено 8 августа 2026 года: под `session_timezone='Europe/Samara'` та же строка даёт 1780262649 без аргумента и 1780277049 с `'UTC'`. - Имя пояса и число смещения связаны явно: в генераторе пояс живёт числом (`world.COUNTER_TIMEZONE_MINUTES = 240`), а `toDate` требует имя IANA (`Europe/Samara`). Сегодня они эквивалентны — Самара часы не переводит, — но разъедутся молча, если однажды поменять одно. - Один учебный комментарий у первой колонки с явным поясом: почему он написан. ## Критерии приёмки - [ ] Конвенция принята веером и записана; отклонённые варианты видны. - [ ] DDL ей соответствует; стенд поднимается с нуля, `make check-clickhouse` зелёный и не медленнее прежнего. - [ ] Имя пояса и смещение связаны в одном месте. - [ ] Учебный комментарий на месте. - [ ] Замер из раздела «что измерено» повторён после правки. Три клетки те же, одна названа заранее и обязана измениться: у клиента с чужим поясом колонка показана не `2026-06-01 01:24:09`, а `2026-05-31 21:24:09`. Это и есть поставляемое — глаз и `GROUP BY` перестают спорить. ## Границы - Постоянных проверок не заводить ни одной — docs/architecture/testing.md. Расхождение глаза и `GROUP BY` учит лабой: Superset и Grafana рендерят в своём поясе, и это готовая сцена урока, а не приёмка. - Модель мира не трогать: пояс счётчика UTC+4 решён спекой генератора. - `Date` остаётся без пояса — у типа его нет. ## Сначала прочитать - docs/architecture/storage.md — служебные колонки и конвенции. - docs/specs/2026-08-01-generator.md, раздел 9 — пояс счётчика и почему `toDate(UTCEventTime)` не равен `EventDate` у ночных событий. - generator/src/clickstream_generator/world.py — `COUNTER_TIMEZONE_MINUTES`. - sql/ddl/ — что править. ## Проверка - `make clean && make up && make check-clickhouse` - два `SELECT` из «что измерено», до и после правки
ddmitry added the needs-triage label 2026-08-07 17:08:24 +03:00
ddmitry added a new dependency 2026-08-07 20:20:57 +03:00
Author
Owner

Хвост с грилинга, чтобы не потерялся до этапа дашбордов.

Пояс показа — развилка, а не недоделка. У дашборда Grafana умолчание
browser, то есть часы читателя. Сутки мира самарские (UTC+4), читатель
скорее московский (UTC+3) — час разницы сдвигает часовые срезы относительно
модельных суток тихо и правдоподобно. Сверено по документации Grafana
8 августа 2026 года.

Два варианта, и оба связные:

  • оставить browser — но только если та же работа пишет лабу, которая
    сдвиг вскрывает («сравни пиковый час на дашборде с пиковым часом из SQL»).
    Подстроенная ловушка учит, просто оставленная — врёт: менти сидит в Москве
    и зацепки, что магазин самарский, у него нет;
  • прибить Europe/Samara явной настройкой дашборда.

В docs/architecture/storage.md этого нет намеренно: зона того документа —
сторона ClickHouse, а не показ. Решать вместе с дашбордами.

Хвост с грилинга, чтобы не потерялся до этапа дашбордов. **Пояс показа — развилка, а не недоделка.** У дашборда Grafana умолчание `browser`, то есть часы читателя. Сутки мира самарские (UTC+4), читатель скорее московский (UTC+3) — час разницы сдвигает часовые срезы относительно модельных суток тихо и правдоподобно. Сверено по документации Grafana 8 августа 2026 года. Два варианта, и оба связные: - **оставить `browser`** — но только если та же работа пишет лабу, которая сдвиг вскрывает («сравни пиковый час на дашборде с пиковым часом из SQL»). Подстроенная ловушка учит, просто оставленная — врёт: менти сидит в Москве и зацепки, что магазин самарский, у него нет; - **прибить `Europe/Samara`** явной настройкой дашборда. В `docs/architecture/storage.md` этого нет намеренно: зона того документа — сторона ClickHouse, а не показ. Решать вместе с дашбордами.
Author
Owner

Реализация легла в ветку docs/63-timezone-convention, три коммита поверх записанной конвенции.

Сделано

  • UTCEventTimeDateTime('UTC'); _load_ts и kafka_timestampDateTime64(3, 'UTC') в STG и ODS. system.columns подтверждает по всем колонкам времени обоих слоёв.
  • parseDateTimeOrNull получил третьим аргументом 'UTC' — три вызова в двух матвью.
  • Контракт схемы, «описание выгрузки» и таблица колонок мастер-спеки несут тип с поясом.
  • Имя пояса Europe/Samara стоит комментарием к COUNTER_TIMEZONE_MINUTES в world.py. Константу с тестом сходимости сначала завели, потом срезали: константу не читал ни один модуль, единственным её читателем был тест про неё же.
  • Учебный комментарий про пояс — в шапке таблицы сырья, у первой колонки с явным поясом.

Замер повторён

Событие WatchID = 384218330540, EventDate = 2026-06-01. Стенд поднят с нуля уже по конвенции.

сервер UTC session_timezone='Europe/Samara'
колонка показана 2026-05-31 23:37:00 2026-05-31 23:37:00
toDate(UTCEventTime) 2026-05-31 2026-05-31
toDate(…, 'Europe/Samara') 2026-06-01 2026-06-01
EventDate 2026-06-01 2026-06-01

Названная заранее клетка изменилась: глаз и GROUP BY больше не спорят. Родной клиент и HTTP отвечают одинаково.

Путь записи от сессии тоже отвязан: под session_timezone='Europe/Samara' строка 2026-06-04T20:58:56Z даёт 1780592336 без имени пояса и 1780606736 с ним.

Проверки

make lint, make typecheck, make test (407), make clean && make up && make check-clickhouse — 9 из 9 за 7 секунд, make smoke — 20, make check-services — 7. Таблица ошибок разбора пуста.

Два холодных ревью

Линия дефектов нашла три неверных утверждения и один мёртвый замер — все приняты и исправлены. Крупнейшее: «тип колонки не решает, какое число ляжет» было сказано шире, чем верно. При вставке строки решает именно он — CAST('2026-06-01 01:24:09' AS DateTime('UTC')) даёт 1780277049, то же в DateTime('Europe/Samara')1780262649. Формулировка сужена до нашего случая, где разбор отдаёт готовое число.

Отдельная находка: матвью приёма оставляет now64(3) и _timestamp_ms без имени пояса, поэтому в схеме hits_raw_mv две колонки голые. На данные это не влияет — значение абсолютно. Назвать пояс у виртуальной колонки Kafka без CAST нельзя, а такой CAST — украшение. Поэтому правило в конвенции привязано к местам, где линза что-то решает: объявление хранимой колонки и выражение, считающее дату.

Линия уместности дала три реза, два приняты: пересказ механики в ADR 0005 схлопнут до фразы со ссылкой, учебный комментарий срезан с семи строк до пяти. Рез повторённого замера отклонён — соседний пункт ледгера измеряет состояние до правки, новый после, и это критерий приёмки.

Реализация легла в ветку `docs/63-timezone-convention`, три коммита поверх записанной конвенции. ## Сделано - `UTCEventTime` — `DateTime('UTC')`; `_load_ts` и `kafka_timestamp` — `DateTime64(3, 'UTC')` в STG и ODS. `system.columns` подтверждает по всем колонкам времени обоих слоёв. - `parseDateTimeOrNull` получил третьим аргументом `'UTC'` — три вызова в двух матвью. - Контракт схемы, «описание выгрузки» и таблица колонок мастер-спеки несут тип с поясом. - Имя пояса `Europe/Samara` стоит комментарием к `COUNTER_TIMEZONE_MINUTES` в `world.py`. Константу с тестом сходимости сначала завели, потом срезали: константу не читал ни один модуль, единственным её читателем был тест про неё же. - Учебный комментарий про пояс — в шапке таблицы сырья, у первой колонки с явным поясом. ## Замер повторён Событие `WatchID = 384218330540`, `EventDate` = `2026-06-01`. Стенд поднят с нуля уже по конвенции. | | сервер UTC | `session_timezone='Europe/Samara'` | |---|---|---| | колонка показана | `2026-05-31 23:37:00` | `2026-05-31 23:37:00` | | `toDate(UTCEventTime)` | `2026-05-31` | `2026-05-31` | | `toDate(…, 'Europe/Samara')` | `2026-06-01` | `2026-06-01` | | `EventDate` | `2026-06-01` | `2026-06-01` | Названная заранее клетка изменилась: глаз и `GROUP BY` больше не спорят. Родной клиент и HTTP отвечают одинаково. Путь записи от сессии тоже отвязан: под `session_timezone='Europe/Samara'` строка `2026-06-04T20:58:56Z` даёт `1780592336` без имени пояса и `1780606736` с ним. ## Проверки `make lint`, `make typecheck`, `make test` (407), `make clean && make up && make check-clickhouse` — 9 из 9 за 7 секунд, `make smoke` — 20, `make check-services` — 7. Таблица ошибок разбора пуста. ## Два холодных ревью Линия дефектов нашла три неверных утверждения и один мёртвый замер — все приняты и исправлены. Крупнейшее: «тип колонки не решает, какое число ляжет» было сказано шире, чем верно. При вставке строки решает именно он — `CAST('2026-06-01 01:24:09' AS DateTime('UTC'))` даёт `1780277049`, то же в `DateTime('Europe/Samara')` — `1780262649`. Формулировка сужена до нашего случая, где разбор отдаёт готовое число. Отдельная находка: матвью приёма оставляет `now64(3)` и `_timestamp_ms` без имени пояса, поэтому в схеме `hits_raw_mv` две колонки голые. На данные это не влияет — значение абсолютно. Назвать пояс у виртуальной колонки Kafka без `CAST` нельзя, а такой `CAST` — украшение. Поэтому правило в конвенции привязано к местам, где линза что-то решает: объявление хранимой колонки и выражение, считающее дату. Линия уместности дала три реза, два приняты: пересказ механики в ADR 0005 схлопнут до фразы со ссылкой, учебный комментарий срезан с семи строк до пяти. Рез повторённого замера отклонён — соседний пункт ледгера измеряет состояние до правки, новый после, и это критерий приёмки.
Author
Owner

Хвост для того, кто будет писать витрины. В доке этого нет намеренно — там осталась одна фраза с симптомом, механика сюда.

EventDate имеет тип Date, а у него места под пояс нет вовсе: лежит номер дня, чьи это сутки — не записано. Пока день сравнивают с днём, это неважно, и нарезка партиций по EventDate безопасна ровно поэтому: ей нужен ярлык, а не момент. Как только по дню считают время, ловушек оказывается две, и симптом у них общий — часы суток уезжают за границы 0–23.

Первая: голое приведение дня в момент. toDateTime(EventDate) берёт полночь по поясу сессии, а тот по умолчанию серверный. На стартовом мире часы от начала суток идут −4 … 19, отрицательных 15 843 события (те же 3,95%, что и ночное расхождение). Момент, который назовёт голое приведение, зависит от того, кто спрашивает:

пояс сессии toDateTime(EventDate) toDateTime(EventDate, 'Europe/Samara')
UTC 1780272000 1780257600
Europe/Samara 1780257600 1780257600
America/New_York 1780286400 1780257600

Вторая: dateDiff через разные пояса. Назови пояс в приведении правильно — и он всё равно соврёт: 4 … 27. Минимальный случай, оба аргумента суть один и тот же момент, epoch 1780257600:

dateDiff('hour', момент как UTC, момент как UTC)      → 0
dateDiff('hour', момент как Samara, момент как UTC)   → 4

Числа одинаковые, ярлыки разные. Документация ClickHouse (сверено через Context7 8 августа 2026 года): функция считает пересечения границ по стенным часам и берёт пояс у каждого аргумента свой; необязательный четвёртый аргумент назначает один пояс на оба.

Правильные формы, замерено на стартовом мире:

  • вычитание epoch с названным поясом — 0 … 23,9997;
  • dateDiff('hour', toDateTime(EventDate, 'Europe/Samara'), UTCEventTime, 'Europe/Samara')0 … 23.
Хвост для того, кто будет писать витрины. В доке этого нет намеренно — там осталась одна фраза с симптомом, механика сюда. `EventDate` имеет тип `Date`, а у него места под пояс нет вовсе: лежит номер дня, чьи это сутки — не записано. Пока день сравнивают с днём, это неважно, и нарезка партиций по `EventDate` безопасна ровно поэтому: ей нужен ярлык, а не момент. Как только по дню считают время, ловушек оказывается две, и симптом у них общий — часы суток уезжают за границы 0–23. **Первая: голое приведение дня в момент.** `toDateTime(EventDate)` берёт полночь по поясу сессии, а тот по умолчанию серверный. На стартовом мире часы от начала суток идут −4 … 19, отрицательных 15 843 события (те же 3,95%, что и ночное расхождение). Момент, который назовёт голое приведение, зависит от того, кто спрашивает: | пояс сессии | `toDateTime(EventDate)` | `toDateTime(EventDate, 'Europe/Samara')` | |---|---|---| | UTC | `1780272000` | `1780257600` | | Europe/Samara | `1780257600` | `1780257600` | | America/New_York | `1780286400` | `1780257600` | **Вторая: `dateDiff` через разные пояса.** Назови пояс в приведении правильно — и он всё равно соврёт: 4 … 27. Минимальный случай, оба аргумента суть один и тот же момент, epoch `1780257600`: ``` dateDiff('hour', момент как UTC, момент как UTC) → 0 dateDiff('hour', момент как Samara, момент как UTC) → 4 ``` Числа одинаковые, ярлыки разные. Документация ClickHouse (сверено через Context7 8 августа 2026 года): функция считает пересечения границ по стенным часам и берёт пояс у каждого аргумента свой; необязательный четвёртый аргумент назначает один пояс на оба. **Правильные формы, замерено на стартовом мире:** - вычитание epoch с названным поясом — `0 … 23,9997`; - `dateDiff('hour', toDateTime(EventDate, 'Europe/Samara'), UTCEventTime, 'Europe/Samara')` — `0 … 23`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: ddmitry/clickstream-data-platform#63