Author SHA1 Message Date
ddadmin 431fed72ad docs(ingestion): влита редактура приема данных (#250)
Зачем: включить принятую редактуру материалов ступеней 5–6 в main.
Что: объединена ветка 250-ingestion-docs после просмотра владельцем.
Проверка: make lint, ссылки и неизменность исполняемого кода проверены
до приемки; git diff --check прошел перед коммитом.

Closes #250
2026-09-13 16:32:57 -04:00
ddadmin 75f28fe99f docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

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

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

Refs #250
2026-09-13 16:32:45 -04:00
ddmitry 83434102d5 docs(warehouse): упрощены объяснения витрин, сессий и пересчета (#253) 2026-09-13 22:22:40 +03:00
ddadmin 086007fbd4 docs(warehouse): объяснены расчет строк и замена дневных разделов
Зачем: выражения «собираются в донор» и «публикуются дни» требовали
знать устройство конвейера до чтения объяснения.

Что: описаны назначение промежуточных таблиц и последовательность
расчета, записи и замены разделов. Перечитаны остальные объяснения:
выбор дней, разворачивание позиций заказа, проверка сессий и повтор запуска.

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

Refs: #249
2026-09-13 15:16:26 -04:00
ddadmin 5f317ba8d2 docs(warehouse): упрощены объяснения витрин, сессий и пересчета
Зачем: читателю ступеней 1–4 нужно проследить выбор дней, расчет строки
и публикацию результата без расшифровки метафор.

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

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

Refs: #249
2026-09-13 14:33:39 -04:00
ddmitry 84dc718f62 docs(context): влита редактура словаря (#252) 2026-09-13 21:00:12 +03:00
ddadmin 5a3e3f92d9 docs(context): упрощен словарь понятий для начинающего
Зачем: понимать термины стенда без изучения устройства генератора.
Что: понятия сгруппированы по учебным темам, определения упрощены;
уточнены идентификаторы, модельное время и повторный запуск стенда.
Проверка: сохранены все 43 прежних термина; проверены 11 входящих
и 6 внутренних ссылок, якорь «Язык» и git diff --check.
2026-09-13 13:59:37 -04:00
ddadmin f3373f1e8f docs(map): упрощен язык интерактивной карты хранилища
Зачем: помочь учащемуся понять назначение объектов и обработку данных по стрелкам карты.

Что: вычитаны карточки и подписи, пояснены термины, удалены повторы. Описания обработки уточнены по SQL, HTML пересобран.

Проверка: сохранены 17 объектов и 17 стрелок, проверены 85 ссылок. В Chromium проверены карточки, фильтры, подсветка, Escape и отображение при ширине 1920 и 1280 пикселей. make lint и git diff --cached --check прошли.
2026-09-13 13:20:58 -04:00
ddadmin 915e976fc2 docs(handbook): упрощен язык путеводителя по стенду
Зачем: помочь учащемуся понять путь данных и работу служб перед выполнением заданий.

Что: вычитаны оглавление и пять глав, пояснены термины, убраны внутренние метафоры и повторы. Уточнены пояснения к SQL и показателям мониторинга.

Проверка: 62 локальные ссылки и 6 входящих ссылок на разделы проверены; 8 блоков SQL и схем сохранены. git diff --cached --check прошел.
2026-09-13 13:02:40 -04:00
ddadmin ce15836093 docs(readme): упрощен язык вводной инструкции
Зачем: помочь учащемуся понять назначение стенда и порядок работы с ним.

Что: убраны внутренние метафоры, пояснены термины, настройка перенесена перед запуском. Обновлена ссылка из путеводителя и удалена ссылка на предыдущий стенд.

Проверка: команды, адреса и пароли сохранены; 24 ссылки README и входящие ссылки на разделы проверены. git diff --cached --check прошел.
2026-09-13 12:47:16 -04:00
ddadmin 8c9c10a875 docs(labs): влита редактура учебных заданий
Зачем: сделать задания понятнее учащемуся и сохранить правила для будущих работ.

Что: объединены три коммита ветки docs/labs-human-language с сохранением истории через merge --no-ff.

Проверка: рабочее дерево чистое перед слиянием, origin/main входит в локальную main, итоговый diff прошел git diff --check. Ссылки и сохранность технических примеров проверены при редактуре.
2026-09-13 11:30:12 -04:00
ddadmin 1b95ce456c docs(labs): сокращены правила написания заданий
Зачем: оставить только необходимые ориентиры для автора учебного текста.

Что: раздел сокращен до трех согласованных абзацев, ссылки на оба образца сохранены.

Проверка: git diff --check прошел; проверен diff единственного измененного файла.
2026-09-13 11:26:55 -04:00
ddadmin 3bfe9ca4ee docs(labs): уточнены самопроверка и формулировки заданий
Зачем: убрать механические повторы в итогах и избежать ложных расхождений при проверке графика Superset.

Что: уточнены правила итоговых списков и название задания о проверках заказов. В разборе событий разделены учет повторных доставок и выбор событий ODS для сравнения. Первый график Superset и проверочный SQL используют весь период витрины.

Проверка: git diff --check, локальные ссылки и переходы к измененным заголовкам прошли. Блоки кода и таблицы требований сохранены. Согласованность периода графика с SQL проверена по тексту; повторный проход через интерфейс Superset не выполнялся.
2026-09-13 11:20:03 -04:00
ddadmin 4058e18864 docs(labs): упрощен язык учебных заданий
Зачем: помочь менти понять действия, проверить результат и оценить освоенное умение.

Что: вычитаны 13 заданий основной программы и общий индекс. Итоговые списки обращены к учащемуся, убраны история разработки и повторы. Правила учебного текста записаны в программе, из AGENTS.md добавлена ссылка.

Проверка: git diff --check, локальные ссылки и ссылки на измененные заголовки прошли. Блоки кода и таблицы требований совпадают с исходными. Упражнения на стенде повторно не выполнялись: изменен только текст.
2026-09-13 11:01:35 -04:00
ddadmin a03838b953 docs(labs): оформлен факультативный раздел Superset и BI
Зачем: дать менти два самостоятельных задания по BI после ступени 2.

Что: обновлено ТЗ дашборда, добавлен опыт с объектом на одной ноде, уточнены программа и индекс. Формулировки обращены к учащемуся.

Проверка: локальные ссылки и git diff --check прошли. На ClickHouse 26.3.17.56 воспроизведены ошибка чтения со второй ноды и исправление; учебный объект удален. Полный проход через интерфейс Superset не выполнялся.
2026-09-13 10:34:48 -04:00
ddmitry 3ac3911e95 Merge pull request 'feat(superset): подача дашборда «Бизнес-сюжеты»' (#247) from 200-superset-dashboard-polish into main
Reviewed-on: #247
2026-09-08 12:28:57 +03:00
ddadminandClaude Opus 5 b6c10ecf21 feat(superset): подача дашборда «Бизнес-сюжеты»
- Зачем:
  - экран отвечал только графиками: итога за период не было, доли категорий
    читались по высоте столбиков, а известные пользователи лежали на нуле
    рядом с посетителями.
- Что:
  - добавлены ряд крупных чисел (выручка, заказы, средний чек, посетители),
    кольцо долей категорий и линия доли известных пользователей;
  - средний чек и доля известных заведены метриками датасетов, знаменатель
    через `nullIf`: на пустом периоде деление Decimal на ноль в ClickHouse
    роняет панель;
  - фильтр «Период» сужает все панели, включая сверку по `order_day`;
  - `label_colors` закрепляет цвет за категорией и за рядами трафика;
  - деньги без копеек, единица в названии панели; подписи дат читаются как
    даты; сводная сверки развернута классами по строкам и уместилась без
    прокрутки;
  - обновлены справочник Superset и глава путеводителя.
- Проверка:
  - `make lint`;
  - `superset import-directory /app/import --overwrite` на живом стенде, экран
    снят в браузере: девять панелей отрисованы, цвет категории одинаков на
    столбиках и в кольце;
  - период 03.06–06.06 сузил все панели, включая сводную по `order_day`;
  - `SUM(revenue) / nullIf(SUM(orders), 0)` на периоде без заказов возвращает
    NULL, без `nullIf` — исключение 153.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:35:00 -04:00
ddadminandClaude Opus 5 3ad377e02e docs(superset): записаны отклоненные варианты подачи дашборда
- Зачем:
  - карту регионов и кросс-фильтрацию предложат снова, а доводы против них
    стоили двух линий ревью и замеров на живом стенде.
- Что:
  - в docs/architecture/superset.md добавлен раздел «Отклоненные варианты»:
    карта регионов, регион как общее измерение витрин, кросс-фильтрация между
    панелями, пороги на панелях.
  - к каждому отказу записаны проверенные факты и поводы вернуться к разговору.
- Проверка:
  - `git diff --check`.
  - `grep -rn "0018" docs` — ссылок на неоставленный ADR нет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:48:24 -04:00
ddadmin 10c94b206a docs(agents): добавлены требования к читабельности
- Зачем:
  - сохранить ясный и естественный стиль текстов проекта.
- Что:
  - в CLAUDE.md добавлены требования к лёгкости чтения и отказу от манерной прозы.
- Проверка:
  - проверен diff CLAUDE.md.
2026-09-07 16:34:09 -04:00
ddadmin f438e91d80 docs(labs): уточнен запуск первой учебной витрины
- Зачем:
  - дать учащемуся воспроизводимый путь от своего DDL до первой проверки витрины.
- Что:
  - уточнено применение `sql/ddl/70-labs.sql` в работающем стенде через подключение `etl`.
  - добавлены указания запуска `homework_check` и чтения журнала для `events_enriched`.
- Проверка:
  - `git diff --check`.
  - `make config-test` — зелёный.
2026-09-07 09:12:26 -04:00
ddmitry 7b4842d684 Merge pull request 'Факультативы в отдельной папке' (#245) from 244-optional-labs into main
Reviewed-on: #245
2026-09-07 14:54:09 +03:00
ddadmin 8771e2d675 docs(agents): назначение поддержки стенда уточнено
- Зачем:
  - эксплуатационные улучшения не должны становиться самостоятельной целью учебного проекта.
- Что:
  - поддержка стенда ограничена сохранением учебного пути и учебными задачами.
  - зафиксировано отсутствие отдельной эксплуатационной ценности.
- Проверка:
  - git diff --check.
2026-09-07 07:53:02 -04:00
ddadmin 30ddcf470e docs(agents): целевая аудитория стенда уточнена
- Зачем:
  - агентам нужен точный портрет читателя и проверяемый результат обучения.
- Что:
  - описана подготовка начинающих инженеров данных перед работой со стендом.
  - зафиксирована цель: освоить общепринятые приёмы и суметь построить похожее хранилище.
  - добавлена ссылка на программу учебных заданий.
- Проверка:
  - git diff --check.
2026-09-07 07:51:39 -04:00
ddadmin af3f23f9ea docs(dm): изложение выбора витрин переписано
- Зачем:
  - объяснение формы витрин читалось искусственно и использовало неуместное слово «менти».
- Что:
  - выбор таблицы или представления объяснён через конкретные действия в задании.
  - слово «менти» убрано из dm.md и AGENTS.md.
  - описание факультатива и ссылка на раздел DM приведены к новому тексту.
- Проверка:
  - git diff --check.
  - проверено отсутствие слова «менти» в изменённых документах и наличие целей ссылок.
2026-09-07 07:42:40 -04:00
ddadmin f0c7fe0c74 docs(labs): факультативы вынесены в отдельную папку
- Зачем:
  - расположение файла должно отличать задания основной программы от факультативов.
- Что:
  - существующее ТЗ Superset перенесено в `docs/labs/optional/`, ссылки обновлены.
  - правило размещения записано в `AGENTS.md`.
  - выбор формы витрин объяснен учебной целью стенда.
- Проверка:
  - `git diff --check`.
  - проверены все упоминания `superset-dashboard` и относительные ссылки.
2026-09-07 07:31:08 -04:00
ddmitry 1671e67813 Merge pull request 'docs(labs): добавлено задание по мониторингу' (#243) from 242-monitoring-lab into main
Reviewed-on: #243
2026-09-07 12:23:14 +03:00
ddadmin ea56314c25 docs(labs): уточнено расследование стоимости мониторинга
- Зачем:
  - Сохранить самостоятельное открытие источника нагрузки в задании.
- Что:
  - Убрано заранее названное имя пользователя, уточнен поиск запроса панели.
  - Исправлены пунктуация и формулировки по ревью Fable.
- Проверка:
  - Проверены сценарии с запросом панели и другого клиента наверху списка.
  - Проверены относительные ссылки и git diff --check.
2026-09-07 05:16:17 -04:00
ddadmin 3ec4d79576 docs(labs): добавлено задание по мониторингу
- Зачем:
  - Завершить ТЗ основной программы расследованиями нагрузки и свежести данных.
- Что:
  - Добавлено ТЗ ступени 7 с наблюдением запросов Grafana и доставки слепка заказов.
  - В индексе лаб открыта ссылка на последнее задание программы.
- Проверка:
  - Проверены относительные ссылки, git diff --check и соответствие панелям стенда.
2026-09-07 04:54:11 -04:00
ddmitry 9734449eae Merge pull request 'docs(labs): добавлено задание по разбору событий' (#241) from 240-event-parsing-lab into main
Reviewed-on: #241
2026-09-07 10:58:05 +03:00
ddadmin 6e4680d12d docs(labs): уточнено ТЗ разбора событий по ревью
- Зачем:
  - сделать постановку ступени 6 понятнее после редакторского ревью, сохранив её учебный договор и границы.
- Что:
  - оставлена свобода выбрать форму разбора без навязывания Nullable и готовой схемы 0/1.
  - уточнены пакетная обработка, баланс годных строк, модельное и ручное время и убрана лишняя персонификация.
- Проверка:
  - проверены SHA256, `git diff --check` и изменение только `docs/labs/6-1-event-parsing.md`.
2026-09-07 03:19:58 -04:00
ddadmin 7918eeed25 docs(labs): добавлено задание по разбору событий
- Зачем:
  - ступени 6 не хватало ТЗ для пакетного разбора сырого среза рядом с живым приемом.
- Что:
  - добавлено задание с одной собственной таблицей, строгим входом и сверкой всех 47 полей с ODS.
  - индекс основной программы дополнен ссылкой на ступень 6 и актуальным статусом ступени 7.
- Проверка:
  - выполнен `git diff --check`, проверены локальные ссылки и договор задания по DDL STG и ODS.
2026-09-06 17:33:00 -04:00
ddmitry 7ca39edb4c Merge pull request 'docs(labs): добавлена ступень по проверкам заказов' (#239) from 238-order-checks-lab into main
Reviewed-on: #239
2026-09-06 23:29:27 +03:00
ddadmin 596bcba01e docs(labs): разделено задание по заказам на три шага
- Зачем:
  - одно длинное ТЗ заставляло одновременно держать в голове три самостоятельных опыта и их очистку.
- Что:
  - проверки, разбор ODS и сборка DDS разнесены по трем последовательным заданиям.
  - уточнены границы учебной реализации, место диагностики и выбор закрытого дня.
  - оглавление ступени 5 дополнено ссылками на все три задания.
- Проверка:
  - проверены ссылки, пробная пачка jq, кластерные мутации и отсутствие ошибок пробелов в diff.
2026-09-06 16:25:04 -04:00
ddadmin d686de516b docs(labs): добавлено задание по проверкам заказов
- Зачем:
  - менти нужен наблюдаемый опыт, в котором проверки защищают изменение конвейера заказов.
- Что:
  - добавлено ТЗ ступени 5 с договорами DQ, управляемыми ошибками и восстановлением.
  - в оглавление лабораторных работ добавлена ссылка на новое задание.
- Проверка:
  - проверены локальные ссылки, пробная пачка jq и отсутствие ошибок пробелов в diff.
2026-09-06 15:12:11 -04:00
ddmitry d7e8a6f1a1 Merge pull request 'docs(labs): ТЗ ступени 4 «Перепиши нарезку стенда»' (#237) from 236-session-pipeline-lab into main
Reviewed-on: #237
2026-09-06 19:27:55 +03:00
ddadmin 05f4e775e2 docs(labs): уточнено ТЗ конвейера сессий
- Зачем:
  - связать четвертую ступень с результатом третьей и сделать пересборку проверяемой.
- Что:
  - уточнены переход от третьей ступени, договор с дагом и синхронная вставка в донор;
  - ограничен сохраненный срез строк и вынесена независимая проверка атрибутов;
  - упрощены формулировки и исправлены инструкции по DBeaver и удалению партиции.
- Проверка:
  - git diff --check;
  - проверены ссылки документа и якорь README#подключиться-из-dbeaver.
  - узкая повторная вычитка Claude Opus одобрила все девять исправлений.
2026-09-06 12:22:34 -04:00
ddadmin 49a156537b docs(labs): добавлено ТЗ переписывания нарезки сессий
- Зачем:
  - учащийся меняет расчет сессии внутри готового конвейера и проверяет результат внешним эталоном.
- Что:
  - добавлено ТЗ ступени 4 с договором трех SQL-файлов, адресным прогоном и самопроверкой.
  - индекс лаб ведет на задание; будущими остаются ступени 5–7.
- Проверка:
  - make lint, относительные ссылки и git diff --check прошли.
  - запрос размещения сессий по шардам проверен на стенде под analyst; данные не изменялись.
  - выполнены независимое ревью, writing-for-agents, ai-text-lint и литературная вычитка.
2026-09-06 11:31:33 -04:00
ddmitry 2a06582062 Merge pull request 'docs(handbook): раздел DDS главы о хранилище дополнен сессией и примером' (#235) from 234-dds-chapter into main
Reviewed-on: #235
2026-09-06 18:03:43 +03:00
ddadminandClaude Fable 5.1 e07484e9c2 docs(handbook): раздел DDS главы о хранилище дополнен сессией и примером
- Зачем:
  - ТЗ ступени 3 отправляет в главу за определением визита и доводом, почему
    стенд считает сессии сам; раздел давал о сессии одно предложение.
- Что:
  - в разделе «DDS: язык бизнеса» описаны договоренность о визите, роль
    Метрики и эталон VisitID, session_id и чтение через dds.session_v.
  - в «Посмотрите сами» добавлен запрос сессий дня между примерами ODS и DM,
    итоговая фраза переписана на три запроса.
- Проверка:
  - make lint; запрос к dds.session_v выполнен на стенде под analyst.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:59:35 -04:00
ddmitry 4c95dc3a5b Merge pull request 'docs(labs): ТЗ ступени 3 «Нарежь сессии сам»' (#233) from 232-lab-sessions into main
Reviewed-on: #233
2026-09-06 17:40:42 +03:00
ddadminandClaude Fable 5.1 c4f8051c70 docs(labs): добавлено ТЗ ступени 3 «Нарежь сессии сам»
- Зачем:
  - программа лаб (спека 2026-09-01) требует ТЗ ступени 3: своя нарезка
    визитов против эталона VisitID и расследование расхождения по строкам.
- Что:
  - создан docs/labs/3-1-sessions-by-hand.md: нарезка по куке и паузе
    30 минут над ods.event_v, сверка на зерне кука × день через
    FULL OUTER JOIN, расследование одной разошедшейся куки.
  - в docs/labs/README.md строка 3 программы ведет на ТЗ, фраза про будущие
    ТЗ сдвинута на ступени 4–7.
- Проверка:
  - make lint; запрос эталона выполнен на стенде под analyst.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:17:34 -04:00
ddmitry 6804929fb0 Merge pull request 'docs(labs): файлы ТЗ переименованы по схеме S-N-slug' (#231) from 230-labs-filenames into main
Reviewed-on: #231
2026-09-06 16:16:33 +03:00
ddadminandClaude Fable 5.1 d955f7d694 docs(labs): файлы ТЗ переименованы по схеме S-N-slug
- Зачем:
  - в docs/labs/ будет много заданий; номер ступени и номер задания в имени
    делают папку оглавлением программы и избавляют от поиска по README (#230).
- Что:
  - шесть ТЗ переименованы через git mv: 1-1…1-5 для ступени 1,
    2-1-superset-chart.md для ступени 2; факультатив superset-dashboard.md
    остался без номера.
  - обновлены ссылки в docs/labs/README.md, 2-1-superset-chart.md,
    docs/architecture/dds/session.md и пути в dags/homework_check.py.
  - правило имен для docs/labs/ в AGENTS.md переписано под S-N-slug.
- Проверка:
  - make lint; скрипт обхода относительных ссылок в docs/ и AGENTS.md —
    битых нет.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 09:09:14 -04:00
ddmitry 8b9258ed41 Merge pull request 'docs(labs): проза пяти ТЗ ступени 1 и раздел «Как принято на ступени 1»' (#229) from 228-labs-prose into main
Reviewed-on: #229
2026-09-06 16:05:27 +03:00
ddadminandClaude Fable 5.1 65575346b6 docs(labs): уточнены строки ступеней 3 и 5 в программе и слово «руками»
- Зачем:
  - владелец при чтении PR #229 отметил образы в таблице программы
    («счет показывает проблему», «построить сторожей») и оборот
    «запускается рукой».
- Что:
  - ячейки «Чему учит» ступеней 3 и 5 переписаны как навыки, без образов.
  - «запускается рукой» заменено на «запускается руками».
- Проверка:
  - grep по «ё», «менти», словам-законам по docs/labs/*.md молчит.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 09:00:43 -04:00
ddadminandClaude Fable 5.1 29a01f0016 docs(labs): переписана проза пяти ТЗ ступени 1 и раздел «Как принято»
- Зачем:
  - владелец при чтении забраковал прозу ТЗ ступени 1: образы вместо
    утверждений, заголовки-метафоры, договоренности стенда поданы как
    законы. Тикет #228.
- Что:
  - пять ТЗ (задания 1–5) написаны заново по форме ТЗ ступени 2:
    содержание, колонки, формулы, самопроверка и приемка сохранены.
  - раздел README лаб «Общие правила ступени 1» переименован в «Как принято
    на ступени 1» и переписан как выбор с доводом; точечно поправлены
    «Самопроверка» и таблица ступени 1.
- Проверка:
  - grep по «ё», «менти», формам «ты» и словам-законам по docs/labs/*.md
    молчит (единственное «правило» — в нетронутом разделе «Уборка»).
  - скрипт ссылок и якорей из #226 печатает пусто; git diff --check чист.
  - сверка имен в обратных кавычках и чекбоксов старых и новых файлов —
    без расхождений.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 08:44:27 -04:00
ddmitry dc2cbc99dc Merge pull request 'docs(labs): ТЗ ступени 2 «Свой график в Superset» и порядок программы' (#227) from 226-superset-chart into main
Reviewed-on: #227
2026-09-06 12:59:32 +03:00
ddadmin cffe1aeb73 docs(labs): обращение «вы» во всех учебных заданиях
- Зачем:
  - README и путеводитель обращаются к читателю на «вы», а ТЗ в docs/labs/
    были на «ты»; после пересборки индекса смесь стала заметна.
- Что:
  - во всех восьми файлах docs/labs/ местоимения, глаголы и повелительное
    наклонение переведены во второе лицо множественного числа.
  - названия ступеней в таблице README оставлены как в спеке.
- Проверка:
  - grep по формам «ты/твой», 'ё' и 'менти' по docs/labs/ молчит;
    git diff --check чистый; ревью диффа свежей сессией.
2026-09-03 17:29:13 -04:00
ddadmin 830efa7ef4 docs(labs): ТЗ ступени 2 «Свой график в Superset» и порядок программы
- Зачем:
  - программа лаб принята спекой, но индекс docs/labs/ показывал только
    пять витрин и дашборд; ступень 2 не имела ТЗ (#226).
- Что:
  - новое ТЗ docs/labs/superset-chart.md: один столбиковый график доли
    отказов по устройствам поверх dm.session_overview_v, метрика в датасете.
  - docs/labs/README.md: семь ступеней основной программы и пять разделов
    факультативов; правила пяти витрин помечены как правила ступени 1.
  - шапка docs/labs/superset-dashboard.md: факультатив раздела «Superset и BI».
- Проверка:
  - grep 'ё' и 'менти' по трем файлам молчат; скрипт ссылок из тикета
    печатает пусто; git diff --check чистый.

Closes #226
2026-09-03 17:12:37 -04:00
ddmitry 92948986a0 Merge pull request 'docs(handbook): добавить поле «Как соединяет» на карте' (#225) from 224-edge-join into main
Reviewed-on: #225
2026-09-03 12:32:41 +03:00
ddadmin 3fa669a920 docs(handbook): добавлено описание соединений на карте
- Зачем:
  - читатель карты видит ключи переноса данных, не переходя к карточкам узлов.
- Что:
  - для 17 стрелок добавлено поле «Как соединяет», сверенное с SQL.
  - раздел выведен в карточке стрелки и пересобрана карта.
- Проверка:
  - uv run docs/handbook/architecture-map/render.py --check.
  - make lint.
  - make typecheck && make test в generator/.
2026-09-03 04:34:25 -04:00
ddmitry fddb35c8c4 Merge pull request 'docs(handbook): интерактивная карта слоев хранилища' (#223) from 222-architecture-map into main
Reviewed-on: #223
2026-09-03 11:06:36 +03:00
ddadmin 466acafce6 docs(handbook): интерактивная карта слоев хранилища
Зачем: путеводитель рассказывает путь данных через слои словами, а карта
дает его увидеть и потрогать: у одного смысла в каждом слое свое имя и своя
поверхность, а переносят данные три разных механизма — матвью непрерывно,
даг пачкой, представление без копии. Постановка — тикет #222, форма —
резолюция #208 (один самодостаточный HTML, исходники рядом).

Что: docs/handbook/architecture-map.html — готовая карта; исходники в
docs/handbook/architecture-map/ (map.yaml — слои, узлы, стрелки, карточки;
template.html — разметка, стили, скрипт; render.py — сборка и --check;
README.md). Щелчок по узлу открывает карточку и подсвечивает путь, щелчок по
стрелке — кто и каким SQL переносит данные; переключатель потока приглушает
чужую часть карты. Ссылки карточек ведут в справочники и DDL на Gitea.
Путеводитель и глава о хранилище ведут на карту; make lint проверяет ruff
для сборщика и свежесть рендера; testing.md и AGENTS.md обновлены.

Проверка: uv run docs/handbook/architecture-map/render.py --check и
make lint зеленые; внешних запросов в HTML нет; ссылки и якоря карточек
сверены с Gitea; карта прощелкана в headless Chromium на ширинах
1280–1920: подсветка пути, фильтр, Esc, карточки узлов и стрелок.
Ревью: постановка — Opus; реализация — Codex (Sol), два слепых ревью Opus
(уместность и код), вычитка прозы Sol.
2026-09-03 03:40:14 -04:00
ddmitry 4995628f2a Merge pull request 'docs(handbook): четыре главы путеводителя — Airflow, мониторинг, Superset, генератор' (#221) from 220-handbook-chapters into main
Reviewed-on: #221
2026-09-03 09:03:53 +03:00
ddadminandClaude Fable 5.1 043d1c1382 docs(handbook): четыре главы переписаны живым русским
Зачем: владелец прочел главы и нашел неестественные фразы: дашборды
«спрашивают», даги «двигают ось», заказ «ездит в слепках», Superset —
«витрины глазами», объекты «приезжают из файлов». Читателю такие
образы надо расшифровывать, а урок в них не прячется.

Что: в четырех главах олицетворения заменены прямыми формулировками,
«позиция мира» и «шаговый даг» раскрыты через world_position и имена
дагов, паспорт мира описан по составу и назначению, окно слепка
заказов объяснено словами, режимы генератора и правило импорта
Superset сказаны прямо. Тире и стрелки оставлены: это домашний стиль.

Проверка: проверки тикета #220 на битые ссылки и запретные слова
пусты, строки прозы не длиннее 80 знаков, git diff --check чист.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 02:00:37 -04:00
ddadminandClaude Fable 5.1 ac21399fe4 docs(handbook): написаны четыре главы путеводителя
Зачем: после главы о хранилище путеводитель обещал еще четыре главы, а
вместо них стояли заглушки «Пока главы нет…». Без них читатель не видит,
кто двигает мир и раскладывает данные по слоям, куда смотреть в Grafana,
как витрина становится графиком и откуда берутся данные.

Что: docs/handbook/airflow-dags.md, monitoring.md, superset.md,
generator.md — по скелетам тикета #220, форма — как у главы о хранилище.
Сквозная линия трех глав: ход мира на день D везет слепок заказов дня D−1,
поэтому выручка всегда на день позади кликстрима. README путеводителя
ведет заголовками в файлы глав, заглушки убраны; «Куда дальше» главы о
хранилище ведет прямо в главу об Airflow.

Проверка: три SELECT из глав прогнаны на живом стенде от analyst — трафик
получил сыгранный день, выручка только предыдущий, счетчики первых восьми
дней совпали с паспортом мира. Скрипт проверки ссылок и запретных слов из
тикета пуст. Постановка отревьюена Opus (18 находок внесены); главы прошли
саморевью Sol и два слепых ревью Opus — факты и проза (ai-text-lint плюс
оптика литературного русского): 25 находок внесены, 1 отклонена.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:14:43 -04:00
ddmitry b15d5253ee Merge pull request 'docs(handbook): глава «Хранилище и его слои»' (#219) from 218-storage-chapter into main
Reviewed-on: #219
2026-09-02 23:19:15 +03:00
ddadminandClaude Fable 5 46e82b5240 docs(handbook): написана глава «Хранилище и его слои»
- Зачем:
  - первая глава путеводителя: без нее читатель попадает из README сразу
    в справочники, минуя объяснение пути данных (тикет #218, карта #206).
- Что:
  - добавлена docs/handbook/storage-layers.md: путь STG → ODS → DDS → DM,
    правило чтения через _v, зона dic, таблица ключей соединения слоев
    и два SELECT для самостоятельной пробы.
  - в docs/handbook/README.md раздел «Будущие главы» стал «Главы»,
    аннотация первой главы ведет в ее файл.
- Проверка:
  - обе команды из раздела «Проверка» тикета #218 дают пустой вывод;
    оба SELECT прогнаны на живом стенде от пользователя analyst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:15:16 -04:00
ddmitry 354b8abe56 Merge pull request 'docs(readme): корневой README пересобран по четырем блокам' (#217) from 216-readme-rebuild into main
Reviewed-on: #217
2026-09-02 22:44:54 +03:00
ddadminandClaude Fable 5.1 7108cd4af3 docs(readme): корневой README пересобран по четырем блокам
Зачем: README был складом сведений, осевших там, где писались. По
раскладке резолюции #211 он становится лицом репозитория: промо-блок,
«куда я попал», типовые действия, ссылки. Слово «менти» уходит из README
и путеводителя, хвосты #212 и #214 закрываются.

Что: промо-цитата со ссылкой на план обучения и ментора; шапка с
нейтральной адресацией и одной фразой о составе и пути данных; «Быстрый
старт» сжат до двух команд по решению владельца — проверки остаются
роботам, в README одна ссылка на карту проверок; разделы «Адреса и
пароли», «Сдвинуть мир вперед», «Вернуть мир к началу», «Выключить или
сбросить целиком», «Подключиться из DBeaver»; «Документация» называет все
семь справочников вложенным списком. Умерли «Статус», «Что здесь будет»,
«Чем отличается от предшественника» и три заголовка-якоря. В world.md
снята устаревшая фраза о «даге этапа 5», stand.md и ADR 0007 ведут на
новый раздел «Адреса и пароли», в путеводителе снято слово «менти».

Проверка: скрипт ссылок пуст, grep «менти» по README и путеводителю пуст,
make config-test зеленый. Саморевью Sol 1/1; слепое ревью Opus — 0
блокеров, 2 minor, 3 nit, все закрыты; вычитка прозы ai-text-lint и
оптикой литературного русского — 15 находок, 9 внесены, 6 отклонены как
слова владельца и термины.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:37:04 -04:00
ddmitry e56b23816e Merge pull request 'docs(glossary): четыре термина переименованы, world.md и stand.md вычитаны' (#215) from 214-glossary-terms into main
Reviewed-on: #215
2026-09-02 18:09:13 +03:00
ddadminandClaude Fable 5.1 59d8c83b17 docs(glossary): четыре термина переименованы, world.md и stand.md вычитаны
- Зачем:
  - справочники читает человек со стороны, а «пульт мира», «опись мира»,
    «работник» и «сырьё» звучали шуткой или калькой времён разработки
    (тикет #214, выбор владельца).
- Что:
  - CONTEXT.md: статьи «Паспорт мира», «Даги модельного времени»,
    «Шаговый даг», «Сырые данные», старые слова в строках «Избегать»;
    пометки в ADR 0005, 0008, 0009 без переписывания решений.
  - замены по README, справочникам, путеводителю, лабам, спекам,
    комментариям дагов и SQL, тегу дага и панели Grafana; имена в коде
    не тронуты.
  - world.md и stand.md: 35 находок вычитки внесены, регистр выровнен.
- Проверка:
  - make config-test; make lint в корне и в generator/; make test в
    generator/ (437 passed); скрипт ссылок из #212 пуст; grep старых
    слов вне ADR, research и CONTEXT.md пуст.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
2026-09-02 16:46:50 +03:00
ddmitry 86ad8afaf2 Merge pull request 'docs(architecture): операторские разделы README переехали в справочники' (#213) from 212-operator-sections-move into main
Reviewed-on: #213
2026-09-01 23:27:05 +03:00
ddadminandClaude Fable 5.1 7a3542d933 docs(architecture): ледгер stand.md разбит по темам, кальки в world.md сняты
Зачем: ревью владельца по PR #213. Сверка конфигурации в stand.md
приехала из README одним абзацем на двадцать строк, а Superset был
приклеен к Airflow без пустой строки. Три места в world.md читались
калькой с английского.

Что: ледгер «Что проверено» разбит на записи по темам — ClickHouse,
Kafka, метрики и Grafana, Airflow, Superset. В world.md заголовок
«Как позвать генератор» стал «Как запустить генератор» (якоря в README
и generator/README.md обновлены), переписаны абзацы про значения по
умолчанию и про сокет Docker. Смысл не менялся. Словарь проекта
(«пульт мира», «опись мира», «работник», «сырье») не трогался: это
термины CONTEXT.md и ADR, их пересмотр — отдельная развилка.

Проверка: скрипт ссылок из тикета — пустой вывод; make config-test —
ЗЕЛЁНО; старый якорь «как-позвать-генератор» в репозитории не встречается.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
2026-09-01 23:04:45 +03:00
ddadminandClaude Fable 5.1 bad5981904 docs(architecture): операторские разделы README переехали в справочники
Зачем: README служил складом для двух зон без своих справочников —
«службы и доступ» и «мир и его пульт». Раскладка принята резолюцией
#211: README остается лицом, устройство живет в docs/architecture/.

Что: новые docs/architecture/stand.md («Стенд: службы, доступ,
настройка», доводы — ADR 0001 и 0007) и docs/architecture/world.md
(«Мир и его пульт», доводы — ADR 0009 и 0013). Доводы смоука и
описания целей make слиты в testing.md без повторов: фраза о
check_containers_survived и абзац про config-test умерли как дубли.
На месте каждого переехавшего раздела README — абзац со ссылкой,
адреса и пароли интерфейсов остались. Путеводитель и README
генератора ссылаются на world.md вместо якорей README. Переехавший
текст не правился, только относительные ссылки.

Проверка: скрипт ссылок из тикета — пустой вывод; make config-test —
ЗЕЛЁНО; подиффовая сверка переехавших диапазонов с README@2588515 —
без расхождений (слепое ревью); в новой прозе нет «ё».

Closes #212

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
2026-09-01 22:49:23 +03:00
ddmitry 2588515b9a Merge pull request 'docs(handbook): скелет путеводителя и README-шапка' (#210) from 209-handbook-skeleton into main
Reviewed-on: #210
2026-09-01 21:30:25 +03:00
ddadminandClaude Fable 5 ab35560ece docs(handbook): скелет путеводителя и человеческая шапка README
- Зачем:
  - менти негде было понять с первого экрана, что перед ним и куда идти;
    тикет #209 карты #206 открывает учебную документацию стенда.
- Что:
  - создан docs/handbook/README.md: тропа чтения и пять аннотированных
    будущих глав с временными ссылками на справочники.
  - шапка README.md переписана для менти: четыре действия и ссылки на
    путеводитель и уроки; в разделе «Документация» добавлены handbook и labs.
  - в AGENTS.md добавлено правило имен для docs/handbook/.
- Проверка:
  - команда проверки ссылок из тикета #209 — пустой вывод;
  - git diff: операторские разделы README не тронуты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:15:25 -04:00
ddmitry d1dc0dd7bf Merge pull request 'docs(specs): итог тикета #203 в разделе 9 спеки программы лаб' (#207) from 203-map-endpoint into main 2026-09-01 16:33:26 +03:00
114 changed files with 8267 additions and 3446 deletions

No files matched your search

+33 -8
View File
@@ -8,15 +8,26 @@
Стенд строится по спеке Стенд строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md). [«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней Стенд рассчитан на начинающих инженеров данных. Они недавно изучили ClickHouse,
научится менти?** Ответ называется одной фразой до того, как писать. Не но почти не работали с ним руками. Здесь они разбирают и достраивают хранилище
небольшого интернет-магазина: проходят путь от загрузки данных до витрин и BI.
Программа дает практику общепринятых инженерных приемов. После нее участники
должны уметь построить похожее хранилище без готового образца. Состав и порядок
работы задает [программа учебных заданий](docs/specs/2026-09-01-labs-program.md).
Стенд существует только ради обучения. Его поддержка имеет смысл, когда
сохраняет возможность пройти программу или сама становится учебной задачей.
Отдельной эксплуатационной ценности у стенда нет.
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему она
учит?** Ответ называется одной фразой до того, как писать. Не
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
репозитории однажды уже переросли продукт. репозитории однажды уже переросли продукт.
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее **Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая стенд, тем хуже он как учебный материал: внимание читателя конечно, и каждая
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит, этого счёта. Поэтому вопрос выше имеет вторую половину: **что читатель платит,
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего» чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
означает, что писать не нужно. означает, что писать не нужно.
@@ -70,6 +81,9 @@ Airflow) и названия из кода. Если для понятия ес
писал: простые слова, короткие фразы, сложную мысль поясняй при первом писал: простые слова, короткие фразы, сложную мысль поясняй при первом
упоминании. Если понятность и буквальная точность спорят — выбирай понятность. упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
При создании или редактировании лабораторных следуй
[правилам учебного текста](docs/specs/2026-09-01-labs-program.md#как-писать-задания).
Внутренние рассуждения и промежуточные пометки по ходу работы веди на Внутренние рассуждения и промежуточные пометки по ходу работы веди на
английском — он экономнее по токенам. На русском остаётся всё, что видит и английском — он экономнее по токенам. На русском остаётся всё, что видит и
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
@@ -157,10 +171,21 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками. пишется руками.
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис - Имена файлов основной программы в `docs/labs/` — `S-N-slug.md`:
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных номер ступени, номер задания внутри ступени и слаг строчными латинскими
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ, буквами через дефис (`1-2-top-pages-daily.md`). Здесь живут ТЗ
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания. учебных заданий. Номера повторяют порядок программы из `README.md`, чтобы
папка читалась как оглавление; даты нет: задание — живой документ и
правится вместе с тем, о чем говорит. Слаг называет предмет задания.
Факультативы живут в `docs/labs/optional/`, без номеров, под одним слагом
(`superset-dashboard.md`). Общий индекс обеих частей — `docs/labs/README.md`;
отдельного индекса в `optional/` нет.
- Имена файлов в `docs/handbook/` — слаг строчными латинскими буквами через
дефис, индекс папки — `README.md`. Здесь живет путеводитель по стенду:
учебные главы для читателя. Ни дат, ни номеров: глава — живой документ и
правится вместе с тем, что описывает. Исходники интерактивной карты лежат
подпапкой `architecture-map/`, а ее рендер — файлом `architecture-map.html`
рядом с главами.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
в имени не нужна. в имени не нужна.
+6
View File
@@ -1 +1,7 @@
@AGENTS.md @AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+247 -188
View File
@@ -1,216 +1,275 @@
# Кликстрим-платформа (стенд v2) # Кликстрим-платформа (стенд v2)
Словарь понятий проекта: одни и те же слова для одних и тех же вещей — Словарь понятий, которые встречаются в учебных заданиях, документации и коде
у владельца, кода, документов и агентов. Только язык, никаких решений. стенда.
## Язык ## Язык
**Хранилище**: - [Люди и события](#люди-и-события)
Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM. - [Хранилище и форматы данных](#хранилище-и-форматы-данных)
Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили - [Заказы](#заказы)
источников, строится по их документации. - [Модельное время и запуск стенда](#модельное-время-и-запуск-стенда)
_Избегать_: склад, склад данных - [Устройство генератора](#устройство-генератора) — для чтения его кода
и технических справочников.
**Состав мира**: ### Люди и события
Постоянная часть мира генератора — популяция посетителей, их привычки,
двухкуковые пары. По дням его выдаёт план состава.
_Избегать_: состояние мира
**План состава**:
Способ спросить состав мира: функция зерна, выдающая его по дням —
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
**Подпоток**:
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
порядком вычислений.
**Конфигурация мира**:
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
там колонки, здесь числа.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
активности. Единица плана состава: когорта — функция зерна и номера дня.
**Человек**: **Человек**:
Устойчивая личность в составе мира, которой принадлежат одна или две куки. Участник модели магазина, который может заходить с одного или двух
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина. устройств. Генератор знает, какие идентификаторы посетителя ему принадлежат,
но в событиях сайта этой связи нет.
**Посетитель**: **Посетитель**:
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица Браузер на устройстве, который счетчик сайта узнает по куке — сохраненному
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями. в браузере идентификатору. В кликстриме он обозначается `ClientID`:
_Избегать_: «посетитель» про человека после склейки `uniq(ClientID)` считает посетителей, и у одного человека их может быть несколько.
_Избегать_: «посетитель» про человека после объединения его идентификаторов.
**Пользователь магазина**: **Пользователь магазина**:
Представление человека со стороны заказов; в записи заказа обозначается Человек, известный серверной части магазина под идентификатором `user_id`.
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую Он указан в заказах и позволяет связать разные `ClientID` одного человека;
не попадает. в кликстриме поля `user_id` нет. Например, покупки с телефона и ноутбука
могут иметь разные `ClientID`, но один `user_id` в заказах.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
горизонтом и не совпадает с дневной.
**Хвост возвратов**:
Окно активности, отсчитанное от первого дня человека и общее на обе
его куки; дольше окна кука не возвращается.
**Предыстория**:
Когорты плана с первым днём активности до D0; событий не порождают.
**День активности**:
День, в который кука хоть раз появилась в мире: день её рождения или день
возврата. Единица плана состава: за день он у куки один, а визитов внутри
него бывает несколько.
_Избегать_: визит (визит — про сессию)
**Визит**: **Визит**:
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие Последовательность событий одного посетителя без пауз длиннее 30 минут
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же и без перехода через полночь по часовому поясу счетчика. Сессия означает
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена то же самое; поле `VisitID` служит эталоном в задании по нарезке сессий.
вроде «лаба сессий» и «сборка сессий» остаются.
**День активности**:
День, в который посетитель появился на сайте хотя бы раз: впервые или
вернувшись. За один день активности у него может быть несколько визитов.
_Избегать_: визит в значении целого дня активности.
**Паспорт куки**: **Паспорт куки**:
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на Устройство и город, закрепленные за посетителем во все дни его активности.
устройстве. Держит их план состава, разворачивает в поля события Генератор записывает их в поля событий. У двух посетителей одного человека
день-функция; у двухкуковой пары город один на две куки, устройства разные. город совпадает, а устройства различаются.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
**Торговое событие**: **Торговое событие**:
Событие корзины или покупки — отдельная строка потока, а не просмотр Запись о добавлении товара в корзину или о покупке: `add_to_cart` или
страницы. Садится на ту страницу, где случилось: корзина — на карточку `purchase`. Это отдельная строка кликстрима со страницей, где произошло
товара, покупка — на страницу подтверждения заказа. действие: карточкой товара для корзины или страницей подтверждения для покупки.
### Хранилище и форматы данных
**Хранилище**:
Аналитическая база стенда в ClickHouse. Данные проходят слои STG, ODS, DDS
и DM: от исходных сообщений до витрин для отчетов. Хранилище принимает
данные по документации источников и приводит их к своей модели и именам.
_Избегать_: склад, склад данных.
**Сырые данные**:
Исходные сообщения Kafka, сохраненные в слое STG без разбора: одно сообщение
занимает одну строку таблицы. Слой STG также называют сырым слоем.
_Избегать_: сырьё.
**Справочник**: **Справочник**:
Данные, которые хранилище не производит, а получает готовыми со стороны: Готовые сведения из внешнего источника, которыми хранилище дополняет
содержимым владеет источник вне хранилища, слои только читают. Живут в базе свои данные. На стенде они находятся в базе `dic`; пока это только каталог
`dic`. Первый и пока единственный — каталог товаров. товаров. Объект `DICTIONARY` ClickHouse — одна из форм доступа к справочнику.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и _Избегать_: словарь в значении справочных данных: это слово уже обозначает
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен. `DICTIONARY` и словарь понятий.
**Каталог товаров**: **Каталог товаров**:
`data/catalog/products.csv` — общий файл генератора и словаря Список товаров с артикулами, названиями, категориями, брендами, ценами
ClickHouse. Форма файла решена, длина — нет: строки дописываются. и уровнями спроса. Файл `data/catalog/products.csv` общий для генератора
и справочника ClickHouse; товары можно добавлять строками в этом формате.
**Уровень спроса**:
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
брендам различимой.
**Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного
первого дня D0; реальный календарь в модели не участвует. Между прогонами
живут только зерно и позиция на оси.
**Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
на нем принимаются следующие этапы.
_Избегать_: зерновой мир
**Опись мира**:
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
Счётчик сверх этого заводится только с названным читателем. Сам мир не
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
_Избегать_: манифест, мини-манифест
**Пошаговый режим**:
Базовый способ движения по оси модельного времени: «прожить следующий
день» — явное действие.
**Живой день**:
Проигрывание текущего модельного дня в реальном времени с ускорением;
включается по требованию — постоянным фоном идёт, только пока включён
выключатель пульта.
**Пакетный режим**:
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
стенда, пересборки и проверки.
**Пульт мира**:
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
_Избегать_: управляющие даги
**Граница суток**:
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
снимается на ней, день проживается только целиком.
**Контракт схемы**:
Python-модуль с описателями колонок события — собственность генератора.
Из него выводятся генератор, валидация и документация формата; хранилище
строится по документации, не по модулю.
**Нормализованное имя**: **Нормализованное имя**:
Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в Имя поля источника, записанное в стиле `snake_case`: например,
контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели `ClientID` становится `client_id`. Оно указано в описании выгрузки и
данных: слой DDS складывает модель и называет атрибуты по ней. контракте схемы. Слой DDS выбирает имена по своей модели данных, поэтому
они не обязаны повторять нормализованные имена источника.
**Описание выгрузки**: **Описание выгрузки**:
Публичная документация формата события: таблица колонок, собранная из Документ, который объясняет поля и формат событий источника:
контракта схемы. По ней пишется сторона хранилища — как в бою по документации [`docs/formats/clickstream-event.md`](docs/formats/clickstream-event.md).
источника. Правится только контракт, документ пересобирается. По нему строится прием данных в хранилище. Документ собирается из
_Избегать_: описание схемы, документация контракта описаний колонок генератора: правят их, затем пересобирают документ.
_Избегать_: описание схемы, документация контракта.
**Канонический сериализатор**:
Единственная граница, где запись генератора превращается в байты. Фиксированный
порядок ключей и строк — основа побайтовой воспроизводимости.
**Проигрыватель**:
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
может отсутствовать в ранних слепках; после первого появления ездит до конца
своего окна.
**Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
отправки сообщения.
**Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника.
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
**Техническая проверка**: **Техническая проверка**:
Проверка, чей оракул — внутренний эталон стенда (опись мира), которого у Проверка по внутреннему эталону стенда, например сравнение числа событий
настоящего магазина нет. За учебный урок DQ не выдаётся. с паспортом мира. Проверка качества данных (DQ) опирается на данные
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина. магазина и связи между ними; ей не нужен эталон генератора, которого
у настоящего магазина нет.
_Избегать_: DQ-проверка в значении проверки по паспорту мира.
### Заказы
**Слепок**:
Дневная выгрузка состояния заказов из серверной части магазина.
Она содержит заказы, оформленные за последние семь модельных дней,
с учетом изменений к концу дня. Опоздавший заказ может отсутствовать
в первых слепках.
**Дата слепка**:
Дата модельного дня, на конец которого снято состояние заказов;
поле `snapshot_date` одинаково во всех строках выгрузки. Даты оформления
заказов могут различаться; выгрузка отправляется на следующий модельный день.
**Время аудита источника**:
Время создания и последнего изменения записи заказа по часам базы
магазина: поля `created_at` и `updated_at`. Время покупки в событии
`purchase` записывает счетчик сайта, и оно может отличаться от этих полей.
**Окно изменяемости**:
Семь модельных дней, включая день оформления, в течение которых заказ
может измениться и входит в слепки. После этого его состояние окончательно.
Например, заказ от 1 июня входит в окно слепков за 1–7 июня; опоздание
сокращает число его появлений, но не сдвигает конец окна.
**Пакетный забор**:
Чтение сообщений Kafka отдельным запросом по команде. Так хранилище
принимает заказы после отправки всего слепка. События сайта оно принимает
непрерывно через материализованное представление.
_Избегать_: смешивать с пакетным режимом генератора: забор — чтение
на стороне хранилища, режим генератора — отправка.
### Модельное время и запуск стенда
**Мир**:
Модель интернет-магазина: люди, их посещения сайта, товары и заказы,
которые создает генератор.
**Ось модельного времени**:
Календарь магазина в генераторе. Первый день, D0, — 1 июня 2026 года,
понедельник (`world.ORIGIN`); следующие дни отсчитываются от него.
Реальное время — часы компьютера, на котором работает стенд. Дата запуска
не меняет даты событий: D0 остается 1 июня, даже если стенд запущен осенью.
**Граница суток**:
Конец одного модельного дня и начало следующего, полночь по часовому
поясу счетчика. Здесь заканчиваются визиты и снимается состояние заказов
для слепка. Даги времени продвигают мир только на целые дни.
**Стартовый мир**:
Данные первых восьми модельных дней, с понедельника по следующий
понедельник, которыми стенд наполняется при первом `make up`.
Команда вызывает `world_initialize`: если мир уже готов и сохранен,
повторный запуск оставляет данные и позицию времени прежними.
`make rebuild-storage` пересоздает стартовый мир. При неизменных коде,
каталоге и настройках его данные повторяются и служат эталоном проверок.
_Избегать_: зерновой мир.
**Паспорт мира**:
Файл для сверки с эталонными данными: `data/world-inventory.json`.
В нем записаны зерно генерации, версия генератора, контрольная сумма
каталога, число событий и контрольная сумма каждого начального дня,
контрольные суммы слепков и счетчики расхождений по заказам.
В git хранится паспорт; сами события и слепки пересчитываются генератором.
_Избегать_: опись мира, манифест, мини-манифест.
**Пошаговый режим**:
Продвижение модельного времени по явной команде: запустить генерацию
следующего дня или нескольких дней.
**Пакетный режим**:
Отправка событий готового модельного дня без пауз между ними.
Используется для начального наполнения, пересборок и проверок.
**Живой день**:
Постепенная отправка событий по времени их появления в модельном дне,
с ускорением: по умолчанию сутки занимают около 24 реальных минут.
Чтобы следующие дни запускались автоматически, снимают с паузы `world_live`.
**Даги модельного времени**:
Наборы связанных задач Airflow для генерации следующих дней магазина:
`world_next_day`, `world_live_day` и `world_live`. Первые два выполняют
шаг по времени, третий автоматически запускает живые дни, пока снят с паузы.
Короткое название — даги времени.
_Избегать_: пульт мира, управляющие даги.
**Шаговый даг**:
Даг, который отправляет данные следующих модельных дней и после успеха
записывает новую позицию времени. `world_next_day` отправляет их пачкой,
`world_live_day` — один день с ускорением. У них нет расписания;
их запускают вручную или из другого дага.
_Избегать_: работник.
### Устройство генератора
**Зерно генерации**:
Число, которое задает повторяемую последовательность случайных выборов
генератора. Одинаковые зерно и день дают одинаковые данные при неизменных
коде, каталоге и настройках. Эталон использует `CANONICAL_SEED` из `seeds.py`.
**Состав мира**:
Люди в модели, их идентификаторы посетителей и заранее заданные привычки:
когда они приходят, как часто возвращаются, склонны ли покупать.
У части людей две куки — это двухкуковые пары.
_Избегать_: состояние мира.
**План состава**:
Расчет того, какие посетители придут в каждый день и какие свойства у них
будут. В `plan.py` из зерна и номера дня получаются новые посетители,
их возвраты и назначенные заказы людей с двумя куками.
**Когорта дня**:
Группа людей, впервые пришедших в один день, со всеми их куками и днями
активности. Генератор рассчитывает ее по зерну и номеру первого дня.
**Приток**:
Появление новых посетителей (`ClientID`) по мере продвижения модельного
времени. Поэтому число разных посетителей за весь период растет и
не совпадает с числом посетителей за отдельный день.
**Хвост возвратов**:
Срок от первого появления человека, в пределах которого его куки могут
возвращаться на сайт. У человека с двумя куками конец срока общий.
**Предыстория**:
Люди, впервые пришедшие до D0, и план их активности. До D0 события
не создаются; возвраты этих людей дают повторных посетителей уже в первый
день генерируемых данных.
**Покупатель**:
Человек, которому генератор заранее назначил повышенную склонность
покупать. Он чаще доходит до заказа, но иногда покупают и остальные.
Из таких людей выбираются те, у кого будут две куки.
_Избегать_: покупатель в значении любого посетителя с покупкой за день;
для этого случая — «визит с заказом».
**Уровень спроса**:
Свойство товара, от которого зависит, как часто после просмотра карточки
его добавляют в корзину. В колонке `demand` каталога три значения:
`магнит`, `обычный`, `залёживается`. Они постоянны по дням и задают
различия в конверсии товаров и брендов.
**Конфигурация мира**:
Числовые настройки модели магазина в `world.py`: приток, частота возвратов,
доли покупателей, начальная дата и другие параметры. Изменение этих
настроек меняет генерируемые данные.
**Подпоток**:
Отдельная последовательность случайных выборов для части генератора,
например для торговли в заданный день. В `seeds.py` она определяется
общим зерном, днем и компонентом; для состава мира есть отдельные
последовательности. Результат не зависит от порядка расчета частей.
**День-функция**:
Расчет событий одних модельных суток по зерну и номеру дня, без генерации
предыдущих дней. Функция `day.stream(seed, day)` возвращает события
в порядке времени.
**Судьба заказа**:
Заранее рассчитанные изменения заказа: итоговый статус, время оплаты
или отмены, удаленная позиция и задержка первого появления в слепке.
Они укладываются в семидневное окно заказа. Слепок показывает состояние
на выбранный день без проигрывания предыдущих изменений.
**Контракт схемы**:
Описания колонок события в модуле генератора `schema.py`: имена,
типы и ограничения. По ним генератор создает и проверяет события,
а `schema_doc.py` собирает описание выгрузки для хранилища.
**Канонический сериализатор**:
Код в `serialize.py`, который превращает события и записи заказов в JSON
для отправки. Порядок ключей и формат значений фиксированы, поэтому
одинаковые записи дают одинаковые байты.
**Проигрыватель**:
Код в `player.py`, который доставляет готовые события дня в приемник,
например в Kafka или файл. В пакетном режиме он отправляет их без пауз,
в режиме живого дня — с ускорением по времени событий.
+4 -2
View File
@@ -51,9 +51,11 @@ config-test:
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне # того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды # нет, а форматтер между версиями меняет вывод — иначе проверка однажды
# покраснела бы сама, без единой правки в репозитории. # покраснела бы сама, без единой правки в репозитории.
# Проверка рендера быстрая, не требует стенда и ловит устаревший HTML.
lint: lint:
uvx ruff@0.16.1 check dags infra/superset uvx ruff@0.16.1 check dags infra/superset docs/handbook/architecture-map
uvx ruff@0.16.1 format --check dags infra/superset uvx ruff@0.16.1 format --check dags infra/superset docs/handbook/architecture-map
uv run docs/handbook/architecture-map/render.py --check
# --- Наполнение миром --- # --- Наполнение миром ---
+113 -382
View File
@@ -1,426 +1,157 @@
# Учебная дата-платформа кликстрима # Учебная дата-платформа кликстрима
Стенд для работы с кликстримом. Преемник учебного стенда > Этим стендом можно пользоваться самостоятельно. Он также входит в
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo). > [план обучения Data Engineering Roadmap](https://de.dementev.space) как
> курсовая работа. Если вам нужна помощь, обратитесь к автору материалов —
> ментору [@dementev_dev](https://t.me/dementev_dev).
## Статус Здесь вы будете работать с хранилищем данных небольшого интернет-магазина:
загружать и обрабатывать данные в ClickHouse, строить витрины, проверять
качество данных и создавать графики. Стенд рассчитан на тех, кто уже изучил
основы ClickHouse и хочет попрактиковаться.
Репозиторий строится по спеке Генератор создает заказы и кликстрим — записи о действиях посетителей
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md). магазина. Данные поступают через Kafka в ClickHouse и проходят четыре слоя
Сейчас работают кластер ClickHouse из двух шардов, отдельный обработки: STG, ODS, DDS и DM. В кластере ClickHouse два шарда и отдельный
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset, ClickHouse Keeper. Airflow управляет загрузкой и обработкой, Superset
Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для показывает отчеты, а Prometheus и Grafana помогают следить за работой стенда.
метаданных. Генератор кликстрима в [`generator/`](generator/) собран
целиком со стороны клиента: у него есть контракт схемы события, из которого При первом запуске стенд загрузит данные за неделю работы магазина.
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и После запуска начните с [путеводителя](docs/handbook/README.md): он объясняет
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до устройство хранилища. Затем переходите к [учебным заданиям](docs/labs/README.md).
типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели
без копии данных. `make up` наполняет стенд стартовым миром — первой неделей
модельного времени.
## Быстрый старт ## Быстрый старт
Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём Для запуска нужны Docker с Compose, `make`, `curl` и `jq`.
ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт Выделите Docker около 8 ГБ оперативной памяти. В Docker Desktop и WSL2
внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано проверьте лимит памяти виртуальной машины: он может быть меньше, чем общий
сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это объем памяти компьютера. Команда `docker info --format '{{.MemTotal}}'`
поднимается параметром `memory` в файле `.wslconfig` домашнего каталога покажет доступный Docker объем в байтах. В WSL2 лимит задается параметром
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не `memory` в файле `.wslconfig` в домашнем каталоге пользователя Windows.
хватает, стенд одинаково хорошо живёт на недорогом VPS. После изменения выполните `wsl --shutdown`. Если ресурсов компьютера
недостаточно, стенд можно запустить на VPS.
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`, По умолчанию должны быть свободны порты
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны и `29092`. Для работы с командами `make` в каталоге `generator/` также
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` понадобится `uv`.
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
всем целям генератора в `generator/` — нужен `uv`.
Сначала скопируйте настройки стенда: В каталоге репозитория скопируйте образец настроек:
```bash ```bash
cp .env.example .env cp .env.example .env
```
В `.env` задаются имя экземпляра стенда, внешние порты, учебные логины,
пароли и ключи. Проверьте `DOCKER_GID`: это числовой идентификатор группы
`docker` на вашей машине. Узнать его можно командой `getent group docker`.
Если число отличается от образца, замените значение в `.env`. От этой
настройки зависит запуск генератора из Airflow.
Запустите стенд:
```bash
make up make up
make smoke
make check-clickhouse
make check-services
``` ```
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что Команда запустит службы и дождется загрузки начальных данных. Порядок запуска
стенд собран, `make check-clickhouse` — что кластер работает кластером, а и проверки результата описаны в [справочнике проверок](docs/architecture/testing.md).
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу. Если обязательной настройки в `.env` не хватает, Compose сообщит об ошибке.
Дальше, в рабочей петле, обычно хватает `make smoke`. Внутренние адреса служб, имена топиков и версии образов заданы в `compose.yaml`
и Dockerfile.
`.env.example` — образец обязательной локальной настройки. В нём живут имя Логины и пароли Postgres и Grafana сохраняются при первом запуске в томах
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца Docker. Простая правка `.env` их не изменит. Чтобы задать новые значения
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и через `.env`, потребуется `make clean` и повторный `make up`. При этом
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь, будут удалены все локальные данные стенда.
кроме пульта мира — о нём ниже. `compose.yaml` не
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
Учётные данные Postgres и Grafana применяются при создании их томов. ## Адреса и пароли
После первого запуска меняйте их только вместе с `make clean`: команда удалит
все локальные данные стенда, а следующий `make up` создаст их с новыми
значениями.
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
подготавливает администратора и два подключения к `clickhouse-01`. Второй
обновляет Superset, создаёт администратора и импортирует подключение к
`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с
пустых томов занял около десяти минут, повторный на живом стенде — около трёх.
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и
подготовленное подключение к `clickhouse-01`. В конце проверка спрашивает у
Docker, не убивало ли ядро что-нибудь в долгоживущих контейнерах за нехватку
памяти и не включалась ли политика перезапуска: убитый контейнер Docker
поднимает сам, и проверка состояния об этом промолчит.
Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`.
Оставлены они потому, что первый вопрос к стенду всё равно «всё ли живо», а
ответ на него стоит меньше секунды. Устройство keeper — другое дело: он должен
работать от пользователя `clickhouse`, с пределом в 262144 открытых файла и со
своим томом под данные. Всё это объявлено в `compose.yaml`, но здоровым keeper
выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли
объявленные настройки до процесса.
Kafka по той же причине спрашивают снаружи. Её `healthcheck` обращается к
брокеру изнутри контейнера и по внутреннему слушателю, поэтому здоровой Kafka
остаётся и тогда, когда объявленный наружу адрес ведёт не туда. Клиент с машины
в этом случае подключается, получает метаданные и молча виснет на адресе,
которого с его стороны не существует. Смоук идёт тем же путём, что и будущий
генератор: стучится в отображённый порт и просит список топиков.
`make check-services` проверяет то, ради чего приходится ждать службу: работу с
топиком Kafka с машины — создан, найден, удалён, — ручной запуск пробников
`test_clickhouse` и `test_kafka`, вход в Superset, его метаданные и подключение
к `clickhouse-02`. Первый пробник создаёт таблицы на обеих нодах и читает через
`Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka
и читает свой маркер. Временный топик проверки с машины и запуски DAG удаляются;
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
единственная на настоящих событиях: она подневно сверяет стартовый мир с описью
и при расхождении говорит, где искать — в событиях или в браке.
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
в diff без запуска стенда.
Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют
тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей
поднятый стенд, сколько стоит прогон и куда класть новую — в
[карте проверок](docs/architecture/testing.md).
Остановить контейнеры без удаления данных можно командой `make down`. Если
нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными
томами и для обычной разработки не нужен. Перед пересборкой поставьте
`world_live` на паузу и дайте уже начатому работнику закончить день.
## Стартовый мир
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
те же.
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
виден разный темп потокового трекера и ночной выгрузки магазина.
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
явной командой `make rebuild-storage`.
Сам мир в git не хранится — он чистая функция зерна, и держать его в
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
[`data/world-inventory.json`](data/world-inventory.json): зерно, версия
генератора, хеш каталога товаров и по строке на каждый день — дата, число
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли
это мир, что был вчера.
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
собирается из кода сегодня: правка генератора меняет мир, и опись надо
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --file tmp/day0.jsonl
sha256sum tmp/day0.jsonl
```
## Как позвать генератор
События производит генератор из [`generator/`](generator/). Модельный день —
функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой
день играть, решает зовущий.
Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз:
так заливается мир и так переигрывается день после обрыва. **Живой** держит темп
модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах, отставание видно в логе.
На стенде генератор ходит своим образом — разовой службой, которую поднимают
и убирают на один прогон. На каждый режим по цели:
```bash
# день D0 целиком в топик hits
make generate-batch
# первая сотня событий дня D3
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
# день D3 живьём, ускорение ×1000
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
```
| Переменная | Что задаёт | Умолчание |
| --- | --- | --- |
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
`kafka:9092` и топик `hits`.
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
или зависимостей, — отдельной командой:
```bash
docker compose --profile generator build generator
```
Тот же образ несёт заливка стартового мира. Перед такой заливкой
`world_initialize` сам собирает его из текущего дерева; на готовом мире
повторный `make up` до сборки не доходит.
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --limit 100 --file tmp/day0.jsonl
```
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается
флагами, принимается и переменными окружения — так генератор позовёт даг
этапа 5; полный список у `--help`.
**Умолчания есть не у всего, и это нарочно.** Зерно, число дней и темп живого
дня описывают модель мира — их генератор знает сам. День на оси, приёмник и имя
топика описывают стенд, на котором его запустили: не назвали — отказ и ненулевой
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
### Пульт мира
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
создают его начальное состояние, а три двигают ось дальше.
| Даг | Что делает |
| --- | --- |
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
Работник не только играет день: следом он отправляет слепок заказов за
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md).
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
машине; для локального учебного стенда размен принят, но знать о нём надо.
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
каждой машины свой, живёт в `.env` и подсматривается командой
`getent group docker`.
## Состав и доступ
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
- `clickhouse-02` — точка подключения Superset;
- `clickhouse-keeper` — координатор кластера;
- `kafka` — один брокер KRaft;
- `kafka-exporter` — экспортёр метрик Kafka для Prometheus;
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
`airflow` и `superset`;
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
Airflow 3.3 с LocalExecutor, без triggerer;
- `superset` — интерфейс и подготовленное подключение ClickHouse;
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
С настройками из `.env.example` порты доступны только с локальной машины: С настройками из `.env.example` порты доступны только с локальной машины:
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`; - узел ClickHouse 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`; - узел ClickHouse 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
- Kafka — `127.0.0.1:29092`; - Kafka — `127.0.0.1:29092`;
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`; - Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`; - Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
- Prometheus — `http://127.0.0.1:29090`; - Prometheus — `http://127.0.0.1:29090`;
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
В ClickHouse семь пользователей и шесть ролей: Подробнее о службах, правах пользователей и изменении настроек —
в [справочнике стенда](docs/architecture/stand.md).
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои ## Добавить данные за следующие дни
хранилища;
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль
`lifecycle_owner` не даёт эти административные права рабочим дагам;
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
`dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` предназначен для подключения человека и читает все слои и
справочники;
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
системные таблицы и данные со всех реплик кластера;
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
за своей подложкой;
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
контейнеров, но не приложения.
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в Время в учебном магазине идет отдельно от календарного. В документации оно
[ADR 0007](docs/adr/0007-clickhouse-access.md). называется модельным временем, а сам магазин с его посетителями и заказами —
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и миром.
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
Значения для локального стенда есть в `.env.example`.
Изменения значений ClickHouse в `.env` и файлов настройки серверов в В Airflow запустите `world_next_day`. Это даг — набор связанных задач,
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются которые Airflow выполняет в заданном порядке. Он создаст и загрузит данные
пересозданием нод: за следующий день работы магазина. В форме запуска можно задать число дней.
```bash Чтобы новые данные поступали непрерывно, снимите с паузы даг `world_live`.
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02 Он будет запускать генерацию день за днем; один модельный день занимает
``` около 24 минут. Подробности — в [справочнике генератора и модельного
времени](docs/architecture/world.md#даги-модельного-времени).
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома ## Восстановить начальные данные
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
может оставить старую версию отдельно смонтированного файла.
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset, Если хотите начать заново, поставьте `world_live` на паузу и дождитесь
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно завершения уже запущенного дага генерации. Затем выполните
ненастоящие настройки локального учебного стенда. Это не пример настройки `make rebuild-storage`.
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
опубликован.
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом Команда удалит прикладные базы ClickHouse, топики и позиции потребителей
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология Kafka, затем заново создаст схему хранилища и загрузит начальные данные.
2×2 намеренно не входят в стенд. Службы и их тома останутся на месте.
Конфигурация сверена 30 июля 2026 года с официальной документацией ClickHouse: ## Остановить или удалить стенд
[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
Описание кластера задаётся через `remote_servers`, макросы — через `macros`,
подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит
`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную
таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER`
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
образа зафиксирован точный текущий
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
на серверах и keeper. Подготовка источников Grafana сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
Prometheus собирает встроенные метрики двух серверов и keeper, а через
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
мониторинга](docs/architecture/monitoring/README.md), разбор панелей — в
[описании дашборда](docs/architecture/monitoring/data.md); правил оповещения
пока нет.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor, `make down` остановит стенд и сохранит данные. Для повторного запуска
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с выполните `make up`.
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
общий `SQLExecuteQueryOperator`, программные запросы идут через
`ClickHouseHook`. Провайдер работает поверх закреплённого
`clickhouse-connect`. Официальный провайдер Kafka сам использует
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому
клиент Kafka добавлен в образ напрямую.
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
`clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
## Что здесь будет `make clean` удалит контейнеры и все данные в томах Docker. Для обычного
завершения работы достаточно `make down`.
- одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо ## Подключиться из DBeaver
четырёх топиков;
- второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka;
- сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду,
поведение и атрибуцию — по трекеру;
- анонимный кликстрим и склейка кука↔пользователь через покупки;
- ClickHouse кластером как единственным режимом.
## Чем отличается от предшественника Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
Для подключения ко второму узлу укажите порт `28124`.
Предшественник остаётся стабильным учебным стендом и заморожен для новых фич: Пользователь `analyst` может читать данные во всех слоях и справочниках.
там событие разрезано на четыре топика, есть только просмотры страниц, Для создания учебных витрин используйте подключение, описанное
посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь. в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl).
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
Остальные пользователи и роли описаны в
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
## Документация ## Документация
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном. - [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми - [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
вариантами: почему сделано так, а не иначе. самопроверкой.
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам - [docs/specs/](docs/specs/) — требования к стенду и программа обучения.
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён, - [docs/adr/](docs/adr/) — решения об устройстве стенда: что выбрали,
раскладка по шардам, приём событий, карта таблиц; какие варианты отклонили и почему.
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make` - [docs/architecture/](docs/architecture/) — технические справочники:
и куда класть новую проверку. - [хранилище](docs/architecture/storage.md) — правила именования,
распределение данных по шардам, прием событий и список таблиц.
- [конвейер](docs/architecture/etl.md) — движение данных между слоями и
работа дагов.
- [мир и модельное время](docs/architecture/world.md) — начальные данные,
создание данных за следующие дни и управление генератором.
- [стенд](docs/architecture/stand.md) — службы, доступ и настройка.
- [проверки](docs/architecture/testing.md) — команды `make` для проверки
стенда и правила добавления новых проверок.
- [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и
устройство дашборда.
- [Superset](docs/architecture/superset.md) — подключения, наборы данных и
автоматическая настройка при запуске.
- [docs/research/](docs/research/) — исследования; сейчас это формат - [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события. кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — описания форматов источников: по ним - [docs/formats/](docs/formats/) — форматы исходных данных, необходимые
пишется сторона хранилища. Собираются из кода командой `make docs` в для их загрузки и обработки. Эти документы собираются из кода командой
`generator/`, руками не правятся. `make docs` в `generator/`; вручную их менять не нужно.
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории. - [AGENTS.md](AGENTS.md) — правила работы в репозитории для ИИ-агентов.
+3 -3
View File
@@ -34,7 +34,7 @@ x-clickhouse-common: &clickhouse-common
retries: 30 retries: 30
start_period: 10s start_period: 10s
# Факты генератора и пульта мира названы по одному разу — иначе они однажды # Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды
# разойдутся, и заметит это не проверка, а менти с пустым топиком. # разойдутся, и заметит это не проверка, а менти с пустым топиком.
x-generator-image: &generator-image clickstream-generator:local x-generator-image: &generator-image clickstream-generator:local
x-orders-topic: &orders-topic orders x-orders-topic: &orders-topic orders
@@ -70,7 +70,7 @@ x-airflow-common: &airflow-common
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env} AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается # Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
# из имени проекта: у второй копии стенда оно другое. # из имени проекта: у второй копии стенда оно другое.
<<: *kafka-target <<: *kafka-target
GENERATOR_IMAGE: *generator-image GENERATOR_IMAGE: *generator-image
@@ -291,7 +291,7 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
command: scheduler command: scheduler
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor # Даги времени зовут генератор отдельным контейнером, а при LocalExecutor
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному. # задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root # Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660 # на машине. Открывает дверь не монтирование, а группа: у сокета права 660
+28 -36
View File
@@ -1,18 +1,17 @@
"""Трансформации слоя DDS: сборка моделей. """Трансформации DDS: заказы, сессии и карта идентичностей.
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
пересчитывается целиком после заказа, потому что читает его текущую модель. в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам Карта идентичностей пересчитывается целиком после заказов. Новые строки
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
верен из триггера дневного конвейера, с кнопки менти и после падения версию каждой строки.
(docs/architecture/etl.md, «Объём прогона»).
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в Даг запускается без расписания и параметров. Даты он выбирает по состоянию
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
sql/dds/order_replace.sql; точное чтение карты описано в Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
sql/ddl/50-dds-views.sql.
""" """
from __future__ import annotations from __future__ import annotations
@@ -46,8 +45,8 @@ HITS_PARTITIONS = (0, 1)
READINESS_TIMEOUT_SECONDS = 300 READINESS_TIMEOUT_SECONDS = 300
READINESS_POLL_SECONDS = 3 READINESS_POLL_SECONDS = 3
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит # Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят
# пульт мира, и только она отличает прожитый день от живого — зачем это # даги модельного времени, и только она отличает прожитый день от живого — зачем это
# сессиям, разобрано в sql/dds/session_scope.sql. # сессиям, разобрано в sql/dds/session_scope.sql.
WORLD_POSITION = "world_position" WORLD_POSITION = "world_position"
@@ -224,8 +223,8 @@ def _wait_for_phase(
def _world_position() -> int: def _world_position() -> int:
"""Позиция мира числом, с внятным отказом на непригодном значении. """Позиция мира числом, с внятным отказом на непригодном значении.
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно: Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно:
связь между пультом и трансформацией держит сама переменная, а не импорт. связь между дагами времени и трансформацией держит сама переменная, а не импорт.
""" """
raw_position = Variable.get(WORLD_POSITION, default=None) raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None: if raw_position is None:
@@ -257,16 +256,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены. """Подготовить параметры отдельной задачи replace для каждого дня.
Шаг существует ради ограничения Airflow: размножать задачу можно expand_kwargs получает список словарей и создает по одному экземпляру
только по целому возврату другой задачи — ни по ключу словаря, ни по задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
карте поверх него. Отсюда отдельный возврат под один список. параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -276,8 +270,8 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
schedule=None, schedule=None,
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы # Одновременные запуски используют одни промежуточные таблицы.
# чужую сборку между вставкой и заменой партиций. # Второй мог бы очистить результат первого до замены партиций.
max_active_runs=1, max_active_runs=1,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dds"], tags=["dds"],
@@ -298,7 +292,7 @@ def dds_transform():
@task_group(group_id="order") @task_group(group_id="order")
def order(): def order():
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет.""" """Заказ: изменяемые дни последнего слепка и пропущенные дни."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
@@ -306,8 +300,7 @@ def dds_transform():
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION) hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query) row = hook.get_first(query)
# Пустой ответ означает пустой источник: собирать нечего, и # Без заказов ODS расчет невозможен: задача сообщает об ошибке.
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
if row is None: if row is None:
raise AirflowException( raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; " "в ods.order нет ни одного заказа: собирать нечего; "
@@ -342,9 +335,9 @@ def dds_transform():
do_xcom_push=False, do_xcom_push=False,
) )
# Замена идёт по партиции за раз — другой формы у операции нет. День # На каждый день создается отдельная задача замены партиции.
# становится отдельной задачей: в интерфейсе видно, сколько дней несёт # Неудачную замену можно повторить, пока промежуточная таблица
# прогон, а повтор после отказа стоит одного дня, а не всей пересборки. # сохраняет рассчитанные строки этого запуска.
replace = SQLExecuteQueryOperator.partial( replace = SQLExecuteQueryOperator.partial(
task_id="replace", task_id="replace",
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
@@ -370,9 +363,8 @@ def dds_transform():
"проверьте, дошёл ли поток до хранилища" "проверьте, дошёл ли поток до хранилища"
) )
first_day, last_day = row first_day, last_day = row
# Пропуск, а не отказ: слой полон по последний прожитый день, а # Для каждого прожитого дня уже есть строки сессий.
# сегодняшний ещё идёт. Это обычное состояние повторного прогона, # Новых дней для расчета нет: группа задач будет пропущена.
# и в интерфейсе оно видно пропущенной группой.
if first_day is None: if first_day is None:
raise AirflowSkipException( raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, " f"сессии собраны по {last_day} включительно, "
+12 -8
View File
@@ -1,9 +1,12 @@
"""Трансформации слоя DM: сборка стендовых витрин. """Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым Даты для расчета выручки берутся из последнего слепка заказов и списка
хвостом событий. Обе заменяют дневные партиции. Трафик наследует пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни
ретроспективность карты идентичностей и пересчитывает всю историю новой событий и дни, где заказ еще не найден. Запросы записывают результаты
версией. Объем каждого прогона выводится из состояния слоев. в промежуточные таблицы, затем заменяют дневные разделы основных таблиц.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
""" """
from __future__ import annotations from __future__ import annotations
@@ -62,10 +65,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены. """Подготовить параметры отдельной задачи replace для каждого дня.
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же expand_kwargs получает список словарей и создает по одному экземпляру
прием разобран в ``dds_transform.py`` на замене партиций заказов. задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -141,7 +145,7 @@ def dm_transform():
@task_group(group_id="purchase_vs_orders") @task_group(group_id="purchase_vs_orders")
def purchase_vs_orders(): def purchase_vs_orders():
"""Сверка: окно заказов, прожитый хвост и предварительные дни.""" """Сверка: изменяемые заказы, прожитые покупки и дни ожидания заказа."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
+1 -1
View File
@@ -148,7 +148,7 @@ def build_classes_vs_inventory() -> list[str]:
for mismatch_class, count in day["orders"].items() for mismatch_class, count in day["orders"].items()
} }
if not expected: if not expected:
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней") raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней")
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
# GROUP BY закономерно не возвращает группу из нуля строк. # GROUP BY закономерно не возвращает группу из нуля строк.
+5 -5
View File
@@ -109,28 +109,28 @@ def homework_check():
"""Дать отдельный результат самопроверки по каждой домашней витрине.""" """Дать отдельный результат самопроверки по каждой домашней витрине."""
check_mart.override(task_id="events_enriched")( check_mart.override(task_id="events_enriched")(
"dm.events_enriched_v", "dm.events_enriched_v",
"docs/labs/events-enriched.md", "docs/labs/1-1-events-enriched.md",
"events_enriched_vs_dds.sql", "events_enriched_vs_dds.sql",
"events_enriched_scope.sql", "events_enriched_scope.sql",
) )
check_mart.override(task_id="top_pages_daily")( check_mart.override(task_id="top_pages_daily")(
"dm.top_pages_daily_v", "dm.top_pages_daily_v",
"docs/labs/top-pages-daily.md", "docs/labs/1-2-top-pages-daily.md",
"top_pages_daily_vs_dds.sql", "top_pages_daily_vs_dds.sql",
) )
check_mart.override(task_id="session_overview")( check_mart.override(task_id="session_overview")(
"dm.session_overview_v", "dm.session_overview_v",
"docs/labs/session-overview.md", "docs/labs/1-3-session-overview.md",
"session_overview_vs_dds.sql", "session_overview_vs_dds.sql",
) )
check_mart.override(task_id="dq_errors_daily")( check_mart.override(task_id="dq_errors_daily")(
"dm.dq_errors_daily_v", "dm.dq_errors_daily_v",
"docs/labs/dq-errors-daily.md", "docs/labs/1-4-dq-errors-daily.md",
"dq_errors_daily_vs_ods.sql", "dq_errors_daily_vs_ods.sql",
) )
check_mart.override(task_id="utm_effectiveness")( check_mart.override(task_id="utm_effectiveness")(
"dm.utm_effectiveness_v", "dm.utm_effectiveness_v",
"docs/labs/utm-effectiveness.md", "docs/labs/1-5-utm-effectiveness.md",
"utm_effectiveness_vs_dds.sql", "utm_effectiveness_vs_dds.sql",
) )
+14 -21
View File
@@ -1,18 +1,13 @@
"""Приём заказов: пакетный забор слепка из Kafka в хранилище. """Пакетный прием заказов: Kafka → STG → ODS.
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор Шаговый даг модельного времени отправляет слепок, запускает orders_ingest
порции в сырьё, затем разбор того же среза в типизированный 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
+15 -15
View File
@@ -1,25 +1,25 @@
"""Пульт мира: даги, которыми двигают ось модельного времени. """Даги модельного времени: ими двигают ось мира.
Их три, и они двух родов. **Работники** играют день и запускаются руками: Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` — `world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
один день в темпе модельного времени. **Выключатель** `world_live` своей работы один день в темпе модельного времени. **Выключатель** `world_live` своей работы
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
границе модельных суток. границе модельных суток.
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
`orders_ingest`. `orders_ingest`.
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
только разгоняет источники: слои после нее менти догоняет ручным запуском. только разгоняет источники: слои после нее менти догоняет ручным запуском.
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день. при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
спрашивает у переменной, а не у логической даты прогона. спрашивает у переменной, а не у логической даты прогона.
Решения и доводы целиком — ADR 0009. Решения и доводы целиком — ADR 0009.
@@ -51,7 +51,7 @@ ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"]) STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
# Позиция на оси: номер первого несыгранного дня. До конца инициализации # Позиция на оси: номер первого несыгранного дня. До конца инициализации
# работники не имеют права угадывать её — состояние объяснено в ADR 0013. # шаговые даги не имеют права угадывать её — состояние объяснено в ADR 0013.
# #
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее # Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
# при таком правиле — сыгранный дважды день, а повтор схлопнет # при таком правиле — сыгранный дважды день, а повтор схлопнет
@@ -63,12 +63,12 @@ WORLD_POSITION = "world_position"
# двадцати четырёх минут: тик только спрашивает «не пора ли снова». # двадцати четырёх минут: тик только спрашивает «не пора ли снова».
LIVE_TICK = datetime.timedelta(minutes=25) LIVE_TICK = datetime.timedelta(minutes=25)
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все # Какой день играть, шаговый даг узнаёт у первой задачи. Спрашивают её все
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш. # генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}" PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["пульт мира"] TAGS = ["модельное время"]
@task @task
@@ -120,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
def _ingest_orders() -> TriggerDagRunOperator: def _ingest_orders() -> TriggerDagRunOperator:
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился. """Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
не должен переживать отказ выше (ADR 0003). не должен переживать отказ выше (ADR 0003).
""" """
@@ -128,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
task_id="trigger_orders_ingest", task_id="trigger_orders_ingest",
trigger_dag_id="orders_ingest", trigger_dag_id="orders_ingest",
wait_for_completion=True, wait_for_completion=True,
# Умолчание — минута опроса, а весь прогон работника пачкой короче. # Умолчание — минута опроса, а весь прогон шагового дага пачкой короче.
poke_interval=10, poke_interval=10,
) )
@@ -151,7 +151,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
# (params.rst, Airflow 3.3), чтобы правило было видно сразу. # (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=( description_md=(
"Форма задает только число дней. Номер первого несыгранного " "Форма задает только число дней. Номер первого несыгранного "
"дня работник берет из переменной `world_position` (Admin → " "дня шаговый даг берёт из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow " "Variables). Поля Logical date и Run ID нужны самому Airflow "
"и на выбор дня не влияют." "и на выбор дня не влияют."
), ),
@@ -163,7 +163,7 @@ def world_next_day():
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день работник берет из `world_position`. Логическая дата прогона на выбор день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор
дня не влияет. дня не влияет.
""" """
@@ -215,7 +215,7 @@ def world_live_day():
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
на выбор дня не влияет. на выбор дня не влияет.
""" """
+1 -1
View File
@@ -185,7 +185,7 @@ def world_initialize():
@task @task
def wait_for_events() -> None: def wait_for_events() -> None:
"""Дождаться приёма всех событий описи, включая строки брака.""" """Дождаться приёма всех событий паспорта, включая строки брака."""
inventory = json.loads(INVENTORY.read_text(encoding="utf-8")) inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
expected = sum(day["events"] for day in inventory["days"]) expected = sum(day["events"] for day in inventory["days"])
first_date = inventory["days"][0]["date"] first_date = inventory["days"][0]["date"]
+4
View File
@@ -2,6 +2,10 @@
Дата: 3 августа 2026 года. Статус: принято. Дата: 3 августа 2026 года. Статус: принято.
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
`CONTEXT.md`.
## Решение ## Решение
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
+2 -2
View File
@@ -43,7 +43,7 @@
Сетевых ограничений на `default` нет: порты стенда и так открыты только на Сетевых ограничений на `default` нет: порты стенда и так открыты только на
`127.0.0.1`, а внутри сети Compose дверь заперта паролем. `127.0.0.1`, а внутри сети Compose дверь заперта паролем.
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где Раздел README «Адреса и пароли» описывает, кто каким пользователем ходит и где
живут пароли; здесь остаются решение и доводы. Отдельного справочника по живут пароли; здесь остаются решение и доводы. Отдельного справочника по
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
чтения через `_v` описана в доке хранилища. чтения через `_v` описана в доке хранилища.
@@ -107,7 +107,7 @@
источник истины — файл в git. Стенд считает `access.xml` и переданные через источник истины — файл в git. Стенд считает `access.xml` и переданные через
`.env` значения настройкой при создании контейнера; отдельного способа `.env` значения настройкой при создании контейнера; отдельного способа
применять их на живом стенде нет, потому что уроки не меняют базовую модель применять их на живом стенде нет, потому что уроки не меняют базовую модель
доступа. Порядок применения изменений — в разделе README «Состав и доступ». доступа. Порядок применения изменений — в разделе README «Адреса и пароли».
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
реплицированное в keeper — делают пользователей состоянием тома: `.env` на реплицированное в keeper — делают пользователей состоянием тома: `.env` на
них больше не влияет, и появляется расхождение того же сорта, которое README них больше не влияет, и появляется расхождение того же сорта, которое README
+4
View File
@@ -3,6 +3,10 @@
Дата: 12 августа 2026 года. Статус: частично заменено Дата: 12 августа 2026 года. Статус: частично заменено
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93. [ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» —
шаговым дагом. Текст ниже оставлен в словах своего времени, действующие
имена — в `CONTEXT.md`.
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
+4
View File
@@ -2,6 +2,10 @@
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79. Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
действующие имена — в `CONTEXT.md`.
## Решение ## Решение
Ось модельного времени двигают даги пульта, и они двух родов. Ось модельного времени двигают даги пульта, и они двух родов.
+3 -3
View File
@@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт, - **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
DDL ODS и лекции. DDL ODS и лекции.
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы - **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
колонки читает ODS. `product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
- **`_load_ts`** — у представления нет собственной загрузки, а чужие - **`_load_ts`** — у представления нет собственной загрузки, а чужие
координаты слой не переносит (правило слоя — [модель координаты слой не переносит (правило слоя — [модель
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
+86 -107
View File
@@ -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` приёма связывает годные строки, ошибки и сырьё; дальше В таблице ошибок `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.
+92 -85
View File
@@ -1,117 +1,124 @@
# Модель сессии в DDS # Модель сессии в DDS
Учебный результат: менти строит расчётную сущность, которой нет в потоке, — Сессия объединяет события одного визита. На стенде вы учитесь определять
режет события на сессии по правилу визита, чеканит идентификатор и сверяет ее границы по событиям, вычислять устойчивый идентификатор и сверять
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый `VisitID`. Этот идентификатор источника остается в ODS для проверки.
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
## Зерно ## Зерно
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие Зерно описывает, чему соответствует одна строка: здесь это один
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток. [визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
В нарезку входят все три типа событий — торговые садятся на свои страницы события одного `client_id` без пауз длиннее 30 минут и без перехода через
и держат сессию наравне с просмотрами. События внутри сессии модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
приёма).
Граница суток проходит по модельному дню счётчика, и он у события уже есть: День сессии `session_date` берется из `event_date` событий DDS — это
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех `EventDate` источника, дата по часовому поясу счетчика. У всех событий
её событий `EventDate` один; он и становится **днём сессии** — колонкой одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
партиции, без пересчёта.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор ## Идентификатор
Идентификатор сессии в природе не существует: сессия — расчётная сущность, `session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат, При повторной сборке тех же событий посетитель и начало визита остаются
отчеканенный генератором в роли Метрики; наш — `session_id`, прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`). выдали бы другие значения.
Детерминированность обязательна: партиция дня пересобирается заменой, и
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: По `session_id` можно сопоставить сессии до и после пересборки и назвать
в соединениях он не участвует — события его не несут, мост к заказам живёт конкретную строку при разборе расхождения. Он не используется для сортировки
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со или шардирования. У событий этого поля нет; связь с заказами строится
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений. по событиям покупки.
## Состав строки ## Состав строки
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по - `client_id`, `session_id`, `session_date` — посетитель, сессия и ее день.
ней состав и собран: - `started_at`, `finished_at`, `duration_seconds` — время первого и последнего
события и разница между ними в секундах. Времена хранятся в часовом поясе
счетчика.
- `events_total`, `pageviews`, `cart_adds`, `purchases` — число всех событий
и счетчики трех типов.
- `entry_url`, `exit_url` — страницы первого и последнего события.
- `utm_*` — метки первого события: по ним визит относят к рекламной кампании.
- `device_category`, `region_city` — устройство и город посетителя.
На стенде они постоянны для одной куки.
- `_load_id`, `_load_ts` — идентификатор запуска и время сборки строки.
- **ключи и времена**: кука, `session_id`, начало и конец сессии, В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
длительность, день сессии. Времена — в поясе счётчика по правилу слоя; устройство и город — через `argMin`, только по `event_time`. При одинаковом
- **счётчики**: событий всего, просмотров, корзин, покупок; времени выбор между событиями не определен. Полный порядок окон нарезки
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
списка страниц нет и у Метрики, топы считаются по событиям; в [задании ступени 4](../../labs/4-1-session-pipeline.md).
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
и город — у куки они постоянны, берутся с первого события;
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
([модель заказа](order.md), «Служебные колонки»).
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
счётчики торговых событий.
## Движок и наполнение ## Движок и наполнение
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу `dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии. версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением, раздел таблицы за один `session_date`; ключ сортировки — посетитель
что события, — это ко-локация с `dds.identity_map_rep` ([дока и начало сессии. Читайте результат через `dds.session_v`: представление
хранилища](../storage.md), «Раскладка по шардам»). над `dds.session_dist` явно перечисляет деловые и служебные колонки.
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
источник приходит раз в сутки. Собирается день, который мир уже прожил, и распределены по одному ключу `cityHash64(client_id)`: данные посетителя
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции находятся на одном шарде. Роли объектов и правило записи через `_dist`
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Публичная поверхность — `dds.session_v`: обычное представление с явным Три SQL-файла выполняются последовательно в группе `session` дага
списком деловых и собственных служебных колонок над `dds.session_dist`. `dds_transform`:
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии 1. `session_scope.sql` находит первую дату событий, за которую еще нет
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки. сессий. Последний день для расчета — первый день событий ODS
Механика запуска — где собирается партиция и как подменяется на кластере — плюс `world_position` минус один; `world_position` — номер следующего
не входит: это устройство [дневного конвейера](../etl.md). дня для генерации. Так в расчет попадают только полностью
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
пропускается.
2. `session_rebuild.sql` очищает промежуточную таблицу
`dds.session_stage_rep`. Затем рассчитывает сессии выбранных дней
и записывает строки через `dds.session_stage_dist`. Пока идет расчет,
в основной таблице остаются прежние данные. Вставка ждет доставки
строк на шарды благодаря `distributed_foreground_insert = 1`.
3. `session_replace.sql` заменяет в `dds.session_rep` раздел за каждый
выбранный день готовым разделом `dds.session_stage_rep`. Для каждого
дня Airflow создает отдельную задачу `replace`.
Когда все события завершенного дня приняты в ODS, их набор на стенде
больше не меняется. Поэтому готовые сессии обычно не пересчитываются. Если пропущен ранний
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
Если за день сохранилась хотя бы одна сессия, поиск пропущенных дней
не обнаружит потерю остальных. Условия повторного запуска и замены
разделов описаны в [справочнике конвейера](../etl.md#идемпотентность).
Сессий еще идущего дня в модели нет; его события доступны через
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
в ODS — см. [готовность источника](../etl.md#готовность-источника).
## Сверка с эталоном ## Сверка с эталоном
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
компенсация ошибок внутри одной куки за день; эталон в модель не тащим — скрывает `VisitID`.
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки — Нулевое число расхождений подтверждает только число сессий каждого
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт посетителя за день. Например, ошибочное объединение двух визитов
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v` и разделение другого на два сохранят их общее число. Поэтому в заданиях
([контракт события](event.md)). вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели ## Читатели
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в - Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/session-overview.md) (#135). в ее [ТЗ](../../labs/1-3-session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше). - DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой. потом сравнивает — с эталоном и со стендовой.
+179 -148
View File
@@ -1,184 +1,211 @@
# Слой DM: витрины # Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
принимает витрина, кто какие витрины строит и как слой проверяется. Состав хранения, правила обновления и проверки. Состав колонок задан в
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма, [спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
наполнение и контроль. и [учебных заданиях](../labs/README.md).
## Форма: по зерну, не по цене запроса ## Таблица или представление
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, — Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным а в дневной выручке — день и категория товара. На стенде результат
списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её с новым зерном хранится в таблице: отчет читает готовые итоги без
`dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно повторного расчета. Таблицу наполняет `dm_transform`.
источника — переименование, обогащение строки, — остаётся обычным
представлением: копию событий ради других имён колонок не держит ни один
боевой склад.
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
представлениями обошлось бы всё: условия сквозь цепочку представлений представление `_v` с явным списком колонок. Для обновления по дням
проходят ([исследование](../research/2026-08-22-view-predicate-pushdown.md)). нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
Но пересборка агрегатов конвейером — производственный паттерн, ради В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
которого стенд и строится, а пара «таблица рядом с представлением» Затем дневные разделы основной таблицы заменяют готовыми разделами
показывает: форма — решение по роли витрины, у одного слоя законны обе. промежуточной. Такие разделы называются партициями. Подробности — в
[справочнике хранилища](storage.md#имена-объектов).
## Кто строит: стенд и менти Если запрос только переименовывает или дополняет поля и сохраняет одну
строку на событие, достаточно обычного представления. Оно выполняет
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и На небольших данных стенда и агрегаты могли бы вычисляться при чтении
инфраструктуру контроля: ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
Готовые таблицы нужны в программе для практики загрузки, обновления
и включения витрин в конвейер.
- `revenue_daily` — выручка по дням и категориям; образец материализованной ## Что готово в стенде, а что остаётся заданием
витрины, на него ссылаются учебные задания;
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
[картой идентичностей](dds/identity.md);
- `dq_summary` — сводка проверок качества (ниже).
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с Готовы три витрины дашборда и сводка качества:
образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит - `revenue_daily` — одна строка на день заказа и категорию товара;
от выполненных домашек. Свой дашборд по витринам менти — образец дневной пересборки для учебных заданий.
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство - `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
стендового дашборда — в [доке зоны Superset](superset.md). из кликстрима с заказом магазина и класс расхождения.
- `daily_traffic` — одна строка на день: число посетителей и известных
пользователей с учетом [карты идентичностей](dds/identity.md).
- `dq_summary` — одна строка на день данных и проверку качества.
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
Представление `events_enriched_v` отдельной задачи не требует.
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
Дашборд по своим витринам можно собрать в
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
Устройство стендового дашборда — в [справочнике Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
День витрины пересобирается, пока может измениться его источник, — Дни для пересчета выбирают по тому, как меняется источник:
поэтому ритмов три, по числу источников:
- **от событий и сессий** — день замерзает сразу; пересборка догоняет - События и сессии прожитого дня на стенде больше не меняются. Витрины
хвост несобранных дней заменой дневных партиций; по ним рассчитывают показатели от первого пропущенного дня до последнего
- **от заказов** — день дышит, пока входит в последний слепок; пересборка прожитого и заменяют дневные партиции.
заменяет партиции этого отрезка, дни за ним закрыты по построению; - Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
- **от карты идентичностей** — не замерзает никогда: склейка задает диапазон дат для пересборки; пропущенные дни могут расширить его.
ретроспективна, покупка сегодня делает куку известной во всех её прошлых - Новая связь в карте идентичностей позволяет определить пользователя
днях, а карта пересчитывается целиком. по его прежним визитам: один `client_id` мог встречаться и до покупки,
которая связала его с `user_id`.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику: `daily_traffic` каждый раз добавляет всю историю новой версией
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`, в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
как у самой карты. Дневных партиций у неё нет: партиция — единица замены, строки выбирает представление через `FINAL`.
а эта витрина заменяется только целиком. На стенде такая работа остается
быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149).
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
сторона задает изменяемое окно последним принятым слепком ODS, событийная — из трех дат: первая дата оформления заказа в последнем слепке, первый
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и пропущенный день и первый день с `awaiting_order` — покупкой, для которой
самый ранний день с `awaiting_order`: предварительная строка уже есть в заказ пока не поступил. День ожидания проверяется отдельно: строки за него
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница уже есть в витрине, поэтому он не считается пропущенным.
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
разгон длиннее K и не оставляет старое ожидание замороженным. прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда ## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не и `_load_ts` — идентификатор запуска и время записи. Новая колонка
попадет. физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v` ### `dm.revenue_daily_v`
Зерно: день заказа × категория товара. Витрина читает только оплаченные Одна строка содержит итоги оплаченных заказов за день по категории товара.
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
категорию артикула через `dic.products`. Один заказ с несколькими позициями и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
категории считается в `orders` один раз. `units` складывает количество единиц, становятся одной строкой. Категория артикула берется из `dic.products`.
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Первый запуск строит всю доступную историю. Следующие берут границы дней из `orders` считает разные заказы: несколько позиций одной категории
последнего слепка ODS: только источник знает, какие даты еще может изменить. в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ `revenue` — сумма произведений цены на количество. Скидка и доставка
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
отвечает только на вопрос об объеме. с округлением до двух знаков после запятой.
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в Первый запуск рассчитывает выручку за всю доступную историю. Следующие
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются берут диапазон дат оформления заказов из последнего слепка ODS.
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
шесть категорий стартового мира близки по объему. Более сложный составной ключ левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
распределил бы эту маленькую таблицу без нового урока. только на вопрос об объеме.
В группе `revenue_daily` дага `dm_transform` три шага:
1. [`revenue_daily_scope.sql`](../../sql/dm/revenue_daily_scope.sql)
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
запрос не возвращает строк, и задача завершается ошибкой.
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные
дни и записывает строки через `dm.revenue_daily_stage_dist`.
Вставка ждет доставки строк на шарды.
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
готовой партицией `dm.revenue_daily_stage_rep`.
Повтор замены не добавляет копий строк. Ограничения при частичном отказе
описаны в [конвейере](etl.md#идемпотентность).
Основная и промежуточная таблицы распределяют строки по
`cityHash64(product_category)`. На каждый день приходится не больше шести
категорий стартового мира. Для такой маленькой таблицы достаточно простого
ключа по категории.
### `dm.purchase_vs_orders_v` ### `dm.purchase_vs_orders_v`
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по Одна строка сопоставляет события покупки с заказом магазина. События
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
сумму, а отдельный счет событий оставляет класс `duplicate_event` по формату источника у покупки один номер и одна заявленная сумма.
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны Отдельный счет событий позволяет обнаружить повтор покупки
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)). ([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При Если для строки нет пары, поля отсутствующей стороны получают `NULL`
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total` нулями или пустыми строками, которые сложнее отличить от данных источника.
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку: Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`, `awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без `duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным. события остается отмененным, а не потерянным.
Витрина нарезана по `order_day`, цель и донор шардируются по Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
заменяется по дням. Первичная сборка охватывает всю историю обоих источников; выбранный диапазон и одной вставкой записывает строки в промежуточную
следующие прогоны идут по двустороннему ритму, описанному выше. таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v` ### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения `dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине. с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного Несколько кук, связанных с одним пользователем магазина, учитываются
`known_user`, поэтому `known_users <= visitors`. в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3 чтение через публичные `_v` с `distributed_product_mode = 'local'`
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка не дало локального соединения. Поэтому в запросе явно указан
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому `GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не в `known_users` учитываются только ненулевые `user_id`.
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев Обновление всей истории новой версией описано в разделе
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск [«Ритм пересборки»](#ритм-пересборки).
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
публичное представление выбирает текущие строки через `FINAL`.
## `dm.dq_summary` ## `dm.dq_summary`
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка». Одна строка `dm.dq_summary_v` — день данных и одна проверка.
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow. Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`. Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки: Деловые колонки сводки:
- `data_date` — проверяемый день; - `data_date` — проверяемый день;
- `check_name` — устойчивое имя инварианта; - `check_name` — имя проверяемого правила;
- `reference_rows` — число деловых ключей на эталонной стороне; - `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте; - `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей; - `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`. - `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
перечисляет колонки явно. Порогов и промежуточного состояния нет. сводки. Даже одно расхождение дает `fail`.
## Звено DQ конвейера ## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
атомарных договоров: правил:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон — - `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк `uniqExact(VisitID)` из `ods.event_v`, объект — число строк
`dds.session_v`; `dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в описи мира, объект — число заказов класса в `orders` в паспорте мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`, `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней; `duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
дней;
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`; `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые - `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
@@ -188,43 +215,47 @@ Distributed-таблица вернула бы каждый непарный з
сравниваются с прямыми агрегатами `dds.session_v` и сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`. `dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут Поэтому потерянная строка остается видна на другой стороне сравнения.
скомпенсировать друг друга общим итогом: `failed_rows` считается после Расхождения считаются по ключам: завышение одного показателя не скроет
сопоставления деловых ключей. занижение другого в общей сумме за день.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она `classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор: у настоящего магазина такого оракула нет. сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
только сводку и малый срез диагностики. Техническая сверка с описью читает сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их файл JSON в Python. Результаты всех пяти проверок записываются в общую
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраниться до красного завершения, а ошибка сборки не затирает прежний сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
результат. Красная задача печатает не больше двадцати нарушенных ключей за Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки не выполнен, прежняя сводка остается на месте. Задача с расхождениями
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date, заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`. business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре. Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
Такой день удаляется из сводки отдельно: старая красная строка не должна для него строк. Даг отдельно удаляет прежний результат за этот день
превращаться в историю попыток. из сводки.
## Даг самопроверки ## Даг самопроверки
Домашки контролирует `homework_check` — отдельный даг без расписания: менти `homework_check` запускается вручную и содержит задачу для каждого из
запускает его рукой. По задаче на каждое из [пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
[пяти заданий по витринам](../labs/README.md). расходятся с источником, задача завершается ошибкой. Журнал показывает
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда причину и примеры несовпадений. Успех означает, что проверяемые значения
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины, совпали; остальные требования задания вы проверяете сами.
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum: Ожидаемые значения рассчитываются по исходным данным текущего стенда.
валидационный даг с группами проверок по слоям давал менти обратную связь Для четырех заданий это события или сессии DDS, для сводки брака —
по каждому шагу домашки, не трогая рабочий конвейер. таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
самопроверка учебных витрин отделена от качества готового конвейера.
Готовые SQL-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты ## Отклонённые варианты
@@ -232,7 +263,7 @@ business_key`.
производственный паттерн слоя — пересборка агрегатов — остался бы производственный паттерн слоя — пересборка агрегатов — остался бы
невидим, а `dm_transform` — без работы. невидим, а `dm_transform` — без работы.
- **Материализовать всё.** Копия событий ради переименования колонок — - **Материализовать всё.** Копия событий ради переименования колонок —
антипаттерн, который менти увёз бы в бой. лишняя; такой пример приучал бы без нужды дублировать исходные данные.
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах - **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
стенда нет: сторож без настоящей опасности. стенда нет: сторож без настоящей опасности.
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до - **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
@@ -241,8 +272,8 @@ business_key`.
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого - **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
состояния; изображать его логами — семантика, которую пришлось бы состояния; изображать его логами — семантика, которую пришлось бы
расшифровывать. Красное «ещё не построено» честнее. расшифровывать. Красное «ещё не построено» честнее.
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с - **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы
историей качества конвейера. с историей качества конвейера.
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили - **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
бы число известных на момент закрытия — витрина тихо расходится с картой бы число известных на момент закрытия — витрина тихо расходится с картой
и протаскивает обратно «знание на дату», отвергнутое развилкой карты и протаскивает обратно «знание на дату», отвергнутое развилкой карты
+112 -87
View File
@@ -1,28 +1,23 @@
# Дневной конвейер трансформаций # Дневной конвейер трансформаций
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
трансформации, когда и в каком объёме. Правила самих сущностей — зерно, результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
движки, что именно пересобирается — живут в доках слоя Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
## Форма ## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы `etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
у него нет: он дёргает слойные даги последовательно и ждёт каждый — и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
[доке слоя DM](dm.md). Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком. два запуска одного дага используют одни промежуточные таблицы и могли бы
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
Слойные даги устроены по образцу `orders_ingest`: без расписания, один задачей или группой задач.
активный прогон, запускаются триггером конвейера или рукой при разборе
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
## Три пути запуска ## Три пути запуска
@@ -34,83 +29,113 @@ world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transfor
world_next_day ─────> источники без ETL world_next_day ─────> источники без ETL
``` ```
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним - `world_live_day` запускает конвейер после успешной генерации, приема
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, заказов и обновления `world_position`. Если трансформация упала, день
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но уже прожит и позиция не откатывается. После исправления причины
зеленеть, не узнав, разложился ли день, работник не должен запустите `etl_pipeline` вручную.
([ADR 0003](../adr/0003-dag-run-state.md)). - `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего не запускает. После него запустите `etl_pipeline` сами.
не нужно помнить, см. «Объём прогона». - `world_initialize` запускает конвейер последним шагом: при успешном
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет первом `make up` начальные данные уже собраны в DDS и DM.
генератором и только им. После разгона менти запускает конвейер сам —
и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
## Объём прогона ## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по не нужно: даги определяют их по состоянию таблиц и `world_position`.
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта Например, после генерации семи дней без ETL один запуск рассчитает сессии
идентичностей пересчитывается целиком; правила витрин — у за все семь дней.
[слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона.
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа Для расчета сессий выбираются дни от первого, за который в DDS еще нет
`snapshot_date` равен последнему слепку, в котором источник его показал. сессий, до последнего прожитого.
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них `world_position` хранит номер следующего дня для генерации, поэтому
минимальный и максимальный `order_date`. Это границы дней, состояние которых последний прожитый день равен `min(EventDate) + world_position - 1`.
источник ещё может изменить Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна, Один `max(EventDate)` не годится: события за день есть и во время его
трансформация закрывает пропуски: находит самый ранний день, представленный в генерации, когда последние визиты еще не закончились.
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
границу.
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой Для заказов диапазон задает последний принятый слепок ODS. Среди строк
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан даты оформления заказа. Состояние заказов за эти дни еще может измениться.
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет. Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Для сессий границу задаёт `world_position`: она отличает полностью прожитый Позиция мира для выбора дней заказов не подходит: с событиями дня D
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D, приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди их наличие еще не доказывает полноту слепка.
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
повторно не собирает. Поэтому последний прожитый день равен первому дню Карта идентичностей пересчитывается целиком. Витрины выбирают дни
событий плюс позиция минус один по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)). Если за день сохранилась хотя бы одна строка, запрос не считает его
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не пропущенным. Полноту данных проверяют отдельно.
позиция, а «Готовность источника».
## Готовность источника ## Готовность источника
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд. Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы условий:
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
Только после этого шаг проверяет обе ноды ClickHouse через 1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не Подтвержденные смещения сравниваются с верхними смещениями Kafka.
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и Группа и смещения должны существовать, суммарное отставание — равняться нулю.
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят 2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
один крайний срок. После Kafka срок не отсчитывается заново. доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
Зелёный приём заказов о событиях не говорит ничего — топики разные и Опросы идут с паузой в три секунды; на обе стадии задан общий срок
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог 300 секунд. Журнал показывает смещения и отставание Kafka, затем число
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард. файлов и `last_exception` очереди на каждой ноде. Если готовность не
Два барьера закрывают обе части пути до того, как сессии выберут свой объем. подтверждена за срок ожидания, задача завершается ошибкой.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность ## Идемпотентность
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник — Идемпотентность означает, что повторная обработка тех же исходных данных
вставка через Distributed раскладывает строки по шардам, — затем не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта при новой сборке меняются.
механика — часть самих трансформаций и строится этапом 4: без неё дневной
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без Результат дневного расчета сначала записывается в промежуточную таблицу.
замены: полная перезапись в `ReplacingMergeTree` — так живут карта Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)). еще рассчитываются. Например, для сессий это `dds.session_stage_rep`,
а основная таблица — `dds.session_rep`.
Задачи выполняются в таком порядке:
1. `scope` выбирает даты для расчета.
2. `rebuild` очищает промежуточную таблицу, рассчитывает строки за выбранные
дни и записывает их через `_stage_dist`. Настройка
`distributed_foreground_insert = 1` заставляет вставку дождаться
доставки строк на шарды.
3. `replace` заменяет в основной таблице раздел за каждый выбранный день
готовым разделом промежуточной таблицы. Раздел таблицы называется
партицией; для замены используется `REPLACE PARTITION`.
Структура и распределение строк по шардам у двух таблиц должны совпадать.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. На каждом шарде
читатель видит либо старый, либо новый раздел целиком — это атомарность
замены. Общей транзакции для двух шардов и всех дней нет: при сбое один
шард может уже содержать новый результат, а другой — прежний. Повторить
`replace` можно, пока промежуточная таблица хранит тот же результат;
копии строк в основной таблице от этого не появятся.
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
пропусков ее не выберет. В частности, изменение SQL сессий само по себе
не заставляет пересчитать заполненные дни. Повторная сборка выбранного
дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md).
Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск
добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`.
Публичные представления читают текущие строки через `FINAL`. Старые версии
могут физически оставаться до фонового слияния; повторяемость относится
к результату чтения, а не к числу строк на диске.
## Чего конвейер о себе не знает ## Чего конвейер о себе не знает
@@ -145,9 +170,9 @@ ODS и ещё не собранный в DDS, затем при необходи
## Отклонённые варианты ## Отклонённые варианты
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон — - **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
весь день, но пульт — про ось времени: работников два, шаги дублировались весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
бы, а этапу 5 пришлось бы выдирать их обратно. дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с - **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
режиме слои копили бы отставание молча. режиме слои копили бы отставание молча.
@@ -163,8 +188,8 @@ ODS и ещё не собранный в DDS, затем при необходи
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не - [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
переживает отказ выше: отсюда ожидания в триггерах. переживает отказ выше: отсюда ожидания в триггерах.
- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник - [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый
живого дня и дёргает конвейер. даг живого дня и дёргает конвейер.
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему - [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
прикладным процессом владеет Airflow и почему `world_initialize` кончается прикладным процессом владеет Airflow и почему `world_initialize` кончается
собранными слоями. собранными слоями.
+1 -1
View File
@@ -42,7 +42,7 @@
Новый бросок в середине функции сдвигает все последующие значения этого Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении. канонического мира проявляется в паспорте и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно [спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
+14 -14
View File
@@ -19,23 +19,23 @@
## Почему слои у панелей разные ## Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
разбор — свежесть ODS это покажет, свежесть сырья нет. Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку. Местом диагностики доставленных версий физическая таблица здесь про доставку. Местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`). названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд. сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырья, которая не прошла разбор. отдельная куча, а часть сырых данных, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов. приехавшего и однажды перевалила бы за сотню процентов.
@@ -51,7 +51,7 @@
укладываются в минуты реального времени, и соседние дни мира помещаются в одно укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами. окно панели сами.
## Приземлившееся считают по слою сырья ## Приземлившееся считают в сыром слое
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka` экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
@@ -66,7 +66,7 @@
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse. Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк сырья по тому же интервалу. стороны ClickHouse — `count()` строк STG по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase` Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на растягивает приращение на всё окно и на минутном интервале промахивается на
@@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
один столбец не значит ничего. один столбец не значит ничего.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
он приезжает полем `snapshot_date`. `snapshot_date`.
## Что проверено ## Что проверено
+3 -3
View File
@@ -30,9 +30,9 @@
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы, `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
пересечения выходят арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов — [что хранит
[что хранит опись](inventory.md). паспорт](inventory.md).
- **Мост к склейке**: план состава владеет человеком; его непрозрачный - **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным — `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md). [мост к склейке](identity.md).
+20 -21
View File
@@ -8,18 +8,18 @@
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа, **Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком. решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, **Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
дубль. Довод за разделение — различимость по описи: правка заказной механики за разделение — различимость по паспорту: правка заказной механики не двигает
не двигает хеши событий, правка событийной не двигает байты слепка, и по хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а решения не переживают — они названы по классам витрины, а компонент называет
компонент называет часть мира; новые стороны занимают те же позиции. часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо 3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка *один компонент на всю судьбу* — правка событийной механики молча меняла бы
событийной механики молча меняла бы байты слепка; *компонент на класс* — байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
пять имён под ручки калибровки, которые крутятся разом. крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -62,15 +62,14 @@
процентов» не заводим. процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: строки STG и физические версии `ods.order`
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние убыла. Полное различение не обещано: слепок — состояние на границе суток, и
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у оплата с отменой в один день в сырых данных неразличимы; то же у сильно
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* — как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1, отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
он в резерве.
## Классы расхождений и опоздание ## Классы расхождений и опоздание
+4 -4
View File
@@ -24,10 +24,10 @@
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`; `ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
числа пар контрактом описи не являются. Межкогортные столкновения принимаются закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
по той же дисциплине, что у случайных `ClientID`: принимается конкретный дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
канонический мир по внешнему результату. по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* — пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
+135 -203
View File
@@ -1,240 +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 упадет, эти сообщения уже не будут прочитаны той же группой повторно;
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он часть из них может успеть записаться на шарды. Позиция модельного времени
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
([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. Это результаты прежних опытов на стенде; при редактуре они заново
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные не выполнялись.
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
партицию и на один шард, а `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#актуальные-события).
## Связанные решения ## Связанные решения
+12 -12
View File
@@ -1,20 +1,20 @@
# Что хранит опись # Что хранит паспорт мира
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72) обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
и [«Места заказов в стартовом и [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74). мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md). статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
> Опись хранит только то, чего движение мира не меняет. > Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
> Хеш кладём всегда, счётчик — только когда назван его читатель. > счётчик — только когда назван его читатель.
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты - **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
байты») опись начинает сторожить. паспорт начинает сторожить.
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по - **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
@@ -24,7 +24,7 @@
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных - **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий. не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на - **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
приёма заказов — число заведётся вместе с ней. число заведётся вместе с ней.
+2 -2
View File
@@ -112,5 +112,5 @@
такого значения означает ошибку арифметики источника. такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка — Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
его даром, а хешу слепка в описи нужен именно названный порядок. даром, а хешу слепка в паспорте нужен именно названный порядок.
+8 -8
View File
@@ -25,11 +25,11 @@
Цена принята с открытыми глазами: у части пар стартового мира поздний Цена принята с открытыми глазами: у части пар стартового мира поздний
назначенный заказ падает на день 7, и до первого хода мира этих пар в назначенный заказ падает на день 7, и до первого хода мира этих пар в
`dds.identity_map` нет. Опись пар не считает `dds.identity_map` нет. Паспорт пар не считает ([что хранит
([что хранит опись](inventory.md)), поэтому красной проверки из этого не паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
число верно ровно до первого хода мира; *отменить сдвиг отправки* — верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
перестал бы быть чистой функцией зерна. функцией зерна.
+127
View File
@@ -0,0 +1,127 @@
# Стенд: службы, доступ, настройка
Здесь описано, из каких служб собран стенд, кто к кому подключается и как
настройки доходят до контейнеров. Состав каркаса обоснован в
[ADR 0001](../adr/0001-stand-services.md), доступ к ClickHouse — в
[ADR 0007](../adr/0007-clickhouse-access.md). Адреса и пароли интерфейсов
остаются в [корневом README](../../README.md#адреса-и-пароли).
## Службы
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
- `clickhouse-02` — точка подключения Superset;
- `clickhouse-keeper` — координатор кластера;
- `kafka` — один брокер KRaft;
- `kafka-exporter` — экспортёр метрик Kafka для Prometheus;
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
`airflow` и `superset`;
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
Airflow 3.3 с LocalExecutor, без triggerer;
- `superset` — интерфейс с заранее настроенным подключением к ClickHouse;
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
## Пользователи и роли ClickHouse
В ClickHouse семь пользователей и шесть ролей:
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
пишет слои хранилища;
- `lifecycle` — учётная запись дагов жизненного цикла; роль `lifecycle_owner`
разрешает им применять канонический DDL и пересоздавать прикладные базы. У
рабочих дагов этих административных прав нет;
- `bi` — учётная запись Superset; роль `bi_reader` читает ODS, DDS, DM и
справочники `dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и
справочников;
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
системные таблицы и данные со всех реплик кластера;
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
`dic`; словарь читает через неё свою подложку;
- `default` — служебная учётная запись для проверок состояния и скриптов внутри
контейнеров; приложения с ней не подключаются.
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
[ADR 0007](../adr/0007-clickhouse-access.md).
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
Значения для локального стенда есть в `.env.example`.
## Применение настроек
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
```bash
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
```
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
может оставить старую версию отдельно смонтированного файла.
## Секреты и порты
В локальном учебном стенде намеренно используются простые ненастоящие пароли и
ключи. Kafka и Prometheus работают без проверки доступа. Это не пример настройки
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
опубликован.
## Одна реплика на шард
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
2×2 намеренно не входят в стенд.
## Что проверено
**Конфигурация сверена 30 июля 2026 года** с официальной документацией
ClickHouse:
[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
Кластер описан в `remote_servers`, макросы — в `macros`, а подключение к
keeper — в `zookeeper`. Путь `ReplicatedMergeTree` содержит `{shard}` и
`{replica}`.
Для `Distributed` задаются имя кластера, база, локальная таблица и ключ
шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал
отдельный путь для каждого шарда без вписанных вручную значений. В образе
закреплена точная версия текущего LTS-выпуска —
[26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
серверы и keeper используют один образ.
Настройки Kafka 4.3.1 сверены с
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
Раздел метрик взят из конфигурации закреплённого образа ClickHouse и проверен
на серверах и keeper. Автоматическая настройка источников данных Grafana
сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
Prometheus собирает встроенные метрики двух серверов и keeper, а через
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
мониторинга](monitoring/README.md), разбор панелей — в
[описании дашборда](monitoring/data.md); правил оповещения
пока нет.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
публичный `airflow.sdk`, API проверки состояния и SimpleAuthManager сверены с
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
общий `SQLExecuteQueryOperator`, программные запросы идут через
`ClickHouseHook`. Провайдер работает поверх закреплённого
`clickhouse-connect`. Официальный провайдер Kafka уже использует
`confluent-kafka`. В стенде клиент добавлен в образ напрямую, без отдельного
подключения Airflow и обёрток провайдера.
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, схема адреса
подключения `clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
+361 -405
View File
@@ -7,20 +7,19 @@
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное `Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
событие с таблицей ошибок, поверхность актуального состояния и три матвью. таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
Тот же этап принёс первый справочник — словарь товаров из общего с генератором принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
публичным договором события `dds.event_v` над точным состоянием ODS, а следом события `dds.event_v` над точным состоянием ODS, а следом получил две физические
получил две физические модели слоя — заказ и сессию: их собирает заменой модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
нет, стенд режет её из событий сам. сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
Дальше по тексту устройство описано так, как оно проектируется; построенное от от заложенного отличает карта таблиц в конце.
заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно: Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -31,223 +30,202 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d
## Имена объектов ## Имена объектов
Имя объекта заканчивается тем, что это за объект: Суффикс показывает назначение объекта:
| Суффикс | Что это | | Суффикс | Что это |
|---|---| |---|---|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` | | `_rep` | локальная таблица: хранит данные своего шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной | | `_dist` | таблица `Distributed`: читает данные шардов и распределяет запись между ними |
| `_kafka` | таблица на движке `Kafka` | | `_kafka` | таблица на движке `Kafka`: читает сообщения топика |
| `_file` | таблица на движке `File` — чтец файла | | `_file` | таблица на движке `File`: читает файл |
| `_mv` | материализованное представление | | `_mv` | материализованное представление: обрабатывает вставляемые данные |
| `_v` | обычное представление, публичная поверхность чтения | | `_v` | обычное представление: выполняет сохраненный запрос при чтении |
Суффикс носит каждый физический объект. Голого имени у таблицы не существует: Для запросов к моделям DDS и витринам DM используйте `_v`. Представления
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым явно перечисляют колонки, поэтому добавление поля в физическую таблицу
именем в документах и разговоре понимается сущность, у которой этих объектов не меняет набор полей для читателя. У `dds.event_v` нет отдельной таблицы
несколько. Единственное исключение — словари: у них воплощение одно, шардировать DDS: представление читает события ODS.
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Распространённая конвенция, где голое имя означает локальную таблицу, а В документации имя без суффикса, например `stg.hits_raw`, обозначает
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо сущность с несколькими объектами. Таблицы с таким именем нет: в запросе
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы — нужно указать суффикс. Исключение — словари ClickHouse: `dic.products`
только по `Distributed`» такую тишину переживает плохо. обращается к каталогу товаров, а `dic.products_file` читает его файл.
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются слоях оно называет сущность в единственном числе: `event`, `order`,
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`: `session`. Объекты одной сущности различаются суффиксом и стоят рядом
порядок выдачи `SHOW TABLES` документация не оговаривает. при сортировке имен по алфавиту.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя `_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем `bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях принято использовать `_v`. Это соглашение о чтении, а не ограничение прав;
имя приходит от предметной области и стоит в единственном числе: `event`, причина широкого доступа описана в
`order`, `session`. Граница между «как привезли» и «что это такое» [ADR 0007](../adr/0007-clickhouse-access.md) и
проходит по STG, и имена её показывают.
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
кластера и случайно добавленные в физическую таблицу колонки. У события
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
договор.
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
публичной поверхностью. Конвенцию держат явный список колонок, документация и
ревью, а не запрет на физические таблицы.
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
ClickHouse и отклонённые варианты записаны в
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
## Раскладка по шардам ## Раскладка по шардам
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания — Шард хранит часть строк. При записи через `_dist` место строки выбирает
операций с партициями, ручной переобработки. Правило не про удобство: при записи ключ шардирования. При прямой записи в `_rep` она остается на той ноде,
через распределённую таблицу раскладку определяет ключ шардирования, то есть где выполнена вставка. Поэтому данные записывают через `_dist`,
свойство данных, а при записи в локальную — то, какая нода случайно выполняла а операции с партициями выполняют на локальных `_rep`.
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у У событий ключ — `cityHash64(ClientID)`, у заказов —
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш `cityHash64(order_id)`, у STG и таблиц ошибок — хеш сырой строки.
выбран против перекоса: структурированный числовой идентификатор распределяется Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не сообщения на один шард. У сырой строки и разобранного события разные
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают
шард. по всему кластеру через `_dist`, а не отдельно по каждой ноде.
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и При `INSERT INTO цель_dist SELECT … FROM источник_dist` важна настройка
разобранное из неё событие почти всегда оказываются на разных шардах. `parallel_distributed_insert_select`. При значении `2` и одном кластере
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны — вставка может выполняться локально на шардах, сохраняя раскладку источника
сверять слои можно только через `_dist`. вместо ключа цели. Если ключи различаются, укажите
`parallel_distributed_insert_select = 0`: нода, принявшая запрос,
соберет результат и распределит его по ключу цели. Так сделано в
[`sql/ods/order_parse.sql`](../../sql/ods/order_parse.sql) и при сборке
выручки. Неверную раскладку проверяют по `hostName()` и ожидаемому шарду
строки; общий счет по `_dist` ее не показывает.
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT … События, `dds.session_dist` и `dds.identity_map_dist` распределены
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` — по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
ошибки при этом нет — расхождение видно только пересчётом ключей по по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
`hostName()`. Так половина заказов и легла на оба шарда, где Само совпадение ключей еще не гарантирует локального выполнения запроса
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
`_dist` и писать в `_dist` с другим ключом.
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из Для дневной пересборки DDS и DM результат сначала записывают
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё в промежуточную таблицу. Основная таблица в это время остается доступна
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist` для чтения с прежними данными. Например, заказы записываются через
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют `dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин заменяют разделы основной `dds.order_rep`.
появляются вместе с ними.
Замена партиций не используется для `ods.order`: версии прошлых дней У промежуточной и основной таблиц должны совпадать структура, ключи
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR партиции и сортировки, первичный ключ и политика хранения. Поэтому
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка `dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее ключами. Требования сверены через Context7 с
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
объектом, а не переносить на ODS заранее. для схемы на ClickHouse 26.3. DDL показан в
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
и задвоилась на другом. Доводы целиком — в
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql). [`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
Промежуточная таблица должна также распределять строки по тому же ключу,
здесь — `cityHash64(order_id)`. `REPLACE PARTITION` копирует раздел между
локальными таблицами каждого шарда и не перераспределяет строки.
Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде.
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
доставки данных на шарды.
В `ods.order` этот способ не применяется: туда добавляют версии заказов
из слепков, а актуальные строки выбираются при чтении
([ADR 0010](../adr/0010-order-versions-in-ods.md)).
## Служебные колонки ## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: 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()` только в локальной таблице назвал бы шард-получатель.
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, Таблицы STG используют `ReplicatedMergeTree` с
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и `ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради отдельной строкой; 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 можно разобрать
`_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 сразу в В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
`Array(Decimal(18, 2))`. 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).
## Часовые пояса ## Часовые пояса
@@ -257,16 +235,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде
**Линза называется явно — в типе колонки либо в вызове функции.** Третий **Линза называется явно — в типе колонки либо в вызове функции.** Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
где линза что-то решает — в объявлении хранимой колонки и в выражении, где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
считающем дату, — его не остаётся. Прописать этот пояс своей рукой — дату, — его не остаётся. Прописать этот пояс своей рукой — `<timezone>` в
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции: поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда,
пишется тогда, когда нужна другая линза, чем у колонки: когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика». — это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок **Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
@@ -309,168 +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`, разбираются в ожидаемые типы.
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято Это граница приема: годная строка не означает, что проверен смысл
осознанно — подробности в разделе «Что проверено». всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано `ods.event_mv` принимает строки, для которых условие истинно,
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля `ods.event_errors_mv` — для которых оно ложно. Условие не должно
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы Разбор использует функции без исключений на некорректном сообщении,
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. поскольку ошибка материализованного представления прерывает вставку
и останавливает чтение Kafka до устранения причины.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет ### Доставка и повторы
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с Вставка в Distributed на этом пути фоновая: блок сначала записывается
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). в локальную очередь на диске, затем отправляется на шарды.
Удвоение сырья проходит мимо неё только потому, что при повторном чтении 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 хранит всё, поэтому равенство «сырьё = события + Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не Поэтому повторная отправка модельного дня увеличивает число строк STG.
смотреть на оповещения
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
опыт не становится, и почему — в [карте
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
мержей успело пройти, и спека это прямо запрещает (раздел 6).
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS Пустое значение сообщения и значение `null` не дают строки даже в STG:
против [описи мира](../../data/world-inventory.json), лежащей в git чтец продвигает позицию Kafka, но не передает строку на разбор.
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в Генератор таких сообщений не создает. Поведение измерено на стенде
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён и описано в разделе «Что проверено».
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
одной стороны.
## Срок жизни сырья ### Что можно сверять
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно Для короткого неизменного среза доставки можно проверить баланс:
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, число строк STG равно сумме годных строк и ошибок до удаления повторов.
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL ODS оставляет события и объединяет повторные доставки.
значение проставлено явно, чтобы поведение не зависело от умолчания версии. Счетчики по слоям тогда расходятся при штатной работе.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица `make check-clickhouse` сравнивает актуальные события в `ods.event_v`
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок с [паспортом мира](../../data/world-inventory.json) только за восемь
снимается целиком, только когда в нём истекли все строки, а в партиции модельного начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, и не зависит от срока жизни STG. Границы остальных проверок —
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно. в [карте проверок](testing.md).
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного ## Срок жизни сырых данных
возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
«уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а TTL — правило автоматического удаления старых данных. В обеих таблицах
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
режется своими координатами: и разбор полётов, и переобработка фильтруют по Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по для поиска ошибок и повторного разбора, после которого данные освобождают
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. место на диске.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной дня загрузки возраст строк отличается не более чем на сутки.
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы Поэтому к трем суткам может добавиться до суток ожидания самой свежей
эталонные дни прямо на входе. строки части, а затем ожидание фоновой обработки TTL. Точного времени
удаления правило не обещает.
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе. Модельная дата для TTL не подходит: события июня могут загружаться
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
ноль. тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
продлевать хранение старых строк после слияния частей.
Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
из которых модельную дату извлечь нельзя. `EventDate` появляется
отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
такой срез разбирается рядом с действующим приемом.
## Таблица ошибок ## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым `ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у исходный `raw`, сведения о доставке и класс ошибки `error_class`.
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у Класс имеет тип `LowCardinality(String)` и выбирается по первому
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и нарушенному условию:
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
загрузки разного возраста не более чем на сутки, и куску незачем переживать
срок из-за самой свежей строки.
Класс брака лежит в колонке `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.
## Справочники ## Справочники
@@ -500,13 +458,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а
## Раскладка SQL ## Раскладка SQL
Исполняемые дагами запросы лежат по правилу Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в Слой — слой цели запроса: забор заказов в STG живёт в
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в `sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
порядок выполнения, но не хранит текст преобразований. Два выражения разбора текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между же файле; подключаемого SQL-фрагмента между ними нет.
ними нет.
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
обработчик DAG читает список DDL-файлов, загружает содержимое каждого обработчик DAG читает список DDL-файлов, загружает содержимое каждого
@@ -524,10 +481,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|---|---| |---|---|
| `00-databases.sql` | базы слоёв и зона справочников | | `00-databases.sql` | базы слоёв и зона справочников |
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё | | `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников | | `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё | | `40-stg-views.sql` | матвью приёма: чтец в STG |
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций | | `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS | | `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций | | `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
@@ -537,14 +494,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
правила порядка слоёв к ней не применяются. правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
читать топик, когда к нему привязывают первую матвью; создай её раньше топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
снесены, а данные Kafka целы, — то есть на обычной отладке. обычной отладке.
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
`lifecycle`: один файл — одна последовательная задача `lifecycle`: один файл — одна последовательная задача
@@ -579,13 +536,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|---|---|---| |---|---|---|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | | `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | | `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца | | `stg` | `stg.hits_raw_mv` | наполняет STG из чтеца |
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | | `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | | `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие | | `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
| `ods` | `ods.event_v` | актуальная версия события с полями источника | | `ods` | `ods.event_v` | актуальная версия события с полями источника |
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | | `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | | `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор STG в событие и в ошибки |
| `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа | | `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
| `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_v` | текущая версия заказа на языке источника |
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
@@ -612,9 +569,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки | | `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
`INSERT SELECT` одной задачи `parse_batch` из файла `INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
`sql/ods/order_parse.sql`.
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
@@ -655,11 +611,11 @@ DDL-словарь с файловым источником внутри `user_f
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и **Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку `system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT` значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
лежали на обоих шардах, после явного нуля и повторного разбора того же обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v` одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
сошлись с эталоном, посчитанным прямо из сырья. посчитанным прямо из STG.
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
@@ -683,19 +639,19 @@ DDL-словарь с файловым источником внутри `user_f
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
далёкий» год: 2999-й не разбирается вовсе. далёкий» год: 2999-й не разбирается вовсе.
Про сам приём заказов снято следующее. Предикат формы провода разложил все Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без 661 строку STG, приехавшую при исполнении #93, без исключений и без `NULL`: 13
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому 653 годных и 8 брака, сумма сошлась с общим счётом по каждому `_load_id`,
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
заказа с отрицательным `total`; это дефект генератора, разобранный в отрицательным `total`; это дефект генератора, разобранный в [спецификации
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
порции из пяти строк (две версии одного заказа плюс по одной строке каждого строк (две версии одного заказа плюс по одной строке каждого класса брака)
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с годные ушли в `ods.order`, брак — в `ods.order_errors` с ожидаемыми классами.
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL` Обе версии легли в одну партицию и на один шард; `FINAL` через распределённую
через распределённую таблицу вернул одну строку — ту, что несла позднейшую таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
записала тот же брак второй раз, как и обещано спецификацией приёма. второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, — запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
@@ -758,27 +714,26 @@ DDL-словарь с файловым источником внутри `user_f
вовсе, до 7 августа утверждение держалось на опыте владельца. вовсе, до 7 августа утверждение держалось на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки. - Упавшая матвью роняет вставку и останавливает потребление до починки.
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало
доехало само, без повторной отправки, отставание ушло в ноль, событие само, без повторной отправки, отставание ушло в ноль, событие разобралось.
разобралось. Сносить надо именно распределённую таблицу: вставка в Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на блок в спул и сразу возвращает управление, так что на сносе локальной ошибка
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы всплыла бы фоном и утверждение показалось бы опровергнутым.
опровергнутым. - `_load_ts` в `ods.event` — это метка исходной строки STG, а не время
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня, разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
залитого дважды: у каждого события метка совпала с меткой одной из двух его залитого дважды: у каждого события метка совпала с меткой одной из двух его
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
доставок» ноль, то есть `now64()` в матвью разбора нет. доставок» ноль, то есть `now64()` в матвью разбора нет.
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново - Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после,
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки; легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же
то же сообщение через минуту разобралось штатно. Воспроизведено дважды сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация 2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL,
файлов DDL, только приходит он с другой стороны — не при первом создании, а только приходит он с другой стороны — не при первом создании, а при замене
при замене матвью на работающем стенде. Практический вывод один: правишь матвью на работающем стенде. Практический вывод один: правишь матвью — не верь
матвью — не верь ближайшей отправке, повтори её. Чем именно держится ближайшей отправке, повтори её. Чем именно держится задержка, не измерено;
задержка, не измерено; наблюдение записано как наблюдение. наблюдение записано как наблюдение.
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение - Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а `intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11 `SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
@@ -811,18 +766,19 @@ DDL-словарь с файловым источником внутри `user_f
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`) Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том
том же наборе даёт ровно те же две строки и тот же офсет. же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он. самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это
он.
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно <локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
@@ -865,7 +821,7 @@ Kafka с пустым значением (ноль байт) и запись-н
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив - **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается `purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки. ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше. матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+93 -4
View File
@@ -36,7 +36,7 @@ Airflow. В ClickHouse он не пишет ничего.
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет | | `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid | | `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` | | `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
| `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика | | `charts/*.yaml` | девять графиков: четыре крупных числа, столбики выручки и кольцо долей, сводная таблица сверки, линии трафика и доля известных |
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей | | `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график — Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
@@ -46,20 +46,51 @@ Airflow. В ClickHouse он не пишет ничего.
Uuid у стенда розданы рукой: общее начало Uuid у стенда розданы рукой: общее начало
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3` `19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
у датасетов, `101`–`103` у графиков, `201` у дашборда у датасетов, `101`–`109` у графиков, `201` у дашборда
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные; (`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
обновления первой. обновления первой.
Датасет несет русские имена колонок, формат денег и метрику — а график только Датасет несет русские имена колонок, формат чисел и метрики — а график только
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
не в каждом графике. не в каждом графике. Два исключения проверены на живом стенде, Superset 6.1.
Подписи оси график берет не из датасета, а из своего `y_axis_format`: без него
ось выручки уходит в «1.2M». И разрез сводной таблицы подписан именем колонки
(`mismatch_class`), хотя колонке даты в той же таблице русское имя достается.
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
запрос, который уже живет в `sql/ddl/60-dm-views.sql`. запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
## Из чего собран экран
Четыре строки панелей:
- ряд крупных чисел — выручка, заказы, средний чек, посетители;
- выручка по дням и категориям, рядом доля категорий кольцом;
- сводная сверки purchase и заказов;
- посетители и известные пользователи, рядом доля известных.
Правая панель пары отвечает на вопрос «сколько из целого» и берет цвета левой:
категория окрашена одинаково на столбиках и в кольце, доля известных — как
линия известных пользователей рядом. Цвет закрепляет `label_colors` в
`metadata` дашборда. Шесть категорий выписаны в него руками по каталогу
генератора (`generator/src/clickstream_generator/catalog.py`): седьмая
останется без закрепленного цвета, пока файл не поправят.
Средний чек и доля известных считаются метриками датасета —
`SUM(revenue) / nullIf(SUM(orders), 0)` и то же для доли известных. Знаменатель
обернут не зря: на периоде без заказов деление Decimal на ноль в ClickHouse —
не `nan`, а исключение, и панель показала бы ошибку вместо пустоты. У витрины
выручки есть готовая колонка `aov`, но среднее по ней дало бы другое число:
день с тремя заказами получил бы тот же вес, что день с тремя сотнями.
«Период» — единственный фильтр дашборда, тип `filter_time` с пустой целью
(`targets: [{}]`). Такой фильтр колонку не называет: он подставляет выбранный
период в то временное условие, которое уже стоит у каждого графика. Поэтому
сверка сужается по `order_day`, а остальные панели — по `report_date`.
## Импорт при каждой инициализации ## Импорт при каждой инициализации
Последняя строка `infra/superset/init.sh`: Последняя строка `infra/superset/init.sh`:
@@ -152,6 +183,17 @@ superset import-directory /app/import --overwrite
завершается нулем в любом случае. Поэтому импорт проверяют глазами или завершается нулем в любом случае. Поэтому импорт проверяют глазами или
запросом к API — но не тем, что команда отработала. запросом к API — но не тем, что команда отработала.
**Знак рубля приходит не из формата валюты.** Символ Superset берет у
`Intl.NumberFormat` по коду валюты и английской локали стенда: код `RUB`
печатается как «RUB», а знак «₽» вызов роняет, и форматирование молча
откатывается к числу без валюты. Поэтому единица стоит в названии панели —
«Выручка, ₽», — а метрика несет обычный формат `,d`.
**`label_colors` знает имя ряда, а не подпись.** Для разреза имя ряда — это
значение колонки («Бытовая техника»), для графика без разреза — имя метрики
(`known_share`), а не ее русская подпись. Цвет, закрепленный за подписью
«Доля известных», не применяется.
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики **`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
исчезнет при следующем старте контейнера. Правка датасета — это правка файла. исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
@@ -163,3 +205,50 @@ superset import-directory /app/import --overwrite
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
[доке проверок](testing.md). [доке проверок](testing.md).
## Отклоненные варианты
Оживить дашборд предложат снова, поэтому отказы записаны с доводами. Замерено
на живом стенде 7 сентября 2026 года, Superset 6.1.
**Карта регионов России.** Замысел стоял на клике: щелчок по региону
перестраивает весь экран. Плагин Country Map кросс-фильтр не отдает вовсе —
`behaviors` не объявлены, `setDataMask` не вызывается, а щелчок приближает
карту. Без клика остается картинка, и она рассказывает меньше, чем кажется: 16
городов стенда сворачиваются в 14 регионов из 83, и больше половины сессий
приходится на один. Карта отдана факультативу «Добавь названия регионов в
отчет»: там читатель собирает справочник сам, и она становится наградой за
него.
**Регион как общее измерение витрин.** Чтобы фильтр по региону действовал на
весь экран, регион пришлось бы завести в каждой витрине: Superset датасеты
между собой не соединяет, а фильтр по колонке, которой нет в датасете панели,
отбрасывается с причиной `COL_NOT_IN_DATASOURCE`. У заказа географии нет — она
бралась бы от человека через карту идентичностей и приписывалась бы сущности, у
которой ее в источнике не было. Вдобавок не сходятся ритмы: витрина от карты
идентичностей не замерзает никогда, а выручка живет окном последнего слепка
([дока DM](dm.md)). Стендовый словарь регионов отклонен раньше и по своей
причине — он повторил бы урок `dictGet` каталога вторым разом.
**Кросс-фильтрация между панелями.** Общих измерений у витрин почти нет:
категория есть только у выручки, а день у сверки называется `order_day` против
`report_date` у остальных. Клик перестроил бы часть экрана и оставил другую
нетронутой, а вывод из этого читатель делает про данные, а не про устройство
витрин. Панель фильтров этой болезнью не болеет — там колонка задается явно для
каждой витрины, — поэтому фильтр периода дашборду разрешен, а кросс-фильтрация
нет.
**Вкладки и блоки «что смотреть».** Вкладки были условным вариантом: их
заводят, когда экран перестает читаться одним взглядом. Четыре строки панелей
читаются сразу, поэтому вкладки спрятали бы половину экрана за щелчком без
выигрыша. Блок с текстом «что смотреть» рядом с панелью означал
бы, что панель сама себя не объясняет: тогда чинить надо название и подписи, а
не дописывать к ним пояснение.
**Пороги и раскраска панелей.** Правило
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел 9: на дашборд смотрят
глазами, красное и зеленое живет в проверках.
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
заказа появляется адрес доставки; витрины получают общее измерение по своей
причине, не ради дашборда.
+96 -45
View File
@@ -23,9 +23,9 @@
заметит потерянного подключения Superset: он про ClickHouse и только. заметит потерянного подключения Superset: он про ClickHouse и только.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
они подошли бы смоуку. подошли бы смоуку.
## Карта целей ## Карта целей
@@ -36,19 +36,19 @@
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make smoke` и `make check-services` перемерены 28 августа 2026 года, `make smoke` и `make check-services` перемерены 28 августа 2026 года,
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа, `make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с корневой `make lint` — 3 сентября, `make test` — 30 августа; остальные стоят с
замера 6 августа. замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена | | Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---| |---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | корень | Код стенда и сборщик карты отформатированы и проходят ruff; готовый HTML карты соответствует исходникам | не нужен | 0,8 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с | | `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с | | `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с | | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с | | `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -76,9 +76,18 @@
Airflow. В цену целей она не входит. На готовом мире подъём больше не Airflow. В цену целей она не входит. На готовом мире подъём больше не
пересчитывает данные: `world_initialize` зеленеет без изменений. пересчитывает данные: `world_initialize` зеленеет без изменений.
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
подготавливает администратора и два подключения к `clickhouse-01`. Второй
обновляет Superset, создаёт администратора и импортирует подключение к
`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с
пустых томов занял около десяти минут, повторный на живом стенде — около трёх.
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет `make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL, прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
их тома она сохраняет, поэтому возврат к каноническим данным не требует их тома она сохраняет, поэтому возврат к каноническим данным не требует
холодного запуска всего стенда. холодного запуска всего стенда.
@@ -119,13 +128,12 @@ ClickHouse отвечает сразу.
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test` Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
из каталога `generator/`: стенд им не нужен, а `make test` из них самая из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`. дорогая. Правки дагов, Superset и карты — корневой `make lint`.
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
узнать, тот ли получается мир, его надо получить. Место выбрано по той же узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -163,10 +171,10 @@ ClickHouse отвечает сразу.
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
становится темой урока. становится темой урока.
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
их. Хуже явного мусора: явный видно. Хуже явного мусора: явный видно.
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.** Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
Дешевле всего это выходит партицией — опыт играет модельный день за границей Дешевле всего это выходит партицией — опыт играет модельный день за границей
@@ -175,10 +183,10 @@ ClickHouse отвечает сразу.
же механизм, на котором стоит переобработка дня X. же механизм, на котором стоит переобработка дня X.
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех, Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
что стенд произвёл сам: подневный счёт событий против описи мира ловит и что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге, сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
Kafka → STG → ODS, и живёт он в `make check-clickhouse`. STG → ODS, и живёт он в `make check-clickhouse`.
Правило, которое такая проверка обязана выдержать и которое стоит держать в Правило, которое такая проверка обязана выдержать и которое стоит держать в
голове для следующей: **на растущих данных утверждать можно только про голове для следующей: **на растущих данных утверждать можно только про
@@ -237,8 +245,51 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое
утверждает смоук. утверждает смоук.
### Что спрашивает смоук
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и
подготовленное подключение к `clickhouse-01`.
Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`.
Оставлены они потому, что первый вопрос к стенду всё равно «всё ли живо», а
ответ на него стоит меньше секунды. Устройство keeper — другое дело: он должен
работать от пользователя `clickhouse`, с пределом в 262144 открытых файла и со
своим томом под данные. Всё это объявлено в `compose.yaml`, но здоровым keeper
выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли
объявленные настройки до процесса.
Kafka по той же причине спрашивают снаружи. Её `healthcheck` обращается к
брокеру изнутри контейнера и по внутреннему слушателю, поэтому здоровой Kafka
остаётся и тогда, когда объявленный наружу адрес ведёт не туда. Клиент с машины
в этом случае подключается, получает метаданные и молча виснет на адресе,
которого с его стороны не существует. Смоук идёт тем же путём, что и будущий
генератор: стучится в отображённый порт и просит список топиков.
### Что спрашивают глубокие проверки
`make check-services` проверяет то, ради чего приходится ждать службу: работу с
топиком Kafka с машины — создан, найден, удалён, — ручной запуск пробников
`test_clickhouse` и `test_kafka`, вход в Superset, его метаданные и подключение
к `clickhouse-02`. Первый пробник создаёт таблицы на обеих нодах и читает через
`Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka
и читает свой маркер. Временный топик проверки с машины и запуски DAG удаляются;
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая из
десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — подневный
счёт событий против паспорта, о нем сказано выше.
## Что проверено ## Что проверено
**Перезамер 3 сентября 2026 года при исполнении #222.** `make lint` прошел за
0,768 секунды; в таблице 0,8 с. `ruff format --check` проверил 13 файлов стенда,
включая исходники карты, а затем проверка рендера подтвердила свежесть HTML.
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.** **Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
сверены замером внутренностей генератора — окно 28 модельных дней, сверены замером внутренностей генератора — окно 28 модельных дней,
@@ -304,11 +355,11 @@ wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
time make test time make test
``` ```
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт: Паспорт пересобрался за 20,515 с. Файловый приемник записал 460 731 812 байт:
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 за
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов стало
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней 437 против 407, и потяжелел сам мир — та же пересборка восьми дней стоила 13 с,
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня. теперь 20,5. Эта правка добавила один тест — проигрыш дня.
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал **Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел 28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
@@ -416,45 +467,45 @@ shell-версия узкой пересборки за 2 м 25 с пересо
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за `make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
3 м 35 с. Отправка семи слепков заняла около 28 секунд. 3 м 35 с. Отправка семи слепков заняла около 28 секунд.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа
цену нынешнего стенда, а не выносят вердикт о регрессии. здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии.
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой **Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
24 секунды. Это цена всей цели, а не одной новой строки. 24 секунды. Это цена всей цели, а не одной новой строки.
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона **Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На подряд — 0,39, 0,36 и 0,39 секунды на трех файлах стенда, которые тогда входили в проверку; в таблице 0,4 с. На
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff. машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора. перечислял ровно три файла стенда, из `generator/` — только файлы генератора.
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
от файла, и ближний выигрывает. от файла, и ближний выигрывает.
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля **Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: (`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test` `check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для 58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
#41 и #43. #41 и #43.
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала `ods.event_rep` — проверка покраснела, показала недостающий день и назвала
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и Затем положили в STG заведомо негодную строку — брак появился, и диагноз
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с сменился на второй, с признаком для различения: «сойдётся недостача с числом
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил 401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
счёт под `FINAL`. `FINAL`.
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону: совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
+164
View File
@@ -0,0 +1,164 @@
# Мир и модельное время
Оператор управляет модельным временем стенда. Здесь разобрано, как рождается
мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы
об устройстве дагов модельного времени — в [ADR
0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном
цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день
стартового мира неполон и что хранит паспорт, разобрано в [справочнике
стартового мира](orders/start-world.md) и [справочнике
паспорта](orders/inventory.md).
## Стартовый мир
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и
те же.
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
виден разный темп потокового трекера и ночной выгрузки магазина.
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
явной командой `make rebuild-storage`.
## Паспорт мира
Сам мир в git не хранится: он вычисляется из зерна, поэтому готовая копия была
бы лишь кэшем. Вместо него лежит **паспорт мира**,
[`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия
генератора, хеш каталога товаров и по строке на каждый день — дата, число
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов
расхождений по заказам. Паспорт отвечает на единственный вопрос: тот ли это мир,
что был вчера.
Спрашивают его двое. `make test` в `generator/` сверяет паспорт с тем, что
собирается из кода сегодня: правка генератора меняет мир, и паспорт надо
пересобрать — `make inventory` там же. `make check-clickhouse` в корне по дням
сверяет паспорт с данными, поступившими в `ods.event`. Хеш дня можно пересчитать
и руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --file tmp/day0.jsonl
sha256sum tmp/day0.jsonl
```
## Как запустить генератор
События производит генератор из [`generator/`](../../generator/). Модельный день
— функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
события: обрыв лечится повтором. Позицию на оси генератор не помнит — номер дня
передаёт вызывающая команда.
Режима два, и различаются они темпом. В **пакетном** режиме события дня
отправляются подряд, без пауз: так заливается мир и так переигрывается день
после обрыва. **Живой** держит темп модельного времени — по умолчанию ×60:
модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах,
отставание видно в журнале.
На стенде генератор запускается из отдельного образа как разовая служба. После
прогона её убирают. Для каждого режима есть своя цель:
```bash
# день D0 целиком в топик hits
make generate-batch
# первая сотня событий дня D3
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
# день D3 живьём, ускорение ×1000
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
```
| Переменная | Что задаёт | Умолчание |
| --- | --- | --- |
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
Адрес назначения для обеих целей задаёт стенд: внутри сети Compose это брокер
`kafka:9092` и топик `hits`.
При обычном запуске образ генератора не пересобирается. Если образа нет, Compose
соберёт его; если есть, использует готовый. После правки `generator/Dockerfile`
или зависимостей образ пересобирают отдельной командой:
```bash
docker compose --profile generator build generator
```
Для заливки стартового мира нужен тот же образ. Перед такой заливкой
`world_initialize` собирает его из текущего дерева; на готовом мире повторный
`make up` до сборки не доходит.
Сборка образа и запуск контейнера разделены намеренно. Если цель запуска сама
пересобирает образ, различие между этими действиями исчезает. Следующий запуск
покажет, что образ устарел.
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --limit 100 --file tmp/day0.jsonl
```
За один запуск можно проиграть несколько дней подряд: `--days 8`. Всё, что
принимается флагами, принимается и переменными окружения — так генератор
запускают даги модельного времени; полный список у `--help`.
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
сам. День на оси, приёмник и имя топика относятся к конкретному запуску
генератора на стенде: если их не задать, он откажется работать и вернёт
ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
## Даги модельного времени
Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
два дага создают начальное состояние мира, а три двигают ось дальше.
| Даг | Что делает |
| --- | --- |
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
шаговый даг, а позиция остаётся на месте ([ADR
0008](../adr/0008-order-ingestion.md)).
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
дня. Шаговый даг записывает позицию только после успешного завершения дня:
оборванный прогон позицию не двигает, и следующий запуск играет тот же день
заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` —
очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не
завершена. Шаговые даги начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт
из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced
Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и
новую позицию шаговый даг пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и
доводы целиком — [ADR 0009](../adr/0009-world-control.md).
**У этого решения есть цена: планировщик Airflow подключён к сокету Docker.**
Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету
равен правам root на машине; для локального учебного стенда это приемлемо, но
знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в
группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать
его можно командой `getent group docker`.
+47
View File
@@ -0,0 +1,47 @@
# Путеводитель по стенду
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
на запущенном стенде перед переходом к учебным заданиям.
## Как читать
Читайте главы в порядке ниже. Незнакомые термины можно найти в
[словаре понятий](../../CONTEXT.md), а подробности устройства —
в [технических справочниках](../architecture/). К ним достаточно обращаться
по мере необходимости. После путеводителя переходите к
[учебным заданиям](../labs/README.md).
## Карта хранилища
[Интерактивная карта](architecture-map.html) показывает путь данных через слои.
Gitea показывает HTML как текст. Откройте файл из своей копии репозитория
или скачайте его кнопкой «Raw» и откройте в браузере.
Сверяйтесь с картой при чтении главы «Хранилище и его слои».
## Главы
### [Хранилище и его слои](storage-layers.md)
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM.
В конце главы вы посмотрите события, сессии и выручку с помощью SQL.
### [Потоки Airflow и даги модельного времени](airflow-dags.md)
Какие даги создают новые данные, а какие обрабатывают их в хранилище.
Вы запустите генерацию и обработку за день и посмотрите результат в витринах.
### [Мониторинг: куда смотреть](monitoring.md)
Что показывают панели Grafana и как меняются их показания, пока в стенд
поступают события и заказы.
### [Немного Superset](superset.md)
Как Superset строит графики по витринам и что происходит с вашими отчетами
при перезапуске стенда.
### [Генератор: откуда берутся данные](generator.md)
Откуда берутся события и заказы, почему начальные данные повторяются
и как сверить число загруженных событий с ожидаемым.
+128
View File
@@ -0,0 +1,128 @@
# Потоки Airflow и даги модельного времени
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
этапы: сначала в Kafka поступают события и заказы за день работы магазина,
затем по ним рассчитываются данные DDS и витрины DM.
```mermaid
flowchart LR
world_initialize --> etl_pipeline
world_live_day --> etl_pipeline
manual[запуск рукой] --> etl_pipeline
etl_pipeline --> dds_transform --> dm_transform --> dq_check
world_next_day --> sources[источники без ETL]
```
## Два потока входят по-разному
События поступают в топик `hits` и обрабатываются по мере поступления:
материализованные представления переносят строки из STG в ODS без участия
Airflow.
Заказы поступают в топик `orders` раз в модельные сутки одной выгрузкой.
Она содержит состояние заказов на конец дня и называется слепком.
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется
[пакетным](../../CONTEXT.md).
## Даги мира
В документации миром называется учебный магазин с его посетителями,
товарами и заказами. Время в нем идет отдельно от календарного.
Два дага с тегом «жизненный цикл мира» создают начальные данные:
| Даг | Что делает |
|---|---|
| `world_initialize` | Создает начальные данные, если их еще нет |
| `world_recreate` | Удаляет прикладные данные и создает их заново |
Для добавления следующих дней есть три дага с тегом «модельное время»:
| Даг | Что делает |
|---|---|
| `world_next_day` | Генерирует данные за следующие дни без пауз между событиями |
| `world_live_day` | Генерирует данные за следующий день постепенно, примерно за 24 минуты |
| `world_live` | Запускает `world_live_day` день за днем, пока снят с паузы |
В переменной Airflow `world_position` хранится номер следующего дня для
генерации. Например, при значении `8` генератор уже отправил события
за дни с 0 по 7.
`world_next_day` и `world_live_day` сначала отправляют события дня D,
затем — слепок заказов за предыдущий день D−1. Они ждут, пока `orders_ingest`
загрузит слепок, и только после успешного завершения дня обновляют
`world_position`. Если генерация или прием данных завершились с ошибкой,
номер дня остается прежним. Подробности — в [справочнике генератора
и модельного времени](../architecture/world.md#даги-модельного-времени).
## Дневной конвейер
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform`
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS,
обновляют витрины DM и проверяют качество результата.
При запуске `etl_pipeline` не нужно указывать даты. Дни для обработки
определяются по `world_position` и по уже загруженным данным в слоях.
Детали есть в
[справочнике конвейера](../architecture/etl.md#объём-прогона).
После `world_next_day` новые события и заказы уже загружены, но данные DDS
и DM еще не пересчитаны: запустите `etl_pipeline` вручную.
`world_live_day` запускает его автоматически и ждет завершения. Поэтому
`world_live_day` может продолжать работу после окончания генерации за день.
## Если даг завершился с ошибкой
Ошибка в `etl_pipeline` не меняет `world_position`: генерация и прием
данных за день уже завершились. После устранения причины ошибки
перезапустите конвейер вручную. Даты для повторной обработки он определит сам.
Кто запускает конвейер, описано в
[разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска).
За качество данных отвечают проверки DQ (Data Quality) в даге `dq_check`.
Если проверка находит расхождение, задача завершается с ошибкой и записывает
в журнал ключи строк, которые ее не прошли. Состав
проверок описан в [разделе «Звено DQ
конвейера»](../architecture/dm.md#звено-dq-конвейера).
## Как найти в интерфейсе
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
заданиях.
## Посмотрите сами
Откройте Airflow по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
`world_position`. Затем запустите `world_next_day` на один день по [инструкции
из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
`orders_ingest` завершился успешно, а в витринах нового дня еще нет.
Запустите `etl_pipeline` вручную и дождитесь его завершения.
В DBeaver, подключенном по
[инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса:
```sql
SELECT report_date, visitors, known_users
FROM dm.daily_traffic_v
ORDER BY report_date DESC
LIMIT 3;
SELECT report_date, sum(revenue) AS revenue
FROM dm.revenue_daily_v
GROUP BY report_date
ORDER BY report_date DESC
LIMIT 3;
```
В витрине трафика уже есть новый день, а в выручке последняя дата —
предыдущий день. Причина в выгрузке заказов: вместе с событиями дня D
приходит слепок за день D−1. Выручка дня D появится после генерации
дня D+1 и запуска конвейера.
## Куда дальше
Детали — в справочниках [конвейера](../architecture/etl.md) и
[мира](../architecture/world.md). Следующая глава — [«Мониторинг: куда
смотреть»](monitoring.md).
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
# Интерактивная карта хранилища
`map.yaml` хранит слои, узлы, стрелки и тексты карточек.
`template.html` задает внешний вид и поведение карты.
`render.py` вкладывает данные в шаблон и пишет файл рядом с главами.
Правьте исходники, а не `architecture-map.html`.
Пересоберите карту командой:
`uv run docs/handbook/architecture-map/render.py`.
Проверьте свежесть готового файла с флагом `--check`.
+693
View File
@@ -0,0 +1,693 @@
title: Карта слоев хранилища
lead: Проследите путь событий и заказов от Kafka до отчетов.
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
intro:
- Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.
- Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.
- Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.
- Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.
- Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже.
streams:
all: Все
events: События
orders: Заказы
dictionary:
title: Словарь
caption: справочные данные
layers:
- id: kafka
title: Kafka
caption: сообщения источников
- id: stg
title: STG
caption: исходные сообщения
- id: ods
title: ODS
caption: данные разобраны по полям
- id: dds
title: DDS
caption: события, заказы и сессии
- id: dm
title: DM
caption: витрины для отчетов
mechanisms:
- id: materialized_view
label: материализованное представление
line: solid
rhythm: непрерывно, по мере прихода сообщений
- id: airflow_dag
label: даг Airflow
line: dashed
rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера'
- id: view
label: представление
line: dotted
rhythm: при запросе к представлению, без копирования данных
nodes:
- id: kafka_hits
name: hits
layer: kafka
order: 0
streams: [events]
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика."
fill:
- "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- label: Формат
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/formats/clickstream-event.md#user-content-описание-выгрузки-событие-кликстрима
- id: kafka_orders
name: orders
layer: kafka
order: 1
streams: [orders]
what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней."
read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика."
fill:
- "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
- label: Устройство слепка
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/snapshot.md#user-content-слепок-и-его-доставка
- id: stg_hits
name: stg.hits_raw_dist
layer: stg
order: 0
streams: [events]
what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts:
- "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`."
- "`stg.hits_raw_rep` — реплицируемая таблица на шарде."
- "`stg.hits_raw_dist` — распределенная таблица для записи и чтения."
- "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
fill:
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
- label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
- id: stg_orders
name: stg.orders_raw_dist
layer: stg
order: 1
streams: [orders]
what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts:
- "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле."
- "`stg.orders_raw_rep` — реплицируемая таблица на шарде."
- "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
fill:
- Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
- label: DDL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
- id: ods_event
name: ods.event_v
layer: ods
order: 0
streams: [events]
what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике."
read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события."
parts:
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
- '`ods.event_dist` — распределенная таблица записи и диагностики.'
- '`ods.event_v` — представление актуального состояния с `FINAL`.'
- '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.'
keys:
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
- '`ClientID` — тот же посетитель, что `client_id` в DDS.'
- '`EventDate` — тот же день, что `event_date` в DDS.'
fill:
- '`ods.event_mv` разбирает строки непрерывно, по мере записи в STG.'
- '`ReplacingMergeTree(_load_ts)` хранит версии, а `FINAL` выбирает актуальную.'
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-актуальные-события
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL представлений
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_event_errors
name: ods.event_errors_dist
layer: ods
order: 1
streams: [events]
what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts:
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.event_errors_dist` — распределенная таблица чтения и записи.'
- '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.'
fill:
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
- Ошибки хранятся один месяц. Остальные события продолжают загружаться.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_order
name: ods.order_v
layer: ods
order: 2
streams: [orders]
what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON."
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
parts:
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
- '`ods.order_dist` — распределенная таблица записи и диагностики.'
- '`ods.order_v` — представление текущего состояния с `FINAL`.'
keys:
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
- '`user_id` — пользователь магазина, который переходит в заказ DDS.'
fill:
- Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-версии-заказов
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_order_errors
name: ods.order_errors_dist
layer: ods
order: 3
streams: [orders]
what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts:
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.order_errors_dist` — распределенная таблица чтения и записи.'
fill:
- Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.
- Ошибки хранятся один месяц. Остальные заказы продолжают загружаться.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- label: DDL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- id: dds_event
name: dds.event_v
layer: dds
order: 0
streams: [events]
what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории."
read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS."
parts:
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
keys:
- '`event_id` — тот же идентификатор, что `WatchID` в ODS.'
- '`client_id` — тот же посетитель, что `ClientID` в ODS; соединяет событие с сессией и картой идентичностей.'
- '`event_date` — тот же день, что `EventDate` в ODS.'
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
fill:
- "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
- label: DDL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
- id: dds_session
name: dds.session_v
layer: dds
order: 1
streams: [events]
what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты."
read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки."
parts:
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
- '`dds.session_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.session_v` — представление для чтения готовых данных.'
keys:
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
fill:
- "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией."
- "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
- id: dds_order
name: dds.order_v
layer: dds
order: 2
streams: [orders]
what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа."
read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы."
parts:
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
- '`dds.order_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.order_v` — представление для чтения готовых данных.'
keys:
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
- '`user_id` — пользователь магазина, который входит в карту идентичностей.'
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
fill:
- "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS."
- "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
- id: dds_identity
name: dds.identity_map_v
layer: dds
order: 3
streams: [events, orders]
what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру."
read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя."
parts:
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
- '`dds.identity_map_dist` — распределенная таблица записи.'
- '`dds.identity_map_v` — представление последних версий строк с `FINAL`.'
keys:
- "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS."
- "`user_id` — пользователь магазина из заказа DDS."
fill:
- "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов."
- "При каждом запуске записывается новая версия набора связей."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
- id: dm_daily_traffic
name: dm.daily_traffic_v
layer: dm
order: 0
streams: [events]
what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей."
read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день."
parts:
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
- '`dm.daily_traffic_dist` — распределенная таблица записи.'
- '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.'
keys:
- '`report_date` — тот же день, что `session_date` в сессии DDS.'
fill:
- "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
- id: dm_revenue
name: dm.revenue_daily_v
layer: dm
order: 1
streams: [orders]
what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы."
read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров."
parts:
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
- '`dm.revenue_daily_dist` — распределенная таблица загрузки.'
- '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.revenue_daily_v` — представление для чтения готовых данных.'
keys:
- '`report_date` — тот же день, что `order_date` в заказе DDS.'
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
fill:
- "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины."
- "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
- id: dm_purchase
name: dm.purchase_vs_orders_v
layer: dm
order: 2
streams: [events, orders]
what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено."
read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`."
parts:
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
- '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.'
- '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.'
keys:
- "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS."
- "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
fill:
- "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа."
- "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
- id: dm_dq
name: dm.dq_summary_v
layer: dm
order: 3
streams: [events, orders]
what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow."
read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день."
parts:
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
- '`dm.dq_summary_dist` — распределенная таблица чтения и записи.'
- '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.'
- '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.dq_summary_v` — представление для чтения готовых данных.'
fill:
- Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.
- '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.'
- '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.'
- '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.'
- '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.'
- '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.'
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdq_summary
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
- label: DDL представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
- id: dic_products
name: dic.products
layer: dic
order: 0
streams: [orders]
what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях."
read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет."
parts:
- "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория."
- "`dic.products` — словарь в памяти, загруженный из этой таблицы."
keys:
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
fill:
- "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
- label: DDL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/05-dic-catalog.sql
edges:
- id: hits_to_stg
from: kafka_hits
to: stg_hits
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`."
join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- id: stg_to_event
from: stg_hits
to: ods_event
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.
join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- id: stg_to_event_errors
from: stg_hits
to: ods_event_errors
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками."
join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- id: orders_to_stg
from: kafka_orders
to: stg_orders
mechanism: airflow_dag
streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-одно-чтение-kafka
- id: stg_to_order
from: stg_orders
to: ods_order
mechanism: airflow_dag
streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.
join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
- id: stg_to_order_errors
from: stg_orders
to: ods_order_errors
mechanism: airflow_dag
streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.
join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
- id: ods_to_dds_event
from: ods_event
to: dds_event
mechanism: view
streams: [events]
rhythm: при запросе к представлению, без копирования данных
actor: "Представление `dds.event_v` читает данные из `ods.event_v`."
join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
- id: event_to_session
from: dds_event
to: dds_session
mechanism: airflow_dag
streams: [events]
rhythm: при каждом запуске `dds_transform`
actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`.
join: "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql
- label: SQL замены
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_replace.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
- id: order_to_dds
from: ods_order
to: dds_order
mechanism: airflow_dag
streams: [orders]
rhythm: при каждом запуске `dds_transform`
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
- label: SQL замены
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_replace.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
- id: event_to_identity
from: dds_event
to: dds_identity
mechanism: airflow_dag
streams: [events]
rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
- id: order_to_identity
from: dds_order
to: dds_identity
mechanism: airflow_dag
streams: [orders]
rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
- id: session_to_traffic
from: dds_session
to: dm_daily_traffic
mechanism: airflow_dag
streams: [events]
rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
- id: identity_to_traffic
from: dds_identity
to: dm_daily_traffic
mechanism: airflow_dag
streams: [events]
rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
- id: order_to_revenue
from: dds_order
to: dm_revenue
mechanism: airflow_dag
streams: [orders]
rhythm: при каждом запуске `dm_transform`
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
- label: SQL замены
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_replace.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
- id: products_to_revenue
from: dic_products
to: dm_revenue
mechanism: airflow_dag
streams: [orders]
rhythm: при каждом запуске `dm_transform`
actor: "Задача `revenue_daily.rebuild` дага `dm_transform`."
join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
- id: event_to_purchase
from: dds_event
to: dm_purchase
mechanism: airflow_dag
streams: [events]
rhythm: при каждом запуске `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.
join: "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
- label: SQL замены
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
- id: order_to_purchase
from: dds_order
to: dm_purchase
mechanism: airflow_dag
streams: [orders]
rhythm: при каждом запуске `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.
join: "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
- label: SQL замены
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
+54
View File
@@ -0,0 +1,54 @@
# /// script
# requires-python = ">=3.13"
# dependencies = ["pyyaml"]
# ///
"""Собрать интерактивную карту хранилища из YAML и HTML-шаблона."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import yaml
ROOT = Path(__file__).parent
SOURCE = ROOT / "map.yaml"
TEMPLATE = ROOT / "template.html"
OUTPUT = ROOT.parent / "architecture-map.html"
MARKER = "/*__MAP_DATA__*/"
NOTICE = (
"<!-- Файл собран автоматически. Правьте architecture-map/map.yaml и "
"template.html, затем выполните uv run "
"docs/handbook/architecture-map/render.py. -->\n"
)
def render() -> str:
"""Подставить данные карты в шаблон без изменения порядка ключей."""
data = yaml.safe_load(SOURCE.read_text(encoding="utf-8"))
payload = json.dumps(data, ensure_ascii=False, indent=2)
template = TEMPLATE.read_text(encoding="utf-8")
return NOTICE + template.replace(MARKER, payload)
def main() -> int:
"""Записать карту или проверить, что готовый файл не устарел."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="проверить готовый файл")
args = parser.parse_args()
rendered = render()
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
print(f"{OUTPUT} устарел; пересоберите карту")
return 1
print(f"{OUTPUT} соответствует исходникам")
return 0
OUTPUT.write_text(rendered, encoding="utf-8")
print(f"собрана карта: {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,454 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Карта слоев хранилища</title>
<style>
:root {
color-scheme: light;
--paper: #f7f7f5;
--panel: #ffffff;
--ink: #272a2d;
--muted: #6b7177;
--line: #8c9298;
--soft: #e6e8e9;
--accent: #b54a2a;
--accent-soft: #f5ded7;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header { padding: 22px 26px 12px; }
h1 { margin: 0 0 5px; font-size: 25px; }
.lead { margin: 0; color: var(--muted); }
.controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 26px 16px;
}
.switcher { display: flex; gap: 6px; }
button {
border: 1px solid #aeb3b7;
border-radius: 18px;
background: var(--panel);
color: var(--ink);
padding: 6px 13px;
font: inherit;
cursor: pointer;
}
button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
color: #71301e;
}
button:focus-visible, [tabindex="0"]:focus-visible {
outline: 3px solid #d79078;
outline-offset: 2px;
}
.legend { display: flex; flex-wrap: wrap; gap: 15px; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-line { width: 34px; border-top: 2px solid var(--line); }
.legend-line.dashed { border-top-style: dashed; }
.legend-line.dotted { border-top-style: dotted; }
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 350px;
gap: 16px;
padding: 0 18px 24px;
align-items: start;
}
.map-scroll {
overflow-x: auto;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
svg { display: block; width: 100%; min-width: 980px; height: auto; }
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.zone-caption { font-size: 12px; fill: var(--muted); }
.column-guide { stroke: #eef0f1; stroke-width: 1; }
.edge-line {
fill: none;
stroke: var(--line);
stroke-width: 1.8;
marker-end: url(#arrowhead);
transition: opacity .15s, stroke .15s, stroke-width .15s;
}
.edge-line.dashed { stroke-dasharray: 8 6; }
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
.node { cursor: pointer; transition: opacity .15s; }
.node rect {
fill: #f1f2f2;
stroke: #858b90;
stroke-width: 1.2;
rx: 7;
transition: fill .15s, stroke .15s, stroke-width .15s;
}
.node text {
fill: var(--ink);
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
text-anchor: middle;
pointer-events: none;
}
.node .stream-mark { fill: var(--muted); font: 10px system-ui, sans-serif; }
.is-filtered, .is-context-dim { opacity: .16; }
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
.is-path .edge-line, .edge.is-path .edge-line {
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
}
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
.node.is-selected rect { stroke-width: 3; }
.edge.is-selected .edge-line { stroke-width: 4; }
aside {
min-height: 470px;
padding: 20px;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
@media (max-width: 1530px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; } }
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
.meta { margin: 0 0 18px; color: var(--muted); }
.card-section { margin-top: 17px; }
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
.card-section p { margin: 0; }
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
.card-section li + li { margin-top: 5px; }
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
a { color: #8a351e; text-underline-offset: 2px; }
.hint { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<header>
<h1 id="page-title"></h1>
<p class="lead" id="lead"></p>
</header>
<div class="controls">
<div class="switcher" id="switcher" aria-label="Выбор потока"></div>
<div class="legend" id="legend" aria-label="Механизмы переноса"></div>
</div>
<main>
<div class="map-scroll">
<svg id="map" viewBox="0 0 1230 470" role="group" aria-label="Путь данных через слои хранилища"></svg>
</div>
<aside id="details" aria-live="polite"></aside>
</main>
<script>
const MAP = /*__MAP_DATA__*/;
const NS = "http://www.w3.org/2000/svg";
const state = { stream: "all", selected: null, hovered: null };
const nodeById = new Map(MAP.nodes.map((node) => [node.id, node]));
const mechanismById = new Map(MAP.mechanisms.map((item) => [item.id, item]));
const positions = new Map();
const svg = document.getElementById("map");
const details = document.getElementById("details");
function svgElement(name, attributes = {}) {
const element = document.createElementNS(NS, name);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
return element;
}
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? MAP.dictionary.title; }
function appendRichText(parent, value) {
value.split("`").forEach((part, index) => {
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
if (index % 2) element.textContent = part;
parent.append(element);
});
}
function addSection(title, value) {
if (!value || (Array.isArray(value) && !value.length)) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = title;
section.append(heading);
if (Array.isArray(value)) {
const list = document.createElement("ul");
value.forEach((item) => {
const row = document.createElement("li");
appendRichText(row, item);
list.append(row);
});
section.append(list);
} else {
const paragraph = document.createElement("p");
appendRichText(paragraph, value);
section.append(paragraph);
}
details.append(section);
}
function addLinks(links) {
if (!links?.length) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = "Ссылки";
const list = document.createElement("ul");
links.forEach((item) => {
const row = document.createElement("li");
const link = document.createElement("a");
link.href = item.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = item.label;
row.append(link);
list.append(row);
});
section.append(heading, list);
details.append(section);
}
function renderIntro() {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = "Как пользоваться картой";
details.append(heading);
MAP.intro.forEach((line) => {
const paragraph = document.createElement("p");
paragraph.textContent = line;
details.append(paragraph);
});
const paragraph = document.createElement("p");
const link = document.createElement("a");
link.href = MAP.chapter_url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = "Открыть главу «Хранилище и его слои»";
paragraph.append(link);
details.append(paragraph);
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "Повторный щелчок или Esc снимает выделение.";
details.append(hint);
}
function renderNodeCard(node) {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = node.name;
const meta = document.createElement("p");
meta.className = "meta";
meta.textContent = `${layerTitle(node.layer)} — ${node.streams.map((id) => MAP.streams[id]).join(", ")}`;
details.append(heading, meta);
addSection("Что это", node.what);
addSection("Как читать", node.read);
addSection("Связанные таблицы и представления", node.parts);
addSection("Как связать данные", node.keys);
addSection("Как обновляются данные", node.fill);
addLinks(node.links);
}
function renderEdgeCard(edge) {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading);
addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
addSection("Когда выполняется", edge.rhythm);
addSection("Что выполняет обработку", edge.actor);
addSection("Что происходит с данными", edge.join);
addLinks(edge.links);
}
function layoutMap() {
const nodeWidth = 180;
const nodeHeight = 48;
const columnStep = 250;
MAP.layers.forEach((layer, index) => {
const x = 20 + index * columnStep;
MAP.nodes.filter((node) => node.layer === layer.id).forEach((node) => {
positions.set(node.id, { x, y: 88 + node.order * 70, width: nodeWidth, height: nodeHeight });
});
});
const dicX = 20 + ((MAP.layers.findIndex((layer) => layer.id === "dds") + MAP.layers.findIndex((layer) => layer.id === "dm")) * columnStep) / 2 + nodeWidth / 2 - 130;
MAP.nodes.filter((node) => node.layer === "dic").forEach((node) => positions.set(node.id, { x: dicX, y: 402 + node.order * 70, width: 260, height: nodeHeight }));
}
function edgePath(edge, sameColumnIndex) {
const source = positions.get(edge.from);
const target = positions.get(edge.to);
if (nodeById.get(edge.from).layer === "dic") {
const x1 = source.x + source.width / 2;
const y1 = source.y;
const x2 = target.x;
const y2 = target.y + target.height / 2;
return `M ${x1} ${y1} C ${x1} ${y1 - 38}, ${x1} ${y2 + 48}, ${x2} ${y2}`;
}
const x1 = source.x + source.width;
const y1 = source.y + source.height / 2;
if (source.x === target.x) {
const route = source.x + source.width + 16 + sameColumnIndex * 18;
const y2 = target.y + target.height / 2;
return `M ${x1} ${y1} C ${route} ${y1}, ${route} ${y2}, ${x1} ${y2}`;
}
const x2 = target.x;
const y2 = target.y + target.height / 2;
const bend = Math.max(18, (x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
}
function drawMap() {
const defs = svgElement("defs");
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
const marker = svgElement("marker", { id, viewBox: "0 0 8 8", refX: "7", refY: "4", markerWidth: "6", markerHeight: "6", orient: "auto" });
marker.append(svgElement("path", { d: "M 0 0 L 8 4 L 0 8 z", fill }));
defs.append(marker);
});
svg.append(defs);
MAP.layers.forEach((layer, index) => {
const center = 110 + index * 250;
svg.append(svgElement("line", { x1: center, y1: 65, x2: center, y2: 377, class: "column-guide" }));
const title = svgElement("text", { x: center, y: 28, class: "layer-title" });
title.textContent = layer.title;
const caption = svgElement("text", { x: center, y: 47, class: "layer-caption" });
caption.textContent = layer.caption;
svg.append(title, caption);
});
const dictionaryPosition = positions.get(MAP.nodes.find((node) => node.layer === "dic").id);
const zone = svgElement("text", { x: dictionaryPosition.x, y: dictionaryPosition.y - 13, class: "zone-caption" });
zone.textContent = MAP.dictionary.caption;
svg.append(zone);
let sameIndex = 0;
MAP.edges.forEach((edge) => {
const same = positions.get(edge.from).x === positions.get(edge.to).x;
const index = same ? sameIndex++ : 0;
const group = svgElement("g", { class: "edge", "data-id": edge.id, tabindex: "0", role: "button", "aria-label": `${nodeById.get(edge.from).name} — ${nodeById.get(edge.to).name}` });
const path = edgePath(edge, index);
const lineClass = `edge-line ${mechanismById.get(edge.mechanism).line}`;
group.append(svgElement("path", { d: path, class: lineClass }), svgElement("path", { d: path, class: "edge-hit" }));
bindSelection(group, "edge", edge.id);
svg.append(group);
});
MAP.nodes.forEach((node) => {
const position = positions.get(node.id);
const group = svgElement("g", { class: "node", "data-id": node.id, tabindex: "0", role: "button", "aria-label": node.name });
group.append(svgElement("rect", position));
const name = svgElement("text", { x: position.x + position.width / 2, y: position.y + 21 });
name.textContent = node.name;
const mark = svgElement("text", { x: position.x + position.width / 2, y: position.y + 38, class: "stream-mark" });
mark.textContent = node.streams.map((id) => MAP.streams[id]).join(" + ");
group.append(name, mark);
group.addEventListener("mouseenter", () => { state.hovered = node.id; updateHighlights(); });
group.addEventListener("mouseleave", () => { state.hovered = null; updateHighlights(); });
bindSelection(group, "node", node.id);
svg.append(group);
});
}
function bindSelection(element, type, id) {
const choose = () => selectItem(type, id);
element.addEventListener("click", choose);
element.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(); }
});
}
function findReachability(nodeId) {
const nodes = new Set([nodeId]);
const edges = new Set();
const walk = (start, direction) => {
const queue = [start];
while (queue.length) {
const current = queue.shift();
MAP.edges.forEach((edge) => {
const matches = direction === "out" ? edge.from === current : edge.to === current;
if (!matches) return;
edges.add(edge.id);
const next = direction === "out" ? edge.to : edge.from;
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
});
}
};
walk(nodeId, "in");
walk(nodeId, "out");
return { nodes, edges };
}
function selectItem(type, id) {
const repeated = state.selected?.type === type && state.selected.id === id;
state.selected = repeated ? null : { type, id };
if (!state.selected) renderIntro();
else if (type === "node") renderNodeCard(nodeById.get(id));
else renderEdgeCard(MAP.edges.find((edge) => edge.id === id));
updateHighlights();
}
function updateHighlights() {
let activeNodes = new Set();
let activeEdges = new Set();
if (state.selected?.type === "node") ({ nodes: activeNodes, edges: activeEdges } = findReachability(state.selected.id));
if (state.selected?.type === "edge") {
const edge = MAP.edges.find((item) => item.id === state.selected.id);
activeNodes = new Set([edge.from, edge.to]);
activeEdges = new Set([edge.id]);
}
const hoverNodes = new Set(state.hovered ? [state.hovered] : []);
const hoverEdges = new Set();
if (state.hovered) MAP.edges.forEach((edge) => {
if (edge.from === state.hovered || edge.to === state.hovered) {
hoverEdges.add(edge.id); hoverNodes.add(edge.from); hoverNodes.add(edge.to);
}
});
document.querySelectorAll(".node").forEach((element) => {
const node = nodeById.get(element.dataset.id);
element.classList.toggle("is-filtered", state.stream !== "all" && !node.streams.includes(state.stream));
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(node.id));
element.classList.toggle("is-path", activeNodes.has(node.id));
element.classList.toggle("is-hover", hoverNodes.has(node.id));
element.classList.toggle("is-selected", state.selected?.type === "node" && state.selected.id === node.id);
});
document.querySelectorAll(".edge").forEach((element) => {
const edge = MAP.edges.find((item) => item.id === element.dataset.id);
element.classList.toggle("is-filtered", state.stream !== "all" && !edge.streams.includes(state.stream));
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeEdges.has(edge.id));
element.classList.toggle("is-path", activeEdges.has(edge.id));
element.classList.toggle("is-hover", hoverEdges.has(edge.id));
element.classList.toggle("is-selected", state.selected?.type === "edge" && state.selected.id === edge.id);
});
}
function renderControls() {
const switcher = document.getElementById("switcher");
Object.entries(MAP.streams).forEach(([id, label]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.setAttribute("aria-pressed", String(id === state.stream));
button.addEventListener("click", () => {
state.stream = id;
switcher.querySelectorAll("button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
updateHighlights();
});
switcher.append(button);
});
const legend = document.getElementById("legend");
MAP.mechanisms.forEach((item) => {
const row = document.createElement("span");
row.className = "legend-item";
const line = document.createElement("span");
line.className = `legend-line ${item.line}`;
const label = document.createElement("span");
appendRichText(label, `${item.label} — ${item.rhythm}`);
row.append(line, label);
legend.append(row);
});
}
document.getElementById("page-title").textContent = MAP.title;
document.getElementById("lead").textContent = MAP.lead;
layoutMap();
drawMap();
renderControls();
renderIntro();
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.selected) selectItem(state.selected.type, state.selected.id);
});
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
# Генератор: откуда берутся данные
Генератор имитирует работу двух систем интернет-магазина. Счетчик
на сайте отправляет записи о действиях посетителей в топик `hits`. Серверная часть
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`.
Эта дневная выгрузка называется слепком.
## Что он производит
Событие содержит сведения о действии посетителя: например, время,
адрес страницы и идентификатор посетителя. Формат соответствует образцу
выгрузки Яндекс Метрики. Генератор создает три вида событий: просмотр
страницы, добавление в корзину и покупку. Все колонки перечислены
в [описании выгрузки](../formats/clickstream-event.md).
Заказ содержит сведения о покупке со стороны магазина: товары, суммы
и состояние заказа. После оформления он еще может измениться, например
при отмене. Каждый дневной слепок содержит текущее состояние заказов,
оформленных за последние семь модельных дней. Состав и формат слепка
разобраны в [отдельном
справочнике](../architecture/orders/snapshot.md).
## Почему данные всегда одни и те же
Случайные решения генератора зависят от заданного числа —
[зерна генерации](../architecture/generator/seeds.md) — и номера дня.
При неизменных настройках и версии генератора одинаковые зерно и день
дают одинаковые события. Поэтому при создании стенда вы каждый раз
получаете те же начальные данные. Повторный `make up` на работающем стенде
сохраняет уже загруженные данные.
В файле `data/world-inventory.json` записаны зерно, версия генератора,
число событий и контрольная сумма за каждый из восьми начальных дней.
В документации этот файл называется паспортом мира. С ним можно сверить
результат генерации и проверить, все ли события загрузились в ODS.
## Кто его запускает
На стенде генератором управляют [даги модельного
времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды
собраны в [справочнике генератора и модельного
времени](../architecture/world.md#как-запустить-генератор).
У генератора два режима. Пакетный отправляет все события дня без пауз.
В живом режиме события поступают постепенно, как в работающем магазине,
но время идет ускоренно: модельные сутки занимают около 24 минут.
## Посмотрите сами
Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver)
и выполните запрос:
```sql
SELECT EventDate, count() AS events
FROM ods.event_v
GROUP BY EventDate
ORDER BY EventDate;
```
Сравните число событий за каждый из первых восьми дней со значением
`events` за ту же дату в [паспорте мира](../../data/world-inventory.json).
Числа должны совпасть. Если вы уже генерировали следующие дни,
в результате запроса будут дополнительные даты; в паспорте их нет.
## Куда дальше
Подробнее генератор разобран в [README папки
`generator/`](../../generator/README.md) и в [справочнике его
устройства](../architecture/generator/README.md). Теперь можно переходить
к [учебным заданиям](../labs/README.md).
+83
View File
@@ -0,0 +1,83 @@
# Мониторинг: куда смотреть
В Grafana настроены три дашборда. «Данные» показывает загрузку и обработку
событий и заказов, «Кластер» — состояние узлов и самого ClickHouse,
а «Запросы» — выполненные запросы и затраченные на них ресурсы.
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
Grafana получает данные из Prometheus и напрямую из ClickHouse.
На панелях не заданы пороги для цветовых предупреждений: оценивать показания
вы будете самостоятельно. Качество данных автоматически проверяет
даг `dq_check`.
## Дашборд «Данные»
«Свежесть» показывает, сколько времени прошло с последнего события и с
последнего заказа в ODS. Показатель для заказов растет весь модельный день
и снижается, когда поступает очередная дневная выгрузка — слепок.
Это ожидаемо: заказы загружаются раз в день.
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
показывает долю строк, которые не удалось разобрать.
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние
интервалы на графике.
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
STG: по нему видно, поступила ли очередная выгрузка.
Источники и правила чтения всех панелей разобраны в [справочнике дашборда
«Данные»](../architecture/monitoring/data.md).
## Почему у панелей разные источники
Свежесть считается по ODS: если новые сообщения перестали разбираться,
показатель будет расти. Число поступивших сообщений берется из STG,
где сохраняются все сообщения, включая повторы. Для доли брака это число
сравнивается с числом строк в таблицах ошибок ODS.
В основных таблицах ODS повторы событий и старые версии заказов удаляются
при слияниях. Поэтому число строк в них не подходит для подсчета
всех доставленных сообщений. Подробности — в [разделе о слоях
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
## Два других дашборда
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же
показаны состояние ClickHouse Keeper и очередь распределенных DDL-команд.
Устройство панелей описано в [справочнике дашборда
«Кластер»](../architecture/monitoring/cluster.md).
«Запросы» поможет найти медленные запросы, ошибки и пользователей,
чьи запросы потребляют много ресурсов. Как читать панели, объясняет
[справочник дашборда
«Запросы»](../architecture/monitoring/queries.md).
## Посмотрите сами
Откройте Grafana по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в
Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельный день займет
около двадцати четырех минут.
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
со времени последнего события проходят секунды, а время с последней
загрузки заказов растет. После завершения генерации событий отправляется
слепок заказов. Когда он загружен, показатель свежести заказов снижается.
Показатель для событий начинает расти: новые события поступят только
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
обработки данных в дневном конвейере.
## Куда дальше
Устройство мониторинга описано в [техническом
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» —
в [отдельном разделе](../architecture/monitoring/data.md).
Почему выбраны именно эти показатели, объясняет
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[«Немного Superset»](superset.md).
+165
View File
@@ -0,0 +1,165 @@
# Хранилище и его слои
В хранилище приходят два потока из Kafka: в топике `hits` — события
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS →
DDS → DM. В STG сообщения сохраняются как есть, в ODS разбираются по полям,
в DDS из них собираются события, заказы и сессии, а в DM — витрины для отчетов.
```mermaid
flowchart LR
Kafka --> STG --> ODS --> DDS --> DM
```
## Как читать имена
Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют
менять устройство таблиц, сохраняя привычные запросы к данным.
Суффиксы `_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему устройству
хранилища. Эти объекты нужны для загрузки, обслуживания и диагностики.
У словаря товаров `dic.products` суффикса нет.
Подробный разбор имен есть в [справочнике
хранилища](../architecture/storage.md#имена-объектов).
## STG: как поступило
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе
с ним записываются сведения о доставке: топик, партиция, смещение и время
приема. Содержимое сообщения сохраняется в STG, даже если в нем есть ошибки.
Сырые сообщения хранятся трое суток реального времени. В течение этого срока
их можно использовать для поиска ошибок и повторной обработки. Затем они
удаляются по правилу из [справочника
хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
## ODS: язык источника
В ODS содержимое сообщения разбирается на колонки с нужными типами данных:
датами, числами, строками. Колонки сохраняют имена из источника. Например,
в таблице событий есть `WatchID`, `ClientID` и `EventDate`.
Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу
`*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные
сообщения обрабатываются дальше.
## DDS: язык бизнеса
DDS — детальный слой: в нем данные подготовлены для анализа событий,
заказов и сессий. Здесь же хранится карта идентичностей — таблица связей
между идентификатором посетителя `ClientID` и пользователем магазина
из заказа. `ClientID` хранится в cookie браузера: по нему можно узнать
браузер посетителя, но не всегда самого человека.
В стенде визитом, или сессией, считают подряд идущие события одного
`ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток.
Источник передает готовый идентификатор визита `VisitID` по образцу
Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени
событий; каждой присваивается `session_id`. Поле `VisitID` остается
эталоном для проверки. Так в заданиях вы сможете самостоятельно определить
границы сессий и сравнить их с результатом источника.
Готовые сессии читайте через `dds.session_v`. События для самостоятельного
расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт.
## DM: данные для дашбордов
DM — слой витрин. Витрина — готовый набор показателей для отчета или
дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и
дневные показатели трафика.
К каждой витрине можно обращаться коротким запросом, не повторяя в нем весь
путь расчета. Состав готовых витрин смотрите в [карте
таблиц](../architecture/storage.md#карта-таблиц).
## Справочные данные: зона `dic`
В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно
от событий и заказов и могут использоваться в расчетах любого слоя.
Каталог товаров `dic.products` — один из таких справочников. Его ключ `sku` —
артикул товара. По нему можно узнать название, бренд и категорию товара. Как
устроена зона, смотрите в [разделе о
справочниках](../architecture/storage.md#справочники).
## Сквозные ключи
Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS
часть колонок переименовывается: например, `WatchID` становится `event_id`.
В таблице указано, как найти соответствующие колонки и соединить по ним данные.
| Колонка | Что означает | Что соединяет |
|---|---|---|
| `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` |
| `ClientID` | Идентификатор посетителя из cookie браузера | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` |
| `order_id` | Идентификатор заказа | Заказ ODS с заказом DDS и витриной сверки `dm.purchase_vs_orders_v` |
| `sku` | Артикул товара в каталоге | Справочник `dic.products` с позициями заказа DDS, где артикулы лежат в `item_sku`; через категорию товара — с витриной выручки DM |
| `EventDate` | Дата события в [модельном времени](../../CONTEXT.md), указанная счетчиком сайта в [его часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM |
Почему идентификатор посетителя не равнозначен человеку, объясняет
[словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с
[картой таблиц](../architecture/storage.md#карта-таблиц).
## Посмотрите сами
Подключитесь к ClickHouse по инструкции из разделов [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из
DBeaver»](../../README.md#подключиться-из-dbeaver). Во всех трех примерах
`2026-06-01` — первый день работы учебного магазина. В документации он
обозначается D0; подробнее — в [словаре понятий](../../CONTEXT.md).
Чтобы посмотреть другой день, замените дату в условии.
Первый запрос покажет 20 событий за выбранный день:
```sql
SELECT
WatchID,
ClientID,
EventDate,
EventType,
URL
FROM ods.event_v
WHERE EventDate = '2026-06-01'
ORDER BY UTCEventTime
LIMIT 20;
```
Второй запрос покажет 20 сессий того же дня с наибольшим числом событий.
Сессии рассчитаны по всем событиям дня:
```sql
SELECT
client_id,
started_at,
finished_at,
events_total,
pageviews,
purchases,
entry_url
FROM dds.session_v
WHERE session_date = '2026-06-01'
ORDER BY events_total DESC
LIMIT 20;
```
Третий запрос покажет готовую выручку по категориям за тот же день:
```sql
SELECT
product_category,
orders,
units,
revenue
FROM dm.revenue_daily_v
WHERE report_date = '2026-06-01'
ORDER BY revenue DESC;
```
Все три запроса читают представления `_v`.
## Куда дальше
Откройте [интерактивную карту](architecture-map.html) из своей копии
репозитория и проследите путь данных.
Список объектов, сроки хранения и устройство таблиц — в [справочнике
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в
следующей главе: [потоки Airflow и даги модельного
времени](airflow-dags.md).
+69
View File
@@ -0,0 +1,69 @@
# Немного Superset
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех
витрин:
| Витрина | Что показывает |
|---|---|
| `dm.revenue_daily_v` | Выручку, число заказов и средний чек; выручку по дням и категориям товаров, долю каждой категории |
| `dm.purchase_vs_orders_v` | Результат сверки покупок из кликстрима с заказами: сколько заказов за день относится к каждому виду расхождения |
| `dm.daily_traffic_v` | Число посетителей и известных пользователей по дням, долю известных пользователей |
Известные пользователи — те, кого удалось связать с посетителями сайта
по заказам. Эти связи хранятся в карте идентичностей DDS.
Слева от панелей стоит фильтр «Период». Он задает даты сразу для всех панелей.
## Как витрина становится графиком
В Superset сначала создается датасет — описание таблицы или представления,
из которого будут браться данные. В нем задаются подписи колонок, формат
чисел и показатели, например средний чек.
Затем на основе датасета создается график: выбираются показатели и способ
их отображения. Несколько графиков можно разместить на одном дашборде.
## Стендовые объекты и ваши
Готовые датасеты, графики и дашборд сохранены в файлах репозитория
и импортируются при каждом `make up`. Если изменить их через интерфейс,
следующий `make up` заменит изменения версией из файлов без предупреждения.
Созданные вами датасеты, графики и дашборды хранятся в базе метаданных
Superset. Импорт их не меняет: они сохраняются после `make up`
и `make rebuild-storage`. Команда `make clean` удалит их вместе со всеми
данными стенда. Для учебных отчетов создавайте собственные объекты,
чтобы повторный запуск не перезаписал вашу работу. Устройство
импорта описано в [справочнике
Superset](../architecture/superset.md#импорт-при-каждой-инициализации).
## Почему выручка отстает на день
Последняя дата с выручкой на день раньше последней даты с событиями
кликстрима. Выгрузка заказов за день D поступает при генерации следующего
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу
в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
## Посмотрите сами
Откройте Superset по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты».
Затем откройте SQL Lab и выполните запрос:
```sql
SELECT report_date, visitors, known_users
FROM dm.daily_traffic_v
ORDER BY report_date DESC
LIMIT 7;
```
Как построить свой отчет, рассказывают [учебные задания](../labs/README.md).
## Куда дальше
Подключение и настройка Superset описаны в [справочнике
Superset](../architecture/superset.md), а состав витрин — в [справочнике
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
данные»](generator.md).
+97
View File
@@ -0,0 +1,97 @@
# Задание 1: `dm.events_enriched_v`
**Чему учит:** подготовить удобный для анализа набор полей события
и обойтись представлением, сохранив одну строку на событие.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md).
- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения
и подключение с правами на создание витрины.
## Для чего нужна витрина
В `dds.event_v` 44 колонки, включая технические поля источника и массивы
товаров. Для анализа событий в Superset нужны деловые поля и три готовых
значения: час события, путь страницы и домен перехода. Тогда вам не придется
повторять эти выражения в каждом запросе.
Витрины следующих заданий читают DDS напрямую. Использовать
`dm.events_enriched_v` как источник для них не нужно: задания независимы, их
можно выполнять в любом порядке.
## Что сделать
Создайте обычное представление `CREATE VIEW dm.events_enriched_v`.
Единственный источник — `dds.event_v`. Зерно — **событие**: каждой строке
источника соответствует одна строка витрины.
Физическая таблица, таблица-донор и задача в `dm_transform` не нужны.
Представление не хранит строки, поэтому ключ шардирования и пересборка ему
не требуются.
Укажите колонки явно. Иначе новая колонка источника попадет в витрину
автоматически, и договор с потребителем изменится без вашего участия.
| Группа | Колонки |
|---|---|
| Время и посетитель | `event_date`, `event_time`, `event_hour`, `client_id`, `event_id` |
| Действие | `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`, `page_path`, `referer`, `referer_host`, `title` |
| UTM-метки | `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` |
| Устройство и регион | `device_category`, `browser`, `operating_system`, `region_country`, `region_city` |
| Товары и покупка | `product_id`, `product_name`, `product_category`, `product_price`, `product_quantity`, `product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon` |
Три колонки вычислите в представлении. Остальные возьмите из источника без
изменений, сохранив массивы `product_*`.
| Колонка | Как считается | Для чего нужна |
|---|---|---|
| `event_hour` | `toHour(event_time)` | Анализ событий по часам суток |
| `page_path` | путь `url` без домена и параметров | Группировка по странице |
| `referer_host` | домен `referer` | Анализ источников перехода |
Не добавляйте `_load_id` и `_load_ts`. Их нет и у `dds.event_v`: события в
DDS читают из ODS без отдельной загрузки. Эти служебные поля указывают на
запуск, который записал строку на диск. Представление ничего не загружает.
## Типичная ошибка
Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя
позициями получится три строки. Зерно изменится. Витрину с новым зерном на
стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
ступени 1»](README.md#как-принято-на-ступени-1).
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
исключает их из результата. Товары есть только в событиях корзины и покупки.
Поэтому исчезнет большинство событий — девятнадцать строк из двадцати.
Запрос выполнится без ошибки, а витрина по имени и составу колонок
по-прежнему будет выглядеть таблицей событий.
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
нужна другая витрина с другим зерном и именем.
## Как проверить себя
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
задачу `events_enriched`: она должна завершиться успешно. Если задача
красная, откройте ее журнал (`Logs`) — там будет причина ошибки, а при
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
Деловой ключ — `event_id` внутри дня. Получите результат прямо из
`dds.event_v` за тот же день. Соедините его с витриной полным внешним
соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной
из сторон или различаются значения деловых колонок. Расхождений должно быть
ноль.
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
выражениями, рассчитанными прямо по `dds.event_v`.
## Что должно получиться
- [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками;
- [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый
прожитый день; расхождений нет;
- [ ] проверка `events_enriched` в `homework_check` прошла успешно;
- [ ] витрина работает без физической таблицы и задачи в `dm_transform`.
+110
View File
@@ -0,0 +1,110 @@
# Задание 2: `dm.top_pages_daily`
**Чему учит:** собрать дневные показатели страниц и настроить их
обновление в `dm_transform` через замену партиций.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
действия с товарами. Добавление в корзину происходит на карточке товара,
покупка — на странице подтверждения заказа. Отношение добавлений к
просмотрам карточки показывает ее конверсию.
Для воронки «просмотр → корзина → покупка» этих данных недостаточно:
действия происходят на разных страницах, а связи между ними в агрегате нет.
Путь человека исследуют по событиям или сессиям.
## Что сделать
Источник — `dds.event_v`. Зерно витрины — **день события × страница**.
Читайте DDS напрямую: так выполнение задания не зависит от витрины
задания 1.
На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не
рассчитывать ее заново для каждого запроса. Причины описаны в разделе
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же
единица пересборки.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `report_date` | `Date` | День события |
| `page_path` | `LowCardinality(String)` | Путь страницы |
| `pageviews` | `UInt64` | Число событий `pageview` на странице |
| `visitors` | `UInt64` | Число разных `client_id` на странице |
| `cart_adds` | `UInt64` | Число событий `add_to_cart` на странице |
| `purchases` | `UInt64` | Число событий `purchase` на странице |
| `_load_id` | `String` | Идентификатор запуска, собравшего день |
| `_load_ts` | `DateTime64(3, 'UTC')` | Время сборки |
Вычислите `page_path` из `url`, убрав домен и параметры запроса. Полный
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
три типа событий, а их счетчики вычисляйте через `countIf`.
### Ритм пересборки
События прожитого дня больше не меняются. Определяйте объем прогона по
состоянию витрины: от первого несобранного прожитого дня до последнего
прожитого. Отсчет от последнего собранного дня оставит пропуски после
частичного сбоя.
Как у `revenue_daily`, заполните донор за весь диапазон одной вставкой,
затем замените дневные партиции по одной.
### Ключ шардирования
Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с
шестью близкими по объему значениями. Здесь страниц много, и число событий
на них сильно различается.
У цели и донора используйте одинаковый ключ: иначе при замене партиции
строка потеряется на одном шарде и удвоится на другом. О том, [как устроен
донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам».
## Типичная ошибка
Из-за имени витрины хочется отобрать только просмотры. Но фильтр источника
по `event_type = 'pageview'` обнулит `cart_adds` и `purchases`, а запрос не
завершится ошибкой. Конверсия карточек станет нулевой. Проверка одной суммы
`pageviews` не обнаружит этот фильтр: все просмотры остались на месте.
Ошибку покажет сверка `purchases` или `cart_adds` с источником, если за день
были покупки или добавления в корзину.
Выбирайте проверку под возможную ошибку. Этот подход описан в
[справочнике проверок](../architecture/testing.md).
## Как проверить себя
Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из
`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
одной из сторон или различается любой из четырех счетчиков. Расхождений
должно быть ноль.
Перед полным сравнением проверьте дневные суммы:
- сумма `pageviews` равна числу событий `pageview` в `dds.event_v`;
- сумма `purchases` равна числу событий `purchase` в том же источнике.
Сумма `visitors` по страницам повторно учитывает посетителя, открывшего
несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует тот же ключ шардирования, что и целевая таблица;
- [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая
`_load_id` и `_load_ts`;
- [ ] ваш запрос сам определяет дни пересборки по состоянию витрины;
- [ ] проверка `top_pages_daily` в `homework_check` прошла успешно;
- [ ] вы включили витрину в `dm_transform` и убедились, что повторный
прогон конвейера не меняет результат.
+115
View File
@@ -0,0 +1,115 @@
# Задание 3: `dm.session_overview`
**Чему учит:** хранить слагаемые для расчета средних и долей и понимать,
когда сложение числа посетителей по устройствам завышает аудиторию.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
число, длительность, просмотры страниц и частоту покупок.
Сессии уже рассчитаны в DDS. Из них вы соберете дневные показатели по
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
[справочник DM](../architecture/dm.md).
## Что сделать
Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**.
Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято
на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.session_overview_v`. Ключ партиции — день.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `report_date` | `Date` | День сессии |
| `device_category` | `LowCardinality(String)` | Категория устройства |
| `sessions` | `UInt64` | Число визитов |
| `visitors` | `UInt64` | Число разных `client_id` |
| `bounce_sessions` | `UInt64` | Визиты ровно из одного события |
| `purchase_sessions` | `UInt64` | Визиты хотя бы с одной покупкой |
| `duration_seconds_total` | `UInt64` | Суммарная длительность визитов |
| `events_total` | `UInt64` | Число событий во всех визитах |
| `pageviews` | `UInt64` | Число просмотров страниц |
| `cart_adds` | `UInt64` | Число добавлений в корзину |
| `purchases` | `UInt64` | Число покупок |
| `_load_id` | `String` | Идентификатор запуска, собравшего день |
| `_load_ts` | `DateTime64(3, 'UTC')` | Время сборки |
Возьмите `device_category` из сессии: DDS уже заменил код словом. Из четырех
категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv`
создавайте только при появлении таких визитов.
Вместо `avg_duration_seconds` храните слагаемые. Среднее из средних по трем
устройствам даст неверный ответ за день: число сессий в категориях
различается, поэтому средние нужно взвешивать. Потребитель делит сумму
`duration_seconds_total` на сумму `sessions`. Долю отказов считают так же:
сумму `bounce_sessions` делят на сумму `sessions`, а не усредняют готовые
доли.
### Ритм пересборки
Сессии прожитого дня больше не меняются. Как в задании 2, определяйте
диапазон от первого несобранного прожитого дня до последнего прожитого.
Соберите его в донор и замените дневные партиции. Добавьте задачу в
`dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Визитов с планшета мало, с телефонов и
компьютеров — значительно больше. Если хотите взять хеш только от
`device_category`, сначала посчитайте строки на каждом шарде. Затем
объясните, почему такое распределение вас устраивает.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Почему сумма `visitors` сходится с `dm.daily_traffic_v`
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за
день она учитывается только в одной строке. Это свойство мира стенда; см.
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
Если появятся визиты одной куки с двух устройств, сумма начнет завышать
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
целиком.
## Типичная ошибка
Повторное выделение визитов из событий может дать другие границы сессий, чем
в DDS. Используйте готовые сессии из `dds.session_v`. Если общее число
событий совпало с источником, а число сессий завышено, вероятная причина —
сборка витрины из событий вместо сессий.
## Как проверить себя
Деловой ключ — `device_category` внутри дня. Получите прямой агрегат из
`dds.session_v` за день. Соедините его с `dm.session_overview_v` полным
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
одной из сторон или различается любой счетчик. Расхождений должно быть ноль.
Еще две проверки дневных итогов:
- сумма `sessions` равна числу строк `dds.session_v` за день — без потерь и
повторного учета визитов;
- сумма `events_total` равна сумме `events_total` по сессиям того же дня.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали
показатели из готовых сессий `dds.session_v`;
- [ ] в таблице хранятся слагаемые для средних и долей;
- [ ] проверка `session_overview` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`.
+111
View File
@@ -0,0 +1,111 @@
# Задание 4: `dm.dq_errors_daily`
**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше
исходных строк.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало
больше и сколько строк забраковано. Причину конкретной ошибки исследуют
в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и
координаты доставки.
## Что сделать
Зерно — **день загрузки × источник × класс брака**. В источнике зерно —
строка брака. Зерно новое, поэтому витрина хранится готовым агрегатом — см.
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов: `dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist` и публичное представление `dm.dq_errors_daily_v`. Ключ
партиции — день загрузки.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `load_date` | `Date` | День загрузки забракованной строки |
| `source` | `LowCardinality(String)` | `event` или `order` |
| `error_class` | `LowCardinality(String)` | Класс брака из таблицы ошибок |
| `error_rows` | `UInt64` | Число забракованных строк |
| `_load_id` | `String` | Идентификатор запуска, собравшего день |
| `_load_ts` | `DateTime64(3, 'UTC')` | Время сборки |
Вычислите `load_date` как `toDate(_load_ts)` из таблицы ошибок — это
реальный день приема строки. Модельный день записан внутри исходной строки.
Ее не удалось разобрать, поэтому надежно получить из нее день не получится.
Служебная `_load_ts` витрины описывает сборку сводки; ее дата может
отличаться от `load_date`.
У событий и заказов свои классы брака. `source` входит в зерно, чтобы
одинаковые имена классов с разным смыслом не объединялись в одну строку.
### Ритм пересборки
Брак хранится месяц по `TTL`, сырые данные — трое суток. Сводка хранится
бессрочно: итоги нужны и после удаления исходных строк.
Получите список дней загрузки, еще имеющихся в источниках. Соберите их в
донор и замените только эти партиции, сохранив остальные.
В течение текущих суток ошибки еще поступают: пересчитайте этот день на
следующем прогоне.
### Ключ шардирования
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
меньше десятка. Объясните, насколько существенен перекос при таком объеме.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Источники: таблицы `_dist`
Только это задание читает данные не через `_v`: брак нужен для диагностики
приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
нет, создавать их для задания не нужно. Источники:
- `ods.event_errors_dist` — ошибки разбора событий;
- `ods.order_errors_dist` — ошибки разбора слепка заказов.
Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
попадет в сводку, хотя запрос выполнится без ошибки.
## Типичная ошибка
Если заменить всю таблицу данными из нового донора, исчезнут итоги дней,
истекших по `TTL` источника. Восстановить их неоткуда.
## Как проверить себя
Деловой ключ — `source` и `error_class` внутри `load_date`. Проверяйте дни,
оставшиеся хотя бы в одной таблице ошибок. Если дня за пределами `TTL` уже
нет в источнике, это не ошибка: сверять его уже не с чем.
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
`dm.dq_errors_daily_v` полным внешним соединением по ключу. Затем посчитайте
строки, где ключ отсутствует с одной из сторон или различается `error_rows`.
Расхождений должно быть ноль.
Сумма `error_rows` по проверяемым дням равна общему числу строк в
`ods.event_errors_dist` и `ods.order_errors_dist`. Если она примерно вдвое
меньше, вероятная причина — чтение `_rep` вместо `_dist`.
Если в источниках нет брака, витрина должна быть пустой. В этом случае
расхождений тоже ноль и проверка успешна.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] сводка включает ошибки событий и заказов из таблиц `_dist`;
- [ ] ваш запрос пересобирает только дни, еще доступные в источниках,
сохраняя более старые итоги;
- [ ] проверка `dq_errors_daily` в `homework_check` прошла успешно;
отсутствие брака тоже дает успешный результат;
- [ ] витрина обновляется при запуске `dm_transform`.
+137
View File
@@ -0,0 +1,137 @@
# Задание 5: `dm.utm_effectiveness`
**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и
серверную выручку, находить причины их расхождения.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
покупки и заявленные клиентом суммы.
На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду.
Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для
оценки покупок по меткам используют клиентскую сумму.
## Что сделать
Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день
события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое,
поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.utm_effectiveness_v`. Ключ партиции — день.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `report_date` | `Date` | День события |
| `utm_source` | `LowCardinality(String)` | Источник кампании |
| `utm_medium` | `LowCardinality(String)` | Канал кампании |
| `utm_campaign` | `String` | Имя кампании |
| `visitors` | `UInt64` | Число разных `client_id` |
| `pageviews` | `UInt64` | Число просмотров страниц |
| `add_to_carts` | `UInt64` | Число добавлений в корзину |
| `purchases` | `UInt64` | Число покупок |
| `declared_revenue` | `Decimal(18, 2)` | Сумма покупок, заявленная клиентом |
| `_load_id` | `String` | Идентификатор запуска, собравшего день |
| `_load_ts` | `DateTime64(3, 'UTC')` | Время сборки |
Для `declared_revenue` сложите `purchase_revenue[1]` событий `purchase`. В
параллельных массивах `purchase*` хранятся один номер заказа и
одна сумма на событие. В сверке `purchase_vs_orders` используют тот же
индекс.
Тип `purchase_revenue` — `Array(Decimal(18, 2))`: исходный `Float64`
преобразован в ODS. Сложение Decimal точное; `CAST` до или после `sum` не
меняет результат и не нужен. Тип итога — `Decimal(18, 2)`.
Для `utm_campaign` выбран тип `String`: имен кампаний со временем становится
много, и затраты на словарь `LowCardinality` перестают окупаться.
### Ритм пересборки
События прожитого дня больше не меняются. Как в задании 2, соберите в донор
дни от первого несобранного до последнего прожитого и замените дневные
партиции. Добавьте задачу в `dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Атрибуция по событию, а не по визиту
Группируйте события по их собственным меткам. Модели «первое касание» и
«последнее касание» здесь не рассчитываются.
В `dds.session_v` записаны UTM первого события визита. Распределение по
каналам может отличаться от этой витрины, и это не означает ошибку в
расчете. В отчете различайте атрибуцию визитов и событий.
## Прямой трафик остается в витрине
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
итоги перестанут сходиться с источником — а по этому совпадению вы
проверяете сборку. Разница составит вклад исключенных событий.
Оставьте пустые метки пустыми строками: новое обозначение прямого трафика
потребовало бы объяснения.
## Расхождение с `dm.revenue_daily_v`
Сравните за один день `sum(declared_revenue)` своей витрины и `sum(revenue)`
из `dm.revenue_daily_v`. Суммы не сходятся; проверять их равенство не нужно.
В `revenue_daily` складываются цены позиций, умноженные на количество, без
скидок и доставки — та же основа, что у клиентской суммы. Расхождение
объясняется историей заказов, а не способом расчета:
- событие покупки остается после отмены заказа, а `revenue_daily` учитывает
только оплаченные заказы;
- событие покупки может потеряться при доставке, хотя заказ поступил, или
поступить дважды;
- клиентская сумма может отличаться от серверной суммы позиций: генератор
намеренно создает это расхождение у одного-двух заказов из ста.
Запишите свой вывод в двух-трех фразах. Число расхождений по заказам и их
причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы
расхождений.
## Как проверить себя
Деловой ключ — `utm_source`, `utm_medium`, `utm_campaign` внутри дня.
Получите прямой агрегат из `dds.event_v` за день и соедините его с
`dm.utm_effectiveness_v` полным внешним соединением по ключу. Посчитайте
строки, где ключ отсутствует с одной из сторон или различается любая из пяти
величин. Расхождений должно быть ноль.
Дополнительно сверьте дневные итоги:
- суммы `pageviews`, `add_to_carts` и `purchases` равны числу событий
соответствующих типов в `dds.event_v`;
- сумма `declared_revenue` равна сумме `purchase_revenue[1]` по всем
событиям `purchase` того же дня.
Сумма `visitors` по строкам повторно учитывает одного посетителя,
приходящего по разным меткам. Для числа посетителей дня она не подходит.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
в `Decimal(18, 2)`;
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
- [ ] проверка `utm_effectiveness` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`.
+91
View File
@@ -0,0 +1,91 @@
# Свой график в Superset
**Чему учит:** увидеть, как собственный результат из хранилища становится
отчетом в BI.
## Сначала прочитать
- [«Немного Superset»](../handbook/superset.md);
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
## На какой вопрос ответит график
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
каких устройств чаще уходят после первой страницы. Постройте столбиковый
график доли отказов по `device_category` за весь период витрины. Измерение
одно — `device_category`, метрика тоже одна.
## Что сделать
Заведите физический датасет в подключении к `ClickHouse`: схема `dm`, объект
`session_overview_v`. Назовите по-русски хотя бы две колонки:
- `device_category` — «Категория устройства»;
- `report_date` — «Дата отчета».
В редакторе датасета, на вкладке «Metrics», создайте метрику:
| Поле | Значение |
|---|---|
| `Metric Key` | `bounce_share` — ключ метрики и ее SQL-псевдоним |
| `Label` | «Доля отказов» |
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
| `D3 format` | `.1%` |
Доля считается из сумм слагаемых: среднее готовых долей соврет. Поэтому
метрике нужно это выражение. Сохраните ее в датасете, а не в графике:
подписи и формат задаются в датасете, а график их использует. Так во всех
будущих графиках у метрики будут одинаковые подписи. Это правило описано в
[справочнике Superset](../architecture/superset.md#дерево-импорта).
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
метрика доступна только этому графику, а нужна метрика датасета.
Создайте столбиковый график с метрикой «Доля отказов». В поле «X-axis»
укажите «Категория устройства», а «Dimensions» не заполняйте. Дашборд
создавать не нужно: сохраненного графика достаточно.
Для этого задания оставьте период «No filter»: график и проверочный запрос
ниже считают долю отказов за весь период витрины. Относительный период,
например «Last week», может дать пустой график: модельный календарь стенда
отстает от настоящего. Последнюю дату витрины посмотрите в SQL Lab:
```sql
SELECT max(report_date)
FROM dm.session_overview_v;
```
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
`make clean`, описано в главе [«Немного Superset»](../handbook/superset.md#стендовые-объекты-и-ваши).
## Как проверить себя
Автоматической проверки этого задания нет. Сверьте числа на графике
с результатом запроса в SQL Lab:
```sql
SELECT device_category,
round(100 * sum(bounce_sessions) / sum(sessions), 1) AS bounce_pct
FROM dm.session_overview_v
GROUP BY device_category
ORDER BY device_category;
```
Сверьте с графиком значение для каждой категории. В разделе «Customize»
включите «Show Value» или наведите указатель на столбик. Запрос возвращает
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
один в один.
## Что должно получиться
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
`ClickHouse`;
- [ ] в датасете есть метрика «Доля отказов» с выражением
`SUM(bounce_sessions) / SUM(sessions)` и форматом `.1%`;
- [ ] колонки `device_category` и `report_date` подписаны «Категория
устройства» и «Дата отчета»;
- [ ] сохраненный столбиковый график показывает долю отказов по категориям
устройств за весь период витрины;
- [ ] числа на графике совпадают с результатом запроса из SQL Lab;
- [ ] вы сохранили график отдельно, без создания дашборда и изменения
стендовых объектов.
+92
View File
@@ -0,0 +1,92 @@
# Нарежьте сессии сами
**Чему учит:** собрать сессии из событий, сравнить их число с `VisitID`
источника и найти события, из-за которых расчеты расходятся.
## Сначала прочитать
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-язык-бизнеса);
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
## Для чего нужны сессии
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
По визитам аналитик считает длительность и глубину просмотра.
В хранилище сессии собираются из отдельных событий.
Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
получается сессия.
## Что сделать
### Нарезка
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
Для своей нарезки и эталона возьмите одни и те же строки за все доступные
дни: для расследования понадобятся события соседних дней.
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
В `dds.event_v` эталонного `VisitID` нет.
Для начала считайте визитом последовательные события одной куки (`ClientID`);
пауза дольше 30 минут открывает новый визит.
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
Присвойте каждому событию номер своей сессии внутри куки.
Подсказка: оконные функции.
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
отличается от `EventDate` для части событий, поэтому берите `EventDate`.
При сверке останутся расхождения из-за еще одной договоренности стенда.
### Сверка с эталоном
В эталоне посчитайте число визитов каждой куки за модельный день:
```sql
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
FROM ods.event_v
GROUP BY ClientID, EventDate
ORDER BY EventDate, ClientID;
```
Посчитайте свои сессии по куке и дню сессии.
Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню.
В результате полного соединения останутся ключи обеих сторон: при расхождении
на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
Выведите сводку по дням с числом разошедшихся кук и список этих кук
с днем и обоими значениями счета. Общая сумма за день могла бы скрыть
взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину.
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира)
и повторите сверку.
### Расследование
Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
Сопоставьте сессии через события: общего ключа у двух нарезок нет,
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
Найдите два соседних события, между которыми границы визитов разошлись.
Запишите куку, день, время обоих событий и причину одним предложением.
Уточните определение визита и добейтесь нуля расхождений во всех днях.
## Как проверить себя
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
Таблицы и представления создавать не нужно. Задачи в `homework_check`
для этого задания нет: вы проверяете расчет своим запросом.
В `sql/dds/session_rebuild.sql` и
`sql/dq/sessions_vs_reference.sql` лежат готовые запросы нарезки и сверки.
Откройте их после собственного решения и расследования. Сравните запросы
со своими.
Сопоставление со стендовой нарезкой по `session_id` остается на ступень 4.
## Что должно получиться
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
доступные дни;
- [ ] у вас есть сводка расхождений по дням и список кук, полученные через
`FULL OUTER JOIN`;
- [ ] вы объяснили причину одного расхождения и записали куку, день и два
события, на которых оно видно;
- [ ] уточненный расчет дает ноль расхождений во всех днях.
+191
View File
@@ -0,0 +1,191 @@
# Перепишите нарезку стенда
**Чему учит:** переписать расчет сессий в готовом конвейере и проверить
результат по исходным событиям.
## Сначала прочитать
- [задание «Нарежьте сессии сами»](3-1-sessions-by-hand.md);
- [модель сессии DDS](../architecture/dds/session.md);
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
Даг, таблицы сессий и проверка их числа уже созданы.
Перепишите только три файла:
- `sql/dds/session_scope.sql`;
- `sql/dds/session_rebuild.sql`;
- `sql/dds/session_replace.sql`.
Удалите из них готовый SQL и соберите свою реализацию. Параметры и порядок
запросов заданы в `dds_transform`. DDL задает целевые таблицы и донор. Донор —
служебная таблица, где день собирается перед заменой. В
`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS.
## Как даг запускает ваши запросы
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
| Файл | Вход | Результат |
|---|---|---|
| `session_scope.sql` | `{position:UInt16}` — номер первого несыгранного дня | `first_day` и `last_day` |
| `session_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | Сессии в `dds.session_stage_dist` |
| `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
Первый прожитый день, которого еще нет в DDS, становится `first_day`.
`last_day` — последний прожитый день. Начало шкалы нужно взять из событий ODS:
`position` считает дни от него. Живой модельный день еще не закончен, поэтому
сессии за него не собираются.
Если в ODS нет ни одного события, запрос не возвращает строк. Если DDS уже
догнал мир, запрос возвращает строку с `first_day = NULL`. В этом случае группа
сессий в Airflow получает состояние `skipped`.
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
Затем он собирает в `dds.session_stage_dist` все дни отрезка. Добавьте к
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
на шарды до замены партиций.
Цель `session_replace.sql` — локальная таблица `dds.session_rep`, источник —
локальный донор `dds.session_stage_rep`. Операция выполняется на всем кластере.
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
ответа, и точка с запятой превратит строку в мультизапрос, который ClickHouse
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
ClickHouse в `ALTER ... PARTITION` здесь не работает.
## Нарежьте сессии
Источник сборки — `dds.event_v`. Одна строка результата представляет один
визит. На третьей ступени вы уже нарезали сессии:
- сессия не пересекает модельные сутки;
- пауза больше 30 минут открывает новую сессию;
- в нарезке участвуют события всех типов.
Пересборка добавляет требования к повторяемости. При одинаковом времени
порядок событий задает `event_id`, поэтому полный порядок окон —
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
при каждом запуске. Тогда его можно сравнить с сохраненным результатом.
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
длительность. Посчитайте все события, просмотры, добавления в корзину и
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
и город. Заполните также служебные колонки из схемы таблицы.
Пишите через `dds.session_stage_dist`: место строки определит ключ
`client_id`. При записи прямо в локальную таблицу оно зависело бы от ноды,
которая выполнила запрос.
## Запустите свою реализацию
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
последний собранный день:
```sql
SELECT max(session_date)
FROM dds.session_v;
```
Берите последний день: более ранняя дыра расширит объем до всех последующих
прожитых дней.
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
```sql
SELECT
session_id,
client_id,
session_date,
started_at,
finished_at,
duration_seconds,
events_total,
pageviews,
cart_adds,
purchases
FROM dds.session_v
WHERE session_date = 'YYYY-MM-DD'
ORDER BY client_id, started_at, session_id
LIMIT 10;
```
Создайте в DBeaver подключение к первой ноде по [инструкции из
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
партицию:
```sql
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
DROP PARTITION 'YYYY-MM-DD';
```
Откройте Airflow и запустите `etl_pipeline`. В даге `dds_transform` раскройте
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
в группе появятся задачи `replace` и для последующих прожитых дней. Следом
выполнятся витрины и `dq_check`.
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
находится в DDS. После правки снова удалите тот же день перед следующим
запуском. Иначе день останется заполненным, а новая сборка для него не
выполнится.
## Как проверить себя
Сначала повторите запрос с десятью строками. `session_id`, границы,
длительность и счетчики должны совпасть с сохраненным результатом. Так вы
завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по
`session_id`.
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
видна в хранилище:
```sql
SELECT data_date, reference_rows, actual_rows, failed_rows, status
FROM dm.dq_summary_v
WHERE check_name = 'sessions_vs_reference'
ORDER BY data_date;
```
Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой
`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут
оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной
куки выбранного дня сопоставьте границы `VisitID` по событиям с границами
строк `dds.session_v`. По исходным событиям проверьте страницы, UTM, устройство
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
времени ожидаемое значение определяйте по исходным событиям и полному порядку
`(event_time, event_id)`.
Осталась физическая раскладка. Напишите отдельный запрос к
`dds.session_dist`. Для каждой строки он должен:
1. получить фактическую ноду через `hostName()`;
2. пересчитать ожидаемую ноду по `cityHash64(client_id)`;
3. посчитать строки, у которых ноды не совпали.
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
1 — `clickhouse-02`. Сгруппируйте ответ по фактической ноде. На каждой ноде
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
Distributed-таблицу и такую ошибку не заметит.
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
партицию. Расчет объема должен пропустить группу сессий: новых прожитых дней и
дыр в слое нет.
## Что должно получиться
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
таблицы и проверку стенда;
- [ ] ваш расчет восстановил удаленный день, `etl_pipeline` завершился успешно;
- [ ] вы сверили десять сохраненных строк: `session_id`, границы,
длительность и счетчики совпали после пересборки;
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
прожитые дни и вручную сверили границы и атрибуты сессии одной куки;
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
- [ ] повторный прогон без удаления данных пропустил группу сессий.
+148
View File
@@ -0,0 +1,148 @@
# Научите проверки замечать ошибки в заказах
**Чему учит:** написать проверки заказов до изменения их обработки
и убедиться на намеренной ошибке, что проверки ее замечают.
## Сначала прочитать
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [правила проверок](../architecture/testing.md#проверка-которая-не-умеет-краснеть-бесполезна).
Посмотрите также на устройство одной проверки в [`sql/dq/`](../../sql/dq/)
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
двух проверок из этого задания в репозитории нет.
## Что предстоит сделать
Добавьте две проверки и включите их в даг:
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
- `dags/dq_check.py` запускает обе проверки, публикует их строки в
`dm.dq_summary` и создает для них отдельные задачи.
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
их, даги загрузки, DDL, таблицы-доноры, Kafka и службы как есть.
Перед опытами остановите автоматическое движение мира: поставьте `world_live`
на паузу и дождитесь завершения активных прогонов. До завершения опытов не
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
`world_recreate`. Это условие стабильного модельного времени: одной паузы
`world_live` для него мало.
## Что будут проверять ваши запросы
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
на день с колонками `data_date`, `reference_rows`, `actual_rows`,
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
статус и координаты запуска. Примеры из `diagnostics` ищите в
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
существующие запросы в `sql/dq/`.
### Сумма позиций
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
`reference_rows` считает ключи со стороны `items_total`, `actual_rows` - ключи
со стороны вычисленной суммы, а `failed_rows` - несовпавшие значения. В
исправном наборе первые два счетчика равны; несовпадения сумм считает
`failed_rows`. Для диагностики достаточно `order_id`, ожидаемой суммы и
суммы позиций.
Проверка относится к типизированному разбору массива `items` на границе
ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
### DDS против ODS
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
генератора для этой проверки не нужно.
Одним запросом соберите обе стороны на зерне дня заказа и сравните:
- число заказов;
- максимальный `updated_at`.
День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область
проверки - объединение дней обеих сторон, включая день, который полностью
пропал из одного слоя. Перед сравнением приведите времена к одному часовому
поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS,
а `failed_rows` равен единице для дня, где расходится хотя бы одно значение.
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны
по этой проверке. Общая потеря при разборе проявится в отдельном балансе по
`_load_id` из [следующего задания](5-2-order-parsing.md).
## Получите красный и зеленый результаты
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
нарушенных дней.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
сохраните его `order_id` и `order_date`. Подключитесь к первой ноде из DBeaver
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
UPDATE items_total = items_total + toDecimal64(0.01, 2)
WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
```
Затем запустите только `dq_check`. Задача `order_items_total` должна
завершиться ошибкой, а ее журнал - назвать выбранный заказ.
`purchase_vs_orders_vs_dds` тоже даст красный результат: сохраненная витрина
содержит прежний `items_total`.
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
результат для нового сторожа.
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
```
До восстановления снова запустите только `dq_check`. Красный результат нужен
для `orders_vs_ods`, `purchase_vs_orders_vs_dds` и `revenue_daily_vs_dds` на
дне оплаченного заказа. Затем запустите `etl_pipeline` и повторите проверку.
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
задания.
## Как проверить себя
В `dm.dq_summary_v` должны быть зелены обе новые и пять прежних проверок:
```sql
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
FROM dm.dq_summary_v
GROUP BY check_name
ORDER BY check_name;
```
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
## Завершите опыт
После восстановления заказа и успешного `dq_check` можно закончить работу или
перейти к разбору заказов, оставив модельное время на паузе.
## Что должно получиться
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
появляются в `dm.dq_summary`;
- [ ] вы увидели ошибку каждой проверки после соответствующего изменения
заказа, а в журнале нашли примеры заказов с расхождениями;
- [ ] вы восстановили заказ через `etl_pipeline` после обоих опытов;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] разбор ODS и сборка DDS остались прежними.
+180
View File
@@ -0,0 +1,180 @@
# Перепишите разбор заказов в ODS
**Чему учит:** переписать разбор заказов и на пробных сообщениях проверить,
что годные строки и брак попадают в свои таблицы без потерь.
## Сначала прочитать
- [проверки заказов](5-1-order-checks.md);
- [прием заказов в ODS](../architecture/orders/ingestion.md);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам);
- [`orders_ingest`](../../dags/orders_ingest.py);
- [`order_parse.sql`](../../sql/ods/order_parse.sql).
Запрос для проверки пробных строк вы напишете сами.
## Что предстоит сделать
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
соберите разбор заново и повторите опыт с другим маркером.
Не меняйте устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
DDS. Оба опыта должны закончиться адресной очисткой только своих строк.
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
все семь DQ остались зелеными. Поставьте `world_live` на паузу, дождитесь
активных прогонов и до завершения опыта не запускайте `world_next_day`,
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
`etl_pipeline` между
отправкой пробной пачки и ее очисткой.
## Четыре строки для двух ветвей
Сделайте четыре сообщения с общим уникальным маркером:
1. годную строку с маркером в `order_id`;
2. JSON-строку вместо объекта - класс `not_an_object`;
3. объект с лишним ключом - класс `keyset_mismatch`;
4. объект с точным набором ключей, но строкой в `user_id` - класс
`field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid`
означает, что ключи на месте, но значение не прошло проверку типа, формата или
вместимости.
Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и
по одному примеру каждого класса брака:
```bash
LAB_MARKER="lab5-$(date +%s)"
LAB_BATCH="/tmp/${LAB_MARKER}.jsonl"
LAB_DAY="$(
docker compose exec -T clickhouse-01 sh -lc \
'clickhouse-client --user etl --password "$CLICKHOUSE_ETL_PASSWORD" \
--query "SELECT toString(max(snapshot_date)) FROM ods.order_dist"'
)"
jq -cn --arg marker "$LAB_MARKER" --arg day "$LAB_DAY" '
def good($id): {
order_id: $id, user_id: 1, status: "paid",
created_at: ($day + "T00:00:00.000Z"),
updated_at: ($day + "T01:00:00.000Z"),
items_total: "10.00", discount: "0.00",
delivery: "0.00", total: "10.00",
items: [{sku: "lab5", qty: 1, price: "10.00"}],
snapshot_date: $day
};
good($marker),
($marker + "-not-an-object"),
(good($marker + "-keys") + {lab_marker: true}),
(good($marker + "-field") | .user_id = "broken")
' > "$LAB_BATCH"
```
Отправьте файл в обычный топик заказов утилитой из контейнера Kafka:
```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
```
Запустите `orders_ingest` и найдите `_load_id` по маркеру в
`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона.
## Зафиксируйте исходный результат
До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`:
- в STG четыре строки;
- в физической `ods.order_dist` одна годная строка;
- в `ods.order_errors_dist` три строки, по одной каждого класса;
- `STG = годные + брак`;
- текущая строка `ods.order_v` совпадает по деловым полям с результатом
`argMax` по `snapshot_date` из годного сырья среза.
Счет физической таблицы нужен именно по `_load_id`: представление
`ods.order_v` оставляет текущую версию сущности и не показывает, сколько строк
разобрал запуск. Запрос сверки с `argMax` напишите сами. Эталонные деловые поля
стройте только из `raw`; отдельно сравните различающиеся `created_at` и
`updated_at`.
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
Фактическая нода должна совпасть с остатком `cityHash64(order_id) % 2`:
0 - `clickhouse-01`, 1 - `clickhouse-02`.
## Уберите пробную пачку
Удалите только строки маркера из локальных таблиц:
```sql
ALTER TABLE stg.orders_raw_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE startsWith(order_id, '<маркер>')
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_errors_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
```
В ClickHouse 26.3 мутации выполняются в фоне. С `mutations_sync = 2` команда
ждет их завершения на всех репликах. Запросами к трем Distributed-таблицам
убедитесь, что строк маркера не осталось, затем удалите временный файл.
Группа `clickstream_orders` уже подтвердила эти сообщения, поэтому обычный
повтор `orders_ingest` их не прочитает. В Kafka они остаются до удаления по
политике хранения. `world_recreate` удаляет группу и сам топик, поэтому в
пересозданный мир пробная пачка не переходит.
Очистка должна закончиться до запуска `etl_pipeline`. Если конвейер все же
успел собрать временный заказ в DDS, сначала очистите три таблицы выше, затем
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
обновит витрины.
## Перепишите разбор
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
`orders_ingest` мог его использовать, сохраните следующие условия:
- вход - строки `stg.orders_raw_dist` одного `{load_id:String}`;
- каждая строка среза попадает ровно в один результат: годный заказ или брак;
- произвольный `raw` не останавливает весь разбор исключением;
- годные строки записываются в `ods.order_dist`, остальные - в
`ods.order_errors_dist`;
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
`field_invalid`;
- метки доставки и `_load_id` переходят из STG без пересчета;
- задача завершается после доставки обоих результатов через Distributed, а
строки распределяются по ключу целевой таблицы.
Точный набор полей, типы и граница строгого приема описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
Внутренность `items` остается сырым JSON: ее разбирает DDS.
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
класса, верную текущую строку по `snapshot_date`, разные времена и правильный
шард. Затем очистите второй срез теми же адресными командами и запустите
`dq_check`.
## Завершите опыт
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
опыта `world_live` работал, снимите его с паузы.
## Что должно получиться
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
один заказ и три класса брака; вы сверили поля текущей версии и шард;
- [ ] вы переписали `sql/ods/order_parse.sql`, сохранив остальные части стенда;
- [ ] ваш разбор дал тот же результат на новой пробной пачке;
- [ ] вы проверили разные значения `created_at` и `updated_at` у годной
строки в обоих опытах и подтвердили правильный шард через `hostName()`;
- [ ] строки обоих опытов удалены по их маркерам;
- [ ] после очистки конвейер и все семь проверок успешны.
+123
View File
@@ -0,0 +1,123 @@
# Перепишите сборку заказов в DDS
**Чему учит:** настроить пересборку заказов по состоянию ODS и DDS
и проверить, что она восстанавливает пропущенный день за пределами текущего окна.
## Сначала прочитать
- [проверки заказов](5-1-order-checks.md);
- [разбор заказов в ODS](5-2-order-parsing.md);
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
Удалите готовый SQL и перепишите только три файла:
- `sql/dds/order_scope.sql`;
- `sql/dds/order_rebuild.sql`;
- `sql/dds/order_replace.sql`.
Даг `dds_transform`, DDL, донор, ODS и обе проверки из первого задания
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
задания удалены, а семь DQ дают зеленый результат.
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
завершения опыта не запускайте `world_live_day`, `world_initialize` и
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже
случае.
## Как даг запускает ваши запросы
Сохраните интерфейс `dds_transform`:
| Файл | Вход | Результат |
|---|---|---|
| `order_scope.sql` | без параметров | одна строка `first_day`, `last_day` |
| `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` |
| `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
Объем состоит из текущего окна источника и хвоста от первого дня, который есть
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
`snapshot_date`: их минимальный и максимальный день заказа. Если источник
пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
началу окна. Если дыра старше, непрерывный отрезок начинается с нее.
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
ставит координаты запуска.
Запись через Distributed должна завершиться до замены и использовать ключ
целевой таблицы для раскладки строк. Способ собрать весь отрезок внутри файла
выберите сами.
`order_replace.sql` работает с локальными `dds.order_rep` и
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` переносит готовую
дневную партицию из донора и оставляет донор на месте. Структура, ключи и
политика хранения совпадают: DDL объявляет донор через `AS dds.order_rep`.
## Восстановите ближайший закрытый день
Найдите последний по дате день DDS, который лежит левее начала текущего окна
ODS. Это ближайший к окну закрытый день: его удаление показывает расширение
объема на дыру без пересборки всей истории.
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
зафиксируйте модельное время до конца опыта и выберите ближайший закрытый день.
Сохраните для него число заказов и контрольную сумму деловых полей без
`_load_id` и `_load_ts`. Удалите только его партицию:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DROP PARTITION 'YYYY-MM-DD';
```
Сразу запустите `dq_check`, до восстановления партиции. Красный результат
должны дать `orders_vs_ods` и `purchase_vs_orders_vs_dds`.
`revenue_daily_vs_dds` тоже даст красный результат, если в партиции были
оплаченные заказы. `classes_vs_inventory` останется зеленым: до запуска
`dm_transform` эта проверка читает прежнюю витрину сверки.
Запустите `etl_pipeline`. В группе `dds_transform.order` значение `first_day`
должно совпасть с удаленным днем, а задачи `replace` - покрыть непрерывный
отрезок от него до конца текущего окна. `first_day` ищите в строке про объем
пересборки в журнале задачи `order.scope` дага `dds_transform`. После успешного
прогона число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными.
## Проверьте раскладку и повтор
Напишите отдельный запрос к `dds.order_dist`, который получает фактическую
ноду через `hostName()`, вычисляет ожидаемую по `cityHash64(order_id) % 2` и
считает несовпадения по каждой ноде. Остаток 0 соответствует
`clickhouse-01`, остаток 1 - `clickhouse-02`. На обеих нодах число строк на
чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую
ошибку раскладки не обнаруживают.
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
деловых полей остаются прежними, а все семь проверок дают зеленый результат.
## Завершите опыт
После восстановления партиции и успешных проверок снимите `world_live`
с паузы, если он работал до упражнения.
## Что должно получиться
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
части стенда;
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до
восстановления;
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
всего отрезка до конца окна;
- [ ] вы сверили число заказов и контрольную сумму восстановленного дня
с сохраненными значениями; они совпали;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли.
+219
View File
@@ -0,0 +1,219 @@
# Разберите сырые события рядом со стендом
**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и
построчно проверить свой результат по нетронутому ODS.
## Сначала прочитать
- [формат события](../formats/clickstream-event.md);
- [справочник хранилища](../architecture/storage.md), разделы про имена
объектов, актуальные события, прием потока и срок жизни сырых данных;
- [ADR о приеме событий](../adr/0005-event-ingestion.md), раздел с принятым
решением;
- схему [`ods.event_rep`](../../sql/ddl/20-ods-tables.sql).
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
после собственной сверки: в нем лежит ответ на основную часть задания.
## Что предстоит сделать
Создайте в базе `default` одну свою таблицу годных событий и наполните ее одним
пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под
пользователем `etl`.
Матвью `ods.event_mv` и `ods.event_errors_mv` продолжают принимать живой
поток. Оставьте их, STG, ODS, Kafka и даги без изменений. Пересоздание матвью
на работающем стенде оставило бы зазор в приеме, а для пакетного опыта оно не
нужно.
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
его вместе с запросами. В конце вы удалите только эту таблицу.
## Выберите сырой срез
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
поступления в хранилище. Модельный день события лежит в `EventDate`. Сначала
найдите среди недавних данных завершившийся интервал со строками. Посмотрите
счет по минутам:
```sql
SELECT
toStartOfMinute(_load_ts) AS minute,
count() AS rows
FROM stg.hits_raw_dist
WHERE _load_ts >= now() - INTERVAL 30 MINUTE
AND _load_ts < toStartOfMinute(now())
GROUP BY minute
ORDER BY minute DESC;
```
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
дает удобное для построчной проверки число строк. Если одна минута велика,
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
`world_live_day`: дождитесь свежих строк от текущего или следующего планового
запуска, а при ошибке сначала разберите ее причину. Запустите `world_live_day`
вручную, только если `world_live` стоит на паузе и активного `world_live_day`
нет. Успешный прогон распределяет события модельного дня по реальным минутам.
Зафиксируйте до разбора:
- обе границы с миллисекундами и число строк;
- минимальный и максимальный `kafka_offset` по каждой паре
`kafka_topic`, `kafka_partition`.
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
опыт до очистки по TTL; после нее прежний срез уже не восстановить из ODS.
## Соберите собственную таблицу
Таблица содержит все 47 полей источника с именами и типами
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
`Array(Decimal(18, 2))`.
Обработайте повторные доставки так же, как ODS. Возьмите
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
локальной таблицы: загрузка и сверка в этом опыте выполняются на одной ноде.
`WatchID` — логический идентификатор события, уникальный по
[договору генератора](../specs/2026-08-01-generator.md). Физический ключ ODS
шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным
ключом сортировки. Используйте его при загрузке и сверке.
## Разберите годные строки
Для каждой строки среза проверьте те же условия приема, что и на стенде:
1. корень `raw` — JSON-объект;
2. набор верхнеуровневых ключей в точности равен 47 ключам формата;
3. `WatchID`, `VisitID`, `ClientID`, `EventDate` и `UTCEventTime` разбираются
в ожидаемые типы.
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
выберите способ безопасного извлечения пяти опорных полей, форму общих выражений
и место фильтра. Остальные поля извлеките по описанию формата.
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
таблицу только годные строки выбранного среза.
Считайте отвергнутые строки прямо в исходном срезе, без отдельной таблицы
брака. Одним запросом посчитайте:
- все доставки;
- строки, прошедшие ваше условие отбора;
- строки, для которых это условие ложно.
Проверьте равенство общего числа строк сумме прошедших и отвергнутых. Так каждая
исходная строка учитывается ровно в одной группе. Если отвергнутые строки есть,
сохраните их `raw`, `_load_ts` и координаты Kafka. Найдите те же
координаты в `ods.event_errors_dist` и запишите стендовый `error_class`. Если
брака нет, так и зафиксируйте: опыт проверил успешную ветвь и выполнение
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
сообщения ради задания не требуется.
## Приведите стороны к одному зерну
### Учтите повторные доставки
В STG одна строка представляет доставку. Повтор сообщения остается второй
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
что представление читает физическую таблицу с `FINAL`.
Свою сторону тоже читайте с `FINAL`. Ожидаемый счет равен числу различных
полных ключей среди годных доставок. Разница между числом доставок и этим
счетом показывает повторы в исходном срезе.
### Выберите события ODS для сравнения
Теперь нужно найти в ODS события из выбранного среза. Список стройте по
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
Снова возьмите весь исходный STG-срез. По координатам Kafka исключите
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
`WatchID` без риска прервать запрос и выберите эти события из свежего
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
доставки вы берете из результата стендового разбора.
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
## Сверьте 47 полей
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
которых нет на одной из сторон. Разделите результат на три исхода:
- строка есть только в ODS — вы ее потеряли;
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
ключа;
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов.
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
в каждую сторону. Не определяйте отсутствие по значениям полей события.
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по
результату можно было назвать отличающиеся колонки одной строки. Выведите число
строк каждого из трех исходов. В верном разборе все три числа равны нулю.
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
Проверьте его отдельно. Свяжите каждую строку своей таблицы с меткой одной из
доставок того же полного ключа в исходном срезе. После `FINAL` остается самая
поздняя метка этого ключа внутри среза. С ODS ее не сравнивайте: там может
храниться более поздняя доставка того же события из-за границ среза.
## Расследуйте расхождение
Если сверка не пуста, возьмите одну строку. Вернитесь к независимому списку
ключей и исходному `raw`, выведите рядом координаты Kafka и `_load_ts`.
Для измененной строки назовите поля, которые расходятся, и сравните каждое с
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
результат условия отбора. Если координаты доставки есть в
`ods.event_errors_dist`, посмотрите `error_class`.
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
повторите вставку того же среза. Остановитесь, когда счет исходных доставок по
прежним границам не изменился, число годных и отвергнутых строк в сумме дает
число исходных доставок, а построчная сверка пуста.
## Чего не подтверждает сравнение с ODS
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
второго независимого источника для всех значений на стенде нет.
## Уберите свой объект
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
Убедитесь через `system.tables`, что объекта больше нет.
Прикладные строки стенда вы не меняли, поэтому удалять данные из STG, ODS или
Kafka после опыта не нужно.
## Что должно получиться
- [ ] у вас сохранены границы выбранного среза, число строк и координаты
доставки по партициям Kafka;
- [ ] ваша таблица содержит все 47 полей и `_load_ts` и обрабатывает
повторы по тем же правилам, что и ODS;
- [ ] ваш разбор принимает только JSON-объекты с точным набором ключей и
пятью опорными полями, которые преобразуются в нужные типы;
- [ ] вы сверили число исходных строк с суммой годных и отвергнутых,
а найденный брак разобрали по `raw`, координатам и классу ошибки стенда;
- [ ] вы выбрали события ODS по исходному срезу и таблице ошибок,
независимо от своей таблицы и условия отбора;
- [ ] вы сравнили обе стороны по полному физическому ключу через
`FULL OUTER JOIN`: во всех 47 полях нет расхождений,
пропавших и лишних строк тоже нет;
- [ ] вы отдельно проверили перенос `_load_ts` и учли повторные доставки;
- [ ] вы записали ограничения сравнения с ODS и срок повторения опыта;
- [ ] после опыта вы удалили свою таблицу, сохранив объекты и данные стенда.
+118
View File
@@ -0,0 +1,118 @@
# Мониторинг: куда смотреть
**Чему учит:** начать расследование с подходящего экрана, найти источник
нагрузки и объяснить свежесть данных через путь доставки.
## Перед началом
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
есть три дашборда:
- «Данные» — путь сообщений от источника до хранилища;
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
- «Запросы» — выполненные запросы, их стоимость и ошибки.
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
Проведите два расследования на работающем стенде. Службы останавливать и данные
портить не понадобится.
## Расследование 1. Кто читает больше всех
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
всех» возьмите первую строку.
Сохраните:
- границы окна и время запроса;
- место запроса в списке, пользователя и ноду;
- текст запроса;
- длительность, память, число прочитанных и отданных строк.
По пользователю и тексту определите, кто отправил запрос и для чего. Затем
найдите запрос одного из трех открытых дашбордов, который прочитал больше всего
строк. Если это другая строка, сохраните для нее те же поля.
По тексту найдите панель, которая отправила запрос. Ищите ее среди трех
дашбордов по названиям колонок, таблиц и показателей. Запишите, зачем запрос
читает столько строк и согласуется ли это с вопросом, на который отвечает
панель.
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
откройте Inspect, затем Query. Ответьте на два вопроса:
1. Почему журнал читают сразу с обеих нод?
2. Почему из счета исключены строки начала запроса и работа шардов?
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
шардов. Объясните, почему различаются вопросы панелей, а не только их SQL.
## Расследование 2. Как меняется свежесть заказов
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
доехавшей строки» показывает текущее число, а не историю. До наблюдения
нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого
дня. Отдельно отметьте момент доставки слепка заказов.
Если `world_live` снят с паузы, наблюдайте его текущий или следующий
`world_live_day`: новый ручной запуск встанет в очередь. Если выключатель на
паузе и активного живого дня нет, запустите один `world_live_day` вручную.
Модельные сутки занимают около 24 реальных минут.
Запишите несколько наблюдений до конца дня и сразу после него:
| Реальное время | Свежесть событий, с | Свежесть заказов, с | Что происходило в Airflow |
|---|---:|---:|---|
| | | | |
Дашборд обновляется раз в минуту, поэтому точного совпадения момента
доставки со строкой таблицы ждать не нужно. Рост и сброс свежести заказов
перенесите на свой набросок. Объясните форму линии: какую метку времени
вычитает панель из текущего времени, почему число растет и какое событие его
сбрасывает. Модельную дату сыгранного дня подпишите отдельно от реального
времени наблюдения.
Подтвердите сброс двумя следами:
- в Airflow завершился прием слепка `orders_ingest`;
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
появилась новая верхняя строка.
Если вы начали после доставки, дождитесь следующего дня либо честно запишите,
какую часть формы увидели. Одно текущее значение не доказывает ни рост, ни
сброс.
## Объясните путь данных
Не открывая справочник, объясните своими словами:
- почему свежесть считают в ODS, а поток — в STG;
- почему знаменатель доли брака берут из STG;
- почему у числителя и знаменателя доли брака одно окно.
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
[«Запросы»](../architecture/monitoring/queries.md) и
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
## Что должно получиться
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
- [ ] у вас сохранены окно, текст и показатели верхнего запроса;
вы выяснили, кто его отправил и зачем;
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
строк, и панель, которая его отправила;
- [ ] вы можете объяснить, зачем журнал собирают с двух нод и какие записи
учитывают при подсчете запросов;
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
замеров; рост и сброс на рисунке объяснены через время последней доставки;
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
и почему числитель и знаменатель берут за одно окно;
- [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений.
+132 -82
View File
@@ -1,109 +1,159 @@
# Учебные задания: витрины слоя DM и дашборд в Superset # Учебные задания
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой Программа состоит из двух частей: основная включает семь ступеней, которые вы
наполовину: витрины трех сюжетов дашборда и контроль качества построены, проходите по порядку, а факультативы выбираете по желанию. Причины такого
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
витрина, какое у нее зерно, из чего она собирается и как проверить, что
собралась верно.
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой ## Основная программа
дашборд поверх пяти витрин.
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на | Ступень | Задание | Чему учит |
вопросы «почему так», задание отвечает на вопрос «что сделать». |---|---|---|
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится |
| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.md) | переписать расчет сессий в готовом конвейере и проверить по исходным событиям |
| 5 | [Научите проверки замечать ошибки в заказах](5-1-order-checks.md)<br>[Перепишите разбор заказов в ODS](5-2-order-parsing.md)<br>[Перепишите сборку заказов в DDS](5-3-order-pipeline.md) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов |
| 6 | [Разберите сырые события рядом со стендом](6-1-event-parsing.md) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе |
## Порядок ## Ступень 1: пять витрин слоя DM
Задания идут по нарастанию: каждое следующее опирается на то, что уже Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль
усвоено. качества уже построены, остальные пять витрин строите вы. Каждая из них —
самостоятельное задание: в нем есть состав витрины, зерно, источники и способ
проверки.
| № | Результат | Чему учит | | № | Результат | Чему учит |
|---|---|---| |---|---|---|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных | | 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие |
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер | | 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере |
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит | | 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей |
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни | | 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника |
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение | | 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента |
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
## Общие правила Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать».
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не ## Как принято на ступени 1
повторяются в каждом ТЗ.
**Читать — через `_v`.** Публичный договор сущности DDS — представление с **Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю остается прежним даже при изменении физических объектов. Чтение DDS
домашку витрина источником тоже не берет: витрина слоя DM читает договор напрямую делает задания независимыми: соседняя витрина может быть еще не
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в построена.
задании 4: у таблиц брака публичного договора нет. Исключение — задание 4: у таблиц брака публичного договора нет.
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку **Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При
определяет то, какая нода случайно выполняла код, а не свойство данных. записи в локальную таблицу распределение зависит от узла, выполнившего
Правило и его следствия — [дока хранилища](../architecture/storage.md), запрос, и при следующем запуске может измениться. Подробности — раздел
«Раскладка по шардам». «Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется: **Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и готовым: потребитель читает его много раз без повторного расчета по
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно, источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
остается обычным представлением. Довод — [дока DM](../architecture/dm.md), `_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
«Форма: по зерну, не по цене запроса». зерне достаточно представления: таблица дублировала бы данные. Доводы —
раздел «Таблица или представление» [справочника
DM](../architecture/dm.md).
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет. **Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
Правило выбора и требование к донору — [дока дает существенного перекоса и почему более сложный ничего не добавит.
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель, потеряет строку на одном шарде и удвоит на другом. Детали — раздел
иначе замена партиции потеряет строку на одном шарде и задвоит на другом. «Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
Названный ключ надо уметь защитить: почему он не дает перекоса и почему Пример обоснования — `dm.revenue_daily_dist` в
более сложный тут ничего не добавляет. Образец такой защиты — [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он **Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
применяется после стендовых по порядку имени, и твои объекты не мешаются При создании мира он применяется после стендовых файлов.
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса — В уже работающем стенде сохранение файла и повторный `make up` новый DDL
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает не применят — выполните его сами, как описано ниже. В
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет `sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
партицию, — и задача в собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
представления нет ничего из этого: весь его код — одно `CREATE VIEW`. конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять **Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор,
объектов, донор, сборка, замена дневных партиций и задача в конвейере. сборка, замена дневных партиций и задача в конвейере. Используйте ее как
Списывать с нее устройство можно и нужно; списывать состав колонок — нет, образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
он у каждой витрины свой. получите показатели другой витрины.
## Самопроверка ## Как применить свой DDL
Даг `homework_check` запускается рукой, расписания у него нет. Задача В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver),
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`.
Пользователь `analyst` из той инструкции предназначен для чтения;
у `etl` есть права создавать таблицы и представления в `dm`.
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
задания. Создавайте объекты с
`ON CLUSTER clickstream_cluster`, чтобы они появились на обеих нодах;
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
## Самопроверка ступени 1
Даг `homework_check` запускается руками, расписания у него нет. Задача
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это
это инструмент лабы, а не история качества стенда. инструмент лабы, а не история качества стенда.
Проверка пяти заданий по витринам устроена одинаково и золотых констант не Для каждой витрины проверка независимо рассчитывает ожидаемый результат
содержит: рядом с витриной независимым запросом собирается тот же ответ — прямо по ее источнику. У четырех заданий источник
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это 4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк, строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
где стороны разошлись или одной стороны нет. Ноль — зеленый. соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
Полное соединение здесь обязательно: при обычном пропавший день или ключ не попал бы в результат, и потеря осталась бы незамеченной.
пропавший ключ просто не попал бы в результат и остался бы незамечен.
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и
сравниваемые величины. Тот же прием в стендовом исполнении — [даг сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq). `dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения:
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно они заново рассчитывают показатели витрины, поэтому содержат ответ на
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти существенную часть задания. Сравните свой способ расчета с готовым.
файлы никто не станет — но открыв их до того, как задание сделано, ты
обменяешь урок на готовый SQL. ## Факультативы
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
списать одно — нет. Эти задания вы берете по желанию. Полный список сюжетов — в разделе
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
программы.
### Почему выручка не сходится
Открывается после ступени 1. Расхождения между источниками, повторы
покупок и день, после которого отчет можно считать окончательным.
### Тонкий анализ данных
Открывается после ступени 1. Знание о пользователе на дату и обогащение
отчета своим словарем.
### Superset и BI
Открывается после ступени 2. Два самостоятельных задания:
- [Свой дашборд в Superset](optional/superset-dashboard.md): собрать отчет
из своих витрин и подтвердить выводы запросами.
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
выяснить, почему клиенты по-разному видят один объект хранилища.
### Инженерия стенда
Открывается после ступени 1. Прием и хранение данных, раскладка по шардам,
повторная обработка дня и обновление витрин.
### Что делать, когда стенд сломался
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению
по итогам расследований; весь раздел — повышенная сложность.
## Уборка ## Уборка
Черновики и опыты за собой сносить: правило проверок стенда — После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
снова покраснить свою задачу в `homework_check` — если не покраснила, витрины тоже проверяется. Подробнее — в
проверка ничего и не проверяла. [справочнике проверок](../architecture/testing.md).
-118
View File
@@ -1,118 +0,0 @@
# Задание 4: `dm.dq_errors_daily`
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
по времени загрузки, а не по модельному дню, и переживает собственный
источник — поэтому «пересобрать все» здесь означает «стереть историю».
## Роль витрины
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
брака, потом идут за сырым текстом в саму таблицу ошибок.
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
доставки.
## Источник: единственное исключение из правила `_v`
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
такого договора нет, и заводить его ради этой витрины не надо: брак — не
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
распределенные таблицы напрямую:
- `ods.event_errors_dist` — брак разбора событий;
- `ods.order_errors_dist` — брак разбора слепка заказов.
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
появится позже, когда по витрине примут решение.
## Зерно и форма
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
строка брака, значит зерно сменилось и витрина материализуется:
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
и быть не может: он живет в содержимом, а содержимое как раз и не
разобралось.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `load_date` | `Date` | день загрузки, в который приехал брак |
| `source` | `LowCardinality(String)` | `event` или `order` |
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
| `error_rows` | `UInt64` | число забракованных строк |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
деловая, вторая служебная, и совпадать они не обязаны.
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
заказов слилось бы в одну строку.
## Ритм пересборки: витрина переживает источник
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
срока: сводка «сколько брака было в марте» нужна и после того, как сами
строки марта удалены.
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
еще есть в источнике**. Спроси у источника список живых дней и замени
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
них.
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
брак в него еще добавится. Это нормально, день просто пересоберется на
следующем прогоне.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
«перекос здесь ничего не стоит, и вот почему».
## Как проверить себя
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
ошибка.
Собери прямой счет по обеим таблицам ошибок, соедини с
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
Ноль — зеленый.
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
`_dist` и видит один шард из двух.
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
игнорировать.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] источники — `_dist`-таблицы обоих видов брака;
- [ ] прогон заменяет партиции только живых дней источника и не трогает
истекшие;
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
чистом стенде, где брака нет;
- [ ] задача витрины добавлена в `dm_transform`.
-114
View File
@@ -1,114 +0,0 @@
# Задание 1: `dm.events_enriched_v`
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
меняет зерно источника, остается представлением, и копии событий ради
других имен колонок не заводит ни один боевой склад.
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
соблазну разложить его на диск.
## Роль витрины
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
часть из них технические координаты источника, часть — параллельные массивы
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
производных, которые иначе каждый пишет заново в каждом запросе.
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
надо посмотреть на события глазами человека, а не разбирать широкую строку
источника.
Источником для витрин следующих заданий она не служит. Витрина слоя DM
читает публичный договор DDS — так устроены и стендовые витрины, и потому
задания независимы: соседнюю домашку можно делать первой, а можно не делать
вовсе.
## Зерно и форма
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
Зерно источника сохранено, значит форма — обычное представление
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
без задачи в `dm_transform`. Пересобирать нечего.
Источник — `dds.event_v`, и только он.
## Состав колонок
Явный список обязателен: договор не должен расширяться сам, когда в
источник добавят колонку.
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
`client_id`, `event_id`.
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
`page_path`, `referer`, `referer_host`, `title`.
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
`utm_term`.
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
`region_country`, `region_city`.
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
`product_category`, `product_price`, `product_quantity`,
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
Три колонки в источнике не лежат и считаются здесь:
| Колонка | Как считается | Зачем |
|---|---|---|
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
координат собственной загрузки у него не бывает. Служебная колонка называет
запуск, который положил строку на диск, — здесь на диск никто ничего не
кладет.
## Ловушка задания
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
здесь два.
Очевидный: у события с тремя позициями получилось бы три строки, зерно
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
бы и правило формы — такую витрину уже пришлось бы материализовать.
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
Так «плоская таблица событий» превращается в таблицу товарных позиций и
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
подсвечивает.
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
## Как проверить себя
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
за тот же день, соедини с витриной полным внешним соединением и посчитай
строки, где ключа нет с одной из сторон или где различаются значения
деловых колонок. Ноль — зеленый.
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
`referer_host` должны совпасть с тем же выражением, посчитанным по
`dds.event_v` на лету.
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
все события с пустым торговым блоком.
## Приемка
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
- [ ] список колонок в представлении явный;
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
прожитый день;
- [ ] задача `events_enriched` в `homework_check` зеленая;
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
+113
View File
@@ -0,0 +1,113 @@
# Свой дашборд в Superset
**Чему учит:** собрать из своих витрин отчет о поведении посетителей,
подобрать графики под вопросы и разместить их на одном понятном экране.
Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2,
когда готовы витрины ступени 1.
## На какие вопросы ответит дашборд
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
каналы дали результат, были ли ошибки приема данных.
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
соберите из пяти витрин, которые вы построили в заданиях. Копировать раскладку
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт
при каждой инициализации».
Используйте свои датасеты и графики, соберите из них свой дашборд.
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
создаете вы.
## Датасеты
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
пять публичных представлений схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
- `session_overview_v`;
- `dq_errors_daily_v`;
- `utm_effectiveness_v`.
## Состав дашборда
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
графика выберите сами. На дашборде покажите следующее:
| Витрина | Что показать |
|---|---|
| `events_enriched_v` | число событий по часу и типу события |
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используйте слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Период отчета
Выберите явный период из модельного календаря и задайте его в графиках
поведения посетителей. Последнюю доступную дату посмотрите в своих витринах.
Относительный период вроде `Last week` отсчитывается от настоящего времени
и может дать пустой экран.
Ошибки приема считаются по `load_date`, дню загрузки, а поведение посетителей
по модельному времени. Для панели ошибок выберите период загрузки отдельно
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
## Как проверить себя
Автоматической проверки этого задания нет. Числа вы сверите запросами,
а понятность графиков проверите по подписям.
Откройте список дашбордов и проверьте:
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
- ваш дашборд открывается без ошибок и содержит график от каждого из пяти
датасетов;
- подписи, единицы измерения и легенды читаются без обращения к SQL;
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
По готовому экрану запишите три коротких вывода за выбранный период:
- какой рекламный канал дал больше покупок;
- какая страница получила больше просмотров;
- у какой категории устройств выше доля отказов.
Для каждого вывода укажите период и число, на которое опираетесь. Проверьте
его запросом к своей витрине в SQL Lab с тем же периодом и способом расчета.
Сохраните запросы рядом с выводами. При равенстве значений назовите всех
лидеров. Если данных нет, это тоже результат: подтвердите отсутствие строк
запросом.
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
возможность, попросите другого человека прочитать экран без ваших пояснений.
## Что должно получиться
- [ ] у вас есть пять датасетов поверх публичных представлений витрин заданий 1–5;
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
- [ ] на дашборде есть хотя бы один график от каждой витрины;
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
- [ ] вы записали три вывода и сверили числа с SQL; в отчете указаны периоды
поведения посетителей и ошибок приема.
@@ -0,0 +1,70 @@
# В DBeaver работает, в Superset нет
**Чему учит:** найти причину, по которой один клиент читает объект
хранилища, а другой его не видит.
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
`dm.daily_traffic_v`.
## Подготовьте опыт
В DBeaver подключитесь к первой ноде пользователем `etl`, как описано
в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl).
Для опыта создайте отдельное представление:
```sql
CREATE VIEW dm.bi_node_probe_v AS
SELECT * FROM dm.daily_traffic_v;
```
Запрос выполнится успешно. Теперь проверьте, откуда можно прочитать
созданное представление. Начните с того же подключения:
```sql
SELECT count() FROM dm.bi_node_probe_v;
```
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
Сохраните текст ошибки.
## Найдите причину
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
показывают настройки клиентов, а имя ответившего сервера можно спросить
запросом `SELECT hostName()`.
Проверьте наличие `dm.bi_node_probe_v` на обеих нодах. Для второй ноды
заведите подключение DBeaver с портом из
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
есть, и объясните, почему успешного создания в первом подключении оказалось
недостаточно.
Исправьте DDL так, чтобы представление появилось на обеих нодах. Подключение
Superset должно остаться прежним. Сохраните исправленный DDL и повторите
исходный запрос в обоих клиентах.
Если застряли, прочитайте раздел «Устройство зоны» в
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
## Как проверить себя
В обоих клиентах запрос к вашему представлению должен вернуть столько же
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
при завершенном прогоне конвейера, пока мир не продвигается.
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
запрос Superset и на каких нодах существовало представление.
После опыта удалите только `dm.bi_node_probe_v` на обеих нодах. Не включайте
его в постоянный DDL домашних витрин: это временный объект расследования.
## Что должно получиться
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
создано представление и почему Superset его не видел;
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
сколько исходная витрина;
- [ ] после опыта вы удалили учебное представление с обеих нод.
-121
View File
@@ -1,121 +0,0 @@
# Задание 3: `dm.session_overview`
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
складывать даже тогда, когда на сегодняшних данных сумма сходится.
## Роль витрины
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
были длинные, сколько страниц смотрели и как часто доходили до покупки.
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
компьютера, и чем эти визиты отличаются».
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
которая тихо разойдется с первой.
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
витрина, повторяющая его зерно, оказалась бы обычным представлением по
[правилу формы](../architecture/dm.md): копией сессий с другими именами
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
зерно выше — до дня и среза.
## Зерно и форма
Зерно — **день сессии × категория устройства**. Источник —
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
визиты появятся.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день сессии |
| `device_category` | `LowCardinality(String)` | категория устройства |
| `sessions` | `UInt64` | число визитов |
| `visitors` | `UInt64` | число разных `client_id` |
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
| `events_total` | `UInt64` | событий во всех визитах |
| `pageviews` | `UInt64` | просмотров страниц |
| `cart_adds` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Средней длительности и доли отказов в витрине нет намеренно. Хранить
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
устройствам и получит неправду: средние не складываются, они взвешиваются.
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
стенда устройство приписано куке на всю жизнь ([словарь
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
в одной строке дня, и пересекаться строкам не в чем.
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
которое стоит унести с задания: складывать уникальных нельзя даже там, где
сегодня получается. Число посетителей дня спрашивают у
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
## Ритм пересборки
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
же: донор и замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Здесь у измерения всего три встречающихся значения, и распределены
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
## Как проверить себя
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
соединением и посчитай строки, где ключа нет с одной из сторон или где
расходится любой счетчик. Ноль — зеленый.
Две проверки покрупнее ловят грубую ошибку сразу:
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
ни один визит не потерян и ни один не задвоен;
- сумма `events_total` за день равна сумме `events_total` по сессиям того
же дня.
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
наверняка витрина собрана из событий, а не из сессий.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.session_overview_v` перечисляет колонки явно;
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
- [ ] средних и долей в физической таблице нет;
- [ ] задача `session_overview` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
-87
View File
@@ -1,87 +0,0 @@
# Задание 6 (необязательное): свой дашборд в Superset
**Чему учит:** довести свои витрины до потребителя — завести в Superset
датасеты поверх публичных представлений, подобрать графики под вопросы и
собрать из них один связный экран.
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
интерфейсе BI.
## Роль дашборда
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
каналы дали результат, были ли ошибки приема данных.
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
при каждой инициализации».
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
## Датасеты
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
- `session_overview_v`;
- `dq_errors_daily_v`;
- `utm_effectiveness_v`.
## Состав дашборда
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
графика выбери сам. На дашборде покажи следующее:
| Витрина | Что показать |
|---|---|
| `events_enriched_v` | число событий по часу и типу события |
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используй слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит — урок задания 3 остается в силе.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Как проверить себя
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
Открой список дашбордов и проверь:
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
датасетов;
- подписи, единицы измерения и легенды читаются без обращения к SQL;
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
Последний проход сделай как читатель: найди на экране самый заметный канал,
страницу и устройство, затем проверь, понятно ли из подписей, что именно
сравнивается. Если ответ приходится угадывать, поправь график или его название.
## Приемка
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
- [ ] каждая витрина представлена хотя бы одним графиком;
- [ ] дашборд проверен глазами по разделу выше.
-114
View File
@@ -1,114 +0,0 @@
# Задание 2: `dm.top_pages_daily`
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
объектов, донор, замена партиции, задача в `dm_transform`.
## Роль витрины
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
посетителей ее видели и что на ней делали кроме просмотра.
Торговое событие садится на ту страницу, где случилось: корзина — на
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
карточки товара рядом стоят просмотры и корзины, и их отношение —
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
корзину, едва открыв, а какие смотрят и уходят.
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
покупка живут на разных страницах, и связать их шаги в одну цепочку на
зерне страницы не из чего. Путь одного человека собирают из событий или из
сессий, а витрина страниц отвечает про страницу.
## Зерно и форма
Зерно — **день события × страница**. Источник — события, у него зерно
события, значит зерно сменилось и витрина материализуется:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.top_pages_daily_v`.
Страница — это `page_path`, путь `url` без домена и параметров запроса;
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
берется: с параметрами запроса одна и та же страница рассыпалась бы на
сотни строк, и витрина перестала бы отвечать на свой вопрос.
Ключ партиции — день: он же единица пересборки.
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
витрины, и не зависит от того, выполнена ли соседняя домашка.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `page_path` | `LowCardinality(String)` | путь страницы |
| `pageviews` | `UInt64` | число событий `pageview` на странице |
| `visitors` | `UInt64` | число разных `client_id` на странице |
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
| `purchases` | `UInt64` | число событий `purchase` на странице |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Счетчики условные: строку дают все три типа событий, а различает их
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
подсказывает имя витрины, — значит получить витрину из одних просмотров:
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
конверсия карточки — молча отвечает «ноль везде».
## Ритм пересборки
Источник — события: день замерзает сразу, как только прожит. Значит
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
витрине нет, — от первого несобранного прожитого дня до последнего
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
первый несобранный.
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
по одной, как у `revenue_daily`.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
дня было одно измерение из шести близких по объему значений, здесь —
страницы, которых много и которые по популярности очень неровные.
## Как проверить себя
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
где расходится любой из четырех счетчиков. Ноль — зеленый.
Две проверки попроще, ими удобно ловить грубую ошибку до полного
соединения:
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
`dds.event_v`;
- сумма `purchases` за день равна числу событий `purchase` этого дня.
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
пропускает: отбор строк по типу не меняет счет событий того же типа.
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
краснеть, бесполезна](../architecture/testing.md).
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
страницам и в сумме посчитается несколько раз. Число посетителей дня
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
`_load_ts`;
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
результат не меняет.
-150
View File
@@ -1,150 +0,0 @@
# Задание 5: `dm.utm_effectiveness`
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
самому.
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
нечем, клиентская сумма берется, но называется своим именем.
## Роль витрины
Эффективность рекламных каналов по дням: сколько трафика привел канал,
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
словам клиента.
## Атрибуция: по событию, а не по визиту
Метки едут на каждом событии, и витрина группирует события по их
собственным меткам. Модели «первое касание» или «последнее касание» здесь
нет: их построение — отдельная тема, а это витрина трафика по меткам.
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
канал привел» из сессии и из этой витрины будет разным. Это не
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
различать.
## Прямой трафик из витрины не выбрасывается
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
перестал сходиться с итогом источника, и проверить сборку стало нечем.
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
меньше расшифровывать читателю.
## Зерно и форма
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
Зерно источника — событие, значит витрина материализуется:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
договор DDS, а не соседнюю витрину.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `utm_source` | `LowCardinality(String)` | источник кампании |
| `utm_medium` | `LowCardinality(String)` | канал кампании |
| `utm_campaign` | `String` | имя кампании |
| `visitors` | `UInt64` | число разных `client_id` |
| `pageviews` | `UInt64` | просмотров страниц |
| `add_to_carts` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
покупки в них ровно один элемент — контракт источника обещает один номер
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
сторону стендовая сверка `purchase_vs_orders`.
Приводить тип не нужно: в договоре `purchase_revenue` уже
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
становится много, и словарь `LowCardinality` на растущем множестве
перестает окупаться.
## Ритм пересборки
Источник — события: день замерзает сразу. Объем прогона и механика те же,
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
одной из них» надо защищать отдельно: назови, какая доля строк придется на
самое частое значение выбранной колонки.
## Конфликт, ради которого витрина и написана
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
верное поведение обеих витрин.
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
- отмененный заказ из кликстрима никуда не девается: событие покупки
случилось, а `revenue_daily` считает только оплаченные;
- событие покупки может потеряться по дороге, а заказ при этом доехать —
и наоборот, событие приехать дважды;
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
делает это намеренно у одного-двух заказов из ста.
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
в двух-трех фразах, своими словами.
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
сходиться.
## Как проверить себя
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
зеленый.
Две проверки покрупнее ловят самые частые ошибки:
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
событий соответствующего типа в `dds.event_v` за тот же день;
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
событиям `purchase` этого дня.
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
задании 3, по строкам не складывается: один посетитель приходит по разным
меткам.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] строки без UTM-меток в витрине остались;
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
+1 -1
View File
@@ -116,7 +116,7 @@
**17. Свой дашборд в Superset (готовая необязательная лаба).** **17. Свой дашборд в Superset (готовая необязательная лаба).**
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются. Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`. Материал: `docs/labs/optional/superset-dashboard.md`, `docs/architecture/superset.md`.\
Форма: построить свое. Форма: построить свое.
Проверка: глазами — счетного инварианта у раскладки панелей нет. Проверка: глазами — счетного инварианта у раскладки панелей нет.
+64 -68
View File
@@ -272,12 +272,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Классы пересекаются — приоритет: `cancelled` > `lost_event` > Классы пересекаются — приоритет: `cancelled` > `lost_event` >
`duplicate_event` > `amount_delta` > `match`. `duplicate_event` > `amount_delta` > `match`.
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире ~10%. Точные доли зафиксированы замером на каноническом мире (28 дней,
(28 дней, 2026-08-30, #194) — запись в «Что проверено» 2026-08-30, #194) — запись в «Что проверено» [карты
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
только по дням с закрытым окном (раздел 8). классов после приоритета — в заказах и только по дням с закрытым окном (раздел
8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B. механически дублирует B.
@@ -311,14 +312,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
запуск пишет полный текущий набор различных пар; предыдущий результат не запуск пишет полный текущий набор различных пар; предыдущий результат не
служит источником следующего. служит источником следующего.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — - **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed.
от seed. Ядро лабы: Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу
Константа мира: каждый двухкуковый покупатель делает минимум по одному с каждой куки — иначе вторая кука не попадает в карту соответствий (она
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий строится только из покупок) и лаба не воспроизводится. Число таких пар растёт,
(она строится только из покупок) и лаба не воспроизводится. Число таких пока мир едет, и в паспорт не кладётся ([что хранит
пар растёт, пока мир едет, и в опись не кладётся паспорт](../architecture/orders/inventory.md)).
([что хранит опись](../architecture/orders/inventory.md)).
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и - Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде. **«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
@@ -339,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами. VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна - **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)). `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
Раскладку в обоих случаях решает ключ: Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)`
события — по `cityHash64(ClientID)` (см. 1.3), заказы — (см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`;
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок
список и доводы — в [доке хранилища](../architecture/storage.md). Урок «какая нода читала топик — меняется между прогонами, куда легли данные — нет»
«какая нода читала топик — меняется между прогонами, куда легли данные — живёт на событиях: при пакетном заборе читает та нода, которую спросили.
нет» живёт на событиях: при пакетном заборе читает та нода, которую
спросили.
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
сообщения сверяется с контрактным; строка с NULL среди опорных колонок сообщения сверяется с контрактным; строка с NULL среди опорных колонок
@@ -444,23 +442,24 @@ README.
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
см. [доку хранилища](../architecture/storage.md). — см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
Потребительские запросы и витрины читают сущности DDS через `_v` с явным витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию. служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
Договор держится конвенцией, а не правами: модель доступа описана в правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.md),
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в договор чтения — в [доке хранилища](../architecture/storage.md), «Имена
[доке хранилища](../architecture/storage.md), «Имена объектов». объектов».
Заказы принимаются **пакетным забором** ([ADR Заказы принимаются **пакетным забором** ([ADR
0008](../adr/0008-order-ingestion.md), [ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но 0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow — событий, но матвью к нему не привязана, и порцию в STG забирает шаг под
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска. управлением Airflow — одно прямое чтение вставляет её в `stg.orders_raw` с
Один следующий `task_id` двумя последовательными запросами пишет годные строки `_load_id` запуска. Один следующий `task_id` двумя последовательными запросами
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой версию по максимальной `snapshot_date` ([ADR
сырья остаётся точкой повтора и разбора одной принятой порции. 0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
точкой повтора и разбора одной принятой порции.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию `ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
@@ -531,31 +530,28 @@ Kafka день переигрывается генератором заново:
пачками), Postgres остаётся только служебной базой Airflow. Смешанность пачками), Postgres остаётся только служебной базой Airflow. Смешанность
ландшафта выражена режимами и частотами, а не второй трубой. ландшафта выражена режимами и частотами, а не второй трубой.
## 8. Эталонный мир и опись ## 8. Эталонный мир и паспорт
В git хранится только опись эталонного мира; сам снимок генерируется на В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
месте — при `make up` и при проверках (решение развилки «Производительность» — при `make up` и при проверках (решение развилки «Производительность» карты
карты #26, подробности — [спека генератора](2026-08-01-generator.md), #26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
снято на этапе 7. Опись несёт 7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман #10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
карты #10) закрыта этим же ходом: версионируется опись.
Контрольные числа описи:
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание - по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями; «слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
- заказная сторона: счётчики наблюдаемых классов расхождений после - заказная сторона: счётчики наблюдаемых классов расхождений после
приоритета; каждый показывает число заказов и хранится только для дня с приоритета; каждый показывает число заказов и хранится только для дня с
закрытым окном (читатель — техническая проверка `classes_vs_inventory` закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
общего числа заказов и выручки в описи пока нет: для них не назван заказов и выручки в паспорте пока нет: для них не назван читатель;
читатель;
- контрольные числа идентичности не заводятся: uniq известных пользователей - контрольные числа идентичности не заводятся: uniq известных пользователей
и число двухкуковых покупателей растут, пока мир едет, а счётчик без и число двухкуковых покупателей растут, пока мир едет, а счётчик без
названного читателя в опись не кладётся названного читателя в паспорт не кладётся ([что хранит
([что хранит опись](../architecture/orders/inventory.md)). паспорт](../architecture/orders/inventory.md)).
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено» Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
[карты проверок](../architecture/testing.md). [карты проверок](../architecture/testing.md).
@@ -572,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность | | SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов | | Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла | | Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L | | Эталонный мир | пересборка снимка на месте, счётчики паспорта, чек-скрипты | M–L |
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды | | Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR | | Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
@@ -619,8 +615,8 @@ v2 стартует пустым, поэтому объём ниже — это
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map, 4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
выручка, сверка A+C. выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка). 5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи. 6. Расхождения B+D и опоздания; счётчики паспорта.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация 7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация
снята (спека генератора, раздел 5). снята (спека генератора, раздел 5).
8. Superset-дашборд v2. 8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
@@ -658,15 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в
конвейер, в который она встает, проектируются вместе. Сюда же — который она встает, проектируются вместе. Сюда же — необязательная лаба «свой
необязательная лаба «свой дашборд в Superset» (#197): она стоит на дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый
остальные лабы пока на уровне идей. Единый стиль блока стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам
домашек — за работой учебного пути. Спека даёт будущим лабам только только опорные точки — контрольные числа паспорта (сверка, идентичность).
опорные точки — контрольные числа описи (сверка, идентичность). Явное Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. нет.
- Инкрементальный ETL (#8) — свой issue. - Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
+132 -139
View File
@@ -41,10 +41,10 @@
конструкция внутри оправдана только наблюдаемым эффектом: без него это конструкция внутри оправдана только наблюдаемым эффектом: без него это
сложность, за которую никто не заплатил. сложность, за которую никто не заплатил.
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств - **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую
другую последовательность. Опираться на конкретные числа поэтому нечем. последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал
молча: пересобрал опись — увидел diff. паспорт — увидел diff.
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он - **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
правкой генератора после взгляда на получившиеся распределения. Оборона, правкой генератора после взгляда на получившиеся распределения. Оборона,
которая дороже ошибки, не заводится. которая дороже ошибки, не заводится.
@@ -59,15 +59,15 @@
модельный день D — функция (зерно, D). Между прогонами живут только зерно модельный день D — функция (зерно, D). Между прогонами живут только зерно
и позиция на оси времени. и позиция на оси времени.
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка — - **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания. хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными; - **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит строгий хранилище строится по рендеренной документации, границу сторожит строгий
приём на стороне хранилища. приём на стороне хранилища.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом. байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со - **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
стартовым миром — 8 дней; стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов
в git — только опись мира; автоматических порогов по времени нет. по времени нет.
## 1. Модель мира ## 1. Модель мира
@@ -109,23 +109,21 @@
начинается в D0) — только дают, кому возвращаться в первые дни: дневная начинается в D0) — только дают, кому возвращаться в первые дни: дневная
аудитория на полке с самого D0, разгона «пустого магазина» нет. аудитория на полке с самого D0, разгона «пустого магазина» нет.
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица - **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
здесь — человек, не кука: план помечает часть людей покупателями здесь — человек, не кука: план помечает часть людей покупателями (доля —
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку.
вторую куку. Обе куки пары принадлежат одному человеку одной когорты; Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в
вторая рождается в пределах его окна активности, без фиксированного пределах его окна активности, без фиксированного зазора — по тому же
зазора — по тому же затухающему профилю, что и возвраты (уточнение при затухающему профилю, что и возвраты (уточнение при исполнении #38,
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну
Равные шансы по всему окну означали бы вторую куку у давно ушедшего означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар,
человека — и вчетверо меньше пар, реализованных внутри снимка. реализованных внутри снимка. Каждой паре план назначает дни гарантированных
Каждой паре план назначает дни гарантированных заказов: по заказов: по одному на куку, из дней активности этой куки, на оси от D0 и
одному на куку, из дней активности этой куки, на оси от D0 и позже; позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара
человеку предыстории, чьё окно активности таких дней не оставляет, не назначается. День-функция обязана назначенные заказы реализовать; остальные
пара не назначается. День-функция обязана назначенные заказы покупки — вольные, их решает генератор торговых событий (#40). План считает
реализовать; остальные покупки — вольные, их решает генератор торговых пары, реализованные в запрошенном горизонте; паспорт это число не хранит — оно
событий (#40). План считает пары, реализованные в запрошенном горизонте; растёт вместе с миром. Условность в данных не видна: дни назначены той же
опись это число не хранит — оно растёт вместе с миром. Условность в данных случайностью, просто брошенной планом один раз.
не видна: дни назначены той же случайностью, просто брошенной планом один
раз.
- **Своя ось модельного времени.** Ось событий начинается в - **Своя ось модельного времени.** Ось событий начинается в
фиксированный день D0 (понедельник — см. раздел 5); реальный фиксированный день D0 (понедельник — см. раздел 5); реальный
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
@@ -133,10 +131,10 @@
зависящая (значение — раздел 9). Между прогонами живут только зерно и зависящая (значение — раздел 9). Между прогонами живут только зерно и
позиция на оси: выключенный ноутбук — мир замер, потом продолжил. позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
с эталонного снимка, дальше «прожить следующий день» — явное действие. с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»;
«дышат»; включается по требованию — постоянным фоном идёт, только пока включается по требованию — постоянным фоном идёт, только пока включён
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)). выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)).
- **Граница суток — единственный структурный шов.** Сессии режутся по ней, - **Граница суток — единственный структурный шов.** Сессии режутся по ней,
дневная партиция самодостаточна; в конце модельного дня — слепок заказов. дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
День проживается целиком, полдня не бывает: недожитый из-за обрыва день День проживается целиком, полдня не бывает: недожитый из-за обрыва день
@@ -148,14 +146,13 @@
Отклонено с доводами: Отклонено с доводами:
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по - *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум; дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни
ни один урок стенда на старении не стоит. один урок стенда на старении не стоит.
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы - *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
выводить в каждом дне заново — тот же план мира, но неявный и размазанный. выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
- *Привязка модельного времени к реальному календарю* (T-1 с догоном): - *Привязка модельного времени к реальному календарю* (T-1 с догоном):
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты
даты эталонного мира зависимыми от даты запуска, опись теряет эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость.
воспроизводимость.
Отклонено при исполнении #38 (2026-08-02): Отклонено при исполнении #38 (2026-08-02):
@@ -170,9 +167,9 @@
константы — потолка одновременно живущих кук, которого в жизни нет и константы — потолка одновременно живущих кук, которого в жизни нет и
который менти нечем объяснить. который менти нечем объяснить.
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не - *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм
так что реализм этой лотереи невидим. этой лотереи невидим.
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор - *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
«все пары покупают в первый день» виден в данных ровно там, куда лаба «все пары покупают в первый день» виден в данных ровно там, куда лаба
склейки смотрит пристальнее всего. Условность допустима, пока она не склейки смотрит пристальнее всего. Условность допустима, пока она не
@@ -185,31 +182,29 @@
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же - **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени. набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
Снимок при пересборке побайтово совпадает: канонический порядок ключей и Снимок при пересборке побайтово совпадает: канонический порядок ключей и
строк; сверка — по хешам описи, а два локально пересобранных снимка строк; сверка — по хешам паспорта, а два локально пересобранных снимка
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания —
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп
`_load_ts`, темп живого дня. живого дня.
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock` - **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже;
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с
любой платформе — баг генератора, а не допуск. паспортом». Расхождение на любой платформе — баг генератора, а не допуск.
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
состава мира, ветвящийся по номеру дня на когорты плана (состав состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается
спрашивается по дням — раздел 1; уточнение при исполнении #38). по дням — раздел 1; уточнение при исполнении #38). Дни предыстории
Дни предыстории отрицательны, а позиция в дереве — неотрицательное отрицательны, а позиция в дереве — неотрицательное целое, поэтому у
целое, поэтому у предыстории своя ветвь состава, отдельная от оси предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении
(уточнение при исполнении #38, 2026-08-02); #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, компонентов: трафик, торговые события, заказная и событийная стороны (судьба
торговые события, заказная и событийная стороны (судьба заказов и порча заказов и порча событийного потока) — в фиксированном порядке (уточнение
событийного потока) — в фиксированном порядке (уточнение [судьбой [судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и
заказа](../architecture/orders/fate.md): прежние «расхождения» и «опоздания» были названы по классам витрины, а компонент называет часть мира).
«опоздания» были названы по классам витрины, а компонент называет часть По построению: параллельный прогон равен последовательному; продление истории
мира). днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть
По построению: параллельный прогон равен последовательному; продление снимка — в паспорте меняются хеши только затронутых дней, дифф двух локальных
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет пересборок читаем.
только его часть снимка — в описи меняются хеши только затронутых
дней, дифф двух локальных пересборок читаем.
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через - **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
расширением `spawn_key`, потомок полностью определяется парой расширением `spawn_key`, потомок полностью определяется парой
@@ -223,8 +218,8 @@
представление в клиентском `purchase` (урок мастер-спеки о расхождениях). представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним - **Канонический seed и паспорт мира.** Эталонный мир собирается одним
каноническим зерном — константой репозитория; свои зёрна менти крутит без каноническим зерном — константой репозитория; свои зёрна менти крутит без
гарантий описи. Опись хранит паспорт мира — зерно и версию гарантий паспорта. В шапке паспорта лежат зерно и версия генератора;
генератора; чек-скрипты сверяют паспорт раньше счётчиков. чек-скрипты сверяют шапку раньше счётчиков.
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны: - **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
ночной провал, обеденный и вечерний пики, различие будней и выходных; ночной провал, обеденный и вечерний пики, различие будней и выходных;
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
@@ -241,8 +236,8 @@
«параллельно равно последовательно» недостижимо. «параллельно равно последовательно» недостижимо.
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно - *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка
пересборка описи одним PR. паспорта одним PR.
## 3. Контракт схемы ## 3. Контракт схемы
@@ -289,9 +284,9 @@
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт - **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
упорядоченный поток канонических байтов — единственное место, где событие упорядоченный поток канонических байтов — единственное место, где событие
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для
для пересборки и проверок описи), Kafka пачкой — пакетный режим, пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом
Kafka с темпом ×60 — живой день. Новых топиков нет. ×60 — живой день. Новых топиков нет.
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу - **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
@@ -299,9 +294,9 @@
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
событий в одно сообщение сломала бы разбор целиком. событий в одно сообщение сломала бы разбор целиком.
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`, - **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных:
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте
разбирает событие глазами, а число эпохи этот урок убивает. Колонка и разбирает событие глазами, а число эпохи этот урок убивает. Колонка
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт `ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
Метрика. Метрика.
@@ -321,9 +316,9 @@
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
разойдётся это уже после приёмки. разойдётся это уже после приёмки.
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31). numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена
Смена библиотеки меняет канонические байты, поэтому проходит как библиотеки меняет канонические байты, поэтому проходит как обновление
обновление зависимости: осознанная пересборка описи одним PR. зависимости: осознанная пересборка паспорта одним PR.
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции: - **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
потерял — пересчитал. В git снимок не попадает (раздел 5). потерял — пересчитал. В git снимок не попадает (раздел 5).
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает - **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
@@ -341,7 +336,7 @@
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне - *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
обещания детерминизма, retention конечен, в git топик не положишь, хеш с обещания детерминизма, retention конечен, в git топик не положишь, хеш с
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
мастер-спеки). мастер-спеки).
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено - *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
через дедуп, канон держит единственный сериализатор, а не диск; файл через дедуп, канон держит единственный сериализатор, а не диск; файл
@@ -360,27 +355,25 @@
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
правдоподобный средний магазин. правдоподобный средний магазин.
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник - **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
по понедельник, полная неделя и один закрытый день за окном K = 7. по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30): 14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи, нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N — паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две
мир на окне в две недели, не меняются: мир — функция без края, окну недели, не меняются: мир — функция без края, окну замера паспорт не нужен.
замера опись не нужна. Удлинение описи остается дешевым ходом Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда
(пересборка) и делается, когда читатель появится — например, если читатель появится — например, если растяжке `classes_vs_inventory` понадобится
растяжке `classes_vs_inventory` понадобится сверять больше одного сверять больше одного закрытого дня.
закрытого дня. - **В git — только паспорт, снимок не хранится.** Снимок генерируется при
- **В git — только опись, снимок не хранится.** Снимок генерируется при
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней. git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный
повторный подъём не пересчитывает. Честная потеря — подъём не пересчитывает. Честная потеря — страховка на случай платформенного
страховка на случай платформенного бага: раньше менти с расходящимися бага: раньше менти с расходящимися байтами мог взять готовый снимок из git,
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной
описи; смягчение — дисциплина целочисленной случайности (раздел 2): случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30).
прогона не заводится (решение владельца 2026-08-30).
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты, - **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с, суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
в пиковые часы сильных дней — до ~100. Число — в пиковые часы сильных дней — до ~100. Число —
@@ -399,7 +392,7 @@
| Операция | Ориентир | | Операция | Ориентир |
|---|---| |---|---|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) | | Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) | | Пересборка эталонного мира (8 дней + паспорт, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) | | Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
| Лаг живого дня | секунды | | Лаг живого дня | секунды |
@@ -460,9 +453,9 @@
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data - **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
contract — хранилище пишется по документации, границу сторожит строгий contract — хранилище пишется по документации, границу сторожит строгий
приём (раздел 3 здесь). приём (раздел 3 здесь).
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью; - **Раздел 8**: артефакт `data/startup_history/` в git заменён паспортом;
снимок генерируется на месте (раздел 5 здесь). Туман «политика снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования
версионирования артефакта» закрыт этим же ходом: версионируется опись. артефакта» закрыт этим же ходом: версионируется паспорт.
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования - **Раздел 11**: пункт «до этапа 3 зафиксировать требования
производительности» закрыт числами раздела 5. производительности» закрыт числами раздела 5.
- Мелкие согласования там, где текст опирался на артефакт в git: источник - Мелкие согласования там, где текст опирался на артефакт в git: источник
@@ -491,24 +484,25 @@
теперь есть. теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается. по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 — - **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа
5 —
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не [ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
она переменной Airflow: отсутствие переменной означает, что стартовый мир она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, — не создан. В обычной жизни её ставит шаговый даг, который сыграл день, —
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и
и ставят первый несыгранный день только после приёма стартового мира ставят первый несыгранный день только после приёма стартового мира ([ADR
([ADR 0013](../adr/0013-world-lifecycle.md)). 0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и
Довод — генератор отдельная и заменяемая сущность, привязывать его к заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean`
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с сводит сохранённую позицию с заново залитым миром. Расхождение переменной с
заново залитым миром. данными (менти почистил партицию руками) лечится документацией, а не сторожем:
Расхождение переменной с данными (менти почистил партицию руками) лечится постоянная сверка была бы проверкой, красной в норме ([ADR
документацией, а не сторожем: постоянная сверка была бы проверкой, красной 0002](../adr/0002-monitoring-scope.md)).
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)). - **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт. артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5. 5.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя — - **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
готовая демонстрация идемпотентности конвейера. готовая демонстрация идемпотентности конвейера.
@@ -521,10 +515,10 @@
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора [ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
записывают в ADR и в спеке этапа, на котором их принимают. записывают в ADR и в спеке этапа, на котором их принимают.
Журнал собран по темам, но дата и тикет остались у каждого решения. По Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу
журналу видно, какие границы затрагивала каждая правка: иногда она меняла видно, какие границы затрагивала каждая правка: иногда она меняла сразу план
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не
не собрали; позже такой ход потребовал бы пересборки описи. собрали; позже такой ход потребовал бы пересборки паспорта мира.
### План состава и числа мира ### План состава и числа мира
@@ -547,10 +541,10 @@
покупателей ~190 в день, что дает конверсию ~2% на сессию. покупателей ~190 в день, что дает конверсию ~2% на сессию.
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с - **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
контрактом схемы: все числа мира собраны в одном месте, которое менти может контрактом схемы: все числа мира собраны в одном месте, которое менти может
крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет:
сторожит только канон. Вне модуля остается то, что мира не меняет, — свое паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, —
зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
переопределения окружением — переменная мира, которой нет в паспорте описи; переопределения окружением — переменная мира, которой нет в шапке паспорта;
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
валидацией (довод раздела 3 против YAML). валидацией (довод раздела 3 против YAML).
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение - **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
@@ -749,8 +743,8 @@
четвертям цены и по медиане, потому что одни только края списка обманываются четвертям цены и по медиане, потому что одни только края списка обманываются
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь
файле живёт ещё и поведение. в файле живёт ещё и поведение.
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%** - **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои (#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
позиции, а визит с намерением должен класть почти все сравненное; обе границы позиции, а визит с намерением должен класть почти все сравненное; обе границы
@@ -764,7 +758,7 @@
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
предел считается в другой валюте — дневной бюджет событий, на котором стоит предел считается в другой валюте — дневной бюджет событий, на котором стоит
опись #42. Если будущая правка упрется в него, двигать надо бюджет и его паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его
причины, а не долю. причины, а не долю.
### Номер заказа, промокод и деньги ### Номер заказа, промокод и деньги
@@ -933,28 +927,27 @@
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине. нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
### Опись мира и словарь ### Паспорт мира и словарь
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7, - **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
2026-08-30). Стартовый мир этапа 2 — первые восемь дней, 2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7. понедельник: полная неделя и первый замкнутый цикл K = 7.
`data/world-inventory.json` — та самая опись, которую обещает раздел 5. `data/world-inventory.json` — тот самый паспорт, который обещает раздел 5.
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не (резолюция — в разделе 5); вырастет он продолжением этого же файла, а не
вторым. В JSON лежат вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с — и строки дней с датой, числом событий и хешем. Паспорт собирает `make
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть inventory`, свежесть сторожит `test_inventory.py` тем же способом, что
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки. свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две
Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее правды об одном мире и лишнее слово в словаре.
слово в словаре.
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07). - **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
менялось: разошлись дни и каталог — CSV, только дни — код. менялось: разошлись дни и каталог — CSV, только дни — код.
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»** - **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
(#42, решение владельца 2026-08-07). Обычные русские слова убирают пару (#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на
«манифест / мини-манифест» и необходимость различать две сущности там, где «паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест»
вещь одна. и необходимость различать две сущности там, где вещь одна.
### Открытое на момент заморозки ### Открытое на момент заморозки
+21 -21
View File
@@ -12,11 +12,11 @@ DDL и двумя одноразовыми службами заливал во
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
после записи в топик, до приёма сообщений хранилищем. после записи в топик, до приёма сообщений хранилищем. Повторный `make up`
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем, ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не
кто миром не управляет. управляет.
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат **Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
прикладная схема, данные мира и позиция модельного времени, — и Compose, прикладная схема, данные мира и позиция модельного времени, — и Compose,
@@ -63,7 +63,7 @@ Airflow держит прикладные топики Kafka, каноничес
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`, Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого `data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
собирается канонический образ генератора, из второго читается опись мира, а собирается канонический образ генератора, из второго читается паспорт мира, а
последний не пускает в контекст сборки локальные окружения и кэши. Запросы последний не пускает в контекст сборки локальные окружения и кэши. Запросы
стенда приезжают прежним монтированием `sql/`. стенда приезжают прежним монтированием `sql/`.
@@ -101,7 +101,7 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
кода: сборкой образа при подъёме больше никто не занимается. кода: сборкой образа при подъёме больше никто не занимается.
5. Играет стартовые дни одним прогоном `batch` того же образа. 5. Играет стартовые дни одним прогоном `batch` того же образа.
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего 6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк
`ods.event_errors_dist` не меньше суммы событий из `ods.event_errors_dist` не меньше суммы событий из
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то [`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
приехало» условием не считается. приехало» условием не считается.
@@ -136,21 +136,21 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
третьим местом с той же последовательностью, а первое же расхождение между третьим местом с той же последовательностью, а первое же расхождение между
дагами пришлось бы прятать в его параметрах. дагами пришлось бы прятать в его параметрах.
### Сторожа у работников мира ### Сторожа у шаговых дагов
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше `world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг: `WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа
сторожа не получает — он краснеет вслед за работником. не получает — он краснеет вслед за шаговым дагом.
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда `orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
готового мира ещё нет. готового мира ещё нет.
## Состояния позиции ## Состояния позиции
`world_position` остаётся единственным состоянием пульта. `world_position` остаётся единственным состоянием дагов модельного времени.
| Позиция | `world_initialize` | `world_recreate` | Работники | | Позиция | `world_initialize` | `world_recreate` | Шаговые даги |
|---|---|---|---| |---|---|---|---|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ | | переменной нет | создаёт мир | очищает и создаёт | красный отказ |
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ | | `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
@@ -187,9 +187,9 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо. ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
Поэтому сначала удаляются базы, затем топики. Поэтому сначала удаляются базы, затем топики.
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё **Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
делает себе сам и тут же видит. делает себе сам и тут же видит.
@@ -197,10 +197,10 @@ ODS истинно сразу — этим оно и ограничено, об
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом `world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline` режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот. и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live` пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу
на паузу и даёт работникам доиграть. Предусловие остаётся в документации; и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм
механизм блокировок стоил бы дороже этой строки. блокировок стоил бы дороже этой строки.
## Границы ## Границы
@@ -226,9 +226,9 @@ ODS истинно сразу — этим оно и ограничено, об
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
мир уже есть. мир уже есть.
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит - **`make rebuild-storage`** возвращает канонический мир: позиция проходит
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы
заново, офсеты групп идут с нуля, DDS и DM собраны. заново, офсеты групп идут с нуля, DDS и DM собраны.
- **Красные пути проверяются своими отказами.** Сторож работника при - **Красные пути проверяются своими отказами.** Сторож шагового дага при
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`; отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки, оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
доказывает только то, что умеет печатать «ЗЕЛЁНО». доказывает только то, что умеет печатать «ЗЕЛЁНО».
+40 -12
View File
@@ -40,6 +40,20 @@
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а - *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее. менти ищет причину и объясняет ее.
### Как писать задания
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания.
«Как проверить себя» описывает действия для проверки и поиска ошибок.
«Что должно получиться» — результат работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно.
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md).
## 3. Основная программа — семь ступеней ## 3. Основная программа — семь ступеней
### Ступень 1. Построй пять витрин слоя DM ### Ступень 1. Построй пять витрин слоя DM
@@ -64,7 +78,7 @@
ступени предстоит написать при реализации программы. Полный собственный дашборд ступени предстоит написать при реализации программы. Полный собственный дашборд
остается факультативом в разделе «Superset и BI». остается факультативом в разделе «Superset и BI».
### Ступень 3. Нарежь сессии сам ### Ступень 3. Нарежьте сессии сами
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
@@ -75,7 +89,7 @@
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
из счетной сверки с `VisitID` и ручного сопоставления границ. из счетной сверки с `VisitID` и ручного сопоставления границ.
### Ступень 4. Перепиши нарезку стенда ### Ступень 4. Перепишите нарезку стенда
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
и замену. Урок — изменить расчетную сущность внутри готового конвейера и и замену. Урок — изменить расчетную сущность внутри готового конвейера и
@@ -86,7 +100,7 @@
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка, Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
построенная на третьей ступени. построенная на третьей ступени.
### Ступень 5. Сначала сторож, потом ломай: заказы ### Ступень 5. Научите проверки замечать ошибки в заказах
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их «цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
@@ -104,14 +118,14 @@
### Ступень 6. Разбери сырые события и сверь результат со стендом ### Ступень 6. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
отдельно от живого приема и использовать стендовый результат как образец для отдельно от живого приема и использовать стендовый результат как образец для
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
сообщения. сообщения.
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
границы называются прямо: срок работы ограничен окном хранения сырья, а границы называются прямо: срок работы ограничен окном хранения сырых данных, а
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
независимым эталоном. независимым эталоном.
@@ -150,7 +164,8 @@
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно - **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки. разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а - **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока сырья уже не восстановить. некоторые различия после истечения срока хранения сырых данных уже не
восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день - **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может становится окончательным лишь после того, как источник больше не может
изменить этот день. изменить этот день.
@@ -170,11 +185,23 @@
Открывается после ступени 2 основной программы. Открывается после ступени 2 основной программы.
- **Свой дашборд.** Готовая лаба - **[Свой дашборд](../labs/optional/superset-dashboard.md).** Менти использует
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит результат ступени 2, собирает отчет из пяти своих витрин и подтверждает
хранить пользовательские объекты отдельно от стендовых. выводы запросами. Пользовательские объекты остаются отдельно от стендовых.
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может - **[В DBeaver работает, в Superset нет](../labs/optional/superset-missing-object.md).**
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде. Менти создает отдельное представление на первой ноде и расследует ошибку
чтения из Superset, подключенного ко второй. Опыт заканчивается объяснением,
исправлением DDL и удалением учебного объекта.
Задания самостоятельны: расследование использует готовую витрину стенда.
Отсутствие объекта проверяется в SQL Lab, поскольку создание физического
датасета может остановиться раньше, чем появится график. Чтение только одного
шарда остается материалом инженерного раздела: это отдельная причина
неполного результата, которую добавление `ON CLUSTER` само по себе не устраняет.
Технический опыт проверен 2026-09-13 на ClickHouse 26.3.17.56: локальное
представление недоступно пользователю `bi` на второй ноде; после создания
на кластере обе ноды возвращают тот же счет, что исходная витрина.
### Инженерия стенда ### Инженерия стенда
@@ -191,7 +218,8 @@
потребителей создает еще одну полную копию потока. потребителей создает еще одну полную копию потока.
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах - **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
показывает потерю, которую трудно заметить по самим данным. показывает потерю, которую трудно заметить по самим данным.
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по - **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных
идёт по
времени загрузки, а не по модельному дню. времени загрузки, а не по модельному дню.
- **Куда попадают битые строки.** Годные строки и брак делят поток без - **Куда попадают битые строки.** Годные строки и брак делят поток без
пропусков и пересечений; менти подкладывает по строке каждого класса брака пропусков и пересечений; менти подкладывает по строке каждого класса брака
+79 -80
View File
@@ -1,96 +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/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам. Там же побайтовое
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
строк в нём ровно столько, сколько событий.
## Команды ## Команды
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`. Для запуска из каталога `generator/` используйте
`uv run python -m clickstream_generator <команда>`. Справку можно открыть
через `--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).
@@ -1,15 +1,15 @@
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти. """Паспорт мира: чем стенд наполняется при подъёме и каким это обязано выйти.
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики он сам, а паспорт: шапка, число событий и хеш байтов каждого дня, счётчики
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись классов расхождений по заказам. Сам мир пересчитывается когда угодно, а паспорт
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки. хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по Дней в паспорте восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 — Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот у хвоста не нашлось читателя. Понадобится длиннее — паспорт продолжит этот
же файл, а не заведет второй. же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
@@ -30,9 +30,9 @@
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
слепка нет — опись описывает мир, а не доставку. слепка нет — паспорт описывает мир, а не доставку.
Собирается опись из каталога генератора целью `make inventory`, а свежесть её Собирается паспорт из каталога генератора целью `make inventory`, а его свежесть
сторожит тест — как и у «описания выгрузки». сторожит тест — как и у «описания выгрузки».
""" """
@@ -57,7 +57,7 @@ STARTING_DAYS = 8
def build() -> dict[str, Any]: def build() -> dict[str, Any]:
"""Опись целиком: паспорт мира, строка на день и строка на слепок. """Паспорт целиком: шапка, строка на день и строка на слепок.
Дни играются по одному и отпускаются: заказы дня остаются, потому что из Дни играются по одному и отпускаются: заказы дня остаются, потому что из
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
@@ -83,12 +83,12 @@ def build() -> dict[str, Any]:
def render() -> str: def render() -> str:
"""Опись текстом файла: отступы в два пробела, кириллица как есть.""" """Паспорт текстом файла: отступы в два пробела, кириллица как есть."""
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n" return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
def _day(number: int, today: day_module.Day) -> dict[str, Any]: def _day(number: int, today: day_module.Day) -> dict[str, Any]:
"""Строка описи: номер дня, его дата, число событий и хеш байтов. """Строка паспорта: номер дня, его дата, число событий и хеш байтов.
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
@@ -105,7 +105,7 @@ def _day(number: int, today: day_module.Day) -> dict[str, Any]:
def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]: def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов. """Строка паспорта слепка: какой день снят, его дата и хеш отправленных байтов.
Байты те же, что уезжают в топик `orders`, с переводом строки после Байты те же, что уезжают в топик `orders`, с переводом строки после
каждого заказа: пересъёмка слепка обязана дать их снова. каждого заказа: пересъёмка слепка обязана дать их снова.
@@ -169,12 +169,12 @@ def _digest(payload: bytes) -> str:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Собирает опись мира: паспорт, счётчики и хеши дней." description="Собирает паспорт мира: шапку, счётчики и хеши дней."
) )
parser.add_argument("output", type=Path, help="путь к файлу описи") parser.add_argument("output", type=Path, help="путь к файлу паспорта")
output = parser.parse_args().output output = parser.parse_args().output
output.write_text(render(), encoding="utf-8") output.write_text(render(), encoding="utf-8")
print(f"Опись мира собрана: {output}") print(f"Паспорт мира собран: {output}")
if __name__ == "__main__": if __name__ == "__main__":
@@ -15,7 +15,7 @@
Тайминги печатаются раздельно — генерация и доставка, как требует спека Тайминги печатаются раздельно — генерация и доставка, как требует спека
(раздел 5): это разные машины разной природы, и сложенные в одно число они (раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит опись. рождает те самые байты, которые сторожит паспорт.
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу **Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
+2 -2
View File
@@ -25,8 +25,8 @@ from enum import IntEnum
import numpy as np import numpy as np
# Каноническое зерно эталонного мира — константа репозитория; опись хранит # Каноническое зерно эталонного мира — константа репозитория; паспорт хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи. # его в своей шапке. Свои зёрна менти крутит без гарантий паспорта.
CANONICAL_SEED = 20260601 CANONICAL_SEED = 20260601
@@ -18,7 +18,7 @@
строгий приём хранилища сверяет набор ключей (ADR 0005). строгий приём хранилища сверяет набор ключей (ADR 0005).
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как - **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость `2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие сырых данных: менти открывает колонку `raw` обычным клиентом и разбирает событие
глазами, а число эпохи этот урок убивает. глазами, а число эпохи этот урок убивает.
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник - **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
сам решает, чем их разделить. сам решает, чем их разделить.
+3 -3
View File
@@ -2,8 +2,8 @@
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
посмотрите, что стало с данными. Правка любой константы здесь — смена мира, посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
поэтому чек описи честно покраснеет: опись сторожит только канонический мир, поэтому чек паспорта честно покраснеет: паспорт сторожит только канонический мир,
свои миры менти собирает без её гарантий (спека генератора, раздел 9). свои миры менти собирает без его гарантий (спека генератора, раздел 9).
Числа решены спекой и связаны между собой; связки сторожат тесты Числа решены спекой и связаны между собой; связки сторожат тесты
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего. `test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
@@ -28,7 +28,7 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в # D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не # `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
# зависит — иначе опись перестала бы быть воспроизводимой. # зависит — иначе паспорт перестал бы быть воспроизводимым.
ORIGIN = date(2026, 6, 1) ORIGIN = date(2026, 6, 1)
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы, # Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
def test_demo_runs_and_shows_the_whole_chain() -> None: def test_demo_runs_and_shows_the_whole_chain() -> None:
"""Сторожит команду и стадии, а числа мира оставляет описи.""" """Сторожит команду и стадии, а проверку чисел мира оставляет паспорту."""
finished = subprocess.run( finished = subprocess.run(
["make", "--silent", "demo"], ["make", "--silent", "demo"],
check=False, check=False,
+9 -9
View File
@@ -1,7 +1,7 @@
"""Проверка описи мира: та ли она, что собирается из кода сегодня. """Проверка паспорта мира: совпадает ли он с тем, что собирается из кода сегодня.
Опись собирается из кода, значит разойтись они могут только одним способом — Паспорт собирается из кода, значит разойтись они могут только одним способом —
код правили, опись не пересобрали. Ровно это здесь и сторожится, тем же код правили, паспорт не пересобрали. Ровно это здесь и сторожится, тем же
способом, что свежесть «описания выгрузки». способом, что свежесть «описания выгрузки».
Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это
@@ -23,24 +23,24 @@ from clickstream_generator.seeds import CANONICAL_SEED
INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json" INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
# Прогон, слепок которого сверяется с описью байт в байт. День любой из # Прогон, слепок которого сверяется с паспортом байт в байт. День любой из
# отправляемых; этот дешевле прочих — его окно короче. # отправляемых; этот дешевле прочих — его окно короче.
RUN_DAY = 2 RUN_DAY = 2
# День любой: хеш дня не зависит ни от соседей, ни от края описи. # День любой: хеш дня не зависит ни от соседей, ни от края паспорта.
REPLAY_DAY = 4 REPLAY_DAY = 4
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def inventory() -> dict: def inventory() -> dict:
"""Опись, собранная из кода: мир пересчитывается один раз на весь модуль.""" """Паспорт, собранный из кода: мир пересчитывается один раз на весь модуль."""
return build() return build()
def test_inventory_is_up_to_date(inventory: dict): def test_inventory_is_up_to_date(inventory: dict):
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
assert stored == inventory, ( assert stored == inventory, (
"опись мира отстала от кода — пересоберите: make inventory." "паспорт мира отстал от кода — пересоберите: make inventory."
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;" " Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
" разошлись только дни — правили генератор" " разошлись только дни — правили генератор"
) )
@@ -68,7 +68,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
"""У каждого отправленного слепка — своя строка с хешем его байтов. """У каждого отправленного слепка — своя строка с хешем его байтов.
Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это
второй артефакт мира, и побайтовое обещание сторожит опись. Слепков на день второй артефакт мира, и побайтовое обещание сторожит паспорт. Слепков на день
меньше, чем дней: прогон дня 0 не отправляет ничего. меньше, чем дней: прогон дня 0 не отправляет ничего.
Хеш сверяется с настоящей выгрузкой, а не с самим собой: в файл уезжают те Хеш сверяется с настоящей выгрузкой, а не с самим собой: в файл уезжают те
@@ -86,7 +86,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
def test_a_replayed_day_matches_its_inventory_hash(tmp_path): def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же: """Паспорт сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
пересчет сравнивался бы сам с собой. пересчет сравнивался бы сам с собой.
""" """
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
+1 -1
View File
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший «Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
канона: от него зависят байты, а значит и хеши описи. канона: от него зависят байты, а значит и хеши паспорта.
""" """
names = [column.name for column in schema.COLUMNS] names = [column.name for column in schema.COLUMNS]
for event in events: for event in events:
+1 -1
View File
@@ -167,7 +167,7 @@ def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]): def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
"""Порядок строк слепка — порядок рождения заказов, он же рост номера. """Порядок строк слепка — порядок рождения заказов, он же рост номера.
Хешу слепка в описи нужен именно названный порядок: детерминизм даёт его Хешу слепка в паспорте нужен именно названный порядок: детерминизм даёт его
даром, но обещание побайтового повтора держится на нём, а не на удаче. даром, но обещание побайтового повтора держится на нём, а не на удаче.
""" """
numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)] numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
@@ -104,7 +104,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Поток: сколько строк приезжает в слой сырья", "title": "Поток: сколько строк приезжает в сырой слой",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -0,0 +1,19 @@
slice_name: Средний чек, ₽
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: avg_check
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000106
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,19 @@
slice_name: Заказы
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: orders
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000105
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,19 @@
slice_name: Выручка, ₽
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: revenue
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000104
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,19 @@
slice_name: Посетители
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: visitors
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000107
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -0,0 +1,31 @@
slice_name: Доля известных пользователей
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_line
params:
viz_type: echarts_timeseries_line
x_axis: report_date
metrics:
- known_share
groupby: []
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
seriesType: line
show_legend: false
rich_tooltip: true
show_value: false
markerEnabled: true
markerSize: 6
y_axis_format: .1%
x_axis_time_format: '%d.%m'
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000109
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -6,9 +6,9 @@ viz_type: pivot_table_v2
params: params:
viz_type: pivot_table_v2 viz_type: pivot_table_v2
groupbyRows: groupbyRows:
- order_day
groupbyColumns:
- mismatch_class - mismatch_class
groupbyColumns:
- order_day
metrics: metrics:
- order_count - order_count
adhoc_filters: adhoc_filters:
@@ -23,8 +23,7 @@ params:
colOrder: key_a_to_z colOrder: key_a_to_z
order_desc: true order_desc: true
row_limit: 10000 row_limit: 10000
valueFormat: SMART_NUMBER date_format: '%d.%m.%Y'
date_format: smart_date
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000102 uuid: 19600000-0000-4000-8000-000000000102
version: 1.0.0 version: 1.0.0
@@ -1,4 +1,4 @@
slice_name: Выручка по дням и категориям slice_name: Выручка по дням и категориям, ₽
description: null description: null
certified_by: null certified_by: null
certification_details: null certification_details: null
@@ -20,10 +20,13 @@ params:
stack: Stack stack: Stack
orientation: vertical orientation: vertical
show_legend: true show_legend: true
legendType: plain
legendOrientation: bottom
rich_tooltip: true rich_tooltip: true
show_value: false show_value: false
y_axis_format: ',.2f' y_axis_format: ',d'
x_axis_time_format: smart_date x_axis_time_format: '%d.%m'
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000101 uuid: 19600000-0000-4000-8000-000000000101
version: 1.0.0 version: 1.0.0
@@ -0,0 +1,29 @@
slice_name: Доля категорий в выручке
description: null
certified_by: null
certification_details: null
viz_type: pie
params:
viz_type: pie
groupby:
- product_category
metric: revenue
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
sort_by_metric: true
donut: true
innerRadius: 30
outerRadius: 70
show_labels: true
labels_outside: false
label_type: percent
show_legend: false
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000108
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -17,12 +17,16 @@ params:
comparator: No filter comparator: No filter
clause: WHERE clause: WHERE
row_limit: 10000 row_limit: 10000
seriesType: echarts_timeseries_line seriesType: line
show_legend: true show_legend: true
legendOrientation: bottom
rich_tooltip: true rich_tooltip: true
show_value: false show_value: false
markerEnabled: true
markerSize: 6
y_axis_format: ',d' y_axis_format: ',d'
x_axis_time_format: smart_date x_axis_time_format: '%d.%m'
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000103 uuid: 19600000-0000-4000-8000-000000000103
version: 1.0.0 version: 1.0.0
@@ -19,9 +19,79 @@ position:
parents: parents:
- ROOT_ID - ROOT_ID
children: children:
- ROW-kpi
- ROW-revenue - ROW-revenue
- ROW-pvo - ROW-pvo
- ROW-traffic - ROW-traffic
ROW-kpi:
id: ROW-kpi
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-kpi-revenue
- CHART-kpi-orders
- CHART-kpi-avg-check
- CHART-kpi-visitors
meta:
background: BACKGROUND_TRANSPARENT
CHART-kpi-revenue:
id: CHART-kpi-revenue
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 4
height: 20
width: 3
sliceName: Выручка, ₽
uuid: 19600000-0000-4000-8000-000000000104
CHART-kpi-orders:
id: CHART-kpi-orders
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 5
height: 20
width: 3
sliceName: Заказы
uuid: 19600000-0000-4000-8000-000000000105
CHART-kpi-avg-check:
id: CHART-kpi-avg-check
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 6
height: 20
width: 3
sliceName: Средний чек, ₽
uuid: 19600000-0000-4000-8000-000000000106
CHART-kpi-visitors:
id: CHART-kpi-visitors
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 7
height: 20
width: 3
sliceName: Посетители
uuid: 19600000-0000-4000-8000-000000000107
ROW-revenue: ROW-revenue:
id: ROW-revenue id: ROW-revenue
type: ROW type: ROW
@@ -30,6 +100,7 @@ position:
- GRID_ID - GRID_ID
children: children:
- CHART-revenue - CHART-revenue
- CHART-revenue-share
meta: meta:
background: BACKGROUND_TRANSPARENT background: BACKGROUND_TRANSPARENT
CHART-revenue: CHART-revenue:
@@ -43,9 +114,23 @@ position:
meta: meta:
chartId: 1 chartId: 1
height: 50 height: 50
width: 12 width: 8
sliceName: Выручка по дням и категориям sliceName: Выручка по дням и категориям, ₽
uuid: 19600000-0000-4000-8000-000000000101 uuid: 19600000-0000-4000-8000-000000000101
CHART-revenue-share:
id: CHART-revenue-share
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-revenue
children: []
meta:
chartId: 8
height: 50
width: 4
sliceName: Доля категорий в выручке
uuid: 19600000-0000-4000-8000-000000000108
ROW-pvo: ROW-pvo:
id: ROW-pvo id: ROW-pvo
type: ROW type: ROW
@@ -66,7 +151,7 @@ position:
children: [] children: []
meta: meta:
chartId: 2 chartId: 2
height: 50 height: 44
width: 12 width: 12
sliceName: Сверка purchase и заказов sliceName: Сверка purchase и заказов
uuid: 19600000-0000-4000-8000-000000000102 uuid: 19600000-0000-4000-8000-000000000102
@@ -78,6 +163,7 @@ position:
- GRID_ID - GRID_ID
children: children:
- CHART-traffic - CHART-traffic
- CHART-known-share
meta: meta:
background: BACKGROUND_TRANSPARENT background: BACKGROUND_TRANSPARENT
CHART-traffic: CHART-traffic:
@@ -91,15 +177,57 @@ position:
meta: meta:
chartId: 3 chartId: 3
height: 50 height: 50
width: 12 width: 6
sliceName: Посетители и известные пользователи sliceName: Посетители и известные пользователи
uuid: 19600000-0000-4000-8000-000000000103 uuid: 19600000-0000-4000-8000-000000000103
CHART-known-share:
id: CHART-known-share
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-traffic
children: []
meta:
chartId: 9
height: 50
width: 6
sliceName: Доля известных пользователей
uuid: 19600000-0000-4000-8000-000000000109
metadata: metadata:
color_scheme: supersetColors color_scheme: supersetColors
label_colors:
Товары для дома: '#1FA8C9'
Текстиль: '#454E7C'
Посуда: '#5AC189'
Бытовая техника: '#FF7F44'
Детские товары: '#E04355'
Одежда и обувь: '#A868B7'
visitors: '#1FA8C9'
known_users: '#454E7C'
known_share: '#454E7C'
cross_filters_enabled: false cross_filters_enabled: false
chart_configuration: {} chart_configuration: {}
expanded_slices: {} expanded_slices: {}
default_filters: '{}' default_filters: '{}'
native_filter_configuration: [] native_filter_configuration:
- id: NATIVE_FILTER-period
name: Период
filterType: filter_time
type: NATIVE_FILTER
targets:
- {}
controlValues:
enableEmptyFilter: false
defaultDataMask:
extraFormData: {}
filterState: {}
ownState: {}
cascadeParentIds: []
scope:
rootPath:
- ROOT_ID
excluded: []
description: ''
theme_uuid: null theme_uuid: null
version: 1.0.0 version: 1.0.0
@@ -36,6 +36,15 @@ metrics:
currency: null currency: null
extra: null extra: null
warning_text: null warning_text: null
- metric_name: known_share
verbose_name: Доля известных
metric_type: null
expression: SUM(known_users) / nullIf(SUM(visitors), 0)
description: null
d3format: .1%
currency: null
extra: null
warning_text: null
columns: columns:
- column_name: _load_ts - column_name: _load_ts
verbose_name: Время загрузки verbose_name: Время загрузки
@@ -23,10 +23,26 @@ metrics:
metric_type: null metric_type: null
expression: SUM(revenue) expression: SUM(revenue)
description: null description: null
d3format: ',.2f' d3format: ',d'
currency: currency: null
symbol: ₽ extra: null
symbolPosition: suffix warning_text: null
- metric_name: orders
verbose_name: Заказы
metric_type: null
expression: SUM(orders)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
- metric_name: avg_check
verbose_name: Средний чек
metric_type: null
expression: SUM(revenue) / nullIf(SUM(orders), 0)
description: null
d3format: ',d'
currency: null
extra: null extra: null
warning_text: null warning_text: null
columns: columns:
@@ -44,7 +60,7 @@ columns:
datetime_format: null datetime_format: null
extra: null extra: null
- column_name: aov - column_name: aov
verbose_name: Средний чек verbose_name: Средний чек за день и категорию
is_dttm: false is_dttm: false
is_active: true is_active: true
type: DECIMAL(18, 2) type: DECIMAL(18, 2)
+7 -7
View File
@@ -102,12 +102,12 @@ on_signal() {
# Единственный постоянный сторож цепочки Kafka → STG → ODS. # Единственный постоянный сторож цепочки Kafka → STG → ODS.
# #
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт # Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на # в `ods.event` против паспорта мира, лежащего в git. Поэтому она умеет краснеть на
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по # сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
# дороге, а не только на пустой таблице. # дороге, а не только на пустой таблице.
# #
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день, # **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью # этап 5 добавит следующий, — и голый счёт по таблице разойдётся с паспортом
# законно, без всякой поломки. # законно, без всякой поломки.
# #
# **Счёт идёт через ods.event_v** — каноническую поверхность актуального # **Счёт идёт через ods.event_v** — каноническую поверхность актуального
@@ -138,7 +138,7 @@ check_starting_world() {
FORMAT TSV")" FORMAT TSV")"
if [[ "$actual" == "$expected" ]]; then if [[ "$actual" == "$expected" ]]; then
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n' printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с паспортом.\n'
return return
fi fi
@@ -148,13 +148,13 @@ check_starting_world() {
GROUP BY error_class GROUP BY error_class
ORDER BY count() DESC ORDER BY count() DESC
FORMAT TSV")" FORMAT TSV")"
printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2 printf 'Паспорт мира ожидает (дата, событий):\n%s\n' "$expected" >&2
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2 printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
if [[ -z "$broken" ]]; then if [[ -z "$broken" ]]; then
fail 'счёт разошёлся с описью, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv' fail 'счёт разошёлся с паспортом, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv'
fi fi
printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2 printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2
fail 'счёт разошёлся с описью, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka' fail 'счёт разошёлся с паспортом, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka'
} }
assert_ddl_queue_completed() { assert_ddl_queue_completed() {
@@ -282,7 +282,7 @@ assert_equal "$expected_product" "$(query_as_etl clickhouse-01 "$product_sql")"
assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде" assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде"
printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n' printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n' printf 'Проверка 10/10: стартовый мир в ods.event сходится с паспортом...\n'
check_starting_world check_starting_world
printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n' printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
+7 -7
View File
@@ -1,6 +1,6 @@
-- STG: чтецы топиков hits и orders, таблицы сырья обоих источников. -- STG: чтецы топиков hits и orders, таблицы STG обоих источников.
-- --
-- Слой сырья ничего не интерпретирует: сообщение ложится строкой, как пришло, -- Сырой слой ничего не интерпретирует: сообщение ложится строкой, как пришло,
-- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и -- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и
-- сроков — docs/architecture/storage.md. -- сроков — docs/architecture/storage.md.
@@ -30,9 +30,9 @@ SETTINGS
kafka_group_name = 'clickstream_hits', kafka_group_name = 'clickstream_hits',
kafka_format = 'RawBLOB'; kafka_format = 'RawBLOB';
-- Локальная таблица сырья. -- Локальная таблица STG.
-- --
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в сырье -- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в STG
-- обязан быть виден — ради этого слой и заведён. -- обязан быть виден — ради этого слой и заведён.
-- --
-- Служебные колонки не повторяют имён виртуальных (_topic, _partition, -- Служебные колонки не повторяют имён виртуальных (_topic, _partition,
@@ -45,7 +45,7 @@ SETTINGS
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и -- _load_ts рядом тоже миллисекундная, а сырой слой хранит то, что приехало, и
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
-- --
@@ -115,8 +115,8 @@ SETTINGS
kafka_format = 'RawBLOB', kafka_format = 'RawBLOB',
kafka_commit_on_select = 1; kafka_commit_on_select = 1;
-- Локальная таблица сырья заказов. Колонки, типы, ключ, нарезка и срок жизни — -- Локальная таблица STG заказов. Колонки, типы, ключ, нарезка и срок жизни —
-- те же, что у сырья событий, и по тем же доводам: docs/architecture/storage.md. -- те же, что у STG событий, и по тем же доводам: docs/architecture/storage.md.
-- --
-- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен -- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен
-- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает -- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает
+27 -68
View File
@@ -1,49 +1,20 @@
-- ODS: типизированное событие и таблица ошибок разбора.
--
-- Состав, имена и типы колонок списаны с «описания выгрузки»
-- (docs/formats/clickstream-event.md) — так же, как в бою хранилище пишут по
-- документации источника. Модуль контракта генератора здесь не читается:
-- граница «трекер | хранилище» проходит по документу.
-- Механика приёма и три класса брака — ADR 0005, конвенции имён, служебных
-- колонок и сроков — docs/architecture/storage.md.
-- Локальная таблица события. -- Локальная таблица события.
-- Поля следуют docs/formats/clickstream-event.md; purchaseRevenue хранится
-- в Decimal для точного счета денег. Правила приема — ADR 0005.
-- ReplacingMergeTree оставляет последнюю доставку по _load_ts среди строк
-- с одинаковым полным ORDER BY. Метка переносится из STG без пересчета.
-- Она не входит в ключ сортировки или партиции: иначе повторные доставки
-- не считались бы версиями одной строки. Для чтения служит ods.event_v с FINAL.
-- --
-- Движок ReplacingMergeTree, колонка версии — _load_ts. Работа у версии ровно -- Партиция 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.
--
-- У сырья ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
-- сходиться не должны — сверять слои можно только через _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): такую таблицу смотрят от класса брака, а внутри класса — по
-- координатам доставки. Нарезка по дню загрузки, как у сырья: модельного дня
-- у брака тоже нет.
--
-- Срок жизни — месяц, вдесятеро дольше сырья. Истеки брак вместе с сырьём —
-- к моменту разбирательства не осталось бы ни того, ни другого.
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,
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more