docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа от генератора до хранилища, включая ошибки и повторные поступления. Что: обновлены вводная генератора, справочники приема и модели заказа, разделы хранилища и комментарии SQL и дага. Сохранены технические условия; исполняемый код, схемы и поведение стенда не изменены. Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI. SQL без комментариев и Python AST без строк документации совпали с исходными. Генерация данных и сброс состояния не выполнялись. Refs #250
This commit is contained in:
1 parent
83434102d5
commit
75f28fe99f
13 files changed
+663
-974
No files matched your search
+27
-68
@@ -1,49 +1,20 @@
|
||||
-- ODS: типизированное событие и таблица ошибок разбора.
|
||||
--
|
||||
-- Состав, имена и типы колонок списаны с «описания выгрузки»
|
||||
-- (docs/formats/clickstream-event.md) — так же, как в бою хранилище пишут по
|
||||
-- документации источника. Модуль контракта генератора здесь не читается:
|
||||
-- граница «трекер | хранилище» проходит по документу.
|
||||
-- Механика приёма и три класса брака — ADR 0005, конвенции имён, служебных
|
||||
-- колонок и сроков — docs/architecture/storage.md.
|
||||
|
||||
-- Локальная таблица события.
|
||||
-- Поля следуют docs/formats/clickstream-event.md; purchaseRevenue хранится
|
||||
-- в Decimal для точного счета денег. Правила приема — ADR 0005.
|
||||
-- ReplacingMergeTree оставляет последнюю доставку по _load_ts среди строк
|
||||
-- с одинаковым полным ORDER BY. Метка переносится из STG без пересчета.
|
||||
-- Она не входит в ключ сортировки или партиции: иначе повторные доставки
|
||||
-- не считались бы версиями одной строки. Для чтения служит ods.event_v с FINAL.
|
||||
--
|
||||
-- Движок ReplacingMergeTree, колонка версии — _load_ts. Работа у версии ровно
|
||||
-- одна: схлопнуть повтор доставки. Метка не ставится здесь заново, а
|
||||
-- переносится из stg.hits_raw как есть — колонка отвечает на вопрос «когда
|
||||
-- строка приехала в хранилище», а не «когда её разобрали». У повтора
|
||||
-- содержимое то же самое, отличается только метка, поэтому какая из двух
|
||||
-- строк переживёт мерж, безразлично.
|
||||
-- Партиция EventDate хранит модельный день события. CounterID на стенде
|
||||
-- постоянен, EventDate постоянна внутри партиции; дальнейшая сортировка —
|
||||
-- по intHash32(ClientID) и WatchID. WatchID отличает события посетителя:
|
||||
-- без него они заменяли бы друг друга.
|
||||
--
|
||||
-- Колонка версии не входит ни в ключ партиции, ни в ключ сортировки, и это
|
||||
-- не случайность: попади она туда — версии одной строки лягут в разные куски
|
||||
-- или в разные места ключа и не встретятся при мерже, то есть дедупликация
|
||||
-- перестанет работать молча.
|
||||
--
|
||||
-- PARTITION BY EventDate — по дню события, а не по месяцу, как у Метрики.
|
||||
-- Отступление осознанное: дневная партиция здесь единица переобработки, и
|
||||
-- переиграть день X значит заменить одну партицию. Месячная партиция тянула
|
||||
-- бы за собой тридцать чужих дней.
|
||||
--
|
||||
-- ORDER BY вырожден, и это учебный факт, а не недосмотр: CounterID на стенде
|
||||
-- константа (сайт один), EventDate константа внутри своей партиции — обе
|
||||
-- головные колонки ключа не различают ни одной строки. Реальная сортировка
|
||||
-- идёт по посетителю и событию: intHash32(ClientID), WatchID. Ключ написан в
|
||||
-- боевой форме «по сайту за период по посетителю» — на стенде она
|
||||
-- вырождается, в бою нет. Хвост WatchID работает ещё и на дедупликацию: без
|
||||
-- него ReplacingMergeTree схлопнул бы в одну строку все события посетителя за
|
||||
-- день.
|
||||
--
|
||||
-- SAMPLE BY intHash32(ClientID) — по тому же выражению, что стоит в ключе
|
||||
-- сортировки (иначе семплирование не разрешено). Семплирование берёт целиком
|
||||
-- посетителей, а не события вразнобой, поэтому SAMPLE 0.1 не портит
|
||||
-- uniq-метрики.
|
||||
--
|
||||
-- Sign всегда равен 1: это колонка формата, взятая без механики. В бою
|
||||
-- исправление записи шлют парой −1/+1 и считают через sum(Sign) поверх
|
||||
-- CollapsingMergeTree; наш генератор исправлений не шлёт, поэтому колонка
|
||||
-- есть, а механики за ней нет.
|
||||
-- SAMPLE BY использует то же выражение, что ключ сортировки, и отбирает
|
||||
-- посетителей целиком. Sign всегда равен 1: поле сохранено по формату
|
||||
-- источника, но исправлений парой −1/+1 генератор не отправляет.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
WatchID UInt64,
|
||||
@@ -100,36 +71,24 @@ PARTITION BY EventDate
|
||||
ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)
|
||||
SAMPLE BY intHash32(ClientID);
|
||||
|
||||
-- Лицо слоя: пишем и читаем через него. Ключ шардирования —
|
||||
-- cityHash64(ClientID), а не сырой ClientID: структурированный числовой
|
||||
-- идентификатор перекашивает остаток по модулю числа шардов, хеш — нет.
|
||||
-- События одной куки при этом остаются на одном шарде, и сессионизация со
|
||||
-- склейкой идентичностей ниже по течению живут локально.
|
||||
--
|
||||
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
||||
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
|
||||
-- сходиться не должны — сверять слои можно только через _dist.
|
||||
-- Запись и диагностика через Distributed; актуальные события — в ods.event_v.
|
||||
-- cityHash64(ClientID) распределяет посетителей равномернее, чем остаток
|
||||
-- самого структурированного идентификатора. События посетителя лежат вместе.
|
||||
-- STG распределен по хешу raw, поэтому сообщение и разобранное событие могут
|
||||
-- лежать на разных шардах. Общие счетчики сравнивают через _dist.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
|
||||
AS ods.event_rep
|
||||
ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID));
|
||||
|
||||
-- Локальная таблица ошибок разбора.
|
||||
-- Ошибки разбора: исходный текст, класс и сведения о доставке.
|
||||
-- У сообщения может не быть разобранного ClientID, поэтому шардирование —
|
||||
-- по cityHash64(raw), партиция — по реальному дню загрузки.
|
||||
-- ORDER BY группирует ошибки по классу и координатам Kafka.
|
||||
--
|
||||
-- Сырой текст сообщения, метаданные доставки и класс брака. Разобранных полей
|
||||
-- у брака нет по определению: строка сюда попала как раз потому, что не
|
||||
-- разобралась.
|
||||
--
|
||||
-- Движок — обычный ReplicatedMergeTree, без замены версий: у брака нет ключа
|
||||
-- сущности, схлопывать его не по чему.
|
||||
--
|
||||
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
|
||||
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
|
||||
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
|
||||
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
|
||||
-- у брака тоже нет.
|
||||
--
|
||||
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
|
||||
-- к моменту разбирательства не осталось бы ни того, ни другого.
|
||||
-- Замены версий нет: надежного ключа события у брака может не быть.
|
||||
-- TTL сохраняет ошибки месяц, дольше трехсуточного срока STG.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
raw String,
|
||||
|
||||
+26
-84
@@ -1,8 +1,6 @@
|
||||
-- ODS: каноническое чтение актуальных событий.
|
||||
--
|
||||
-- Представление сохраняет поля и зерно ods.event_dist, но скрывает FINAL от
|
||||
-- потребителя. Поэтому физическая таблица остаётся местом диагностики
|
||||
-- доставленных версий, а точное чтение получает одно имя.
|
||||
-- Актуальные события: FINAL выбирает последнюю доставку по _load_ts,
|
||||
-- не дожидаясь фоновых слияний. Поля и типы остаются как в ods.event_dist.
|
||||
|
||||
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT *
|
||||
@@ -25,69 +23,24 @@ AS
|
||||
SELECT *
|
||||
FROM ods.order_dist FINAL;
|
||||
|
||||
-- ODS: разбор STG в событие и в таблицу ошибок.
|
||||
-- Разбор STG в события и ошибки. Два представления используют одинаковое
|
||||
-- условие годности; второе берет его отрицание. Условие дает только 0 или 1:
|
||||
-- NULL не выбрала бы ни одна ветвь. Функции разбора не вызывают исключений
|
||||
-- на некорректном raw, чтобы одно сообщение не прервало прием.
|
||||
--
|
||||
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
|
||||
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
|
||||
-- молча. Держится это формой: годность считает один и тот же предикат из трёх
|
||||
-- частей, а вторая матвью берёт его буквальное отрицание.
|
||||
-- Оба представления срабатывают на вставку в stg.hits_raw_dist,
|
||||
-- до распределения по шардам. Порядок SELECT совпадает с целевой таблицей.
|
||||
--
|
||||
-- Отсюда два запрета на выражения предиката, и оба серьёзные.
|
||||
-- Прием проверяет объект JSON, точный набор ключей и разбор пяти полей:
|
||||
-- WatchID, VisitID, ClientID, EventDate, UTCEventTime. Остальные поля
|
||||
-- извлекаются по формату без отдельной проверки. Граница — ADR 0005.
|
||||
-- arraySort с обеих сторон позволяет сравнить ключи независимо от порядка.
|
||||
--
|
||||
-- Первый: ни одна его часть не возвращает 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 её берёт.
|
||||
-- UTCEventTime приходит строкой вида "2026-06-01T12:34:56Z". Ее разбирает
|
||||
-- parseDateTimeOrNull с явной маской: JSONExtract в DateTime такую форму
|
||||
-- не принимает, а BestEffort допускает неполные даты. Пояс UTC указан
|
||||
-- отдельно, потому что Z в маске проверяется как буква. EventDate приходит
|
||||
-- как "2026-06-01" и разбирается через JSONExtract.
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
|
||||
TO ods.event_dist
|
||||
@@ -154,10 +107,8 @@ SELECT
|
||||
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
|
||||
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
|
||||
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
|
||||
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
|
||||
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
|
||||
-- синтаксис подтвержден документацией ClickHouse через Context7 и
|
||||
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
|
||||
-- purchaseRevenue приходит числом рублей и разбирается сразу в Decimal.
|
||||
-- Прямой разбор сверяли через Context7 и на ClickHouse 26.3 (#155).
|
||||
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
|
||||
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
|
||||
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
|
||||
@@ -168,25 +119,16 @@ SELECT
|
||||
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
||||
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
||||
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
||||
-- Метка загрузки переносится из STG как есть, а не ставится заново:
|
||||
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
||||
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
||||
-- _load_ts остается временем исходной доставки и версией события.
|
||||
_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.
|
||||
-- Ошибки: отрицание того же условия годности.
|
||||
-- Класс определяется первым нарушенным условием. Например, JSON-строка
|
||||
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
|
||||
-- Условие повторено рядом с запросом, без отдельной функции ClickHouse.
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
|
||||
TO ods.event_errors_dist
|
||||
AS
|
||||
|
||||
+11
-21
@@ -1,26 +1,16 @@
|
||||
-- STG: матвью приёма — из чтеца в таблицу STG.
|
||||
-- Прием событий из Kafka в STG.
|
||||
-- Подключение этого представления запускает чтение топика. Поэтому файл 40
|
||||
-- применяется после таблиц и представлений разбора из файлов 20 и 30:
|
||||
-- иначе часть сообщений сохранится в STG, но не попадет в ODS.
|
||||
--
|
||||
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
|
||||
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
|
||||
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
|
||||
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
|
||||
-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
|
||||
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
|
||||
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
|
||||
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
|
||||
-- Запись через stg.hits_raw_dist распределяет сообщения по ключу таблицы.
|
||||
-- Она фоновая; возможную потерю при сбое описывает раздел «Приём»
|
||||
-- в docs/architecture/storage.md.
|
||||
--
|
||||
-- Пишем в stg.hits_raw_dist, а не в локальную таблицу: раскладку по шардам
|
||||
-- обязан определять ключ шардирования, а не то, какая нода случайно читала
|
||||
-- топик. Вставка при этом фоновая — окно потери принято осознанно, довод
|
||||
-- целиком в docs/architecture/storage.md, раздел «Приём».
|
||||
--
|
||||
-- Служебные колонки заполняются выражением здесь, а не DEFAULT в таблице.
|
||||
-- Для consumer_host это обязательно: проверено на стенде 5 августа 2026 года —
|
||||
-- DEFAULT hostName() вычисляется на шарде-получателе и назвал бы не ту ноду,
|
||||
-- которая читала топик. hostName() в SELECT снимается на вставляющей ноде,
|
||||
-- то есть отвечает ровно на нужный вопрос.
|
||||
--
|
||||
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
|
||||
-- Сведения о доставке вычисляются здесь, на ноде чтения Kafka.
|
||||
-- DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.
|
||||
-- Порядок SELECT совпадает с порядком колонок цели.
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster
|
||||
TO stg.hits_raw_dist
|
||||
AS
|
||||
|
||||
+19
-42
@@ -1,21 +1,9 @@
|
||||
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций.
|
||||
-- Сборка выбранных дней в донор перед заменой партиций DDS.
|
||||
-- Донор очищается в начале: иначе новые строки добавились бы к прежним.
|
||||
-- После сборки он остается для диагностики до следующего запуска.
|
||||
--
|
||||
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся
|
||||
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем
|
||||
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
|
||||
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
|
||||
-- удвоение в цель.
|
||||
--
|
||||
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
|
||||
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
|
||||
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
|
||||
--
|
||||
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE: последнее — отдельное
|
||||
-- право, роли etl оно не выдано, а снятие всех партиций
|
||||
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
|
||||
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
|
||||
-- операции, для которой уже есть разрешённая форма, значило бы платить
|
||||
-- доступом за синоним.
|
||||
-- DROP PARTITION ALL доступен роли etl через ALTER; отдельного права
|
||||
-- TRUNCATE у нее нет (ADR 0007). Весь отрезок дней собирается одной вставкой.
|
||||
|
||||
ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
|
||||
|
||||
@@ -37,18 +25,13 @@ INSERT INTO dds.order_stage_dist
|
||||
_load_id,
|
||||
_load_ts
|
||||
)
|
||||
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе —
|
||||
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на
|
||||
-- живом слепке, промежуточный toDecimal64 не нужен.
|
||||
--
|
||||
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS
|
||||
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто
|
||||
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
|
||||
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
|
||||
-- генератора такого не бывает, и сумма «цена × количество» сходится с
|
||||
-- items_total у всех заказов, но сходится она по свойству источника, а не
|
||||
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
|
||||
-- контур DQ этапа 4; пока она делается руками при приёмке.
|
||||
-- Разбор позиций из JSON: цена в строке сразу превращается в Decimal.
|
||||
-- ODS проверил только тип массива items. Неверное содержимое здесь может
|
||||
-- дать значения по умолчанию: qty вида "oops" и пропущенная цена дают 0,
|
||||
-- скаляр вместо объекта выпадает из массива. Наблюдения —
|
||||
-- docs/architecture/storage.md, «Что проверено».
|
||||
-- Проверку суммы позиций участник добавляет в docs/labs/5-1-order-checks.md.
|
||||
|
||||
WITH JSONExtract(
|
||||
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
|
||||
) AS lines
|
||||
@@ -56,8 +39,7 @@ SELECT
|
||||
order_id,
|
||||
user_id,
|
||||
status,
|
||||
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает
|
||||
-- человека с дашбордом, и пояс называется здесь в последний раз.
|
||||
-- День и время в Europe/Samara: витрины используют готовые колонки.
|
||||
toDate(toTimeZone(created_at, 'Europe/Samara')),
|
||||
toTimeZone(created_at, 'Europe/Samara'),
|
||||
toTimeZone(updated_at, 'Europe/Samara'),
|
||||
@@ -68,22 +50,17 @@ SELECT
|
||||
arrayMap(line -> line.sku, lines),
|
||||
arrayMap(line -> line.qty, lines),
|
||||
arrayMap(line -> line.price, lines),
|
||||
-- Координаты запуска, собравшего день: у всех строк дня они одни.
|
||||
-- Метки этой сборки DDS, общие для всех вставляемых строк.
|
||||
{load_id:String},
|
||||
now64(3, 'UTC')
|
||||
FROM ods.order_v
|
||||
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
|
||||
BETWEEN {first_day:Date} AND {last_day:Date}
|
||||
-- Вторая граница не меняет отбор, а открывает источнику отсечение
|
||||
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на
|
||||
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы
|
||||
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
|
||||
-- поэтому день слева берётся с запасом.
|
||||
-- Дополнительный фильтр сокращает чтение партиций ODS по UTC.
|
||||
-- Модельные сутки Europe/Samara пересекают две даты UTC, поэтому
|
||||
-- левая граница берется на день раньше. Точный отбор — условием выше.
|
||||
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
|
||||
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку
|
||||
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали
|
||||
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
|
||||
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
|
||||
-- «Раскладка по шардам», капкан #137).
|
||||
-- Ждем доставки на шарды и распределяем строки по ключу цели
|
||||
-- (docs/architecture/storage.md, «Раскладка по шардам»).
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
+12
-24
@@ -1,28 +1,16 @@
|
||||
-- DDS: подмена одного дня заказов собранной партицией двойника.
|
||||
-- Замена одного дня DDS готовой партицией донора.
|
||||
-- Повтор при неизменном источнике сохраняет деловые данные без дублей.
|
||||
--
|
||||
-- Это и есть идемпотентность слоя. Повтор прогона безопасен, а «дыхание»
|
||||
-- статусов внутри окна выражается сменой всей партиции, а не правкой строк.
|
||||
-- ON CLUSTER выполняет замену на обеих нодах по локальным таблицам.
|
||||
-- Донор и цель имеют одинаковую структуру и ключ шардирования
|
||||
-- (sql/ddl/45-dds-tables.sql); REPLACE PARTITION строки не перераспределяет.
|
||||
-- Замена атомарна внутри шарда. Между заменами на разных шардах читатель
|
||||
-- может увидеть часть прежних и часть новых данных. После сбоя этот период
|
||||
-- длится до успешного повтора. Пустая партиция донора удаляет день из цели.
|
||||
--
|
||||
-- Замена идёт по локальным таблицам, шард за шардом: ON CLUSTER рассылает её
|
||||
-- на обе ноды, а по Distributed то же самое не работает — операции с
|
||||
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что
|
||||
-- проверено»). Правильность пошардовой замены держится на том, что двойник
|
||||
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
|
||||
--
|
||||
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
|
||||
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
|
||||
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
|
||||
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
|
||||
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
|
||||
--
|
||||
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
|
||||
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
|
||||
-- источнике больше нет.
|
||||
--
|
||||
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
|
||||
-- сервер параметров не подставляет — замерено, отвечает синтаксической
|
||||
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
|
||||
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
|
||||
-- ClickHouse на запрос объёма, а не снаружи стенда.
|
||||
-- День подставляет Jinja: параметры ClickHouse в ALTER ... PARTITION
|
||||
-- не поддерживаются в закрепленной версии 26.3. Значение получено
|
||||
-- из результата order_scope.sql; замер — docs/architecture/storage.md.
|
||||
|
||||
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
||||
REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep;
|
||||
+16
-37
@@ -1,37 +1,17 @@
|
||||
-- DDS: объём пересборки заказов — с какого дня по какой собирает этот запуск.
|
||||
-- Дни пересборки заказов: first_day и last_day, обе границы включены.
|
||||
-- Текущее окно — дни оформления заказов из последнего snapshot_date.
|
||||
-- Если в DDS нет дня, который есть в ODS, отрезок расширяется до первого
|
||||
-- такого дня. Это восстанавливает пропуски даже за пределами текущего окна.
|
||||
--
|
||||
-- Параметров у дага нет: объём он выводит сам из состояния слоёв
|
||||
-- (docs/architecture/etl.md, «Объём прогона»). Ответ — две даты, границы
|
||||
-- непрерывного отрезка; список дней между ними разворачивает даг. Пустой
|
||||
-- источник даёт пустой ответ, и даг на нём останавливается с внятной ошибкой
|
||||
-- вместо зелёного прогона, собравшего ничего.
|
||||
-- Дата слепка у актуальной версии показывает последнее наблюдение заказа
|
||||
-- (ADR 0014). Поэтому окно берется из данных, без копирования константы
|
||||
-- генератора. FINAL здесь не нужен: created_at одинаков у всех версий,
|
||||
-- а строки последнего слепка сохраняются при слияниях.
|
||||
--
|
||||
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним
|
||||
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
|
||||
-- мультизапрос, который ClickHouse не принимает.
|
||||
--
|
||||
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
|
||||
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
|
||||
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
|
||||
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
|
||||
-- догоняет уехавший вперёд источник.
|
||||
--
|
||||
-- **Хвост считается от первого недостающего дня, а не от последнего
|
||||
-- собранного.** Разница не косметическая: замены партиций идут отдельными
|
||||
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
|
||||
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
|
||||
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
|
||||
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
|
||||
-- в ODS.
|
||||
--
|
||||
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
|
||||
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
|
||||
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
|
||||
-- копии его константы на стороне стенда больше нет.
|
||||
--
|
||||
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
|
||||
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
|
||||
-- оставляет как раз их.
|
||||
-- Пустой ODS не дает строки результата; даг завершится ошибкой.
|
||||
-- Точка с запятой в конце не нужна: клиент дописывает FORMAT ответа.
|
||||
-- Порядок пересборки — docs/architecture/etl.md, «Объём прогона».
|
||||
|
||||
WITH
|
||||
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||
(
|
||||
@@ -44,10 +24,9 @@ WITH
|
||||
FROM ods.order_dist
|
||||
WHERE snapshot_date = last_snapshot
|
||||
) AS window_end,
|
||||
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
||||
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
|
||||
-- заказы легли на соседний шард, показался бы недостающим.
|
||||
-- minOrNull отличает «дыр нет» от даты по умолчанию.
|
||||
-- GLOBAL NOT IN сравнивает дни со всем DDS. Локальное сравнение могло бы
|
||||
-- счесть день пропущенным, если его заказы находятся на соседнем шарде.
|
||||
-- minOrNull возвращает NULL, когда пропущенных дней нет.
|
||||
(
|
||||
SELECT minOrNull(day)
|
||||
FROM (
|
||||
@@ -57,7 +36,7 @@ WITH
|
||||
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
||||
) AS first_missing_day
|
||||
SELECT
|
||||
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
|
||||
-- Начало окна или первый пропущенный день, если он раньше.
|
||||
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
||||
window_end AS last_day
|
||||
WHERE (SELECT count() FROM ods.order_dist) > 0
|
||||
+27
-46
@@ -1,24 +1,15 @@
|
||||
-- Разбор одного среза заказов: сначала годные версии, затем брак.
|
||||
-- Разбор сохраненной порции заказов: сначала годные версии, затем ошибки.
|
||||
-- Макрос Jinja подставляет одно условие годности в обе вставки.
|
||||
-- Вторая вставка берет его отрицание, чтобы каждая строка получила результат.
|
||||
-- Условие должно давать только 0 или 1 и не вызывать исключений на неверном raw.
|
||||
--
|
||||
-- Обе вставки должны делить один предикат. Локальный макрос Jinja
|
||||
-- разворачивает его в каждое выражение до того, как оператор разделит файл по
|
||||
-- точкам с запятой. Так контракт остаётся рядом с запросами и не становится
|
||||
-- третьим SQL-файлом, который нельзя исполнить самостоятельно.
|
||||
--
|
||||
-- Строгий приём проверяет форму провода и ничего сверх неё. Ключей одиннадцать,
|
||||
-- и проверяются все: десять скалярных значений прямо образуют типизированную
|
||||
-- строку заказа. Внутрь items приём не смотрит — содержимое позиций, переходы
|
||||
-- статуса и равенства сумм остаются ниже этой границы
|
||||
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
|
||||
--
|
||||
-- У общего предиката два обязательных свойства. Он всегда даёт 0 или 1, не
|
||||
-- NULL: иначе строку не возьмут ни условие, ни его отрицание. И он не бросает
|
||||
-- исключений на произвольном raw: одна грязная строка не должна ронять весь
|
||||
-- переход, ради этого и существует таблица брака.
|
||||
-- Проверяются одиннадцать полей верхнего уровня. Внутри items поля не проверяются;
|
||||
-- позиции разбирает DDS, а равенства сумм проверяются отдельно.
|
||||
-- Точные правила — docs/architecture/orders/ingestion.md, «Граница строгого приёма».
|
||||
|
||||
{% macro order_wire_contract() %}
|
||||
WITH
|
||||
-- Каноническое время провода: UTC, ровно три знака долей секунды.
|
||||
-- Время источника: UTC, ровно три знака долей секунды.
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
|
||||
JSONType(raw) = 'Object' AS is_object,
|
||||
arraySort(JSONExtractKeys(raw)) = arraySort([
|
||||
@@ -28,15 +19,14 @@ WITH
|
||||
]) AS keys_match,
|
||||
JSONType(raw, 'order_id') = 'String'
|
||||
AND JSONType(raw, 'status') = 'String'
|
||||
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
|
||||
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
|
||||
-- JSONType различает Int64 и UInt64; принимаются оба, если значение
|
||||
-- разбирается в UInt64, в том числе идентификаторы больше 2^63−1.
|
||||
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
|
||||
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
|
||||
AND JSONType(raw, 'items') = 'Array'
|
||||
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
|
||||
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
|
||||
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
|
||||
-- «Что проверено».
|
||||
-- Регулярное выражение проверяет точную форму, включая ведущие нули.
|
||||
-- Разбор проверяет само время: например, отвергает 30 февраля.
|
||||
-- Замеры функций — docs/architecture/storage.md, «Что проверено».
|
||||
AND arrayAll(k ->
|
||||
JSONType(raw, k) = 'String'
|
||||
AND match(JSONExtractString(raw, k),
|
||||
@@ -44,9 +34,8 @@ WITH
|
||||
AND parseDateTime64InJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
|
||||
['created_at', 'updated_at'])
|
||||
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
|
||||
-- форму, разбор — вместимость: у строки из двадцати девяток форма
|
||||
-- хороша, а в Decimal(18, 2) она не влезает.
|
||||
-- Сумма в рублях: строка с двумя знаками после точки, без минуса.
|
||||
-- После проверки формы проверяется возможность разбора в Decimal.
|
||||
AND arrayAll(k ->
|
||||
JSONType(raw, k) = 'String'
|
||||
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
|
||||
@@ -60,11 +49,9 @@ WITH
|
||||
is_object AND keys_match AND fields_valid AS row_is_valid
|
||||
{% endmacro %}
|
||||
|
||||
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
|
||||
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
|
||||
--
|
||||
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
|
||||
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
|
||||
-- Годные строки сохраненного среза по _load_id запуска приема.
|
||||
-- WHERE исключает строки с NULL в разобранных полях; assumeNotNull
|
||||
-- приводит оставшиеся значения к типам целевой таблицы.
|
||||
|
||||
INSERT INTO ods.order_dist
|
||||
{{ order_wire_contract() }}
|
||||
@@ -81,30 +68,25 @@ SELECT
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
|
||||
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
|
||||
-- items сохраняется строкой JSON для разбора позиций в DDS.
|
||||
JSONExtractRaw(raw, 'items') AS items,
|
||||
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, 'snapshot_date'),
|
||||
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
|
||||
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
|
||||
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
|
||||
-- _load_ts остается временем приема в STG; _load_id связывает с исходной порцией.
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM stg.orders_raw_dist
|
||||
WHERE _load_id = {load_id:String} AND row_is_valid
|
||||
-- Заводская двойка parallel_distributed_insert_select исполняет вставку из
|
||||
-- Distributed в Distributed локально на каждом шарде, минуя ключ шардирования
|
||||
-- цели, — так заказы и расползлись по обоим шардам. Ноль отдаёт раскладку
|
||||
-- инициатору, по ключу цели. Капкан целиком — docs/architecture/storage.md,
|
||||
-- «Раскладка по шардам».
|
||||
-- Ноль заставляет распределить результат по ключу цели. При значении 2
|
||||
-- вставка может сохранить раскладку STG, где ключ — raw, вместо order_id.
|
||||
-- Подробнее — docs/architecture/storage.md, «Раскладка по шардам».
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
|
||||
-- Брак: тот же срез и буквальное отрицание того же предиката.
|
||||
--
|
||||
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
|
||||
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
|
||||
-- без объявленного порядка он попал бы то в один класс, то в другой.
|
||||
-- Ошибки: тот же срез и отрицание условия годности.
|
||||
-- Класс определяется первым нарушенным условием: например, JSON-строка
|
||||
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
|
||||
|
||||
INSERT INTO ods.order_errors_dist
|
||||
{{ order_wire_contract() }}
|
||||
@@ -124,7 +106,6 @@ SELECT
|
||||
_load_ts
|
||||
FROM stg.orders_raw_dist
|
||||
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
||||
-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная
|
||||
-- запись легла бы верно — но по совпадению, а не по контракту.
|
||||
-- Та же настройка: распределяем строки по ключу целевой таблицы ошибок.
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
@@ -1,15 +1,11 @@
|
||||
-- Забор порции слепка заказов из Kafka в STG.
|
||||
-- Порция сообщений заказов из Kafka в STG, без разбора JSON.
|
||||
-- Один SELECT читает одну порцию; непрочитанное остается до следующего запуска.
|
||||
-- Полнота слепка не проверяется. Если после чтения вставка упадет, подтвержденные
|
||||
-- сообщения уже не перечитаются (docs/architecture/orders/ingestion.md).
|
||||
--
|
||||
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse
|
||||
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.
|
||||
-- Короткая порция оставит хвост до следующего прогона, а отказ после чтения
|
||||
-- унесёт прочитанное с собой: офсеты коммитятся в момент чтения. Граница
|
||||
-- целиком — ADR 0008, «Следствия».
|
||||
--
|
||||
-- stream_like_engine_allow_direct_select разрешает читать чтеца запросом; вторая
|
||||
-- половина пары объявлена на самой таблице (sql/ddl/10-stg-tables.sql).
|
||||
-- distributed_foreground_insert = 1 — конвенция ETL-вставок стенда: задача не
|
||||
-- должна зеленеть раньше, чем строки легли на шарды.
|
||||
-- stream_like_engine_allow_direct_select разрешает прямое чтение Kafka;
|
||||
-- kafka_commit_on_select включен в sql/ddl/10-stg-tables.sql.
|
||||
-- distributed_foreground_insert = 1 ждет записи на шарды до завершения задачи.
|
||||
|
||||
INSERT INTO stg.orders_raw_dist
|
||||
SELECT
|
||||
|
||||
Reference in new issue
Block a user