docs(ingestion): упрощены объяснения источников и приема данных

Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
This commit is contained in:
ddadmin committed 2026-09-13 16:32:45 -04:00
1 parent 83434102d5
commit 75f28fe99f
13 files changed
+663 -974

No files matched your search

+27 -68
View File
@@ -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
View File
@@ -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
View File
@@ -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