feat(ddl): часовые пояса — линза названа явно #75

Merged
ddmitry merged 6 commits from docs/63-timezone-convention into main 2026-08-08 20:16:58 +03:00
10 changed files with 131 additions and 32 deletions
+4 -1
View File
@@ -218,7 +218,7 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
`2026-06-01` и разбирается `JSONExtract` без оговорок.
Разбор метки времени идёт
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ')`
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ', 'UTC')`
— по буквально названному формату, а не через `parseDateTimeBestEffort`.
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
исключения, то есть годятся в предикат. Выбран точный формат потому, что
@@ -232,6 +232,9 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой.
Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63; правило и его
довод — [конвенция часовых поясов](../architecture/storage.md).
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только
+91 -10
View File
@@ -104,11 +104,11 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms``Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3))` и заполняется из `_timestamp_ms`: у брокера метка
миллисекундная, соседняя `_load_ts` тоже `DateTime64(3)`, а слой сырья хранит
приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности:
брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение
приёма на первом сообщении, либо тихие нули за 1970 год.
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
@@ -124,9 +124,9 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. Ставится она
один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка
отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
@@ -146,6 +146,55 @@ Greenplum, чтобы словарь был общим у двух хранил
пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится
по-настоящему — тогда и заведём, тем же стилем имени.
## Часовые пояса
Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
какие сутки оно попадёт.
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
где линза что-то решает — в объявлении хранимой колонки и в выражении,
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции:
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
пишется тогда, когда нужна другая линза, чем у колонки:
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp`
абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с
дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`,
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
пишет, он берёт готовую колонку. `Date` хранит только номер дня — чьи это
сутки, в нём не записано. Поэтому время по `EventDate` считают, назвав пояс
руками; промах виден сразу: часы суток уезжают за границы 0–23.
Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те
же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с
`EventDate` всегда. Отвергнуто потому, что колонка зовётся `UTCEventTime` и в
настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок,
ради которого мир сделан с поясом счётчика.
**Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать
метку или свести её к дате. На записи — превратить строку в число: суффикс `Z`
на проводе зоны не даёт, маска разбора съедает его буквой, и
`parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу
сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор
отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы
судьбу строки, а не числа. Механика и выбор функции — [ADR
0005](../adr/0005-event-ingestion.md).
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9.
## Путь реплицированных таблиц в keeper
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
@@ -398,10 +447,42 @@ ODS. Второе: матвью приёма создаётся последне
одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}`
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
`parseDateTime` и его родня принимают пояс необязательным последним аргументом,
а без него берут пояс сессии — он же по умолчанию серверный. У колонки с
объявленным поясом значения приводятся к нему; у колонки без объявленного
`session_timezone` перекрывает серверную настройку на выводе, но тип, с которым
считают функции, остаётся прежним (сверка 8 августа 2026 года).
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа) и пять при исполнении #43
(7 августа). Все подтвердили то, что здесь написано.
#37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
(7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь
написано.
- Разбор строки берёт пояс у сессии, а не из строки. Под
`session_timezone = 'Europe/Samara'` одна и та же строка
`2026-06-01T01:24:09Z` по маске `%Y-%m-%dT%H:%i:%SZ` дала 1780262649 без
третьего аргумента и 1780277049 с аргументом `'UTC'` — ровно четыре часа
разницы. Тип результата без аргумента — `Nullable(DateTime('Europe/Samara'))`.
Отсюда имя пояса в разборе: суффикс `Z` маска съедает и выбрасывает.
- `toDate` берёт пояс у типа своего аргумента. Из одного момента:
по `DateTime('UTC')``2026-05-31`, по `DateTime('Europe/Samara')`
`2026-06-01`. Отсюда форма правила: имя пояса нужно там, где его не несёт тип.
- У колонки без объявленного пояса глаз и `GROUP BY` расходятся. Замер снят до
правки типов и на нынешнем стенде не повторяется — колонка уже с поясом.
Событие
`WatchID = 113504893317`, `EventDate` = `2026-06-05`: без настроек колонка
показана `2026-06-04 20:58:56`, под `session_timezone = 'Europe/Samara'`
`2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях
`2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по
поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя
одинаково.
- С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по
конвенции; событие `WatchID = 384218330540`, `EventDate` = `2026-06-01`:
колонка `DateTime('UTC')` показана `2026-05-31 23:37:00` и без настроек, и
под `session_timezone = 'Europe/Samara'`, по родному клиенту и по HTTP.
`toDate(UTCEventTime)` даёт `2026-05-31`, `toDate(UTCEventTime,
'Europe/Samara')` — `2026-06-01`, `EventDate` — `2026-06-01`: расхождение
`toDate(UTCEventTime)` с `EventDate` осталось, это мир, а не пояс колонки.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
+1 -1
View File
@@ -38,7 +38,7 @@
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)``EventDate` |
| 6 | `UTCEventTime` | `DateTime('UTC')` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)``EventDate` |
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
+1 -1
View File
@@ -99,7 +99,7 @@
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
| `CounterID` | UInt32 | константа стенда (один сайт) |
| `EventDate` | Date | дата события |
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики |
| `UTCEventTime` | DateTime('UTC') | единственная метка времени, как у Метрики |
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
@@ -107,7 +107,7 @@ COLUMNS: tuple[Column, ...] = (
),
Column(
name="UTCEventTime",
clickhouse_type="DateTime",
clickhouse_type="DateTime('UTC')",
numpy_dtype="datetime64[s]",
normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS,
+8 -6
View File
@@ -16,12 +16,14 @@ from datetime import date
# Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
# у ночных событий (спека генератора, раздел 9).
COUNTER_TIMEZONE_MINUTES = 240
# Часовой пояс счётчика, минуты от UTC. Модельные сутки считаются в этом поясе,
# как в выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` —
# абсолютная метка. Отсюда следствие, о котором сторона хранилища должна знать
# заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных событий (спека
# генератора, раздел 9). Рядом имя того же пояса: числа ClickHouse в этом месте
# не принимает, витрины пишутся именем (docs/architecture/storage.md, «Часовые
# пояса»).
COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
+1 -1
View File
@@ -31,7 +31,7 @@ NUMPY_BY_CLICKHOUSE_TYPE = {
"String": "object",
"LowCardinality(String)": "object",
"Date": "datetime64[D]",
"DateTime": "datetime64[s]",
"DateTime('UTC')": "datetime64[s]",
}
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
+10 -4
View File
@@ -41,14 +41,20 @@ SETTINGS
-- виртуальные колонки его не несут, а после записи в Distributed он уже
-- невосстановим.
--
-- kafka_timestamp — Nullable(DateTime64(3)), и заполняется из виртуальной
-- kafka_timestamp — Nullable(DateTime64(3, 'UTC')), и заполняется из виртуальной
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
-- _load_ts рядом тоже DateTime64(3), а слой сырья хранит то, что приехало, и
-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
--
-- Пояс у обеих меток написан в типе. Хранимого числа он не меняет, а решает,
-- в какие сутки метка попадёт, — то есть чем окажется toDate(_load_ts) в ключе
-- партиции ниже. Не напиши его — пояс возьмётся у сервера, а это умолчание в
-- коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые
-- пояса».
--
-- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки:
-- модельный день события живёт в ODS, а по нему TTL был бы просто сломан.
-- Срок — трое суток плюс хвост до суток: куски снимаются целиком
@@ -64,9 +70,9 @@ CREATE TABLE IF NOT EXISTS stg.hits_raw_rep ON CLUSTER clickstream_cluster
kafka_topic LowCardinality(String),
kafka_partition UInt64,
kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)),
kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String),
_load_ts DateTime64(3)
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts)
+4 -4
View File
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
ClientID UInt64,
CounterID UInt32,
EventDate Date,
UTCEventTime DateTime,
UTCEventTime DateTime('UTC'),
ClientTimeZone Int16,
EventType LowCardinality(String),
Sign Int8,
@@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
productQuantity Array(UInt64),
productEventType Array(String),
ecommerce String,
_load_ts DateTime64(3)
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', _load_ts)
PARTITION BY EventDate
@@ -137,9 +137,9 @@ CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
kafka_topic LowCardinality(String),
kafka_partition UInt64,
kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)),
kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String),
_load_ts DateTime64(3)
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts)
+10 -3
View File
@@ -52,6 +52,13 @@
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
-- «Что проверено».
--
-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и
-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала
-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип
-- колонки этого не чинит: разбор отдаёт готовое число, и колонка кладёт его
-- как есть — пояс приёмника решал бы судьбу строки, а не числа.
-- Правило и замер — docs/architecture/storage.md, «Часовые пояса».
--
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
-- JSONExtract её берёт.
@@ -79,7 +86,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed
'%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
@@ -88,7 +95,7 @@ SELECT
JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
assumeNotNull(parseDateTimeOrNull(
JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ')) AS UTCEventTime,
'%Y-%m-%dT%H:%i:%SZ', 'UTC')) AS UTCEventTime,
JSONExtract(raw, 'ClientTimeZone', 'Int16') AS ClientTimeZone,
JSONExtract(raw, 'EventType', 'String') AS EventType,
JSONExtract(raw, 'Sign', 'Int8') AS Sign,
@@ -173,7 +180,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed
'%Y-%m-%dT%H:%i:%SZ', 'UTC') IS NOT NULL AS key_fields_parsed
SELECT
raw,
multiIf(