- Зачем: - конвенция #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>
198 lines
13 KiB
SQL
198 lines
13 KiB
SQL
-- ODS: разбор сырья в событие и в таблицу ошибок.
|
||
--
|
||
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
|
||
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
|
||
-- молча. Держится это формой: годность считает один и тот же предикат из трёх
|
||
-- частей, а вторая матвью берёт его буквальное отрицание.
|
||
--
|
||
-- Отсюда два запрета на выражения предиката, и оба серьёзные.
|
||
--
|
||
-- Первый: ни одна его часть не возвращает NULL. Трёхзначная логика дала бы
|
||
-- строку, которую не берёт ни «условие», ни «NOT условие», — и потерялась бы
|
||
-- она бесшумно. Поэтому сравнения здесь дают 0 или 1, а разбор в Nullable
|
||
-- заканчивается IS NOT NULL.
|
||
--
|
||
-- Второй: ни одна его часть не бросает исключений. Упавшая матвью роняет
|
||
-- вставку, офсеты не коммитятся, и потребление топика встаёт до починки —
|
||
-- это единственное, на чём сейчас держится правило репозитория «грязные
|
||
-- записи не валят пайплайн». Поэтому приведение Nullable к необнуляемому типу
|
||
-- (assumeNotNull) стоит только в списке колонок, за предикатом, который NULL
|
||
-- уже отсёк, а в самом предикате живут только функции семейства
|
||
-- JSONExtract/*OrNull. Довод целиком — ADR 0005.
|
||
--
|
||
-- Источник у обеих — stg.hits_raw_dist, лицо слоя, а не локальная таблица:
|
||
-- потребитель цепляется к лицу, а матвью с источником-Distributed срабатывает
|
||
-- на вставку именно в распределённую таблицу, до раскладки по шардам.
|
||
--
|
||
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
|
||
|
||
-- Годное событие: строка, прошедшая строгий приём.
|
||
--
|
||
-- Строгий приём — это три вопроса: объект ли это JSON, тот ли набор ключей,
|
||
-- разбираются ли в свой тип пять опорных колонок. Почему именно так — почему
|
||
-- объект, а не валидность; почему сверка ключей заменяет сорок семь проверок
|
||
-- на присутствие; почему опорных пять, а не сорок семь — ADR 0005, «Решение».
|
||
--
|
||
-- arraySort ниже стоит с обеих сторон, и убирать его нельзя: без обёртки сорок
|
||
-- семь CamelCase-имён пришлось бы держать ровно в байтовом порядке, а сбой
|
||
-- порядка увёл бы в брак вообще всё, и молча (ADR 0005).
|
||
--
|
||
-- Метка времени — единственная из пяти, кого разбирает не JSONExtract, и это
|
||
-- измеренная необходимость, а не вкус. На проводе UTCEventTime уезжает в
|
||
-- ISO-8601 с суффиксом зоны — «2026-06-01T12:34:56Z» (спека генератора,
|
||
-- раздел 4), а JSONExtract с типом DateTime такую строку не берёт и отдаёт
|
||
-- NULL. Оставь его здесь — и в брак уехали бы все события до единого.
|
||
--
|
||
-- Формат назван буквально, а не отдан parseDateTimeBestEffort, и вот почему.
|
||
-- Best-effort понимает десяток записей и на непонятной не краснеет, а
|
||
-- достраивает недостающее: обрезанное «20:00:21» он превращает в первое
|
||
-- января текущего года. Такая строка прошла бы строгий приём с тихо неверным
|
||
-- временем — ровно с той порчей, ради которой класс key_field_unparsed и
|
||
-- заведён. Источник у топика один и шлёт одну запись, так что широта здесь не
|
||
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
|
||
-- «Что проверено».
|
||
--
|
||
-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и
|
||
-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала
|
||
-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип
|
||
-- колонки этого не чинит: он про то, как число покажут, а не какое ляжет.
|
||
-- Правило и замер — docs/architecture/storage.md, «Часовые пояса».
|
||
--
|
||
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
|
||
-- JSONExtract её берёт.
|
||
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
|
||
TO ods.event_dist
|
||
AS
|
||
WITH
|
||
JSONType(raw) = 'Object' AS is_object,
|
||
arraySort(JSONExtractKeys(raw)) = arraySort([
|
||
'WatchID', 'VisitID', 'ClientID', 'CounterID', 'EventDate',
|
||
'UTCEventTime', 'ClientTimeZone', 'EventType', 'Sign',
|
||
'URL', 'Referer', 'Title', 'UTMSource', 'UTMMedium', 'UTMCampaign',
|
||
'UTMContent', 'UTMTerm', 'LastTrafficSource', 'HasGCLID', 'YCLID',
|
||
'Browser', 'BrowserMajorVersion', 'BrowserLanguage', 'OperatingSystem',
|
||
'OperatingSystemRoot', 'DeviceCategory', 'MobilePhoneModel',
|
||
'ScreenWidth', 'ScreenHeight', 'IPAddress', 'RegionCountry',
|
||
'RegionCity', 'RegionCountryID', 'RegionCityID',
|
||
'GoalsReached', 'ParsedParamsKey1',
|
||
'purchaseID', 'purchaseRevenue', 'purchaseCurrency', 'purchaseCoupon',
|
||
'productID', 'productName', 'productCategory', 'productPrice',
|
||
'productQuantity', 'productEventType', 'ecommerce'
|
||
]) AS keys_match,
|
||
JSONExtract(raw, 'WatchID', 'Nullable(UInt64)') IS NOT NULL
|
||
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
|
||
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', 'UTC') IS NOT NULL AS key_fields_parsed
|
||
SELECT
|
||
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
|
||
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
|
||
JSONExtract(raw, 'ClientID', 'UInt64') AS ClientID,
|
||
JSONExtract(raw, 'CounterID', 'UInt32') AS CounterID,
|
||
JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
|
||
assumeNotNull(parseDateTimeOrNull(
|
||
JSONExtractString(raw, '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,
|
||
JSONExtract(raw, 'URL', 'String') AS URL,
|
||
JSONExtract(raw, 'Referer', 'String') AS Referer,
|
||
JSONExtract(raw, 'Title', 'String') AS Title,
|
||
JSONExtract(raw, 'UTMSource', 'String') AS UTMSource,
|
||
JSONExtract(raw, 'UTMMedium', 'String') AS UTMMedium,
|
||
JSONExtract(raw, 'UTMCampaign', 'String') AS UTMCampaign,
|
||
JSONExtract(raw, 'UTMContent', 'String') AS UTMContent,
|
||
JSONExtract(raw, 'UTMTerm', 'String') AS UTMTerm,
|
||
JSONExtract(raw, 'LastTrafficSource', 'String') AS LastTrafficSource,
|
||
JSONExtract(raw, 'HasGCLID', 'UInt8') AS HasGCLID,
|
||
JSONExtract(raw, 'YCLID', 'UInt64') AS YCLID,
|
||
JSONExtract(raw, 'Browser', 'String') AS Browser,
|
||
JSONExtract(raw, 'BrowserMajorVersion', 'UInt16') AS BrowserMajorVersion,
|
||
JSONExtract(raw, 'BrowserLanguage', 'String') AS BrowserLanguage,
|
||
JSONExtract(raw, 'OperatingSystem', 'String') AS OperatingSystem,
|
||
JSONExtract(raw, 'OperatingSystemRoot', 'String') AS OperatingSystemRoot,
|
||
JSONExtract(raw, 'DeviceCategory', 'UInt8') AS DeviceCategory,
|
||
JSONExtract(raw, 'MobilePhoneModel', 'String') AS MobilePhoneModel,
|
||
JSONExtract(raw, 'ScreenWidth', 'UInt16') AS ScreenWidth,
|
||
JSONExtract(raw, 'ScreenHeight', 'UInt16') AS ScreenHeight,
|
||
JSONExtract(raw, 'IPAddress', 'String') AS IPAddress,
|
||
JSONExtract(raw, 'RegionCountry', 'String') AS RegionCountry,
|
||
JSONExtract(raw, 'RegionCity', 'String') AS RegionCity,
|
||
JSONExtract(raw, 'RegionCountryID', 'UInt32') AS RegionCountryID,
|
||
JSONExtract(raw, 'RegionCityID', 'UInt32') AS RegionCityID,
|
||
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
|
||
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
|
||
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
|
||
JSONExtract(raw, 'purchaseRevenue', 'Array(Float64)') AS purchaseRevenue,
|
||
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
|
||
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
|
||
JSONExtract(raw, 'productID', 'Array(String)') AS productID,
|
||
JSONExtract(raw, 'productName', 'Array(String)') AS productName,
|
||
JSONExtract(raw, 'productCategory', 'Array(String)') AS productCategory,
|
||
JSONExtract(raw, 'productPrice', 'Array(Int64)') AS productPrice,
|
||
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
||
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
||
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
||
-- Метка загрузки переносится из сырья как есть, а не ставится заново:
|
||
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
||
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
||
_load_ts
|
||
FROM stg.hits_raw_dist
|
||
WHERE is_object AND keys_match AND key_fields_parsed;
|
||
|
||
-- Брак: буквальное отрицание того же предиката.
|
||
--
|
||
-- Классы брака пересекаются, поэтому проверяются по порядку, а в error_class
|
||
-- пишется первый совпавший. Скаляр проваливает и проверку на объект, и сверку
|
||
-- ключей — JSONExtractKeys от него даёт пустой массив (измерено на стенде
|
||
-- 7 августа 2026 года), — и без объявленного порядка попал бы то в один
|
||
-- класс, то в другой.
|
||
--
|
||
-- Предикат повторён здесь дословно, и это выбор, а не безвыходность: назвать
|
||
-- его один раз на две матвью позволил бы CREATE FUNCTION. Отвергнуто — условие
|
||
-- разбора ушло бы за имя, в отдельный объект со своей жизнью, и слой перестал
|
||
-- бы читаться по своему же DDL.
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
|
||
TO ods.event_errors_dist
|
||
AS
|
||
WITH
|
||
JSONType(raw) = 'Object' AS is_object,
|
||
arraySort(JSONExtractKeys(raw)) = arraySort([
|
||
'WatchID', 'VisitID', 'ClientID', 'CounterID', 'EventDate',
|
||
'UTCEventTime', 'ClientTimeZone', 'EventType', 'Sign',
|
||
'URL', 'Referer', 'Title', 'UTMSource', 'UTMMedium', 'UTMCampaign',
|
||
'UTMContent', 'UTMTerm', 'LastTrafficSource', 'HasGCLID', 'YCLID',
|
||
'Browser', 'BrowserMajorVersion', 'BrowserLanguage', 'OperatingSystem',
|
||
'OperatingSystemRoot', 'DeviceCategory', 'MobilePhoneModel',
|
||
'ScreenWidth', 'ScreenHeight', 'IPAddress', 'RegionCountry',
|
||
'RegionCity', 'RegionCountryID', 'RegionCityID',
|
||
'GoalsReached', 'ParsedParamsKey1',
|
||
'purchaseID', 'purchaseRevenue', 'purchaseCurrency', 'purchaseCoupon',
|
||
'productID', 'productName', 'productCategory', 'productPrice',
|
||
'productQuantity', 'productEventType', 'ecommerce'
|
||
]) AS keys_match,
|
||
JSONExtract(raw, 'WatchID', 'Nullable(UInt64)') IS NOT NULL
|
||
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
|
||
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', 'UTC') IS NOT NULL AS key_fields_parsed
|
||
SELECT
|
||
raw,
|
||
multiIf(
|
||
NOT is_object, 'not_an_object',
|
||
NOT keys_match, 'keyset_mismatch',
|
||
'key_field_unparsed'
|
||
) AS error_class,
|
||
kafka_topic,
|
||
kafka_partition,
|
||
kafka_offset,
|
||
kafka_timestamp,
|
||
consumer_host,
|
||
_load_ts
|
||
FROM stg.hits_raw_dist
|
||
WHERE NOT (is_object AND keys_match AND key_fields_parsed);
|