From a93a3bf3a303536744bff5bb0d81403562bce82f Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 18:36:29 +0300 Subject: [PATCH 1/6] =?UTF-8?q?docs(storage):=20=D0=BA=D0=BE=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D1=86=D0=B8=D1=8F=20=D1=87=D0=B0=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=BE=D1=8F=D1=81=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D0=B0=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - пояс в стенде нигде не назван: числа верны только потому, что сервер ClickHouse стоит в UTC, а правило понадобится в dds и витринах — воронки, удержание, «покупки по дням» (#63). - Что: - раздел «Часовые пояса»: пояс — линза, называется в типе колонки либо в вызове; какая именно — решает слой (ODS на языке выгрузки, DDS и витрины на языке бизнеса); день берётся из EventDate. - названы оба перехода, где пояс выбирается, включая разбор строки в матвью — он берёт пояс у сервера и тип колонки этого не чинит. - отвергнутые варианты прозой: умолчание сервера, TZ серверу, ODS в поясе счётчика, хранение местного времени. - три замера ушли в «Что проверено», сверка с документацией — от 8 августа. - Проверка: - make lint - DDL к конвенции ещё не приведён: документ описывает цель, код идёт следом тем же тикетом. --- docs/architecture/storage.md | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 80138c4..4ec3b94 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -146,6 +146,59 @@ Greenplum, чтобы словарь был общим у двух хранил пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится по-настоящему — тогда и заведём, тем же стилем имени. +## Часовые пояса + +Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от +начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в +какие сутки оно попадёт. + +**Линза называется явно — в типе колонки либо в вызове функции.** Третий +источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому в DDL +и запросах его не остаётся. Правило стоит на источнике пояса, а не на функции: +`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так стоит +`PARTITION BY toDate(_load_ts)` у сырья и у таблицы ошибок. Имя пишется тогда, +когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')` +— это «день по часам счётчика». + +**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок +выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, +значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp` +абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с +дашбордом, поэтому время там лежит местным, в поясе счётчика, и пересчёт идёт +один раз при наполнении слоя: автор отчёта пояса не пишет, он берёт готовую +колонку. `Date` не участвует вовсе — у типа пояса нет. + +**Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать +метку или свести её к дате. На записи — превратить строку в число: на проводе +`UTCEventTime` приезжает как `2026-06-04T20:58:56Z`, но суффикс `Z` в маске +разбора не признак зоны, а буква, которую парсер сверяет и выбрасывает. Значит +`parseDateTimeOrNull` получает показания часов и решает, чьи они; без третьего +аргумента — по поясу сервера. Тип колонки тут не помогает: он про то, как число +читают, а не про то, какое число ляжет. Поэтому имя пояса стоит и в разборе; +сам разбор и выбор функции решены в [ADR 0005](../adr/0005-event-ingestion.md). + +**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана +генератором и лежит колонкой, так что суточные срезы группируются по ней и +пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней +расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. + +Функции ClickHouse требуют имя пояса, а генератор считает смещением; связаны +они в [`world.py`](../../generator/src/clickstream_generator/world.py) — там же +довод, почему смещение не выводится из имени. + +Отвергнуто по дороге четыре варианта. **Оставить умолчание сервера**: числа +сегодня верные, но держатся они на настройке окружения, которой нет ни в одном +файле репозитория. **Прописать пояс серверу** — та же болезнь с другим +умолчанием, и вдобавок отнимает проверку: когда линза названа в типах и в +разборе, пояс сервера на данные не влияет нигде, и менти может в этом +убедиться, поменяв его. **Объявить и ODS в поясе счётчика** +(`DateTime('Europe/Samara')`) — байты те же, меняется линза, и +`toDate(UTCEventTime)` начинает всегда совпадать с `EventDate`; отвергнуто +потому, что колонка зовётся `UTCEventTime` и в настоящей выгрузке Метрики она в +UTC, а стёртое расхождение — тот самый урок, ради которого мир сделан с поясом ++4. **Хранить местное время вместо абсолютного** ломает формат выгрузки и +названо было для полноты веера. + ## Путь реплицированных таблиц в keeper Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути @@ -398,10 +451,34 @@ ODS. Второе: матвью приёма создаётся последне одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}` завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает. +`parseDateTime` и его родня принимают пояс необязательным последним аргументом, +а без него берут пояс сессии или сервера. У колонки с объявленным поясом +значения приводятся к нему, у колонки без объявленного действует пояс сервера +(сверка 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` живёт по HTTP и только там. Событие + `WatchID = 113504893317`, `EventDate` = `2026-06-05`: по HTTP без настроек + колонка показана `2026-06-04 20:58:56`, с `session_timezone = 'Europe/Samara'` + — `2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях + `2026-06-04`. Родной `clickhouse-client` `session_timezone` к отображению + колонки не применяет и показывает `20:58:56` в обоих случаях. То есть сцену + видно из клиентов поверх HTTP — Superset в стенде ходит именно так. После объявления + `DateTime('UTC')` она уходит: проверено кастом на том же событии — колонка + показывает `20:58:56` и при чужом поясе сессии. - Матвью с источником-`Distributed` срабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над From e6f300a66ec78d96f87d5547f0477760244872f4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 19:05:39 +0300 Subject: [PATCH 2/6] =?UTF-8?q?docs(storage):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B4=D0=B2=D1=83=D0=BC=20=D1=85=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - раздел «Часовые пояса» прошёл два холодных ревью — по дефектам и по уместности. Первое поймало ложный замер и три расхождения с живым стендом, второе — материал не своей зоны и дубли (#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 к конвенции по-прежнему не приведён: документы описывают цель --- docs/adr/0005-event-ingestion.md | 5 ++ docs/architecture/storage.md | 78 +++++++++++++++----------------- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index a6733a8..d933e94 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -232,6 +232,11 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не нужна вовсе, а платится за неё отключённой проверкой. +Пояс разбору при #63 добавлен третьим аргументом — `'UTC'`; вызов выше приведён +без него, каким он был до этого решения. Без имени пояса функция трактует +показания часов по поясу сессии, а тот по умолчанию серверный. Правило целиком +и его довод — [конвенция часовых поясов](../architecture/storage.md). + Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья модельного дня (день залит дважды) точный формат разобрал метку у каждой, и ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 4ec3b94..796c068 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -154,9 +154,14 @@ Greenplum, чтобы словарь был общим у двух хранил **Линза называется явно — в типе колонки либо в вызове функции.** Третий источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому в DDL -и запросах его не остаётся. Правило стоит на источнике пояса, а не на функции: -`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так стоит -`PARTITION BY toDate(_load_ts)` у сырья и у таблицы ошибок. Имя пишется тогда, +и запросах его не остаётся. Прописать этот пояс своей рукой — `` в +конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим +умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в +разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, +поменяв его. Правило стоит на источнике пояса, а не на функции: +`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и +работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок, когда +`_load_ts` типизирован. Имя пишется тогда, когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика». @@ -164,41 +169,29 @@ Greenplum, чтобы словарь был общим у двух хранил выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp` абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с -дашбордом, поэтому время там лежит местным, в поясе счётчика, и пересчёт идёт -один раз при наполнении слоя: автор отчёта пояса не пишет, он берёт готовую -колонку. `Date` не участвует вовсе — у типа пояса нет. +дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`, +UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не +пишет, он берёт готовую колонку. `Date` не участвует вовсе — у типа пояса нет. + +Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те +же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с +`EventDate` всегда. Отвергнуто потому, что колонка зовётся `UTCEventTime` и в +настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок, +ради которого мир сделан с поясом счётчика. **Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать -метку или свести её к дате. На записи — превратить строку в число: на проводе -`UTCEventTime` приезжает как `2026-06-04T20:58:56Z`, но суффикс `Z` в маске -разбора не признак зоны, а буква, которую парсер сверяет и выбрасывает. Значит -`parseDateTimeOrNull` получает показания часов и решает, чьи они; без третьего -аргумента — по поясу сервера. Тип колонки тут не помогает: он про то, как число -читают, а не про то, какое число ляжет. Поэтому имя пояса стоит и в разборе; -сам разбор и выбор функции решены в [ADR 0005](../adr/0005-event-ingestion.md). +метку или свести её к дате. На записи — превратить строку в число: суффикс `Z` +на проводе зоны не даёт, маска разбора съедает его буквой, и +`parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу +сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: он про то, +как число читают, а не про то, какое ляжет. Механика и выбор функции — [ADR +0005](../adr/0005-event-ingestion.md). **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана генератором и лежит колонкой, так что суточные срезы группируются по ней и пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. -Функции ClickHouse требуют имя пояса, а генератор считает смещением; связаны -они в [`world.py`](../../generator/src/clickstream_generator/world.py) — там же -довод, почему смещение не выводится из имени. - -Отвергнуто по дороге четыре варианта. **Оставить умолчание сервера**: числа -сегодня верные, но держатся они на настройке окружения, которой нет ни в одном -файле репозитория. **Прописать пояс серверу** — та же болезнь с другим -умолчанием, и вдобавок отнимает проверку: когда линза названа в типах и в -разборе, пояс сервера на данные не влияет нигде, и менти может в этом -убедиться, поменяв его. **Объявить и ODS в поясе счётчика** -(`DateTime('Europe/Samara')`) — байты те же, меняется линза, и -`toDate(UTCEventTime)` начинает всегда совпадать с `EventDate`; отвергнуто -потому, что колонка зовётся `UTCEventTime` и в настоящей выгрузке Метрики она в -UTC, а стёртое расхождение — тот самый урок, ради которого мир сделан с поясом -+4. **Хранить местное время вместо абсолютного** ломает формат выгрузки и -названо было для полноты веера. - ## Путь реплицированных таблиц в keeper Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути @@ -452,9 +445,10 @@ ODS. Второе: матвью приёма создаётся последне завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает. `parseDateTime` и его родня принимают пояс необязательным последним аргументом, -а без него берут пояс сессии или сервера. У колонки с объявленным поясом -значения приводятся к нему, у колонки без объявленного действует пояс сервера -(сверка 8 августа 2026 года). +а без него берут пояс сессии — он же по умолчанию серверный. У колонки с +объявленным поясом значения приводятся к нему; у колонки без объявленного +`session_timezone` перекрывает серверную настройку на выводе, но тип, с которым +считают функции, остаётся прежним (сверка 8 августа 2026 года). **Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении #37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43 @@ -470,15 +464,15 @@ ODS. Второе: матвью приёма создаётся последне - `toDate` берёт пояс у типа своего аргумента. Из одного момента: по `DateTime('UTC')` — `2026-05-31`, по `DateTime('Europe/Samara')` — `2026-06-01`. Отсюда форма правила: имя пояса нужно там, где его не несёт тип. -- Расхождение глаза и `GROUP BY` живёт по HTTP и только там. Событие - `WatchID = 113504893317`, `EventDate` = `2026-06-05`: по HTTP без настроек - колонка показана `2026-06-04 20:58:56`, с `session_timezone = 'Europe/Samara'` - — `2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях - `2026-06-04`. Родной `clickhouse-client` `session_timezone` к отображению - колонки не применяет и показывает `20:58:56` в обоих случаях. То есть сцену - видно из клиентов поверх HTTP — Superset в стенде ходит именно так. После объявления - `DateTime('UTC')` она уходит: проверено кастом на том же событии — колонка - показывает `20:58:56` и при чужом поясе сессии. +- У колонки без объявленного пояса глаз и `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 ведут себя + одинаково. После объявления `DateTime('UTC')` расхождение уходит: проверено + кастом на том же событии — колонка показывает `20:58:56` и при чужом поясе + сессии. - Матвью с источником-`Distributed` срабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над From d67e6978210353ecdb7533d45e4c23398dbdb79c Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 19:30:50 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(ddl):=20=D0=BF=D0=BE=D1=8F=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=20=D1=8F=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=20=D1=82=D0=B8=D0=BF=D0=B0=D1=85=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=20=D0=B8=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B5=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - конвенция #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 --- docs/adr/0005-event-ingestion.md | 11 +++--- docs/architecture/storage.md | 36 ++++++++++--------- docs/formats/clickstream-event.md | 2 +- docs/specs/2026-07-30-stand-v2-realism.md | 2 +- generator/src/clickstream_generator/schema.py | 2 +- generator/src/clickstream_generator/world.py | 14 +++++--- generator/tests/test_schema.py | 2 +- generator/tests/test_world.py | 10 ++++++ sql/ddl/10-stg-tables.sql | 14 ++++++-- sql/ddl/20-ods-tables.sql | 8 ++--- sql/ddl/30-ods-views.sql | 12 +++++-- 11 files changed, 73 insertions(+), 40 deletions(-) diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index d933e94..8c64f81 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -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 строкам сырья модельного дня (день залит дважды) точный формат разобрал метку у каждой, и diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 796c068..ead70b9 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -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` срабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index 4a03031..ecf0686 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -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: колонка формата, исправлений записей генератор не шлёт | diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 0153c4c..4159545 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -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) | diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index 83e36a8..a1c560a 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -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, diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 872e9fd..dfa0c96 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -16,11 +16,15 @@ from datetime import date # Счётчик стенда: сайт один, номер — константа мира. COUNTER_ID = 42150607 -# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки -# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе -# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором -# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` -# у ночных событий (спека генератора, раздел 9). +# Часовой пояс счётчика — Самара, UTC+4 — записан двумя способами, потому что +# стороны просят разное. Генератору нужны минуты: в этом поясе считаются +# модельные сутки, как в выгрузке Метрики — `EventDate` дата в поясе счётчика, +# `UTCEventTime` абсолютная метка. Отсюда следствие, о котором сторона +# хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных +# событий (спека генератора, раздел 9). Хранилищу нужно имя из базы поясов: его +# просят `toDate` и типы колонок DDS (docs/architecture/storage.md, «Часовые +# пояса»). +COUNTER_TIMEZONE = "Europe/Samara" COUNTER_TIMEZONE_MINUTES = 240 # D0 — первый день оси модельного времени, понедельник. Реальный календарь в diff --git a/generator/tests/test_schema.py b/generator/tests/test_schema.py index 7257e24..cbbf170 100644 --- a/generator/tests/test_schema.py +++ b/generator/tests/test_schema.py @@ -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]*$") diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index 872b16f..57da1fe 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -6,6 +6,9 @@ «в среднем 3–4 возврата» и «средняя кука активна ≈1,9 дня». """ +from datetime import datetime, time, timedelta +from zoneinfo import ZoneInfo + from clickstream_generator import catalog, world @@ -27,6 +30,13 @@ def test_origin_is_a_monday(): assert world.ORIGIN.weekday() == 0 +def test_the_counter_timezone_name_and_offset_say_the_same_thing(): + """Имя пояса просит хранилище, минуты — генератор; расходиться им нельзя.""" + midnight = datetime.combine(world.ORIGIN, time()) + named = ZoneInfo(world.COUNTER_TIMEZONE).utcoffset(midnight) + assert named == timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES) + + def test_weekly_profile_covers_a_week_and_averages_to_one(): assert len(world.WEEKLY_PROFILE_PERCENT) == 7 assert sum(world.WEEKLY_PROFILE_PERCENT) == 700 diff --git a/sql/ddl/10-stg-tables.sql b/sql/ddl/10-stg-tables.sql index 73a7c10..da3f01d 100644 --- a/sql/ddl/10-stg-tables.sql +++ b/sql/ddl/10-stg-tables.sql @@ -45,10 +45,18 @@ SETTINGS -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, --- _load_ts рядом тоже DateTime64(3), а слой сырья хранит то, что приехало, и +-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- +-- Пояс у обеих меток написан в типе — DateTime64(3, 'UTC'); в DDL стенда он +-- встречается здесь впервые. Само число от пояса не зависит, это секунды от +-- начала эпохи. Пояс — линза: по нему решают, какие часы покажут метку и в +-- какие сутки она попадёт, то есть чем окажется toDate(_load_ts) в ключе +-- партиции ниже. Не назови линзу — её выберет пояс сервера, умолчание, которого +-- в коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые +-- пояса». +-- -- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки: -- модельный день события живёт в ODS, а по нему TTL был бы просто сломан. -- Срок — трое суток плюс хвост до суток: куски снимаются целиком @@ -64,9 +72,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) diff --git a/sql/ddl/20-ods-tables.sql b/sql/ddl/20-ods-tables.sql index 7c2cb40..b4854d3 100644 --- a/sql/ddl/20-ods-tables.sql +++ b/sql/ddl/20-ods-tables.sql @@ -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) diff --git a/sql/ddl/30-ods-views.sql b/sql/ddl/30-ods-views.sql index fa1ab3e..553d2d7 100644 --- a/sql/ddl/30-ods-views.sql +++ b/sql/ddl/30-ods-views.sql @@ -52,6 +52,12 @@ -- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005, -- «Что проверено». -- +-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и +-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала +-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип +-- колонки этого не чинит: он про то, как число покажут, а не какое ляжет. +-- Правило и замер — docs/architecture/storage.md, «Часовые пояса». +-- -- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и -- JSONExtract её берёт. @@ -79,7 +85,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 +94,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 +179,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( From 359570ec6538f2185f6b911de0f3b12f257f2070 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 19:44:53 +0300 Subject: [PATCH 4/6] =?UTF-8?q?docs(63):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B4=D0=B2=D1=83=D0=BC=20=D1=85=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - линия дефектов нашла три неверных утверждения и мёртвый замер, линия уместности — три пересказа уже сказанного. - Что: - «тип колонки не решает, какое число ляжет» сужено до правды: разбор отдаёт готовое число, а пояс приёмника решал бы судьбу строки. - замер до правки типов помечен как неповторяемый на нынешнем стенде. - правило о поясе сервера привязано к местам, где линза что-то решает: матвью приёма пояс не называет, и это не нарушение. - убраны: пересказ механики в ADR 0005, четыре строки учебного комментария, утверждение о порядке файлов и «секунды от начала эпохи» у миллисекундной метки. - Проверка: - make lint, make typecheck, make test (408 тестов) - make clean && make up && make check-clickhouse — 9 из 9 Co-Authored-By: Claude Opus 5 --- docs/adr/0005-event-ingestion.md | 7 ++----- docs/architecture/storage.md | 20 ++++++++++++-------- generator/src/clickstream_generator/world.py | 7 ++++--- sql/ddl/10-stg-tables.sql | 12 +++++------- sql/ddl/30-ods-views.sql | 3 ++- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index 8c64f81..8f1ec33 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -232,11 +232,8 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не нужна вовсе, а платится за неё отключённой проверкой. -Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63. Маска сверяет -суффикс `Z` как букву и выбрасывает, зоны из строки не берёт вовсе, поэтому без -имени функция трактует показания часов по поясу сессии, а тот по умолчанию -серверный. Правило целиком и его довод — [конвенция часовых -поясов](../architecture/storage.md). +Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63; правило и его +довод — [конвенция часовых поясов](../architecture/storage.md). Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья модельного дня (день залит дважды) точный формат разобрал метку у каждой, и diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index ead70b9..551ff41 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -153,11 +153,12 @@ Greenplum, чтобы словарь был общим у двух хранил какие сутки оно попадёт. **Линза называется явно — в типе колонки либо в вызове функции.** Третий -источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому в DDL -и запросах его не остаётся. Прописать этот пояс своей рукой — `` в -конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим -умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в -разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, +источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, +где линза что-то решает — в объявлении хранимой колонки и в выражении, +считающем дату, — его не остаётся. Прописать этот пояс своей рукой — +`` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью +с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах +и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя @@ -182,8 +183,9 @@ UTC+4), и пересчёт идёт один раз при наполнении метку или свести её к дате. На записи — превратить строку в число: суффикс `Z` на проводе зоны не даёт, маска разбора съедает его буквой, и `parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу -сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: он про то, -как число читают, а не про то, какое ляжет. Механика и выбор функции — [ADR +сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор +отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы +судьбу строки, а не числа. Механика и выбор функции — [ADR 0005](../adr/0005-event-ingestion.md). **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана @@ -463,7 +465,9 @@ ODS. Второе: матвью приёма создаётся последне - `toDate` берёт пояс у типа своего аргумента. Из одного момента: по `DateTime('UTC')` — `2026-05-31`, по `DateTime('Europe/Samara')` — `2026-06-01`. Отсюда форма правила: имя пояса нужно там, где его не несёт тип. -- У колонки без объявленного пояса глаз и `GROUP BY` расходятся. Событие +- У колонки без объявленного пояса глаз и `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)` в обоих случаях diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index dfa0c96..3f99b83 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -21,9 +21,10 @@ COUNTER_ID = 42150607 # модельные сутки, как в выгрузке Метрики — `EventDate` дата в поясе счётчика, # `UTCEventTime` абсолютная метка. Отсюда следствие, о котором сторона # хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных -# событий (спека генератора, раздел 9). Хранилищу нужно имя из базы поясов: его -# просят `toDate` и типы колонок DDS (docs/architecture/storage.md, «Часовые -# пояса»). +# событий (спека генератора, раздел 9). Хранилищу тот же пояс нужен именем из +# базы поясов — так пишутся `toDate` и типы колонок DDS +# (docs/architecture/storage.md, «Часовые пояса»). Пути отсюда в SQL нет, имя +# переносят руками — но берут его здесь. COUNTER_TIMEZONE = "Europe/Samara" COUNTER_TIMEZONE_MINUTES = 240 diff --git a/sql/ddl/10-stg-tables.sql b/sql/ddl/10-stg-tables.sql index da3f01d..9a163a1 100644 --- a/sql/ddl/10-stg-tables.sql +++ b/sql/ddl/10-stg-tables.sql @@ -41,7 +41,7 @@ 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)). Взяты миллисекунды: у брокера метка миллисекундная, @@ -49,12 +49,10 @@ SETTINGS -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- --- Пояс у обеих меток написан в типе — DateTime64(3, 'UTC'); в DDL стенда он --- встречается здесь впервые. Само число от пояса не зависит, это секунды от --- начала эпохи. Пояс — линза: по нему решают, какие часы покажут метку и в --- какие сутки она попадёт, то есть чем окажется toDate(_load_ts) в ключе --- партиции ниже. Не назови линзу — её выберет пояс сервера, умолчание, которого --- в коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые +-- Пояс у обеих меток написан в типе. Хранимого числа он не меняет, а решает, +-- в какие сутки метка попадёт, — то есть чем окажется toDate(_load_ts) в ключе +-- партиции ниже. Не напиши его — пояс возьмётся у сервера, а это умолчание в +-- коде не видно. Правило целиком — docs/architecture/storage.md, «Часовые -- пояса». -- -- Нарезка и срок жизни — по _load_ts, то есть по реальному времени загрузки: diff --git a/sql/ddl/30-ods-views.sql b/sql/ddl/30-ods-views.sql index 553d2d7..d51a1b2 100644 --- a/sql/ddl/30-ods-views.sql +++ b/sql/ddl/30-ods-views.sql @@ -55,7 +55,8 @@ -- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и -- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала -- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип --- колонки этого не чинит: он про то, как число покажут, а не какое ляжет. +-- колонки этого не чинит: разбор отдаёт готовое число, и колонка кладёт его +-- как есть — пояс приёмника решал бы судьбу строки, а не числа. -- Правило и замер — docs/architecture/storage.md, «Часовые пояса». -- -- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и From a02eba56c234bc21f3ff57e9fd842a1059450c33 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 19:56:26 +0300 Subject: [PATCH 5/6] =?UTF-8?q?refactor(generator):=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=8F=D1=81=D0=B0=20=E2=80=94=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=BC,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - константу COUNTER_TIMEZONE не читал ни один модуль, единственным её читателем был тест про неё же; связь имени и смещения держится тем, что они стоят в одной строке. - Что: - COUNTER_TIMEZONE снят, имя пояса ушло комментарием к COUNTER_TIMEZONE_MINUTES. - тест сходимости имени и смещения снят вместе с ним; test_world.py вернулся к прежнему виду. - Проверка: - make lint, make typecheck, make test (407 тестов) Co-Authored-By: Claude Opus 5 --- generator/src/clickstream_generator/world.py | 19 ++++++++----------- generator/tests/test_world.py | 10 ---------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 3f99b83..e25e6e8 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -16,17 +16,14 @@ from datetime import date # Счётчик стенда: сайт один, номер — константа мира. COUNTER_ID = 42150607 -# Часовой пояс счётчика — Самара, UTC+4 — записан двумя способами, потому что -# стороны просят разное. Генератору нужны минуты: в этом поясе считаются -# модельные сутки, как в выгрузке Метрики — `EventDate` дата в поясе счётчика, -# `UTCEventTime` абсолютная метка. Отсюда следствие, о котором сторона -# хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных -# событий (спека генератора, раздел 9). Хранилищу тот же пояс нужен именем из -# базы поясов — так пишутся `toDate` и типы колонок DDS -# (docs/architecture/storage.md, «Часовые пояса»). Пути отсюда в SQL нет, имя -# переносят руками — но берут его здесь. -COUNTER_TIMEZONE = "Europe/Samara" -COUNTER_TIMEZONE_MINUTES = 240 +# Часовой пояс счётчика, минуты от UTC. Модельные сутки считаются в этом поясе, +# как в выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` — +# абсолютная метка. Отсюда следствие, о котором сторона хранилища должна знать +# заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных событий (спека +# генератора, раздел 9). Рядом имя того же пояса: числа ClickHouse в этом месте +# не принимает, витрины пишутся именем (docs/architecture/storage.md, «Часовые +# пояса»). +COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara # D0 — первый день оси модельного времени, понедельник. Реальный календарь в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index 57da1fe..872b16f 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -6,9 +6,6 @@ «в среднем 3–4 возврата» и «средняя кука активна ≈1,9 дня». """ -from datetime import datetime, time, timedelta -from zoneinfo import ZoneInfo - from clickstream_generator import catalog, world @@ -30,13 +27,6 @@ def test_origin_is_a_monday(): assert world.ORIGIN.weekday() == 0 -def test_the_counter_timezone_name_and_offset_say_the_same_thing(): - """Имя пояса просит хранилище, минуты — генератор; расходиться им нельзя.""" - midnight = datetime.combine(world.ORIGIN, time()) - named = ZoneInfo(world.COUNTER_TIMEZONE).utcoffset(midnight) - assert named == timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES) - - def test_weekly_profile_covers_a_week_and_averages_to_one(): assert len(world.WEEKLY_PROFILE_PERCENT) == 7 assert sum(world.WEEKLY_PROFILE_PERCENT) == 700 From 76405a06eefd5b0bfbfa8dc556ea48f6238471cf Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 8 Aug 2026 20:15:56 +0300 Subject: [PATCH 6/6] =?UTF-8?q?docs(storage):=20=D1=83=20Date=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=8F=D1=81=D0=B0=20=D0=BD=D0=B5=D1=82,=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=BC=D1=83=20=D0=BB=D0=B5=D0=B3?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BF=D1=80=D0=BE=D0=BC=D0=B0=D1=85=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - фраза «Date не участвует вовсе» выводила тип из-под общего правила: про объявление это правда, про употребление — нет. Считая время по EventDate без имени пояса, легко получить часы вне диапазона. - Что: - фраза заменена на две: Date хранит только номер дня, пояс при счёте времени называют руками, промах виден по часам за границами 0–23. - Проверка: - замерено на стенде: без имени пояса часы от начала суток идут -4…19, отрицательных 15 843 события; с названным поясом — 0…23 Co-Authored-By: Claude Opus 5 --- docs/architecture/storage.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 551ff41..caf8f1f 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -171,7 +171,9 @@ Greenplum, чтобы словарь был общим у двух хранил абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`, UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не -пишет, он берёт готовую колонку. `Date` не участвует вовсе — у типа пояса нет. +пишет, он берёт готовую колонку. `Date` хранит только номер дня — чьи это +сутки, в нём не записано. Поэтому время по `EventDate` считают, назвав пояс +руками; промах виден сразу: часы суток уезжают за границы 0–23. Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с