feat(ddl): пояс назван явно — в типах колонок и в разборе строки

- Зачем:
  - конвенция #63 записана, а код её не достиг: колонки времени стояли без
    пояса, и сходилось всё лишь потому, что пояс сервера — UTC.
- Что:
  - UTCEventTime объявлен DateTime('UTC'), служебные метки _load_ts и
    kafka_timestamp — DateTime64(3, 'UTC') в STG и ODS.
  - parseDateTimeOrNull получил третьим аргументом 'UTC': маска сверяет
    суффикс Z как букву, зоны из строки не берёт вовсе.
  - контракт схемы и описание выгрузки несут тип с поясом; имя пояса
    Europe/Samara встало рядом со смещением в world.py, сходимость сверяет
    тест.
  - учебный комментарий о линзе — у первой колонки с явным поясом.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9
  - замер тикета повторён: под session_timezone='Europe/Samara' колонка
    показана 2026-05-31 23:37:00, как и без настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:30:50 +03:00
co-authored by Claude Opus 5
parent e6f300a66e
commit d67e697821
11 changed files with 73 additions and 40 deletions
+6 -5
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,10 +232,11 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой.
Пояс разбору при #63 добавлен третьим аргументом`'UTC'`; вызов выше приведён
без него, каким он был до этого решения. Без имени пояса функция трактует
показания часов по поясу сессии, а тот по умолчанию серверный. Правило целиком
и его довод — [конвенция часовых поясов](../architecture/storage.md).
Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63. Маска сверяет
суффикс `Z` как букву и выбрасывает, зоны из строки не берёт вовсе, поэтому без
имени функция трактует показания часов по поясу сессии, а тот по умолчанию
серверный. Правило целиком и его довод — [конвенция часовых
поясов](../architecture/storage.md).
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
+20 -16
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`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
@@ -160,10 +160,9 @@ Greenplum, чтобы словарь был общим у двух хранил
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции:
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок, когда
`_load_ts` типизирован. Имя пишется тогда,
когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
— это «день по часам счётчика».
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
пишется тогда, когда нужна другая линза, чем у колонки:
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
@@ -452,7 +451,7 @@ ODS. Второе: матвью приёма создаётся последне
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
(7 августа) и три при обсуждении #63 (8 августа). Все подтвердили то, что здесь
(7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь
написано.
- Разбор строки берёт пояс у сессии, а не из строки. Под
@@ -470,9 +469,14 @@ ODS. Второе: матвью приёма создаётся последне
`2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях
`2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по
поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя
одинаково. После объявления `DateTime('UTC')` расхождение уходит: проверено
кастом на том же событии — колонка показывает `20:58:56` и при чужом поясе
сессии.
одинаково.
- С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по
конвенции; событие `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) |