docs(ingestion): влита редактура приема данных (#250)
Зачем: включить принятую редактуру материалов ступеней 5–6 в main. Что: объединена ветка 250-ingestion-docs после просмотра владельцем. Проверка: make lint, ссылки и неизменность исполняемого кода проверены до приемки; git diff --check прошел перед коммитом. Closes #250
This commit is contained in:
commit
431fed72ad
13 files changed
+663
-974
No files matched your search
+14
-21
@@ -1,18 +1,13 @@
|
|||||||
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
"""Пакетный прием заказов: Kafka → STG → ODS.
|
||||||
|
|
||||||
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
Шаговый даг модельного времени отправляет слепок, запускает orders_ingest
|
||||||
порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
|
и ждет его завершения. Собственного расписания у orders_ingest нет.
|
||||||
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
Сначала pull_batch сохраняет порцию сообщений в STG, затем parse_batch
|
||||||
шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
|
разбирает тот же срез по _load_id в заказы и ошибки.
|
||||||
|
|
||||||
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
События принимаются непрерывно через материализованные представления.
|
||||||
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
Заказы читаются одним запросом; полнота слепка этим не гарантируется.
|
||||||
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец,
|
Правила и ограничения — docs/architecture/orders/ingestion.md.
|
||||||
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
|
|
||||||
каждый там, где ему место по природе источника.
|
|
||||||
|
|
||||||
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
|
|
||||||
перехода — docs/architecture/orders/ingestion.md.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,8 +19,7 @@ from airflow.sdk import dag
|
|||||||
|
|
||||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища;
|
# Compose монтирует sql/ по этому пути во все службы Airflow.
|
||||||
# сюда их монтирует compose — всем службам Airflow сразу.
|
|
||||||
SQL_ROOT = "/opt/airflow/sql"
|
SQL_ROOT = "/opt/airflow/sql"
|
||||||
|
|
||||||
|
|
||||||
@@ -34,15 +28,14 @@ SQL_ROOT = "/opt/airflow/sql"
|
|||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=START_DATE,
|
start_date=START_DATE,
|
||||||
is_paused_upon_creation=False,
|
is_paused_upon_creation=False,
|
||||||
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом
|
# Один запуск за раз: параллельные чтения разделили бы порцию
|
||||||
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй
|
# между разными _load_id.
|
||||||
# прогон подождёт своей очереди.
|
|
||||||
max_active_runs=1,
|
max_active_runs=1,
|
||||||
template_searchpath=SQL_ROOT,
|
template_searchpath=SQL_ROOT,
|
||||||
tags=["заказы"],
|
tags=["заказы"],
|
||||||
)
|
)
|
||||||
def orders_ingest():
|
def orders_ingest():
|
||||||
"""Забрать приехавший слепок заказов и разложить его по слоям."""
|
"""Сохранить порцию заказов и разобрать ее в ODS."""
|
||||||
pull_batch = SQLExecuteQueryOperator(
|
pull_batch = SQLExecuteQueryOperator(
|
||||||
task_id="pull_batch",
|
task_id="pull_batch",
|
||||||
conn_id="clickhouse_default",
|
conn_id="clickhouse_default",
|
||||||
@@ -59,8 +52,8 @@ def orders_ingest():
|
|||||||
do_xcom_push=False,
|
do_xcom_push=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
|
# Две вставки parse_batch не образуют транзакцию. Повтор задачи
|
||||||
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
|
# выполняет обе над тем же срезом STG по _load_id.
|
||||||
pull_batch >> parse_batch
|
pull_batch >> parse_batch
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+86
-107
@@ -1,134 +1,113 @@
|
|||||||
# Модель заказа в DDS
|
# Модель заказа в DDS
|
||||||
|
|
||||||
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS —
|
DDS готовит заказ для анализа: выбирает текущее состояние из ODS,
|
||||||
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность
|
разбирает позиции и переводит время в часовой пояс счетчика. Этот слой
|
||||||
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не
|
показывает, как собрать модель данных для витрин и повторить загрузку
|
||||||
версиями.
|
без удвоения заказов.
|
||||||
|
|
||||||
## Зерно
|
## Зерно
|
||||||
|
|
||||||
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает
|
Одна строка — один заказ в последнем известном состоянии, ключ — `order_id`.
|
||||||
на вопрос «что сейчас известно о заказе»; историю версий не хранит:
|
История изменений здесь не хранится. ODS тоже не служит ее архивом:
|
||||||
читателя у истории нет ни в одной витрине, а ODS её и не обещает —
|
промежуточные версии удаляются при фоновых слияниях.
|
||||||
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
|
|
||||||
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
|
|
||||||
число дня меняется вместе с ними.
|
|
||||||
|
|
||||||
## Состав строки
|
## Состав строки
|
||||||
|
|
||||||
Деловые поля источника переезжают из `ods.order_v` без переименований —
|
Поля заказа приходят из `ods.order_v` с именами источника: `order_id`,
|
||||||
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в
|
`user_id`, `status`, `items_total`, `discount`, `delivery`, `total`.
|
||||||
`Decimal` (`items_total`, `discount`, `delivery`, `total`).
|
Денежные колонки хранят рубли с копейками в `Decimal(18, 2)`.
|
||||||
|
|
||||||
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс
|
`created_at` и `updated_at` переводятся из UTC в `Europe/Samara`, пояс
|
||||||
счётчика: DDS обслуживает человека с дашбордом ([дока
|
счетчика сайта. Из `created_at` в этом поясе вычисляется `order_date` —
|
||||||
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается
|
день оформления заказа. Витрины группируют по готовой дате;
|
||||||
**день заказа** — колонка `order_date`, по образцу `EventDate` у события:
|
правило времени общее для [хранилища](../storage.md#часовые-пояса).
|
||||||
суточные срезы группируются по готовой дате и пояса не упоминают.
|
|
||||||
|
|
||||||
Позиции разобраны из сырой строки `items` в три типизированных массива
|
Строка `items` из ODS разбирается в три массива одной длины:
|
||||||
одной длины — артикул, количество, цена (`Decimal`). Это родная для
|
|
||||||
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа
|
| Колонка | Что хранит |
|
||||||
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине
|
|---|---|
|
||||||
выручки, с обогащением категорией через `dictGet`
|
| `item_sku` | Артикулы товаров |
|
||||||
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор
|
| `item_quantity` | Количество каждого товара, `UInt32` |
|
||||||
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека
|
| `item_price` | Цены за единицу в рублях, `Decimal(18, 2)` |
|
||||||
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
|
|
||||||
выбор DDL, не модели.
|
Элементы с одинаковым номером описывают одну позицию заказа. Витрина
|
||||||
|
выручки разворачивает их через `ARRAY JOIN` и получает категорию товара
|
||||||
|
из справочника через `dictGet`. Разбор JSON выполняется один раз
|
||||||
|
при загрузке DDS.
|
||||||
|
|
||||||
|
ODS проверяет только то, что `items` — массив. Разбор в DDS не заменяет
|
||||||
|
проверку его содержимого: например, некорректное количество может стать
|
||||||
|
нулем. Проверку суммы позиций участник добавляет в
|
||||||
|
[первом задании ступени 5](../../labs/5-1-order-checks.md).
|
||||||
|
|
||||||
## Движок и наполнение
|
## Движок и наполнение
|
||||||
|
|
||||||
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция —
|
`dds.order_rep` — таблица на `ReplicatedMergeTree`, без колонки версии.
|
||||||
`order_date` (день заказа неизменен), ключ сортировки — `order_id`.
|
Партиция хранит один `order_date`, ключ сортировки — `order_id`.
|
||||||
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что
|
Запись через `dds.order_dist` распределяет заказы по `cityHash64(order_id)`,
|
||||||
ODS: это единственный устойчивый ключ сущности.
|
как в ODS. Для чтения служит `dds.order_v` с явным списком колонок
|
||||||
|
над `dds.order_dist`; `FINAL` в этом слое не нужен.
|
||||||
|
|
||||||
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после
|
Даг `dds_transform` пересобирает выбранные дни из `ods.order_v`:
|
||||||
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
|
|
||||||
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
|
|
||||||
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
|
|
||||||
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
|
|
||||||
из окна день замерзает.
|
|
||||||
|
|
||||||
Публичная поверхность — `dds.order_v`: обычное представление с явным списком
|
1. [`order_scope.sql`](../../../sql/dds/order_scope.sql) определяет
|
||||||
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`.
|
непрерывный отрезок `first_day`…`last_day`. Он включает дни заказов
|
||||||
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
|
из последнего слепка и начинается раньше, если в DDS отсутствует
|
||||||
объектов».
|
день, который есть в ODS.
|
||||||
|
2. [`order_rebuild.sql`](../../../sql/dds/order_rebuild.sql) очищает
|
||||||
|
промежуточную таблицу `dds.order_stage_rep` и наполняет ее через
|
||||||
|
`dds.order_stage_dist`. Это донор: в нем готовится результат,
|
||||||
|
пока основная таблица остается доступной для чтения.
|
||||||
|
3. [`order_replace.sql`](../../../sql/dds/order_replace.sql) заменяет
|
||||||
|
каждую дневную партицию основной таблицы готовой партицией донора.
|
||||||
|
|
||||||
Механика запуска — где собирается партиция и как подменяется на кластере —
|
Повтор при неизменном ODS дает те же деловые данные без дублей —
|
||||||
сюда не входит: это устройство [дневного конвейера](../etl.md).
|
это идемпотентность загрузки. Пока заказы дня входят в семидневное окно
|
||||||
|
источника, их состояние может меняться, и день пересобирается.
|
||||||
|
За пределами окна новые состояния не приходят, но пропущенный день
|
||||||
|
можно восстановить из ODS. Границы пересборки берутся из данных;
|
||||||
|
число семь в SQL хранилища не повторяется.
|
||||||
|
|
||||||
|
Замена атомарна внутри одного шарда: читатель видит прежнюю или новую
|
||||||
|
партицию целиком. Замены на двух шардах независимы, поэтому между ними
|
||||||
|
запрос через Distributed может увидеть смешанный результат.
|
||||||
|
Донор остается до следующей сборки и доступен для диагностики.
|
||||||
|
Порядок задач и повторы — в [описании дневного конвейера](../etl.md).
|
||||||
|
|
||||||
## Служебные колонки
|
## Служебные колонки
|
||||||
|
|
||||||
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего
|
`_load_id` и `_load_ts` показывают, какой запуск DDS собрал строку
|
||||||
её партицию, — по общей конвенции хранилища. При замене партиции они
|
и когда он выполнил вставку. Они одинаковы у строк одной сборки.
|
||||||
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
|
При следующей пересборке эти метки обновятся, даже если заказ не изменился.
|
||||||
собран этот день».
|
|
||||||
|
|
||||||
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
|
`snapshot_date` и метки приема ODS в DDS не переносятся: они описывают
|
||||||
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
|
получение сообщения источника. Их можно найти в ODS по `order_id`.
|
||||||
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
|
В каждом слое служебные колонки относятся к его собственной загрузке.
|
||||||
называет последний слепок, в котором источник показал заказ
|
|
||||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
|
|
||||||
наблюдения и координаты доставки читатель спрашивает у ODS по
|
|
||||||
`order_id`. Правило слоя:
|
|
||||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
|
||||||
спрашиваются у их слоя по ключу сущности**.
|
|
||||||
|
|
||||||
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
|
При поиске ошибки начните с `_load_id` в DDS: он указывает на запуск
|
||||||
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
|
сборки. По `order_id` найдите версии заказа в ODS, а по их `_load_id` —
|
||||||
день; по `order_id` ODS показывает физические версии и брак — там свой
|
соответствующие сырые сообщения в STG и ошибки того же приема.
|
||||||
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
|
В таблице ошибок `order_id` может быть не разобран, поэтому нужное
|
||||||
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
|
сообщение ищут по `raw` и координатам Kafka.
|
||||||
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
Сырые сообщения хранятся около трех реальных суток, ошибки — месяц;
|
||||||
|
уточнения к срокам — в [справочнике хранилища](../storage.md#срок-жизни-сырых-данных).
|
||||||
|
|
||||||
## Читатели
|
## Читатели
|
||||||
|
|
||||||
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN`
|
- `dm.revenue_daily_v` считает выручку по заказам со статусом `paid`,
|
||||||
позиций, категория через `dictGet` (спека, раздел 7).
|
разворачивает позиции и дополняет их категорией товара.
|
||||||
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`,
|
- `dm.purchase_vs_orders_v` сопоставляет заказ с покупкой на сайте
|
||||||
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами
|
по `order_id`, используя `order_date`, `items_total` и `status`.
|
||||||
([исследование соединений](../../research/2026-08-22-distributed-join.md)).
|
- `dds.identity_map` связывает идентификаторы посетителей с `user_id`
|
||||||
- Сборка карты идентичностей: пара `order_id` ↔ `user_id`, источник —
|
через покупки и заказы.
|
||||||
`dds.order_v`.
|
|
||||||
|
|
||||||
## Не входит
|
|
||||||
|
|
||||||
- Механика запуска трансформации и замены партиций на кластере —
|
|
||||||
[дневной конвейер](../etl.md).
|
|
||||||
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
|
|
||||||
этапа 4.
|
|
||||||
- Инкрементальное наполнение вместо пересборки — техдолг #2.
|
|
||||||
|
|
||||||
## Отклонённые варианты
|
|
||||||
|
|
||||||
- **Одно динамическое представление вместо физической таблицы.** Рамка стенда —
|
|
||||||
показать модель данных, которую хранилище готовит потребителям; урок
|
|
||||||
«слой — контракт, а не копия» уже закреплён за `dds.event_v`, а разбор
|
|
||||||
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
|
||||||
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
|
||||||
не противоречит: оно ничего заново не вычисляет.
|
|
||||||
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
|
|
||||||
физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
|
||||||
и обёртку-представление — а урок версий менти уже отыграл.
|
|
||||||
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
|
||||||
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
|
||||||
реляционного мира, а не модель под колоночное хранилище. `JOIN` менти
|
|
||||||
увидит в сверке, работу с массивами — только здесь.
|
|
||||||
- **Зерно «версия заказа» (история).** Читателя нет; ODS историю не
|
|
||||||
обещает, а мир при нужде переигрывается генератором.
|
|
||||||
- **Перенос ярлыков ODS** — см. «Служебные колонки».
|
|
||||||
- **Без модели вовсе: витрины читают `ods.order_v` напрямую.** Разбор JSON
|
|
||||||
и пересчёт времён расползлись бы по витринам, а модель данных — то, ради
|
|
||||||
чего сущность и строится, — не была бы показана.
|
|
||||||
|
|
||||||
## Связанные решения
|
## Связанные решения
|
||||||
|
|
||||||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в ODS
|
||||||
ODS, устойчивая поверхность `ods.order_v`.
|
и чтение через `ods.order_v`.
|
||||||
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и
|
- [Прием заказов](../orders/ingestion.md) — проверки формата и выбор
|
||||||
четыре координаты строки ODS.
|
актуальной версии.
|
||||||
- [Проталкивание условий сквозь
|
- [Исследование соединений](../../research/2026-08-22-distributed-join.md) —
|
||||||
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
|
почему сверка распределенных источников использует `GLOBAL JOIN`.
|
||||||
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на
|
- [Проталкивание условий сквозь представления](../../research/2026-08-22-view-predicate-pushdown.md) —
|
||||||
котором здесь выбрана физическая таблица.
|
как фильтр запроса влияет на объем чтения ODS.
|
||||||
@@ -1,241 +1,172 @@
|
|||||||
# Приём заказов из Kafka в ODS
|
# Прием заказов из Kafka в ODS
|
||||||
|
|
||||||
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
|
||||||
и запуск загрузки, а затем читает физические версии через явную поверхность
|
проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
|
||||||
текущего состояния.
|
читают из ODS последнюю принятую версию каждого заказа.
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
|
||||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
|
|
||||||
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
|
|
||||||
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
|
|
||||||
PARTITION` могла бы заменить партицию дня неполным набором строк.
|
|
||||||
|
|
||||||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
|
||||||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
|
||||||
|
|
||||||
## Цели
|
|
||||||
|
|
||||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
|
||||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
|
||||||
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
|
||||||
партиции;
|
|
||||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
|
||||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
|
||||||
|
|
||||||
## Не входит
|
|
||||||
|
|
||||||
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
|
||||||
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
|
||||||
- удаление заказа по отсутствию в следующем слепке;
|
|
||||||
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
|
||||||
|
|
||||||
## Поток данных
|
## Поток данных
|
||||||
|
|
||||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
Источник отправляет в топик `orders` дневной слепок: по одному сообщению
|
||||||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
|
||||||
запускает его шаговый даг модельного времени, который положил слепок в топик и
|
D−6…D, с учетом [задержек первого появления](fate.md).
|
||||||
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
В начале модельного времени окно короче. Выгрузку за D отправляет
|
||||||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
|
||||||
Запрос забора лежит в
|
|
||||||
[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
|
||||||
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
|
||||||
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
|
||||||
|
|
||||||
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
После отправки шаговый даг модельного времени запускает
|
||||||
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
[`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
|
||||||
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
|
У `orders_ingest` нет расписания; его два шага выполняются по порядку:
|
||||||
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
|
|
||||||
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
|
|
||||||
Обе вставки лежат в одном самодостаточном файле
|
|
||||||
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
|
|
||||||
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
|
|
||||||
порядку в одной задаче `parse_batch`.
|
|
||||||
|
|
||||||
Условия запросов взаимодополняющие: один общий предикат определяет годную
|
1. `pull_batch` читает порцию из `stg.orders_raw_kafka` и записывает ее
|
||||||
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
|
через `stg.orders_raw_dist`. Содержимое сообщения остается строкой `raw`,
|
||||||
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
рядом сохраняются координаты Kafka, `_load_id` и `_load_ts`.
|
||||||
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
2. `parse_batch` читает сохраненные строки с этим `_load_id`. Первый
|
||||||
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
|
`INSERT SELECT` пишет годные записи в `ods.order_dist`, второй —
|
||||||
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
|
остальные в `ods.order_errors_dist`.
|
||||||
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
|
|
||||||
самостоятельно.
|
`_load_id` равен `run_id` Airflow и связывает строки одного запуска.
|
||||||
|
`_load_ts` — реальное время записи в STG; при разборе оно переносится
|
||||||
|
без пересчета. Запросы лежат в
|
||||||
|
[`orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql) и
|
||||||
|
[`order_parse.sql`](../../../sql/ods/order_parse.sql).
|
||||||
|
`SQLExecuteQueryOperator` передает `run_id` параметром ClickHouse `load_id`.
|
||||||
|
|
||||||
|
Две вставки разбора используют одно условие годности, записанное макросом
|
||||||
|
Jinja внутри `order_parse.sql`. Перед выполнением макрос подставляется
|
||||||
|
в оба запроса; ветвь брака берет отрицание этого условия. Оно возвращает
|
||||||
|
только истину или ложь и не вызывает исключений на некорректном `raw`.
|
||||||
|
Так каждая строка сохраненной порции получает ровно один результат.
|
||||||
|
|
||||||
|
Транзакции между вставками нет: первая может завершиться, а вторая упасть.
|
||||||
|
Повтор `parse_batch` выполняет обе вставки над тем же срезом STG.
|
||||||
|
Текущее состояние заказа останется прежним, но таблица ошибок может
|
||||||
|
получить повторные строки с теми же `_load_id` и координатами Kafka.
|
||||||
|
Обе вставки ждут доставки строк на шарды и используют ключ целевой таблицы;
|
||||||
|
настройки объяснены в [справочнике хранилища](../storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
## Граница строгого приёма
|
## Граница строгого приёма
|
||||||
|
|
||||||
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
Проверяется одна строка `stg.orders_raw_dist`. Корень JSON должен быть
|
||||||
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
|
объектом с точным набором из одиннадцати ключей:
|
||||||
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
|
`order_id`, `user_id`, `status`, `created_at`, `updated_at`,
|
||||||
`items`, `snapshot_date`.
|
`items_total`, `discount`, `delivery`, `total`, `items`, `snapshot_date`.
|
||||||
|
|
||||||
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
|
| Поля | Условия приема |
|
||||||
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту
|
|---|---|
|
||||||
форму не входит; времена — строками RFC 3339 в UTC с обязательными
|
| `order_id`, `status` | Строки JSON |
|
||||||
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только
|
| `user_id` | Целое число JSON, которое разбирается в `UInt64` |
|
||||||
как JSON-массив. Каноническая форма и основания выбора зафиксированы в
|
| `created_at`, `updated_at` | Строки UTC с миллисекундами, например `2026-06-01T12:34:56.000Z`; проверяются форма и разбор времени |
|
||||||
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
|
| `items_total`, `discount`, `delivery`, `total` | Неотрицательные суммы в рублях, записанные строками с двумя знаками после точки; проверяется разбор в `Decimal(18, 2)` |
|
||||||
|
| `snapshot_date` | Строка даты `YYYY-MM-DD`, которая проходит разбор |
|
||||||
|
| `items` | Массив JSON; сохраняется целиком в строковой колонке |
|
||||||
|
|
||||||
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
|
||||||
скалярных значений непосредственно образуют типизированную строку заказа. У
|
статусов, их переходов, полей внутри `items` или равенства денежных сумм.
|
||||||
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
|
||||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
|
||||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
|
Класс ошибки записывается в `error_class` по первому нарушенному условию:
|
||||||
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
|
|
||||||
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
|
|
||||||
однозначность. Модельный источник выполняет условие; в настоящей интеграции
|
|
||||||
его фиксируют в договоре с источником
|
|
||||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
|
||||||
|
|
||||||
Класс брака выбирается первым совпадением:
|
1. `not_an_object` — корень JSON не объект;
|
||||||
|
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
|
||||||
|
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
|
||||||
|
|
||||||
1. `not_an_object`;
|
Имя поля в класс не входит. Для разбора конкретной ошибки в
|
||||||
2. `keyset_mismatch`;
|
`ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
|
||||||
3. `field_invalid`.
|
|
||||||
|
|
||||||
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
|
||||||
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
|
||||||
без постоянной детализации предиката.
|
|
||||||
|
|
||||||
## Роль ODS
|
## Роль ODS
|
||||||
|
|
||||||
`ods.order_rep` хранит физически принятые версии в
|
`ods.order_rep` использует `ReplicatedReplacingMergeTree(snapshot_date)`.
|
||||||
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
Строки с одним ключом сортировки `order_id` считаются версиями одного заказа;
|
||||||
день неизменного `created_at`. `ods.order_dist` шардирует по
|
остается строка с максимальной `snapshot_date`. Партиция —
|
||||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
`toDate(created_at)` в UTC, ключ шардирования — `cityHash64(order_id)`.
|
||||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
Неизменный `created_at` и общий ключ направляют все версии заказа
|
||||||
|
в одну партицию и на один шард.
|
||||||
|
|
||||||
Четыре координаты отвечают на разные вопросы:
|
| Колонка | На какой вопрос отвечает |
|
||||||
|
|---|---|
|
||||||
|
| `snapshot_date` | На конец какого модельного дня источник показал это состояние? |
|
||||||
|
| `updated_at` | Когда запись заказа последний раз меняли в магазине? |
|
||||||
|
| `_load_id` | Какой запуск Airflow принял сообщение? |
|
||||||
|
| `_load_ts` | Когда сообщение записали в STG по реальным часам стенда? |
|
||||||
|
|
||||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
|
||||||
- `updated_at` — когда строку последний раз меняли в источнике;
|
`updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
|
||||||
- `_load_id` — какой запуск Airflow принял строку;
|
строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
|
||||||
- `_load_ts` — когда строка приехала в хранилище.
|
нужна DDS для определения окна пересборки, даже если состояние не изменилось.
|
||||||
|
|
||||||
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
|
Источник выпускает **один слепок на дату**. Повторная отправка дает то же
|
||||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
|
содержимое, а более поздний слепок не возвращает устаревшее состояние.
|
||||||
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
|
Эти условия не проверяются на одной строке при приеме. Если источник
|
||||||
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
|
пришлет два разных состояния с одной `snapshot_date`, эта колонка
|
||||||
той физической строке, которую сохранило слияние, и описывают её доставку.
|
не позволит определить, какое из них актуально.
|
||||||
|
Обоснование версии — [ADR 0014](../../adr/0014-order-version-snapshot-date.md).
|
||||||
|
|
||||||
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
`ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
|
||||||
показывает состояния старше. Модельный мир её держит — состояние читается на
|
на `order_id`, даже если фоновые слияния еще не убрали старые версии.
|
||||||
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
Без `FINAL` физическая таблица может показать несколько строк заказа.
|
||||||
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
|
||||||
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
Отсутствие заказа в новом слепке не удаляет его из ODS.
|
||||||
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
|
||||||
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
|
||||||
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
|
||||||
больше не показывает, и там помогает только сверка самих полей, в терминах
|
|
||||||
dbt snapshots — стратегия `check` вместо `timestamp`.
|
|
||||||
|
|
||||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
|
||||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
позиции. Для своей загрузки DDS записывает собственные служебные метки —
|
||||||
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
[модель заказа](../dds/order.md#служебные-колонки).
|
||||||
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
|
||||||
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
|
||||||
заменить, не меняя потребителей.
|
|
||||||
|
|
||||||
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
|
||||||
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
|
||||||
какие сущности, связи и производные признаки ему нужны. Судьба служебных
|
|
||||||
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
|
|
||||||
([модель заказа](../dds/order.md)).
|
|
||||||
|
|
||||||
## Одно чтение Kafka
|
## Одно чтение Kafka
|
||||||
|
|
||||||
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
|
||||||
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
Обычный слепок содержит около полутора тысяч строк, а предел порции
|
||||||
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
на стенде — десятки тысяч сообщений. Это допущение о размере данных:
|
||||||
|
Kafka не сообщает запросу, что прочитан весь слепок.
|
||||||
|
|
||||||
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
Короткая порция оставляет непрочитанные сообщения для следующих запусков.
|
||||||
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
|
||||||
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
по `snapshot_date` могла бы опубликовать неполный набор заказов
|
||||||
или публикация неполного дня.
|
([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
|
||||||
|
|
||||||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
|
||||||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
|
||||||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
|
||||||
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
|
часть из них может успеть записаться на шарды. Позиция модельного времени
|
||||||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
|
||||||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
того же дня, но не восстановит старые сообщения других слепков,
|
||||||
|
если они попали в неудачную порцию.
|
||||||
## Отклонённые варианты
|
Граница восстановления описана в [ADR 0008](../../adr/0008-order-ingestion.md#следствия).
|
||||||
|
|
||||||
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
|
|
||||||
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
|
|
||||||
признака полноты партиции, а дата наблюдения становится частью ключа
|
|
||||||
сущности.
|
|
||||||
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
|
|
||||||
технические повторы там, где семантика версии уже известна.
|
|
||||||
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
|
|
||||||
объёма, который с большим запасом помещается в одну порцию.
|
|
||||||
- Два `task_id` или материализованный классификатор: дробят один короткий
|
|
||||||
переход слоя, не добавляя транзакционности.
|
|
||||||
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
|
|
||||||
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
|
|
||||||
модель данных.
|
|
||||||
|
|
||||||
## Риски и проверка
|
|
||||||
|
|
||||||
- На малой управляемой порции дать по одной строке каждого класса брака и две
|
|
||||||
годные версии одного `order_id`. Две цели должны сохранить все непустые
|
|
||||||
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
|
|
||||||
слияния.
|
|
||||||
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
|
|
||||||
не должны измениться; версии одного заказа должны остаться на одном шарде и
|
|
||||||
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
|
|
||||||
`_load_id` и Kafka-координаты показывают повтор задачи.
|
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
Это результаты прежних опытов на стенде; при редактуре они заново
|
||||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
|
не выполнялись.
|
||||||
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
|
|
||||||
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
|
|
||||||
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
|
||||||
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
|
||||||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
|
||||||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
|
||||||
|
|
||||||
После исправления
|
- 18 августа 2026 года (#93, #94) прямое чтение приняло слепок,
|
||||||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
а разбор разделил строки на годные и ошибки без потерь и пересечений.
|
||||||
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
|
Две версии заказа попали на один шард в одну партицию; `ods.order_v`
|
||||||
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
|
вернул позднюю. Повтор разбора с тем же `_load_id` сохранил текущую строку
|
||||||
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
|
и ее `_load_ts`, но повторил ошибки. Подробности —
|
||||||
`5490.00 − 823.50 + 299.00 = 4965.50`.
|
[справочник хранилища](../storage.md#что-проверено) и
|
||||||
|
[ADR 0008](../../adr/0008-order-ingestion.md#что-проверено).
|
||||||
|
- 20 августа после исправления
|
||||||
|
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||||||
|
приняты все 1694 строки слепка дня 7 без ошибок. Итог заказа
|
||||||
|
`20260608-0007`: `5490.00 − 823.50 + 299.00 = 4965.50`.
|
||||||
|
- 21 августа (#124) пересборка через `SQLExecuteQueryOperator` завершила
|
||||||
|
`pull_batch` и `parse_batch`; в `system.query_log` осталось по одному
|
||||||
|
завершенному запросу `etl` на забор, годные строки и ошибки.
|
||||||
|
- 22 августа (#137) во всех 3034 парах с одинаковым `updated_at`
|
||||||
|
деловые поля совпали побайтово; по слепкам время изменения не убывало.
|
||||||
|
- 26 августа (#165) после двух слепков физическая таблица содержала
|
||||||
|
3356 версий 1917 заказов. В `ods.order_v` у всех заказов была дата
|
||||||
|
последнего слепка, а деловые поля совпали с `argMax` по `updated_at`
|
||||||
|
из сырых данных.
|
||||||
|
|
||||||
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
|
На ClickHouse `26.3.17.56` отдельно проверены одна порция при прямом
|
||||||
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
|
`SELECT` Kafka и выполнение `FINAL` на шардах через Distributed.
|
||||||
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
При проектировании Context7 был недоступен; чтение сверяли с исходниками
|
||||||
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
той же версии:
|
||||||
|
|
||||||
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
|
|
||||||
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
|
||||||
побайтово, а по слепкам метка ни разу не убывала.
|
|
||||||
|
|
||||||
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
|
||||||
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
|
||||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
|
|
||||||
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
|
|
||||||
данных. В обеих сверках расхождений нет.
|
|
||||||
|
|
||||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
|
||||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
|
||||||
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
|
|
||||||
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
|
|
||||||
|
|
||||||
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
|
|
||||||
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
|
|
||||||
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
|
|
||||||
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
|
|
||||||
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
|
||||||
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
|
|
||||||
поведение чтения — с исходниками той же версии
|
|
||||||
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
|
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
|
||||||
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
||||||
|
Повторная сверка правил версий с документацией — в
|
||||||
|
[справочнике хранилища](../storage.md#актуальные-события).
|
||||||
|
|
||||||
## Связанные решения
|
## Связанные решения
|
||||||
|
|
||||||
|
|||||||
+205
-228
@@ -122,112 +122,110 @@ DDS: представление читает события ODS.
|
|||||||
|
|
||||||
## Служебные колонки
|
## Служебные колонки
|
||||||
|
|
||||||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
STG сохраняет исходное сообщение в строке `raw` и добавляет сведения
|
||||||
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
|
о доставке. JSON на этом шаге не разбирается: некорректное содержимое
|
||||||
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
|
тоже попадает в таблицу.
|
||||||
матвью перестанет читаться, что дано движком, а что положено нами, — а это
|
|
||||||
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
|
|
||||||
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
|
|
||||||
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
|
|
||||||
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
|
|
||||||
|
|
||||||
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
|
| Колонка STG | Что хранит |
|
||||||
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
|
|---|---|
|
||||||
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
|
| `kafka_topic` | Имя топика из виртуальной колонки Kafka `_topic` |
|
||||||
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
| `kafka_partition` | Номер партиции из `_partition` |
|
||||||
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
| `kafka_offset` | Позицию сообщения внутри партиции из `_offset` |
|
||||||
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
| `kafka_timestamp` | Метку времени Kafka из `_timestamp_ms`, если она есть |
|
||||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
|
| `consumer_host` | Имя ноды, прочитавшей Kafka |
|
||||||
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
| `_load_ts` | Реальное время записи сообщения в STG |
|
||||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
|
| `_load_id` | Только у заказов: `run_id` запуска Airflow, который принял порцию |
|
||||||
падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
|
||||||
|
|
||||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
|
||||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
`kafka_topic` и `consumer_host` имеют тип `LowCardinality(String)`,
|
||||||
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
|
`kafka_partition` и `kafka_offset` — `UInt64`.
|
||||||
колонка молча отвечала бы на другой вопрос.
|
`kafka_timestamp` имеет тип `Nullable(DateTime64(3, 'UTC'))`: сохраняет
|
||||||
|
миллисекунды и допускает отсутствие метки.
|
||||||
|
|
||||||
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
Виртуальные колонки предоставляет движок, а колонки STG хранят значения
|
||||||
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
|
||||||
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
|
собственной. Значения вычисляются в запросе приема. В частности,
|
||||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
`hostName()` должен выполняться на ноде, читающей Kafka:
|
||||||
0010](../adr/0010-order-versions-in-ods.md)).
|
`DEFAULT hostName()` только в локальной таблице назвал бы шард-получатель.
|
||||||
|
|
||||||
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
Таблицы STG используют `ReplicatedMergeTree` с
|
||||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
`ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
|
||||||
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
отдельной строкой; STG нужен в том числе для разбора таких повторов.
|
||||||
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
|
||||||
|
|
||||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
`_load_ts` имеет тип `DateTime64(3, 'UTC')`. При переходе в ODS метка
|
||||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
копируется из STG без пересчета. У событий она также выбирает последнюю
|
||||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
доставку; у заказов версию задает `snapshot_date`. `_load_id` заказа
|
||||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
переносится в годную строку ODS и в таблицу ошибок.
|
||||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
У непрерывного потока событий нет запуска Airflow, поэтому `_load_id`
|
||||||
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
в его STG и ODS отсутствует.
|
||||||
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
|
||||||
|
|
||||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
Время события `UTCEventTime` и дата `EventDate` приходят из счетчика сайта.
|
||||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
Поля заказа `created_at` и `updated_at` приходят из базы магазина.
|
||||||
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
Они относятся к модельному времени; `_load_ts` — к реальным часам стенда.
|
||||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
Например, событие июня можно загрузить в сентябре: его `EventDate`
|
||||||
|
останется июньской, а `_load_ts` будет сентябрьской.
|
||||||
|
|
||||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
События разбираются материализованными представлениями. Автоматической
|
||||||
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
пакетной переобработки у них нет; сохраненный срез STG можно разобрать
|
||||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
отдельным запросом до истечения срока хранения. При повторной вставке
|
||||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
с тем же ключом и `_load_ts` `ReplacingMergeTree` оставляет более поздно
|
||||||
`_load_id`; дневные партиции ODS он не заменяет.
|
вставленную строку. Заказы разбирает задача Airflow по `_load_id`.
|
||||||
|
В DDS метки загрузки уже относятся к собственной сборке слоя —
|
||||||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
[пример заказа](dds/order.md#служебные-колонки).
|
||||||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
|
||||||
технических колонок — распространённая запись,
|
|
||||||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
|
||||||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
|
||||||
|
|
||||||
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
|
||||||
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
|
||||||
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
|
||||||
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
|
||||||
|
|
||||||
## Актуальные события
|
## Актуальные события
|
||||||
|
|
||||||
`ods.event_rep` хранит физически принятые версии события в
|
Повторная отправка события может дать несколько строк в `ods.event_rep`.
|
||||||
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
|
Движок `ReplicatedReplacingMergeTree(_load_ts)` считает версиями строки
|
||||||
диагностики доставки. Обычное чтение этой пары может показать несколько строк
|
с одинаковым полным ключом сортировки:
|
||||||
одного `WatchID`, пока фоновые слияния их не схлопнули.
|
`(CounterID, EventDate, intHash32(ClientID), WatchID)`.
|
||||||
|
При слиянии остается строка с максимальным `_load_ts`, то есть последняя
|
||||||
|
доставка. Один `WatchID` обозначает событие, но физический ключ замены шире.
|
||||||
|
|
||||||
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
`ods.event_v` читает `ods.event_dist FINAL`: выбирает актуальную строку
|
||||||
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
|
||||||
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
|
считают через `_v`; число строк в `_dist` зависит от уже прошедших слияний.
|
||||||
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
`dds.event_v` использует этот результат, дает колонкам имена в `snake_case`
|
||||||
числе snake_case-имена и расшифровку кодов.
|
и расшифровывает коды.
|
||||||
|
|
||||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
Выбор по полному `ORDER BY` и максимальной версии, включая чтение с `FINAL`,
|
||||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
сверен через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree).
|
||||||
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
|
||||||
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
результат не зависел от фоновых слияний.
|
||||||
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
|
||||||
с сырыми данными подтвердило: копейки всех покупок целы.
|
Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md)
|
||||||
|
с одним денежным исключением: `purchaseRevenue` объявлен источником
|
||||||
|
как `Array(Float64)`, а из JSON разбирается сразу в
|
||||||
|
`Array(Decimal(18, 2))`. Это сумма в рублях с копейками; DDS наследует тип.
|
||||||
|
`productPrice` содержит округленные целые рубли и не подходит для
|
||||||
|
восстановления точной суммы покупки. Прямой разбор `purchaseRevenue`
|
||||||
|
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
|
||||||
|
|
||||||
## Версии заказов
|
## Версии заказов
|
||||||
|
|
||||||
|
Слепки повторяют состояние заказов из семидневного окна источника.
|
||||||
`ods.order_rep` хранит принятые версии в
|
`ods.order_rep` хранит принятые версии в
|
||||||
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
`ReplicatedReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`,
|
||||||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
партиция — `toDate(created_at)` в UTC. Запись через `ods.order_dist`
|
||||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
распределяет их по `cityHash64(order_id)`. У всех версий заказа одинаковы
|
||||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
день создания и шард, поэтому они могут заменять друг друга.
|
||||||
выбрал одного победителя.
|
|
||||||
|
|
||||||
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
`ods.order_v` читает `_dist FINAL` и оставляет строку с максимальной датой
|
||||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
слепка. Дата `snapshot_date` показывает последнее принятое наблюдение,
|
||||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
а `updated_at` — время изменения заказа в магазине. У неизменившегося
|
||||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
|
||||||
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
|
|
||||||
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
|
Источник выпускает один слепок на дату, а поздний слепок показывает
|
||||||
дате слепка. Модель версионного ODS записана в
|
не менее новое состояние. Отсутствие заказа в следующем слепке
|
||||||
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
|
не означает удаления. Физическая таблица не служит архивом:
|
||||||
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
|
старые версии исчезают при фоновых слияниях.
|
||||||
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
|
|
||||||
задан в [модели заказа в DDS](dds/order.md).
|
Прием добавляет версии отдельными строками. `snapshot_date` не доказывает,
|
||||||
|
что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется.
|
||||||
|
Подробности — [прием заказов](orders/ingestion.md),
|
||||||
|
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||||
|
[ADR 0014](../adr/0014-order-version-snapshot-date.md).
|
||||||
|
Следующий слой описан в [модели заказа DDS](dds/order.md).
|
||||||
|
|
||||||
## Часовые пояса
|
## Часовые пояса
|
||||||
|
|
||||||
@@ -289,169 +287,148 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
|||||||
|
|
||||||
## Приём: поток и его свойства
|
## Приём: поток и его свойства
|
||||||
|
|
||||||
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
|
События принимаются непрерывно. Заказы читает отдельным запросом
|
||||||
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
|
даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md).
|
||||||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
У событий путь такой:
|
||||||
заказов](orders/ingestion.md).
|
|
||||||
|
|
||||||
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
```text
|
||||||
ошибок ODS.
|
stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
|
||||||
|
├→ ods.event_mv → ods.event_dist
|
||||||
|
└→ ods.event_errors_mv → ods.event_errors_dist
|
||||||
|
```
|
||||||
|
|
||||||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
Таблицы Kafka на обеих нодах используют одну группу `clickstream_hits`.
|
||||||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
|
||||||
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
|
свою полную копию потока. Имя группы одинаково в DDL на всем
|
||||||
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
|
`clickstream_cluster`; конфигурация кластера —
|
||||||
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
|
`infra/clickhouse/config.d/cluster.xml`.
|
||||||
задано в `infra/clickhouse/config.d/cluster.xml`.
|
|
||||||
|
|
||||||
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
|
Материализованные представления разбора срабатывают на вставку
|
||||||
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
|
в `stg.hits_raw_dist`, до распределения строк по шардам.
|
||||||
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
|
Оба видят вставляемую порцию на той ноде, которая читает Kafka.
|
||||||
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
|
`stg.hits_raw_mv` создается после них: его подключение запускает
|
||||||
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
|
чтение топика. Если начать чтение раньше, сообщения успеют попасть
|
||||||
шардам.
|
в STG без разбора в ODS.
|
||||||
|
|
||||||
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
|
Условие приема проверяет три вещи:
|
||||||
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
|
|
||||||
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
|
|
||||||
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
|
|
||||||
промежутке — сообщения не перечитаются.
|
|
||||||
|
|
||||||
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
|
- корень `raw` — объект JSON;
|
||||||
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
|
- набор верхнеуровневых ключей совпадает с 47 полями формата;
|
||||||
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
|
- пять опорных полей, `WatchID`, `VisitID`, `ClientID`, `EventDate`
|
||||||
у него не бывает, так что настройка уровня запроса доехала бы только профилем
|
и `UTCEventTime`, разбираются в ожидаемые типы.
|
||||||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
|
||||||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
|
||||||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
|
||||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
|
||||||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
|
||||||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
|
||||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
|
||||||
компромисс полезнее показать, чем спрятать за галочкой.
|
|
||||||
|
|
||||||
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
|
||||||
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
Это граница приема: годная строка не означает, что проверен смысл
|
||||||
измерено и принято осознанно — подробности в разделе «Что проверено».
|
всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||||
|
|
||||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
`ods.event_mv` принимает строки, для которых условие истинно,
|
||||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
`ods.event_errors_mv` — для которых оно ложно. Условие не должно
|
||||||
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
|
||||||
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
Разбор использует функции без исключений на некорректном сообщении,
|
||||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
поскольку ошибка материализованного представления прерывает вставку
|
||||||
|
и останавливает чтение Kafka до устранения причины.
|
||||||
|
|
||||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
### Доставка и повторы
|
||||||
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
|
||||||
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
|
||||||
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
|
||||||
конвейера к сырому слою не относится.
|
|
||||||
|
|
||||||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
Вставка в Distributed на этом пути фоновая: блок сначала записывается
|
||||||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
в локальную очередь на диске, затем отправляется на шарды.
|
||||||
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
Kafka подтверждает обработку после выполнения материализованных
|
||||||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
представлений, когда данные еще могут находиться в очереди.
|
||||||
отсутствии механизма.
|
Если после подтверждения потерять ноду вместе с очередью, сообщения
|
||||||
|
не перечитаются. Возможен и повтор: данные уже записались на шард,
|
||||||
|
но подтверждение Kafka не успело сохраниться.
|
||||||
|
Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
|
||||||
|
|
||||||
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
|
В ETL-запросах Airflow `distributed_foreground_insert = 1` дожидается
|
||||||
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
|
записи на шарды. У фонового приема событий этой настройки нет:
|
||||||
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
|
недоступность одного шарда тогда остановила бы весь прием. В принятом
|
||||||
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
|
режиме доступная нода продолжает принимать и накапливает очередь
|
||||||
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
|
для недоступного шарда. Ограничение сохранено для учебного стенда;
|
||||||
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
|
подробности и наблюдения — в разделе «Что проверено».
|
||||||
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
|
|
||||||
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
|
|
||||||
исключений: упавшая матвью
|
|
||||||
роняет вставку и останавливает потребление до починки
|
|
||||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
|
||||||
|
|
||||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
|
||||||
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
|
||||||
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
|
||||||
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
Поэтому повторная отправка модельного дня увеличивает число строк STG.
|
||||||
красное в норме, учит не смотреть на оповещения ([ADR
|
|
||||||
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
|
||||||
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
|
||||||
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
|
||||||
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
|
||||||
постоянной целью не становится». Счёт актуальных событий идёт через
|
|
||||||
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
|
||||||
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
|
||||||
(раздел 6).
|
|
||||||
|
|
||||||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
Пустое значение сообщения и значение `null` не дают строки даже в STG:
|
||||||
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
чтец продвигает позицию Kafka, но не передает строку на разбор.
|
||||||
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
Генератор таких сообщений не создает. Поведение измерено на стенде
|
||||||
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
и описано в разделе «Что проверено».
|
||||||
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
|
||||||
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
### Что можно сверять
|
||||||
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
|
||||||
|
Для короткого неизменного среза доставки можно проверить баланс:
|
||||||
|
число строк STG равно сумме годных строк и ошибок до удаления повторов.
|
||||||
|
Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
|
||||||
|
ODS оставляет события и объединяет повторные доставки.
|
||||||
|
Счетчики по слоям тогда расходятся при штатной работе.
|
||||||
|
|
||||||
|
`make check-clickhouse` сравнивает актуальные события в `ods.event_v`
|
||||||
|
с [паспортом мира](../../data/world-inventory.json) только за восемь
|
||||||
|
начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
|
||||||
|
и не зависит от срока жизни STG. Границы остальных проверок —
|
||||||
|
в [карте проверок](testing.md).
|
||||||
|
|
||||||
## Срок жизни сырых данных
|
## Срок жизни сырых данных
|
||||||
|
|
||||||
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
TTL — правило автоматического удаления старых данных. В обеих таблицах
|
||||||
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
|
||||||
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
|
||||||
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
для поиска ошибок и повторного разбора, после которого данные освобождают
|
||||||
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
место на диске.
|
||||||
зависело от умолчания версии.
|
|
||||||
|
|
||||||
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
|
||||||
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
|
||||||
снимается целиком, только когда в нём истекли все строки, а в партиции
|
Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
|
||||||
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
дня загрузки возраст строк отличается не более чем на сутки.
|
||||||
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
Поэтому к трем суткам может добавиться до суток ожидания самой свежей
|
||||||
срок неограниченно.
|
строки части, а затем ожидание фоновой обработки TTL. Точного времени
|
||||||
|
удаления правило не обещает.
|
||||||
|
|
||||||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
Модельная дата для TTL не подходит: события июня могут загружаться
|
||||||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
|
||||||
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
|
||||||
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
продлевать хранение старых строк после слияния частей.
|
||||||
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
|
||||||
|
|
||||||
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
|
||||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
|
||||||
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
из которых модельную дату извлечь нельзя. `EventDate` появляется
|
||||||
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
|
||||||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
такой срез разбирается рядом с действующим приемом.
|
||||||
|
|
||||||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
|
||||||
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
|
|
||||||
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
|
|
||||||
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
|
|
||||||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
|
||||||
эталонные дни прямо на входе.
|
|
||||||
|
|
||||||
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
|
||||||
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
|
||||||
а не ноль.
|
|
||||||
|
|
||||||
## Таблица ошибок
|
## Таблица ошибок
|
||||||
|
|
||||||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
`ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
|
||||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
исходный `raw`, сведения о доставке и класс ошибки `error_class`.
|
||||||
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
Класс имеет тип `LowCardinality(String)` и выбирается по первому
|
||||||
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
нарушенному условию:
|
||||||
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
|
||||||
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
|
||||||
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
|
||||||
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
|
||||||
и куску незачем переживать срок из-за самой свежей строки.
|
|
||||||
|
|
||||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
1. `not_an_object` — корень JSON не объект;
|
||||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
2. `keyset_mismatch` — набор ключей не совпал с форматом;
|
||||||
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
3. `key_field_unparsed` — одно из пяти опорных полей не разобралось.
|
||||||
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
|
||||||
|
|
||||||
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
Порядок важен: строка вместо объекта нарушает сразу несколько условий,
|
||||||
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
но получает один класс. Полные условия — в
|
||||||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
[ADR 0005](../adr/0005-event-ingestion.md).
|
||||||
доставки.
|
|
||||||
|
|
||||||
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
|
`ods.order_errors_dist` хранит такой же набор сведений и `_load_id`
|
||||||
запуска. У заказов три класса по приоритету: `not_an_object`,
|
запуска приема. Первые два класса совпадают с событиями,
|
||||||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
третий называется `field_invalid`: у заказа проверяются все поля
|
||||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
верхнего уровня. Точные правила — в [описании приема заказов](orders/ingestion.md#граница-строгого-приёма).
|
||||||
[спецификации заказов](orders/ingestion.md).
|
|
||||||
|
Таблицы ошибок используют `ReplicatedMergeTree` без замены версий:
|
||||||
|
у некорректного сообщения может не быть разобранного ключа сущности.
|
||||||
|
Шард выбирается по `cityHash64(raw)`, ключ сортировки —
|
||||||
|
`(error_class, kafka_partition, kafka_offset)`, партиция — день `_load_ts`.
|
||||||
|
Начать поиск можно с класса ошибки, затем перейти к сообщениям
|
||||||
|
по координатам Kafka.
|
||||||
|
|
||||||
|
Срок хранения — один месяц от `_load_ts`, удаление целыми частями
|
||||||
|
с `ttl_only_drop_parts = 1`, как в STG. Ошибка вместе с исходным текстом
|
||||||
|
остается доступна дольше остальных сырых сообщений. Повторная обработка
|
||||||
|
может добавить тот же брак еще раз; у заказов повтор виден по совпадению
|
||||||
|
`_load_id` и координат Kafka.
|
||||||
|
|
||||||
## Справочники
|
## Справочники
|
||||||
|
|
||||||
|
|||||||
+78
-81
@@ -1,98 +1,95 @@
|
|||||||
# Генератор кликстрима
|
# Генератор событий и заказов
|
||||||
|
|
||||||
Клиентская сторона стенда: отсюда берётся поток событий — широкое событие по
|
Генератор создает данные двух источников интернет-магазина. Счетчик сайта
|
||||||
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
отправляет в топик Kafka `hits` события: просмотр страницы, добавление товара
|
||||||
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
в корзину и покупку. Серверная часть магазина выгружает в `orders` дневной
|
||||||
|
слепок — состояние заказов, оформленных за последние семь модельных дней.
|
||||||
|
Оба источника работают; хранилище принимает события непрерывно, заказы —
|
||||||
|
по команде Airflow.
|
||||||
|
|
||||||
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
|
Событие описывает отдельное действие посетителя. Заказ после оформления
|
||||||
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
|
может измениться, например перейти в статус `paid` или `cancelled`.
|
||||||
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
|
Поэтому один `order_id` встречается в нескольких слепках. Часть заказов
|
||||||
или в Kafka. Клиентская сторона на этом целая. Второй источник строится:
|
впервые появляется с задержкой в один или два дня; конец семидневного окна
|
||||||
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, —
|
от этого не сдвигается. Правила изменений и задержек — в
|
||||||
а судьба заказа и отправка слепка идут следующими тикетами.
|
[описании судьбы заказа](../docs/architecture/orders/fate.md).
|
||||||
|
|
||||||
|
Слепок снимается на конец модельного дня и отправляется на следующий день.
|
||||||
|
Команда `snapshot --day 1` отправляет слепок дня 0, а `snapshot --day 0`
|
||||||
|
не отправляет ничего: до начала модели заказов нет. События и слепки
|
||||||
|
отправляются отдельными запусками генератора.
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
|
Зерно генерации задает случайные выборы: какие посетители придут,
|
||||||
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
|
какие страницы откроют и что купят. При неизменных коде, каталоге
|
||||||
а день-функция проживает один день без накопленного состояния. Код этой
|
и настройках одинаковые зерно и номер дня дают одинаковые данные.
|
||||||
цепочки, ее схема и запуск D0 собраны в
|
День можно рассчитать без генерации всех предыдущих дней.
|
||||||
[путеводителе](../docs/architecture/generator/README.md).
|
|
||||||
|
Сначала рассчитываются посещения и покупки. Из покупки получаются событие
|
||||||
|
`purchase` со стороны сайта и заказ со стороны магазина. Номер связывает
|
||||||
|
их: `purchaseID` в событии соответствует `order_id` заказа. Суммы могут
|
||||||
|
различаться: магазин учитывает скидку, доставку и удаленные позиции.
|
||||||
|
Деньги генератор считает целыми копейками; в JSON заказа это строки
|
||||||
|
в рублях с двумя знаками после точки, например `"1289.99"`.
|
||||||
|
|
||||||
|
Готовые записи превращаются в JSON и отправляются в файл или Kafka:
|
||||||
|
одна запись занимает одну строку файла или одно сообщение. Событие содержит
|
||||||
|
47 полей по [описанию выгрузки](../docs/formats/clickstream-event.md),
|
||||||
|
запись заказа — поля [формата слепка](../docs/architecture/orders/snapshot.md#запись-на-проводе).
|
||||||
|
Подробное устройство генератора разобрано в
|
||||||
|
[техническом справочнике](../docs/architecture/generator/README.md).
|
||||||
|
|
||||||
## Что где лежит
|
## Что где лежит
|
||||||
|
|
||||||
- `src/clickstream_generator/world.py` — конфигурация мира: все его числа
|
Все перечисленные модули находятся в `src/clickstream_generator/`.
|
||||||
одним местом. Правка любого — смена мира; крутить их и предлагается.
|
|
||||||
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
| Модули | Назначение |
|
||||||
подпотока берёт случайность. На ней держится весь детерминизм.
|
|---|---|
|
||||||
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
| `world.py`, `seeds.py`, `plan.py` | Настройки мира, случайные выборы и план посещений |
|
||||||
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой
|
| `reference.py`, `catalog.py` | Устройства, города, источники трафика и каталог товаров |
|
||||||
и счётчики — до генерации событий.
|
| `day.py`, `commerce.py` | Визиты, события сайта и покупки |
|
||||||
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
|
| `orders.py` | Состояние заказов на дату слепка, изменения и задержки |
|
||||||
на весь генератор, чтобы дисциплина целочисленной случайности не жила
|
| `serialize.py`, `player.py`, `sinks.py` | Запись JSON и отправка в файл или Kafka |
|
||||||
копиями.
|
| `cli.py` | Команды и параметры запуска |
|
||||||
- `src/clickstream_generator/reference.py` — справочники: устройства,
|
| `schema.py`, `schema_doc.py` | Описания колонок события и сборка документа выгрузки |
|
||||||
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в
|
| `inventory.py` | Сборка паспорта мира с контрольными суммами и счетчиками |
|
||||||
строке, которой принадлежит.
|
|
||||||
- `src/clickstream_generator/catalog.py` — каталог товаров из
|
Тесты в `tests/` проверяют формат, правила модели и повторяемость данных.
|
||||||
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
Описание выгрузки и [паспорт мира](../data/world-inventory.json) собираются
|
||||||
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
из кода; вручную их не редактируют.
|
||||||
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
|
||||||
который садятся торговые события; прожитый день отдаёт обе половины —
|
|
||||||
поток событий и заказы.
|
|
||||||
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
|
|
||||||
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
|
|
||||||
Своя случайность, поэтому правка торговли трафик не двигает.
|
|
||||||
- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция
|
|
||||||
покупки. Личность покупателя и деньги магазина — скидка по промокоду,
|
|
||||||
доставка, итог; своя случайность, ветвящаяся по дню рождения заказа.
|
|
||||||
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
|
|
||||||
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
|
|
||||||
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
|
|
||||||
единственное место, где событие целиком превращается в JSON. Порядок ключей,
|
|
||||||
все 47 колонок всегда и форма на проводе — ISO-8601.
|
|
||||||
- `src/clickstream_generator/sinks.py` — приёмники: файл (одно событие — одна
|
|
||||||
строка) и Kafka (одно событие — одно сообщение). Про содержимое они не
|
|
||||||
знают; там же довод, почему у сообщения нет ключа.
|
|
||||||
- `src/clickstream_generator/player.py` — проигрыватель: гонит дни в приёмник
|
|
||||||
пачкой или с темпом живого дня. Состояния не хранит.
|
|
||||||
- `src/clickstream_generator/cli.py` — интерфейс запуска: параметры
|
|
||||||
аргументами или окружением, логи в стандартный вывод, итог кодом возврата.
|
|
||||||
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
|
||||||
колонках выгрузки. Собственность генератора; из него выводятся сам
|
|
||||||
генератор, его валидация и описание выгрузки в доках.
|
|
||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
|
||||||
- `src/clickstream_generator/inventory.py` — сборка паспорта мира
|
|
||||||
([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
|
|
||||||
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
|
|
||||||
расхождений по заказам для дней с закрытым окном. Руками не правят —
|
|
||||||
пересобирают целью `make inventory`.
|
|
||||||
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
|
||||||
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
|
||||||
и сборка визитов по задокументированным правилам. Там же побайтовое
|
|
||||||
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
|
|
||||||
строк в нём ровно столько, сколько событий.
|
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
Проиграть день — раздел [«Как запустить
|
Для запуска из каталога `generator/` используйте
|
||||||
генератор»](../docs/architecture/world.md#как-запустить-генератор)
|
`uv run python -m clickstream_generator <команда>`. Справку можно открыть
|
||||||
справочника мира и `--help`.
|
через `--help`, в том числе после имени команды.
|
||||||
|
|
||||||
Из этого каталога — цели генератора живут здесь, а не в корне:
|
| Команда | Что отправляет | Особые параметры |
|
||||||
|
|---|---|---|
|
||||||
|
| `batch` | События дня без пауз | `--limit N` ограничивает число событий за весь запуск |
|
||||||
|
| `live` | События постепенно, с ускорением | `--speed X`, по умолчанию 60: сутки занимают около 24 минут |
|
||||||
|
| `snapshot` | Слепок заказов предыдущего дня | `--day D` задает день отправки |
|
||||||
|
|
||||||
|
Для всех команд нужны номер дня `--day` и приемник: `--file` либо
|
||||||
|
`--brokers` вместе с `--topic`. Общие параметры `--seed` и `--days`
|
||||||
|
задают зерно и число последовательных дней. Параметры можно передать
|
||||||
|
и через окружение; имена и примеры собраны в
|
||||||
|
[справочнике запуска](../docs/architecture/world.md#как-запустить-генератор).
|
||||||
|
Прямой запуск генератора не продвигает сохраненную позицию времени стенда —
|
||||||
|
ей управляют [даги модельного времени](../docs/handbook/airflow-dags.md#даги-мира).
|
||||||
|
|
||||||
|
Из каталога `generator/` доступны:
|
||||||
|
|
||||||
- `make test` — тесты генератора;
|
- `make test` — тесты генератора;
|
||||||
- `make lint` — ruff: проверка и формат;
|
- `make lint` — проверка кода и форматирования через ruff;
|
||||||
- `make typecheck` — ty: проверка типов;
|
- `make typecheck` — проверка типов через ty;
|
||||||
- `make demo` — пройти стадии канонического D0 на одном экране;
|
- `make demo` — показать стадии расчета первого дня, D0;
|
||||||
- `make docs` — пересобрать описание выгрузки;
|
- `make docs` — пересобрать описание выгрузки;
|
||||||
- `make inventory` — пересобрать паспорт мира.
|
- `make inventory` — пересобрать паспорт мира.
|
||||||
|
|
||||||
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
В корне `make lint` проверяет даги, код Superset и сборку карты архитектуры.
|
||||||
Superset. Одноимённые цели за разными дверями охватывают разное:
|
Корневые `make generate-batch` и `make generate-live` запускают события
|
||||||
[карта проверок](../docs/architecture/testing.md).
|
через контейнер генератора. Состав проверок — в
|
||||||
|
[карте проверок](../docs/architecture/testing.md).
|
||||||
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
|
Python и зависимости запускаются через `uv`; версии закреплены в `uv.lock`.
|
||||||
держится обещание побайтовой воспроизводимости (спека, раздел 2).
|
|
||||||
+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. Работа у версии ровно
|
-- Партиция EventDate хранит модельный день события. CounterID на стенде
|
||||||
-- одна: схлопнуть повтор доставки. Метка не ставится здесь заново, а
|
-- постоянен, EventDate постоянна внутри партиции; дальнейшая сортировка —
|
||||||
-- переносится из stg.hits_raw как есть — колонка отвечает на вопрос «когда
|
-- по intHash32(ClientID) и WatchID. WatchID отличает события посетителя:
|
||||||
-- строка приехала в хранилище», а не «когда её разобрали». У повтора
|
-- без него они заменяли бы друг друга.
|
||||||
-- содержимое то же самое, отличается только метка, поэтому какая из двух
|
|
||||||
-- строк переживёт мерж, безразлично.
|
|
||||||
--
|
--
|
||||||
-- Колонка версии не входит ни в ключ партиции, ни в ключ сортировки, и это
|
-- SAMPLE BY использует то же выражение, что ключ сортировки, и отбирает
|
||||||
-- не случайность: попади она туда — версии одной строки лягут в разные куски
|
-- посетителей целиком. Sign всегда равен 1: поле сохранено по формату
|
||||||
-- или в разные места ключа и не встретятся при мерже, то есть дедупликация
|
-- источника, но исправлений парой −1/+1 генератор не отправляет.
|
||||||
-- перестанет работать молча.
|
|
||||||
--
|
|
||||||
-- 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; наш генератор исправлений не шлёт, поэтому колонка
|
|
||||||
-- есть, а механики за ней нет.
|
|
||||||
CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
|
CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
|
||||||
(
|
(
|
||||||
WatchID UInt64,
|
WatchID UInt64,
|
||||||
@@ -100,36 +71,24 @@ PARTITION BY EventDate
|
|||||||
ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)
|
ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)
|
||||||
SAMPLE BY intHash32(ClientID);
|
SAMPLE BY intHash32(ClientID);
|
||||||
|
|
||||||
-- Лицо слоя: пишем и читаем через него. Ключ шардирования —
|
-- Запись и диагностика через Distributed; актуальные события — в ods.event_v.
|
||||||
-- cityHash64(ClientID), а не сырой ClientID: структурированный числовой
|
-- cityHash64(ClientID) распределяет посетителей равномернее, чем остаток
|
||||||
-- идентификатор перекашивает остаток по модулю числа шардов, хеш — нет.
|
-- самого структурированного идентификатора. События посетителя лежат вместе.
|
||||||
-- События одной куки при этом остаются на одном шарде, и сессионизация со
|
-- STG распределен по хешу raw, поэтому сообщение и разобранное событие могут
|
||||||
-- склейкой идентичностей ниже по течению живут локально.
|
-- лежать на разных шардах. Общие счетчики сравнивают через _dist.
|
||||||
--
|
|
||||||
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
|
||||||
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
|
|
||||||
-- сходиться не должны — сверять слои можно только через _dist.
|
|
||||||
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
|
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
|
||||||
AS ods.event_rep
|
AS ods.event_rep
|
||||||
ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID));
|
ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID));
|
||||||
|
|
||||||
-- Локальная таблица ошибок разбора.
|
-- Ошибки разбора: исходный текст, класс и сведения о доставке.
|
||||||
|
-- У сообщения может не быть разобранного ClientID, поэтому шардирование —
|
||||||
|
-- по cityHash64(raw), партиция — по реальному дню загрузки.
|
||||||
|
-- ORDER BY группирует ошибки по классу и координатам Kafka.
|
||||||
--
|
--
|
||||||
-- Сырой текст сообщения, метаданные доставки и класс брака. Разобранных полей
|
-- Замены версий нет: надежного ключа события у брака может не быть.
|
||||||
-- у брака нет по определению: строка сюда попала как раз потому, что не
|
-- TTL сохраняет ошибки месяц, дольше трехсуточного срока STG.
|
||||||
-- разобралась.
|
|
||||||
--
|
|
||||||
-- Движок — обычный ReplicatedMergeTree, без замены версий: у брака нет ключа
|
|
||||||
-- сущности, схлопывать его не по чему.
|
|
||||||
--
|
|
||||||
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
|
|
||||||
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
|
|
||||||
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
|
|
||||||
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
|
|
||||||
-- у брака тоже нет.
|
|
||||||
--
|
|
||||||
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
|
|
||||||
-- к моменту разбирательства не осталось бы ни того, ни другого.
|
|
||||||
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
||||||
(
|
(
|
||||||
raw String,
|
raw String,
|
||||||
|
|||||||
+26
-84
@@ -1,8 +1,6 @@
|
|||||||
-- ODS: каноническое чтение актуальных событий.
|
-- Актуальные события: FINAL выбирает последнюю доставку по _load_ts,
|
||||||
--
|
-- не дожидаясь фоновых слияний. Поля и типы остаются как в ods.event_dist.
|
||||||
-- Представление сохраняет поля и зерно ods.event_dist, но скрывает FINAL от
|
|
||||||
-- потребителя. Поэтому физическая таблица остаётся местом диагностики
|
|
||||||
-- доставленных версий, а точное чтение получает одно имя.
|
|
||||||
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
|
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
|
||||||
AS
|
AS
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -25,69 +23,24 @@ AS
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM ods.order_dist FINAL;
|
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. Трёхзначная логика дала бы
|
-- UTCEventTime приходит строкой вида "2026-06-01T12:34:56Z". Ее разбирает
|
||||||
-- строку, которую не берёт ни «условие», ни «NOT условие», — и потерялась бы
|
-- parseDateTimeOrNull с явной маской: JSONExtract в DateTime такую форму
|
||||||
-- она бесшумно. Поэтому сравнения здесь дают 0 или 1, а разбор в Nullable
|
-- не принимает, а BestEffort допускает неполные даты. Пояс UTC указан
|
||||||
-- заканчивается IS NOT NULL.
|
-- отдельно, потому что Z в маске проверяется как буква. EventDate приходит
|
||||||
--
|
-- как "2026-06-01" и разбирается через JSONExtract.
|
||||||
-- Второй: ни одна его часть не бросает исключений. Упавшая матвью роняет
|
|
||||||
-- вставку, офсеты не коммитятся, и потребление топика встаёт до починки —
|
|
||||||
-- это единственное, на чём сейчас держится правило репозитория «грязные
|
|
||||||
-- записи не валят пайплайн». Поэтому приведение 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
|
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
|
||||||
TO ods.event_dist
|
TO ods.event_dist
|
||||||
@@ -154,10 +107,8 @@ SELECT
|
|||||||
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
|
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
|
||||||
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
|
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
|
||||||
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
|
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
|
||||||
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
|
-- purchaseRevenue приходит числом рублей и разбирается сразу в Decimal.
|
||||||
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
|
-- Прямой разбор сверяли через Context7 и на ClickHouse 26.3 (#155).
|
||||||
-- синтаксис подтвержден документацией ClickHouse через Context7 и
|
|
||||||
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
|
|
||||||
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
|
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
|
||||||
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
|
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
|
||||||
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
|
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
|
||||||
@@ -168,25 +119,16 @@ SELECT
|
|||||||
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
||||||
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
||||||
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
||||||
-- Метка загрузки переносится из STG как есть, а не ставится заново:
|
-- _load_ts остается временем исходной доставки и версией события.
|
||||||
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
|
||||||
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
|
||||||
_load_ts
|
_load_ts
|
||||||
FROM stg.hits_raw_dist
|
FROM stg.hits_raw_dist
|
||||||
WHERE is_object AND keys_match AND key_fields_parsed;
|
WHERE is_object AND keys_match AND key_fields_parsed;
|
||||||
|
|
||||||
-- Брак: буквальное отрицание того же предиката.
|
-- Ошибки: отрицание того же условия годности.
|
||||||
--
|
-- Класс определяется первым нарушенным условием. Например, JSON-строка
|
||||||
-- Классы брака пересекаются, поэтому проверяются по порядку, а в error_class
|
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
|
||||||
-- пишется первый совпавший. Скаляр проваливает и проверку на объект, и сверку
|
-- Условие повторено рядом с запросом, без отдельной функции ClickHouse.
|
||||||
-- ключей — JSONExtractKeys от него даёт пустой массив (измерено на стенде
|
|
||||||
-- 7 августа 2026 года), — и без объявленного порядка попал бы то в один
|
|
||||||
-- класс, то в другой.
|
|
||||||
--
|
|
||||||
-- Предикат повторён здесь дословно, и это выбор, а не безвыходность: назвать
|
|
||||||
-- его один раз на две матвью позволил бы CREATE FUNCTION. Отвергнуто — условие
|
|
||||||
-- разбора ушло бы за имя, в отдельный объект со своей жизнью, и слой перестал
|
|
||||||
-- бы читаться по своему же DDL.
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
|
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
|
||||||
TO ods.event_errors_dist
|
TO ods.event_errors_dist
|
||||||
AS
|
AS
|
||||||
|
|||||||
+11
-21
@@ -1,26 +1,16 @@
|
|||||||
-- STG: матвью приёма — из чтеца в таблицу STG.
|
-- Прием событий из Kafka в STG.
|
||||||
|
-- Подключение этого представления запускает чтение топика. Поэтому файл 40
|
||||||
|
-- применяется после таблиц и представлений разбора из файлов 20 и 30:
|
||||||
|
-- иначе часть сообщений сохранится в STG, но не попадет в ODS.
|
||||||
--
|
--
|
||||||
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
|
-- Запись через stg.hits_raw_dist распределяет сообщения по ключу таблицы.
|
||||||
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
|
-- Она фоновая; возможную потерю при сбое описывает раздел «Приём»
|
||||||
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
|
-- в docs/architecture/storage.md.
|
||||||
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
|
|
||||||
-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
|
|
||||||
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
|
|
||||||
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
|
|
||||||
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
|
|
||||||
--
|
--
|
||||||
-- Пишем в stg.hits_raw_dist, а не в локальную таблицу: раскладку по шардам
|
-- Сведения о доставке вычисляются здесь, на ноде чтения Kafka.
|
||||||
-- обязан определять ключ шардирования, а не то, какая нода случайно читала
|
-- DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.
|
||||||
-- топик. Вставка при этом фоновая — окно потери принято осознанно, довод
|
-- Порядок SELECT совпадает с порядком колонок цели.
|
||||||
-- целиком в docs/architecture/storage.md, раздел «Приём».
|
|
||||||
--
|
|
||||||
-- Служебные колонки заполняются выражением здесь, а не DEFAULT в таблице.
|
|
||||||
-- Для consumer_host это обязательно: проверено на стенде 5 августа 2026 года —
|
|
||||||
-- DEFAULT hostName() вычисляется на шарде-получателе и назвал бы не ту ноду,
|
|
||||||
-- которая читала топик. hostName() в SELECT снимается на вставляющей ноде,
|
|
||||||
-- то есть отвечает ровно на нужный вопрос.
|
|
||||||
--
|
|
||||||
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster
|
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster
|
||||||
TO stg.hits_raw_dist
|
TO stg.hits_raw_dist
|
||||||
AS
|
AS
|
||||||
|
|||||||
+19
-42
@@ -1,21 +1,9 @@
|
|||||||
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций.
|
-- Сборка выбранных дней в донор перед заменой партиций DDS.
|
||||||
|
-- Донор очищается в начале: иначе новые строки добавились бы к прежним.
|
||||||
|
-- После сборки он остается для диагностики до следующего запуска.
|
||||||
--
|
--
|
||||||
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся
|
-- DROP PARTITION ALL доступен роли etl через ALTER; отдельного права
|
||||||
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем
|
-- TRUNCATE у нее нет (ADR 0007). Весь отрезок дней собирается одной вставкой.
|
||||||
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
|
|
||||||
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
|
|
||||||
-- удвоение в цель.
|
|
||||||
--
|
|
||||||
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
|
|
||||||
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
|
|
||||||
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
|
|
||||||
--
|
|
||||||
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE: последнее — отдельное
|
|
||||||
-- право, роли etl оно не выдано, а снятие всех партиций
|
|
||||||
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
|
|
||||||
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
|
|
||||||
-- операции, для которой уже есть разрешённая форма, значило бы платить
|
|
||||||
-- доступом за синоним.
|
|
||||||
|
|
||||||
ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
|
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_id,
|
||||||
_load_ts
|
_load_ts
|
||||||
)
|
)
|
||||||
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе —
|
-- Разбор позиций из JSON: цена в строке сразу превращается в Decimal.
|
||||||
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на
|
-- ODS проверил только тип массива items. Неверное содержимое здесь может
|
||||||
-- живом слепке, промежуточный toDecimal64 не нужен.
|
-- дать значения по умолчанию: qty вида "oops" и пропущенная цена дают 0,
|
||||||
--
|
-- скаляр вместо объекта выпадает из массива. Наблюдения —
|
||||||
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS
|
-- docs/architecture/storage.md, «Что проверено».
|
||||||
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто
|
-- Проверку суммы позиций участник добавляет в docs/labs/5-1-order-checks.md.
|
||||||
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
|
|
||||||
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
|
|
||||||
-- генератора такого не бывает, и сумма «цена × количество» сходится с
|
|
||||||
-- items_total у всех заказов, но сходится она по свойству источника, а не
|
|
||||||
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
|
|
||||||
-- контур DQ этапа 4; пока она делается руками при приёмке.
|
|
||||||
WITH JSONExtract(
|
WITH JSONExtract(
|
||||||
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
|
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
|
||||||
) AS lines
|
) AS lines
|
||||||
@@ -56,8 +39,7 @@ SELECT
|
|||||||
order_id,
|
order_id,
|
||||||
user_id,
|
user_id,
|
||||||
status,
|
status,
|
||||||
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает
|
-- День и время в Europe/Samara: витрины используют готовые колонки.
|
||||||
-- человека с дашбордом, и пояс называется здесь в последний раз.
|
|
||||||
toDate(toTimeZone(created_at, 'Europe/Samara')),
|
toDate(toTimeZone(created_at, 'Europe/Samara')),
|
||||||
toTimeZone(created_at, 'Europe/Samara'),
|
toTimeZone(created_at, 'Europe/Samara'),
|
||||||
toTimeZone(updated_at, 'Europe/Samara'),
|
toTimeZone(updated_at, 'Europe/Samara'),
|
||||||
@@ -68,22 +50,17 @@ SELECT
|
|||||||
arrayMap(line -> line.sku, lines),
|
arrayMap(line -> line.sku, lines),
|
||||||
arrayMap(line -> line.qty, lines),
|
arrayMap(line -> line.qty, lines),
|
||||||
arrayMap(line -> line.price, lines),
|
arrayMap(line -> line.price, lines),
|
||||||
-- Координаты запуска, собравшего день: у всех строк дня они одни.
|
-- Метки этой сборки DDS, общие для всех вставляемых строк.
|
||||||
{load_id:String},
|
{load_id:String},
|
||||||
now64(3, 'UTC')
|
now64(3, 'UTC')
|
||||||
FROM ods.order_v
|
FROM ods.order_v
|
||||||
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
|
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
|
||||||
BETWEEN {first_day:Date} AND {last_day:Date}
|
BETWEEN {first_day:Date} AND {last_day:Date}
|
||||||
-- Вторая граница не меняет отбор, а открывает источнику отсечение
|
-- Дополнительный фильтр сокращает чтение партиций ODS по UTC.
|
||||||
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на
|
-- Модельные сутки Europe/Samara пересекают две даты UTC, поэтому
|
||||||
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы
|
-- левая граница берется на день раньше. Точный отбор — условием выше.
|
||||||
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
|
|
||||||
-- поэтому день слева берётся с запасом.
|
|
||||||
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
|
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
|
||||||
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку
|
-- Ждем доставки на шарды и распределяем строки по ключу цели
|
||||||
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали
|
-- (docs/architecture/storage.md, «Раскладка по шардам»).
|
||||||
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
|
|
||||||
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
|
|
||||||
-- «Раскладка по шардам», капкан #137).
|
|
||||||
SETTINGS distributed_foreground_insert = 1,
|
SETTINGS distributed_foreground_insert = 1,
|
||||||
parallel_distributed_insert_select = 0;
|
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 рассылает её
|
-- День подставляет Jinja: параметры ClickHouse в ALTER ... PARTITION
|
||||||
-- на обе ноды, а по Distributed то же самое не работает — операции с
|
-- не поддерживаются в закрепленной версии 26.3. Значение получено
|
||||||
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что
|
-- из результата order_scope.sql; замер — docs/architecture/storage.md.
|
||||||
-- проверено»). Правильность пошардовой замены держится на том, что двойник
|
|
||||||
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
|
|
||||||
--
|
|
||||||
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
|
|
||||||
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
|
|
||||||
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
|
|
||||||
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
|
|
||||||
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
|
|
||||||
--
|
|
||||||
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
|
|
||||||
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
|
|
||||||
-- источнике больше нет.
|
|
||||||
--
|
|
||||||
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
|
|
||||||
-- сервер параметров не подставляет — замерено, отвечает синтаксической
|
|
||||||
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
|
|
||||||
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
|
|
||||||
-- ClickHouse на запрос объёма, а не снаружи стенда.
|
|
||||||
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
||||||
REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep;
|
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 одинаков у всех версий,
|
||||||
-- источник даёт пустой ответ, и даг на нём останавливается с внятной ошибкой
|
-- а строки последнего слепка сохраняются при слияниях.
|
||||||
-- вместо зелёного прогона, собравшего ничего.
|
|
||||||
--
|
--
|
||||||
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним
|
-- Пустой ODS не дает строки результата; даг завершится ошибкой.
|
||||||
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
|
-- Точка с запятой в конце не нужна: клиент дописывает FORMAT ответа.
|
||||||
-- мультизапрос, который ClickHouse не принимает.
|
-- Порядок пересборки — docs/architecture/etl.md, «Объём прогона».
|
||||||
--
|
|
||||||
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
|
|
||||||
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
|
|
||||||
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
|
|
||||||
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
|
|
||||||
-- догоняет уехавший вперёд источник.
|
|
||||||
--
|
|
||||||
-- **Хвост считается от первого недостающего дня, а не от последнего
|
|
||||||
-- собранного.** Разница не косметическая: замены партиций идут отдельными
|
|
||||||
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
|
|
||||||
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
|
|
||||||
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
|
|
||||||
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
|
|
||||||
-- в ODS.
|
|
||||||
--
|
|
||||||
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
|
|
||||||
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
|
|
||||||
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
|
|
||||||
-- копии его константы на стороне стенда больше нет.
|
|
||||||
--
|
|
||||||
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
|
|
||||||
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
|
|
||||||
-- оставляет как раз их.
|
|
||||||
WITH
|
WITH
|
||||||
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||||
(
|
(
|
||||||
@@ -44,10 +24,9 @@ WITH
|
|||||||
FROM ods.order_dist
|
FROM ods.order_dist
|
||||||
WHERE snapshot_date = last_snapshot
|
WHERE snapshot_date = last_snapshot
|
||||||
) AS window_end,
|
) AS window_end,
|
||||||
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
-- GLOBAL NOT IN сравнивает дни со всем DDS. Локальное сравнение могло бы
|
||||||
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
|
-- счесть день пропущенным, если его заказы находятся на соседнем шарде.
|
||||||
-- заказы легли на соседний шард, показался бы недостающим.
|
-- minOrNull возвращает NULL, когда пропущенных дней нет.
|
||||||
-- minOrNull отличает «дыр нет» от даты по умолчанию.
|
|
||||||
(
|
(
|
||||||
SELECT minOrNull(day)
|
SELECT minOrNull(day)
|
||||||
FROM (
|
FROM (
|
||||||
@@ -57,7 +36,7 @@ WITH
|
|||||||
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
||||||
) AS first_missing_day
|
) AS first_missing_day
|
||||||
SELECT
|
SELECT
|
||||||
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
|
-- Начало окна или первый пропущенный день, если он раньше.
|
||||||
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
||||||
window_end AS last_day
|
window_end AS last_day
|
||||||
WHERE (SELECT count() FROM ods.order_dist) > 0
|
WHERE (SELECT count() FROM ods.order_dist) > 0
|
||||||
+27
-46
@@ -1,24 +1,15 @@
|
|||||||
-- Разбор одного среза заказов: сначала годные версии, затем брак.
|
-- Разбор сохраненной порции заказов: сначала годные версии, затем ошибки.
|
||||||
|
-- Макрос Jinja подставляет одно условие годности в обе вставки.
|
||||||
|
-- Вторая вставка берет его отрицание, чтобы каждая строка получила результат.
|
||||||
|
-- Условие должно давать только 0 или 1 и не вызывать исключений на неверном raw.
|
||||||
--
|
--
|
||||||
-- Обе вставки должны делить один предикат. Локальный макрос Jinja
|
-- Проверяются одиннадцать полей верхнего уровня. Внутри items поля не проверяются;
|
||||||
-- разворачивает его в каждое выражение до того, как оператор разделит файл по
|
-- позиции разбирает DDS, а равенства сумм проверяются отдельно.
|
||||||
-- точкам с запятой. Так контракт остаётся рядом с запросами и не становится
|
-- Точные правила — docs/architecture/orders/ingestion.md, «Граница строгого приёма».
|
||||||
-- третьим SQL-файлом, который нельзя исполнить самостоятельно.
|
|
||||||
--
|
|
||||||
-- Строгий приём проверяет форму провода и ничего сверх неё. Ключей одиннадцать,
|
|
||||||
-- и проверяются все: десять скалярных значений прямо образуют типизированную
|
|
||||||
-- строку заказа. Внутрь items приём не смотрит — содержимое позиций, переходы
|
|
||||||
-- статуса и равенства сумм остаются ниже этой границы
|
|
||||||
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
|
|
||||||
--
|
|
||||||
-- У общего предиката два обязательных свойства. Он всегда даёт 0 или 1, не
|
|
||||||
-- NULL: иначе строку не возьмут ни условие, ни его отрицание. И он не бросает
|
|
||||||
-- исключений на произвольном raw: одна грязная строка не должна ронять весь
|
|
||||||
-- переход, ради этого и существует таблица брака.
|
|
||||||
|
|
||||||
{% macro order_wire_contract() %}
|
{% macro order_wire_contract() %}
|
||||||
WITH
|
WITH
|
||||||
-- Каноническое время провода: UTC, ровно три знака долей секунды.
|
-- Время источника: UTC, ровно три знака долей секунды.
|
||||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
|
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
|
||||||
JSONType(raw) = 'Object' AS is_object,
|
JSONType(raw) = 'Object' AS is_object,
|
||||||
arraySort(JSONExtractKeys(raw)) = arraySort([
|
arraySort(JSONExtractKeys(raw)) = arraySort([
|
||||||
@@ -28,15 +19,14 @@ WITH
|
|||||||
]) AS keys_match,
|
]) AS keys_match,
|
||||||
JSONType(raw, 'order_id') = 'String'
|
JSONType(raw, 'order_id') = 'String'
|
||||||
AND JSONType(raw, 'status') = 'String'
|
AND JSONType(raw, 'status') = 'String'
|
||||||
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
|
-- JSONType различает Int64 и UInt64; принимаются оба, если значение
|
||||||
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
|
-- разбирается в UInt64, в том числе идентификаторы больше 2^63−1.
|
||||||
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
|
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
|
||||||
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
|
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
|
||||||
AND JSONType(raw, 'items') = 'Array'
|
AND JSONType(raw, 'items') = 'Array'
|
||||||
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
|
-- Регулярное выражение проверяет точную форму, включая ведущие нули.
|
||||||
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
|
-- Разбор проверяет само время: например, отвергает 30 февраля.
|
||||||
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
|
-- Замеры функций — docs/architecture/storage.md, «Что проверено».
|
||||||
-- «Что проверено».
|
|
||||||
AND arrayAll(k ->
|
AND arrayAll(k ->
|
||||||
JSONType(raw, k) = 'String'
|
JSONType(raw, k) = 'String'
|
||||||
AND match(JSONExtractString(raw, k),
|
AND match(JSONExtractString(raw, k),
|
||||||
@@ -44,9 +34,8 @@ WITH
|
|||||||
AND parseDateTime64InJodaSyntaxOrNull(
|
AND parseDateTime64InJodaSyntaxOrNull(
|
||||||
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
|
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
|
||||||
['created_at', 'updated_at'])
|
['created_at', 'updated_at'])
|
||||||
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
|
-- Сумма в рублях: строка с двумя знаками после точки, без минуса.
|
||||||
-- форму, разбор — вместимость: у строки из двадцати девяток форма
|
-- После проверки формы проверяется возможность разбора в Decimal.
|
||||||
-- хороша, а в Decimal(18, 2) она не влезает.
|
|
||||||
AND arrayAll(k ->
|
AND arrayAll(k ->
|
||||||
JSONType(raw, k) = 'String'
|
JSONType(raw, k) = 'String'
|
||||||
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
|
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
|
is_object AND keys_match AND fields_valid AS row_is_valid
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
|
-- Годные строки сохраненного среза по _load_id запуска приема.
|
||||||
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
|
-- WHERE исключает строки с NULL в разобранных полях; assumeNotNull
|
||||||
--
|
-- приводит оставшиеся значения к типам целевой таблицы.
|
||||||
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
|
|
||||||
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
|
|
||||||
|
|
||||||
INSERT INTO ods.order_dist
|
INSERT INTO ods.order_dist
|
||||||
{{ order_wire_contract() }}
|
{{ order_wire_contract() }}
|
||||||
@@ -81,30 +68,25 @@ SELECT
|
|||||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
|
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
|
||||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
|
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
|
||||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
|
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
|
||||||
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
|
-- items сохраняется строкой JSON для разбора позиций в DDS.
|
||||||
JSONExtractRaw(raw, 'items') AS items,
|
JSONExtractRaw(raw, 'items') AS items,
|
||||||
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
|
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
|
||||||
JSONExtractString(raw, 'snapshot_date'),
|
JSONExtractString(raw, 'snapshot_date'),
|
||||||
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
|
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
|
||||||
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
|
-- _load_ts остается временем приема в STG; _load_id связывает с исходной порцией.
|
||||||
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
|
|
||||||
_load_id,
|
_load_id,
|
||||||
_load_ts
|
_load_ts
|
||||||
FROM stg.orders_raw_dist
|
FROM stg.orders_raw_dist
|
||||||
WHERE _load_id = {load_id:String} AND row_is_valid
|
WHERE _load_id = {load_id:String} AND row_is_valid
|
||||||
-- Заводская двойка parallel_distributed_insert_select исполняет вставку из
|
-- Ноль заставляет распределить результат по ключу цели. При значении 2
|
||||||
-- Distributed в Distributed локально на каждом шарде, минуя ключ шардирования
|
-- вставка может сохранить раскладку STG, где ключ — raw, вместо order_id.
|
||||||
-- цели, — так заказы и расползлись по обоим шардам. Ноль отдаёт раскладку
|
-- Подробнее — docs/architecture/storage.md, «Раскладка по шардам».
|
||||||
-- инициатору, по ключу цели. Капкан целиком — docs/architecture/storage.md,
|
|
||||||
-- «Раскладка по шардам».
|
|
||||||
SETTINGS distributed_foreground_insert = 1,
|
SETTINGS distributed_foreground_insert = 1,
|
||||||
parallel_distributed_insert_select = 0;
|
parallel_distributed_insert_select = 0;
|
||||||
|
|
||||||
-- Брак: тот же срез и буквальное отрицание того же предиката.
|
-- Ошибки: тот же срез и отрицание условия годности.
|
||||||
--
|
-- Класс определяется первым нарушенным условием: например, JSON-строка
|
||||||
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
|
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
|
||||||
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
|
|
||||||
-- без объявленного порядка он попал бы то в один класс, то в другой.
|
|
||||||
|
|
||||||
INSERT INTO ods.order_errors_dist
|
INSERT INTO ods.order_errors_dist
|
||||||
{{ order_wire_contract() }}
|
{{ order_wire_contract() }}
|
||||||
@@ -124,7 +106,6 @@ SELECT
|
|||||||
_load_ts
|
_load_ts
|
||||||
FROM stg.orders_raw_dist
|
FROM stg.orders_raw_dist
|
||||||
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
||||||
-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная
|
-- Та же настройка: распределяем строки по ключу целевой таблицы ошибок.
|
||||||
-- запись легла бы верно — но по совпадению, а не по контракту.
|
|
||||||
SETTINGS distributed_foreground_insert = 1,
|
SETTINGS distributed_foreground_insert = 1,
|
||||||
parallel_distributed_insert_select = 0;
|
parallel_distributed_insert_select = 0;
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
-- Забор порции слепка заказов из Kafka в STG.
|
-- Порция сообщений заказов из Kafka в STG, без разбора JSON.
|
||||||
|
-- Один SELECT читает одну порцию; непрочитанное остается до следующего запуска.
|
||||||
|
-- Полнота слепка не проверяется. Если после чтения вставка упадет, подтвержденные
|
||||||
|
-- сообщения уже не перечитаются (docs/architecture/orders/ingestion.md).
|
||||||
--
|
--
|
||||||
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse
|
-- stream_like_engine_allow_direct_select разрешает прямое чтение Kafka;
|
||||||
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.
|
-- kafka_commit_on_select включен в sql/ddl/10-stg-tables.sql.
|
||||||
-- Короткая порция оставит хвост до следующего прогона, а отказ после чтения
|
-- distributed_foreground_insert = 1 ждет записи на шарды до завершения задачи.
|
||||||
-- унесёт прочитанное с собой: офсеты коммитятся в момент чтения. Граница
|
|
||||||
-- целиком — ADR 0008, «Следствия».
|
|
||||||
--
|
|
||||||
-- stream_like_engine_allow_direct_select разрешает читать чтеца запросом; вторая
|
|
||||||
-- половина пары объявлена на самой таблице (sql/ddl/10-stg-tables.sql).
|
|
||||||
-- distributed_foreground_insert = 1 — конвенция ETL-вставок стенда: задача не
|
|
||||||
-- должна зеленеть раньше, чем строки легли на шарды.
|
|
||||||
|
|
||||||
INSERT INTO stg.orders_raw_dist
|
INSERT INTO stg.orders_raw_dist
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
Reference in new issue
Block a user