Compare commits

..
7 Commits
Author SHA1 Message Date
ddmitry 5d012cc222 Merge pull request 'feat(ddl): часовые пояса — линза названа явно' (#75) from docs/63-timezone-convention into main
Reviewed-on: #75
2026-08-08 20:16:55 +03:00
ddadminandClaude Opus 5 76405a06ee docs(storage): у Date пояса нет, и по нему легко промахнуться
- Зачем:
  - фраза «Date не участвует вовсе» выводила тип из-под общего правила:
    про объявление это правда, про употребление — нет. Считая время по
    EventDate без имени пояса, легко получить часы вне диапазона.
- Что:
  - фраза заменена на две: Date хранит только номер дня, пояс при счёте
    времени называют руками, промах виден по часам за границами 0–23.
- Проверка:
  - замерено на стенде: без имени пояса часы от начала суток идут -4…19,
    отрицательных 15 843 события; с названным поясом — 0…23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:15:56 +03:00
ddadminandClaude Opus 5 a02eba56c2 refactor(generator): имя пояса — комментарием, а не константой
- Зачем:
  - константу COUNTER_TIMEZONE не читал ни один модуль, единственным её
    читателем был тест про неё же; связь имени и смещения держится тем, что
    они стоят в одной строке.
- Что:
  - COUNTER_TIMEZONE снят, имя пояса ушло комментарием к
    COUNTER_TIMEZONE_MINUTES.
  - тест сходимости имени и смещения снят вместе с ним; test_world.py
    вернулся к прежнему виду.
- Проверка:
  - make lint, make typecheck, make test (407 тестов)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:56:26 +03:00
ddadminandClaude Opus 5 359570ec65 docs(63): правки по двум холодным ревью реализации
- Зачем:
  - линия дефектов нашла три неверных утверждения и мёртвый замер, линия
    уместности — три пересказа уже сказанного.
- Что:
  - «тип колонки не решает, какое число ляжет» сужено до правды: разбор
    отдаёт готовое число, а пояс приёмника решал бы судьбу строки.
  - замер до правки типов помечен как неповторяемый на нынешнем стенде.
  - правило о поясе сервера привязано к местам, где линза что-то решает:
    матвью приёма пояс не называет, и это не нарушение.
  - убраны: пересказ механики в ADR 0005, четыре строки учебного
    комментария, утверждение о порядке файлов и «секунды от начала эпохи»
    у миллисекундной метки.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:44:53 +03:00
ddadminandClaude Opus 5 d67e697821 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>
2026-08-08 19:30:50 +03:00
ddadmin e6f300a66e docs(storage): правки конвенции по двум холодным ревью
- Зачем:
  - раздел «Часовые пояса» прошёл два холодных ревью — по дефектам и по
    уместности. Первое поймало ложный замер и три расхождения с живым
    стендом, второе — материал не своей зоны и дубли (#63).
- Что:
  - замер «расхождение живёт по HTTP» отозван: мерил toString(UTCEventTime)
    в родном клиенте против голой колонки по HTTP, а это разные вещи.
    Перемерено — клиенты ведут себя одинаково; записан верный факт: вывод
    колонки идёт по поясу сессии, функция — по поясу типа.
  - «по поясу сервера» заменено на «по поясу сессии, а тот по умолчанию
    серверный» — в разделе и в ADR 0005; утверждение в ледгере переписано
    под измеренный раскол вывода и типа.
  - PARTITION BY toDate(_load_ts) больше не выдаётся за уже соблюдённое
    правило: _load_ts сегодня DateTime64(3) без пояса.
  - абзац ADR 0005 больше не спорит с цитатой вызова строкой выше.
  - вырезано: веер отклонённых вариантов под заголовком (живые отказы
    разложены прозой по своим абзацам, как принято в этом документе),
    ссылка на несуществующую связку в world.py, осиротевшая строка про
    Grafana, абзац про пояс показа — он уехал комментарием в #63.
- Проверка:
  - make lint
  - замеры повторены на живом стенде 8 августа 2026 года
  - DDL к конвенции по-прежнему не приведён: документы описывают цель
2026-08-08 19:05:39 +03:00
ddadmin a93a3bf3a3 docs(storage): конвенция часовых поясов принята и записана
- Зачем:
  - пояс в стенде нигде не назван: числа верны только потому, что сервер
    ClickHouse стоит в UTC, а правило понадобится в dds и витринах —
    воронки, удержание, «покупки по дням» (#63).
- Что:
  - раздел «Часовые пояса»: пояс — линза, называется в типе колонки либо в
    вызове; какая именно — решает слой (ODS на языке выгрузки, DDS и витрины
    на языке бизнеса); день берётся из EventDate.
  - названы оба перехода, где пояс выбирается, включая разбор строки в
    матвью — он берёт пояс у сервера и тип колонки этого не чинит.
  - отвергнутые варианты прозой: умолчание сервера, TZ серверу, ODS в поясе
    счётчика, хранение местного времени.
  - три замера ушли в «Что проверено», сверка с документацией — от 8 августа.
- Проверка:
  - make lint
  - DDL к конвенции ещё не приведён: документ описывает цель, код идёт
    следом тем же тикетом.
2026-08-08 18:36:29 +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` без оговорок. `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`. — по буквально названному формату, а не через `parseDateTimeBestEffort`.
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
исключения, то есть годятся в предикат. Выбран точный формат потому, что исключения, то есть годятся в предикат. Выбран точный формат потому, что
@@ -232,6 +232,9 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой. нужна вовсе, а платится за неё отключённой проверкой.
Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63; правило и его
довод — [конвенция часовых поясов](../architecture/storage.md).
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только
+91 -10
View File
@@ -104,11 +104,11 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms``Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `_timestamp_ms``Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3))` и заполняется из `_timestamp_ms`: у брокера метка `Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
миллисекундная, соседняя `_load_ts` тоже `DateTime64(3)`, а слой сырья хранит метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности: хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
приёма на первом сообщении, либо тихие нули за 1970 год. либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
@@ -124,9 +124,9 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден. которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. Ставится она Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
отвечает на вопрос «когда строка приехала в хранилище», а не «когда её колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж, самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
@@ -146,6 +146,55 @@ Greenplum, чтобы словарь был общим у двух хранил
пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится пустой формальностью. В слоях, которые наполняет 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 ## Путь реплицированных таблиц в keeper
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
@@ -398,10 +447,42 @@ ODS. Второе: матвью приёма создаётся последне
одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}` одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}`
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает. таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
`parseDateTime` и его родня принимают пояс необязательным последним аргументом,
а без него берут пояс сессии — он же по умолчанию серверный. У колонки с
объявленным поясом значения приводятся к нему; у колонки без объявленного
`session_timezone` перекрывает серверную настройку на выводе, но тип, с которым
считают функции, остаётся прежним (сверка 8 августа 2026 года).
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении **Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа) и пять при исполнении #43 #37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
(7 августа). Все подтвердили то, что здесь написано. (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` срабатывает на вставку именно в эту - Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
+1 -1
View File
@@ -38,7 +38,7 @@
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется | | 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один | | 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая | | 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, в минутах | | 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет | | 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт | | 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
+1 -1
View File
@@ -99,7 +99,7 @@
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования | | `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
| `CounterID` | UInt32 | константа стенда (один сайт) | | `CounterID` | UInt32 | константа стенда (один сайт) |
| `EventDate` | Date | дата события | | `EventDate` | Date | дата события |
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики | | `UTCEventTime` | DateTime('UTC') | единственная метка времени, как у Метрики |
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах | | `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` | | `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) | | `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
@@ -107,7 +107,7 @@ COLUMNS: tuple[Column, ...] = (
), ),
Column( Column(
name="UTCEventTime", name="UTCEventTime",
clickhouse_type="DateTime", clickhouse_type="DateTime('UTC')",
numpy_dtype="datetime64[s]", numpy_dtype="datetime64[s]",
normalized_name="utc_event_time", normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS, group=ColumnGroup.IDENTIFIERS,
+8 -6
View File
@@ -16,12 +16,14 @@ from datetime import date
# Счётчик стенда: сайт один, номер — константа мира. # Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607 COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки # Часовой пояс счётчика, минуты от UTC. Модельные сутки считаются в этом поясе,
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе # как в выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` —
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором # абсолютная метка. Отсюда следствие, о котором сторона хранилища должна знать
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` # заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных событий (спека
# у ночных событий (спека генератора, раздел 9). # генератора, раздел 9). Рядом имя того же пояса: числа ClickHouse в этом месте
COUNTER_TIMEZONE_MINUTES = 240 # не принимает, витрины пишутся именем (docs/architecture/storage.md, «Часовые
# пояса»).
COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в # D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
+1 -1
View File
@@ -31,7 +31,7 @@ NUMPY_BY_CLICKHOUSE_TYPE = {
"String": "object", "String": "object",
"LowCardinality(String)": "object", "LowCardinality(String)": "object",
"Date": "datetime64[D]", "Date": "datetime64[D]",
"DateTime": "datetime64[s]", "DateTime('UTC')": "datetime64[s]",
} }
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
+10 -4
View File
@@ -41,14 +41,20 @@ SETTINGS
-- виртуальные колонки его не несут, а после записи в Distributed он уже -- виртуальные колонки его не несут, а после записи в Distributed он уже
-- невосстановим. -- невосстановим.
-- --
-- kafka_timestamp — Nullable(DateTime64(3)), и заполняется из виртуальной -- kafka_timestamp — Nullable(DateTime64(3, 'UTC')), и заполняется из виртуальной
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
-- _load_ts рядом тоже DateTime64(3), а слой сырья хранит то, что приехало, и -- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
-- --
-- Пояс у обеих меток написан в типе. Хранимого числа он не меняет, а решает,
-- в какие сутки метка попадёт, — то есть чем окажется toDate(_load_ts) в ключе
-- партиции ниже. Не напиши его — пояс возьмётся у сервера, а это умолчание в
-- коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые
-- пояса».
--
-- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки: -- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки:
-- модельный день события живёт в ODS, а по нему TTL был бы просто сломан. -- модельный день события живёт в ODS, а по нему TTL был бы просто сломан.
-- Срок — трое суток плюс хвост до суток: куски снимаются целиком -- Срок — трое суток плюс хвост до суток: куски снимаются целиком
@@ -64,9 +70,9 @@ CREATE TABLE IF NOT EXISTS stg.hits_raw_rep ON CLUSTER clickstream_cluster
kafka_topic LowCardinality(String), kafka_topic LowCardinality(String),
kafka_partition UInt64, kafka_partition UInt64,
kafka_offset UInt64, kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)), kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String), consumer_host LowCardinality(String),
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}') ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts) 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, ClientID UInt64,
CounterID UInt32, CounterID UInt32,
EventDate Date, EventDate Date,
UTCEventTime DateTime, UTCEventTime DateTime('UTC'),
ClientTimeZone Int16, ClientTimeZone Int16,
EventType LowCardinality(String), EventType LowCardinality(String),
Sign Int8, Sign Int8,
@@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
productQuantity Array(UInt64), productQuantity Array(UInt64),
productEventType Array(String), productEventType Array(String),
ecommerce String, ecommerce String,
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', _load_ts) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', _load_ts)
PARTITION BY EventDate 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_topic LowCardinality(String),
kafka_partition UInt64, kafka_partition UInt64,
kafka_offset UInt64, kafka_offset UInt64,
kafka_timestamp Nullable(DateTime64(3)), kafka_timestamp Nullable(DateTime64(3, 'UTC')),
consumer_host LowCardinality(String), consumer_host LowCardinality(String),
_load_ts DateTime64(3) _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}') ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(_load_ts) PARTITION BY toDate(_load_ts)
+10 -3
View File
@@ -52,6 +52,13 @@
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005, -- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
-- «Что проверено». -- «Что проверено».
-- --
-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и
-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала
-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип
-- колонки этого не чинит: разбор отдаёт готовое число, и колонка кладёт его
-- как есть — пояс приёмника решал бы судьбу строки, а не числа.
-- Правило и замер — docs/architecture/storage.md, «Часовые пояса».
--
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и -- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
-- JSONExtract её берёт. -- JSONExtract её берёт.
@@ -79,7 +86,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), 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 SELECT
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID, JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID, JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
@@ -88,7 +95,7 @@ SELECT
JSONExtract(raw, 'EventDate', 'Date') AS EventDate, JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
assumeNotNull(parseDateTimeOrNull( assumeNotNull(parseDateTimeOrNull(
JSONExtractString(raw, 'UTCEventTime'), 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, 'ClientTimeZone', 'Int16') AS ClientTimeZone,
JSONExtract(raw, 'EventType', 'String') AS EventType, JSONExtract(raw, 'EventType', 'String') AS EventType,
JSONExtract(raw, 'Sign', 'Int8') AS Sign, JSONExtract(raw, 'Sign', 'Int8') AS Sign,
@@ -173,7 +180,7 @@ WITH
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), 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 SELECT
raw, raw,
multiIf( multiIf(